///|
/// Ceiling on the template data document `office template` will read.
let office_template_max_data_bytes : Int = 8 * 1024 * 1024

///|
fn template_findings_json(
  findings : Array[@office_xlsx.XlsxTemplateFinding],
) -> Json {
  Json::array(
    findings.map(entry => {
      Json::object({
        "location": Json::string(bounded_text(entry.location, 160)),
        "detail": Json::string(bounded_text(entry.detail, 160)),
      })
    }),
  )
}

///|
/// The office.template/1 record, shared by the success envelope and the
/// strict-refusal failure details.
fn template_report_json(
  file : String,
  data_file : String,
  output : String,
  format : String,
  report : @office_xlsx.XlsxTemplateReport,
  transaction? : Json,
  stories? : Array[String],
  regions? : Array[Json] = [],
) -> Json {
  let fields : Map[String, Json] = {
    "schema": Json::string(@lib.SCHEMA_TEMPLATE_RESULT),
    "file": Json::string(bounded_text(file, 160)),
    "format": Json::string(format),
    "data_file": Json::string(bounded_text(data_file, 160)),
    "output": Json::string(bounded_text(output, 160)),
    "placeholders_found": Json::number(report.placeholders_found.to_double()),
    "replaced": Json::number(report.replaced.to_double()),
    "escapes_applied": Json::number(report.escapes_applied.to_double()),
    "distinct_keys_used": Json::number(report.distinct_keys_used.to_double()),
    "missing": template_findings_json(report.missing),
    "unused": Json::array(
      report.unused.map(key => Json::string(bounded_text(key, 80))),
    ),
    "malformed": template_findings_json(report.malformed),
    "unsupported": template_findings_json(report.unsupported),
    "locations": template_findings_json(report.locations),
    "locations_truncated": Json::boolean(report.locations_truncated),
    "missing_total": Json::number(report.missing_total.to_double()),
    "malformed_total": Json::number(report.malformed_total.to_double()),
    "unsupported_total": Json::number(report.unsupported_total.to_double()),
    "unused_total": Json::number(report.unused_total.to_double()),
    "regions": Json::array(regions),
    "regions_total": Json::number(regions.length().to_double()),
  }
  match stories {
    Some(list) =>
      fields["stories_scanned"] = Json::array(
        list.map(story => Json::string(story)),
      )
    None => ()
  }
  match transaction {
    Some(value) => fields["transaction"] = value
    None => ()
  }
  Json::object(fields)
}

///|
/// Bridges the DOCX report into the shared XLSX report shape so one
/// JSON builder serves both formats (the record schemas are identical
/// except for locations grammar and stories_scanned).
fn docx_report_as_shared(
  report : @office_docx.DocxTemplateReport,
) -> @office_xlsx.XlsxTemplateReport {
  {
    placeholders_found: report.placeholders_found,
    replaced: report.replaced,
    escapes_applied: report.escapes_applied,
    distinct_keys_used: report.distinct_keys_used,
    missing: report.missing.map(entry => {
      (
        { location: entry.location, detail: entry.detail, } :
        @office_xlsx.XlsxTemplateFinding)
    }),
    unused: report.unused,
    malformed: report.malformed.map(entry => {
      (
        { location: entry.location, detail: entry.detail, } :
        @office_xlsx.XlsxTemplateFinding)
    }),
    unsupported: report.unsupported.map(entry => {
      (
        { location: entry.location, detail: entry.detail, } :
        @office_xlsx.XlsxTemplateFinding)
    }),
    locations: report.locations.map(entry => {
      (
        { location: entry.location, detail: entry.detail, } :
        @office_xlsx.XlsxTemplateFinding)
    }),
    locations_truncated: report.locations_truncated,
    missing_total: report.missing_total,
    malformed_total: report.malformed_total,
    unsupported_total: report.unsupported_total,
    unused_total: report.unused_total,
    changed: report.changed,
  }
}

///|
/// Serializes XLSX repeated-region outcomes into the shared regions
/// array; `output_rows` gives the final placement after all insertions.
fn xlsx_regions_json(
  regions : Array[@office_xlsx.XlsxRegionOutcome],
) -> Array[Json] {
  regions.map(region => {
    Json::object({
      "name": Json::string(bounded_text(region.name, 80)),
      "source_location": Json::string(bounded_text(region.source_location, 160)),
      "records": Json::number(region.records.to_double()),
      "replaced": Json::number(region.replaced.to_double()),
      "escapes_applied": Json::number(region.escapes_applied.to_double()),
      "output_rows": Json::object({
        "start": Json::number(region.output_rows_start.to_double()),
        "count": Json::number(region.output_rows_count.to_double()),
      }),
    })
  })
}

///|
/// Serializes DOCX repeated-region outcomes into the shared regions
/// array (a DOCX region emits one cloned row block, no sheet coords).
fn docx_regions_json(
  regions : Array[@office_docx.DocxRegionOutcome],
) -> Array[Json] {
  regions.map(region => {
    Json::object({
      "name": Json::string(bounded_text(region.name, 80)),
      "source_location": Json::string(bounded_text(region.source_location, 160)),
      "records": Json::number(region.records.to_double()),
      "replaced": Json::number(region.replaced.to_double()),
      "escapes_applied": Json::number(region.escapes_applied.to_double()),
    })
  })
}

///|
async fn run_template(matches : @argparse.Matches) -> Unit {
  let file = required_value(matches, "file")
  let data_file = required_value(matches, "data")
  let output = required_value(matches, "out")
  let allow_missing = matches.flags.get_or_default("allow-missing", false)
  let mode = office_validate_output_mode(matches)
  // dispatch by the template's extension; each transaction re-validates
  // the actual package format internally
  let is_docx = file.to_lower().has_suffix(".docx")
  let expected_suffix = if is_docx { ".docx" } else { ".xlsx" }
  guard output.to_lower().has_suffix(expected_suffix) else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments",
        "--out must end in \{expected_suffix} to match the template format",
      ),
    )
  }
  let data_bytes = read_bounded_file(
    data_file,
    office_template_max_data_bytes,
    "office.template.data_read_failed",
    "template data document",
    limit_code="office.template.resource_limit",
  )
  let data_text = @utf8.decode(data_bytes, ignore_bom=true) catch {
    _ =>
      raise CliFailure(
        @lib.protocol_error(
          "office.template.invalid_data", "template data document is not valid UTF-8",
        ),
      )
  }
  let data = @template.parse_template_document(data_text) catch {
    TemplateDataError(reason) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.template.invalid_data",
          "invalid template data: " + bounded_text(reason, 200),
        ),
      )
  }
  let options = @transaction.transaction_options(
    file,
    output_path=output,
    dry_run=matches.flags.get_or_default("dry-run", false),
    overwrite=matches.flags.get_or_default("overwrite", false),
  ) catch {
    error => raise transaction_failure(error)
  }
  fn refusal(
    report : @office_xlsx.XlsxTemplateReport,
    format : String,
    stories : Array[String]?,
  ) -> CliFailure {
    // strict findings: precedence malformed > unsupported > missing
    let (code, message) = if report.malformed.length() > 0 {
      (
        "office.template.malformed_placeholder", "the template carries malformed placeholders",
      )
    } else if report.unsupported.length() > 0 {
      (
        "office.template.unsupported_context", "the template carries placeholders in unsupported contexts",
      )
    } else {
      (
        "office.template.missing_keys", "the data document does not supply every placeholder key",
      )
    }
    CliFailure(
      @lib.protocol_error(
        code,
        message,
        details=template_report_json(
          file,
          data_file,
          output,
          format,
          report,
          stories?,
        ),
      ),
    )
  }
  let record = if is_docx {
    let result = @office_docx.transact_docx_template_document(
      options,
      data,
      allow_missing~,
    ) catch {
      @office_docx.DocxTemplateRefused(report) =>
        raise refusal(
          docx_report_as_shared(report),
          "docx",
          Some(report.stories_scanned),
        )
      @transaction.TransactionError(..) as error =>
        raise transaction_failure(error)
      error if @async.is_being_cancelled() => raise error
      error =>
        raise unexpected_cli_failure(
          "office.template.failed", "template transaction", error,
        )
    }
    template_report_json(
      file,
      data_file,
      output,
      "docx",
      docx_report_as_shared(result.report),
      transaction=result.transaction.to_json(),
      stories=result.report.stories_scanned,
      regions=docx_regions_json(result.regions),
    )
  } else {
    let result = @office_xlsx.transact_template_document(
      options,
      data,
      allow_missing~,
    ) catch {
      @office_xlsx.XlsxTemplateRefused(report) =>
        raise refusal(report, "xlsx", None)
      @transaction.TransactionError(..) as error =>
        raise transaction_failure(error)
      error if @async.is_being_cancelled() => raise error
      error =>
        raise unexpected_cli_failure(
          "office.template.failed", "template transaction", error,
        )
    }
    template_report_json(
      file,
      data_file,
      output,
      "xlsx",
      result.report,
      transaction=result.transaction.to_json(),
      regions=xlsx_regions_json(result.regions),
    )
  }
  match mode {
    JsonDocument => println(@lib.output_success(record).stringify(indent=2))
    JsonLines => println(@lib.output_success(record).stringify())
    Human => {
      let replaced = match record {
        Object(fields) =>
          match fields.get("replaced") {
            Some(Number(count, ..)) => count.to_int()
            _ => 0
          }
        _ => 0
      }
      let missing = match record {
        Object(fields) =>
          match fields.get("missing_total") {
            Some(Number(count, ..)) => count.to_int()
            _ => 0
          }
        _ => 0
      }
      let unused = match record {
        Object(fields) =>
          match fields.get("unused_total") {
            Some(Number(count, ..)) => count.to_int()
            _ => 0
          }
        _ => 0
      }
      println(
        "template: \{replaced} replaced, " +
        "\{missing} missing, " +
        "\{unused} unused -> " +
        human_text(output, 160),
      )
    }
  }
}

///|
fn template_command() -> @argparse.Command {
  let summary = match @lib.find_capability_command("template") {
    Some(command) => command.summary
    None => "Merge strict template data into an Office document"
  }
  Command(
    "template",
    about=summary,
    positionals=[
      PositionArg(
        "file",
        about="existing .xlsx or .docx template package",
        num_args=@argparse.ValueRange::single(),
      ),
      PositionArg(
        "data",
        about="office.template.data/1 JSON document",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
    options=[
      OptionArg(
        "out",
        long="out",
        about="destination path matching the template format (never modified in place)",
      ),
    ],
    flags=[
      FlagArg("dry-run", long="dry-run", about="validate without publishing"),
      FlagArg(
        "overwrite",
        long="overwrite",
        about="replace an existing destination",
      ),
      FlagArg(
        "allow-missing",
        long="allow-missing",
        about="keep unresolved placeholders as literals instead of failing",
      ),
      FlagArg("json", long="json", about="print office.output/1 JSON"),
      FlagArg("jsonl", long="jsonl", about="print one office.output/1 line"),
    ],
  )
}