///|
/// A reproducible export job describing one family of generated deliverables.
pub(all) struct ExportJob {
  name : String
  seed : UInt
  width : Double
  height : Double
  palette : Palette
  variants : Array[(String, Double, Double)]
  tags : Array[String]
} derive(Debug)

///|
pub(all) struct ExportVariant {
  name : String
  document : DesignDocument
  asset : SvgAsset
  metrics : DesignQualityMetrics
  checksum : UInt
} derive(Debug)

///|
pub(all) struct ExportPlan {
  job : ExportJob
  variants : Array[ExportVariant]
  bundle : SvgAssetBundle
  total_bytes : Int
  quality_min : Double
  quality_mean : Double
} derive(Debug)

///|
pub fn ExportJob::new(
  name : String,
  seed : UInt,
  width : Double,
  height : Double,
  palette : Palette,
) -> ExportJob {
  {
    name,
    seed,
    width: width.max(1.0),
    height: height.max(1.0),
    palette,
    variants: [],
    tags: [],
  }
}

///|
pub fn ExportJob::add_variant(
  self : ExportJob,
  name : String,
  width : Double,
  height : Double,
) -> Unit {
  self.variants.push((name, width.max(1.0), height.max(1.0)))
}

///|
pub fn ExportJob::tag(self : ExportJob, value : String) -> ExportJob {
  if value.length() > 0 && !self.tags.contains(value) {
    self.tags.push(value)
  }
  self
}

///|
pub fn ExportJob::ensure_default_variants(self : ExportJob) -> ExportJob {
  if self.variants.length() == 0 {
    self.variants.push(("master", self.width, self.height))
    self.variants.push(
      ("thumbnail", self.width.min(512.0), self.height.min(512.0)),
    )
    self.variants.push(
      (
        "social-square",
        self.width.min(self.height),
        self.width.min(self.height),
      ),
    )
  }
  self
}

///|
pub fn ExportJob::validate(self : ExportJob) -> Array[String] {
  let errors = []
  if self.name.length() == 0 {
    errors.push("export job name is empty")
  }
  if self.width <= 0.0 || self.height <= 0.0 {
    errors.push("export job dimensions are invalid")
  }
  if self.palette.colors.length() == 0 {
    errors.push("export job palette is empty")
  }
  let seen = []
  for variant in self.variants {
    if variant.0.length() == 0 {
      errors.push("export variant name is empty")
    }
    if variant.1 <= 0.0 || variant.2 <= 0.0 {
      errors.push("export variant dimensions are invalid")
    }
    if seen.contains(variant.0) {
      errors.push("duplicate export variant: " + variant.0)
    } else {
      seen.push(variant.0)
    }
  }
  errors
}

///|
pub fn export_job_document(
  job : ExportJob,
  variant : (String, Double, Double),
) -> DesignDocument {
  let composition = SceneRecipe::new(
    job.seed,
    variant.1,
    variant.2,
    18,
    18,
    job.palette,
  ).build()
  let metadata = DocumentMetadata::new(
      job.name + " / " + variant.0,
      "MoonBit Generative Patterns",
      job.seed,
      "production SVG export",
    )
    .tag("export")
    .tag(variant.0)
  DesignDocument::new(composition, metadata)
}

///|
pub fn export_variant(
  job : ExportJob,
  variant : (String, Double, Double),
) -> ExportVariant {
  let document = export_job_document(job, variant)
  let asset = svg_asset_from_document(
    "export",
    job.name + "-" + variant.0,
    1,
    document.composition.render(),
    "batch export",
  )
  let asset = svg_asset_with_tags(asset, job.tags)
  {
    name: variant.0,
    document,
    asset,
    metrics: composition_quality_metrics(document.composition),
    checksum: svg_asset_checksum(asset),
  }
}

///|
pub fn export_plan(job : ExportJob) -> ExportPlan {
  let configured = job.ensure_default_variants()
  let variants = []
  let bundle = SvgAssetBundle::new(configured.name, "1.0.0")
  for variant in configured.variants {
    let output = export_variant(configured, variant)
    let _ = bundle.add(output.asset)
    variants.push(output)
  }
  let mut total = 0
  let mut minimum = 1.0
  let mut sum = 0.0
  for variant in variants {
    total += variant.asset.bytes()
    minimum = minimum.min(variant.metrics.score)
    sum += variant.metrics.score
  }
  {
    job: configured,
    variants,
    bundle,
    total_bytes: total,
    quality_min: if configured.variants.length() == 0 {
      0.0
    } else {
      minimum
    },
    quality_mean: if configured.variants.length() == 0 {
      0.0
    } else {
      sum / configured.variants.length().to_double()
    },
  }
}

///|
pub fn export_plan_validate(plan : ExportPlan) -> Array[String] {
  let errors = plan.job.validate()
  for variant in plan.variants {
    if variant.asset.document.width <= 0.0 {
      errors.push(variant.name + " has invalid document width")
    }
    if variant.checksum == 0 {
      errors.push(variant.name + " has empty checksum")
    }
    if variant.metrics.score < 0.0 || variant.metrics.score > 1.0 {
      errors.push(variant.name + " quality score is outside [0,1]")
    }
  }
  for error in svg_bundle_validate(plan.bundle) {
    errors.push(error)
  }
  errors
}

///|
pub fn export_plan_manifest(plan : ExportPlan) -> String {
  let builder = StringBuilder()
  builder.write_string(
    "name,variant,width,height,bytes,checksum,quality,coverage\n",
  )
  for variant in plan.variants {
    builder.write_string(
      plan.job.name +
      "," +
      variant.name +
      "," +
      variant.asset.view_box.width.to_string() +
      "," +
      variant.asset.view_box.height.to_string() +
      "," +
      variant.asset.bytes().to_string() +
      "," +
      variant.checksum.to_string() +
      "," +
      variant.metrics.score.to_string() +
      "," +
      variant.metrics.coverage.to_string() +
      "\n",
    )
  }
  builder.to_string()
}

///|
pub fn export_plan_report(plan : ExportPlan) -> String {
  "job=" +
  plan.job.name +
  " variants=" +
  plan.variants.length().to_string() +
  " bytes=" +
  plan.total_bytes.to_string() +
  " quality_min=" +
  plan.quality_min.to_string() +
  " quality_mean=" +
  plan.quality_mean.to_string() +
  " bundle_checksum=" +
  svg_bundle_checksum(plan.bundle).to_string()
}

///|
pub fn export_plan_render_index(plan : ExportPlan) -> String {
  let builder = StringBuilder()
  builder.write_string(
    "

" + escape_svg(plan.job.name) + "

    ", ) for variant in plan.variants { builder.write_string( "
  • " + escape_svg(variant.name) + " score=" + variant.metrics.score.to_string() + "
  • ", ) } builder.write_string("
") builder.to_string() } ///| pub fn export_job_from_sizes( name : String, seed : UInt, palette : Palette, sizes : Array[(String, Double, Double)], ) -> ExportJob { let width = if sizes.length() == 0 { 1024.0 } else { sizes[0].1 } let height = if sizes.length() == 0 { 1024.0 } else { sizes[0].2 } let job = ExportJob::new(name, seed, width, height, palette) for size in sizes { job.add_variant(size.0, size.1, size.2) } job } ///| pub fn export_job_standard( name : String, seed : UInt, palette : Palette, ) -> ExportJob { ExportJob::new(name, seed, 2048.0, 2048.0, palette) .tag("standard") .tag("svg") .ensure_default_variants() } ///| pub fn export_job_square( name : String, seed : UInt, size : Double, palette : Palette, ) -> ExportJob { ExportJob::new(name, seed, size, size, palette) .tag("square") .ensure_default_variants() } ///| pub fn export_job_poster( name : String, seed : UInt, width : Double, height : Double, palette : Palette, ) -> ExportJob { ExportJob::new(name, seed, width, height, palette) .tag("poster") .tag("print") .ensure_default_variants() } ///| pub fn export_plan_thumbnail( plan : ExportPlan, width : Double, height : Double, ) -> SvgDocument { let document = SvgDocument::new(width.max(1.0), height.max(1.0)) let mut cursor = 0.0 for variant in plan.variants { let scaled = svg_document_scale( variant.asset.document, (width / variant.asset.view_box.width).min( height / variant.asset.view_box.height, ), ) document.add( SvgElement::SvgGroup( scaled.elements, transform=Matrix2D::translate(cursor, 0.0), ), ) cursor += scaled.width } document } ///| pub fn export_plan_quality_gate( plan : ExportPlan, minimum_score : Double, minimum_coverage : Double, ) -> Bool { if plan.variants.length() == 0 { false } else { for variant in plan.variants { if !quality_threshold_pass( variant.metrics, minimum_score, minimum_coverage, ) { return false } } true } } ///| pub fn export_plan_rechecksum(plan : ExportPlan) -> Array[(String, UInt)] { let result = [] for variant in plan.variants { result.push((variant.name, svg_asset_checksum(variant.asset))) } result } ///| pub fn export_plan_names(plan : ExportPlan) -> Array[String] { let result = [] for variant in plan.variants { result.push(variant.name) } result } ///| pub fn export_job_palette_report(job : ExportJob) -> String { color_analysis_report_text(analyze_palette(job.palette)) + "\n" + palette_report_csv(job.palette) } ///| pub fn export_plan_assets(plan : ExportPlan) -> Array[SvgAsset] { let result = [] for variant in plan.variants { result.push(variant.asset) } result } ///| pub fn export_plan_archive_name(plan : ExportPlan) -> String { plan.job.name + "-" + plan.job.seed.to_string() + "-" + plan.variants.length().to_string() + "-assets" }