// `office format` — the transactional direct-formatting verb (F3).
//
// The CLI half owns REQUEST policy: flag validation, the on/off
// property grammar, the selector shapes, and the output contract.
// Selection, the rPr engine, and every refusal live in the planning
// engine; the shared mutation preflight, the three-check readback, and
// atomic publication live in the transaction. Nothing here decides what
// is formattable.

///|
fn format_command() -> @argparse.Command {
  let summary = match @lib.find_capability_command("format") {
    Some(command) => command.summary
    None =>
      "Apply direct character formatting to selected text in a DOCX, refusing wherever the result could differ from the request"
  }
  Command(
    "format",
    about=summary,
    positionals=[
      PositionArg(
        "file",
        about="existing .docx package (never modified in place)",
        num_args=@argparse.ValueRange::single(),
      ),
      PositionArg(
        "out",
        about="destination .docx; created atomically, nothing written on refusal",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
    options=[
      OptionArg(
        "text",
        long="text",
        action=Append,
        about="literal text to format — never a regular expression; matched over the reader PROJECTION, so it spans run boundaries. Every occurrence is selected unless --nth narrows it",
      ),
      OptionArg(
        "in",
        long="in",
        action=Append,
        about="restrict to a body-relative subtree (p[3], tbl[1]) or a stable p[id=\"…\"]; required by --range, which needs ONE paragraph",
      ),
      OptionArg(
        "range",
        long="range",
        action=Append,
        about="format a paragraph-local unit range START:END (the units `office find` and `office get` report), instead of --text; requires --in naming one paragraph",
      ),
      OptionArg(
        "nth",
        long="nth",
        action=Append,
        about="select ONE text occurrence by ordinal (1-based, counted over all occurrences)",
      ),
      OptionArg(
        "bold",
        long="bold",
        action=Append,
        about="on|off — set or clear bold explicitly; omitted properties are left untouched",
      ),
      OptionArg(
        "italic",
        long="italic",
        action=Append,
        about="on|off — set or clear italic",
      ),
      OptionArg(
        "underline",
        long="underline",
        action=Append,
        about="on|off — set or clear single underline",
      ),
      OptionArg(
        "color",
        long="color",
        action=Append,
        about="RRGGBB or #RRGGBB — set an absolute text colour (theme linkage is removed; the engine refuses if it cannot)",
      ),
      OptionArg(
        "expect",
        long="expect",
        action=Append,
        about="assert exactly N spans are selected; a mismatch refuses before anything is written",
      ),
    ],
    flags=[
      FlagArg(
        "allow-zero",
        long="allow-zero",
        about="treat zero selected spans as success instead of refusing; contradicts --expect",
      ),
      FlagArg(
        "dry-run",
        long="dry-run",
        about="run the identical selection and preflight pipeline, exit as the real run would, write nothing",
      ),
      FlagArg(
        "overwrite",
        long="overwrite",
        about="replace an existing destination",
      ),
      docx_json_flag(),
    ],
  )
}

///|
/// A scalar option given twice is a CONTRADICTION, not a preference for
/// the later spelling: `--bold on --bold off` must refuse, and so must
/// a duplicated selector or scope that would silently change targets.
fn reject_duplicate_scalar_options(
  matches : @argparse.Matches,
  names : Array[String],
) -> Unit raise CliFailure {
  for name in names {
    match matches.values.get(name) {
      Some(values) if values.length() > 1 =>
        raise CliFailure(
          @lib.protocol_error(
            "office.invalid_arguments",
            "--\{name} was given \{values.length()} times; pass each option once",
          ),
        )
      _ => ()
    }
  }
}

///|
/// The exact single-segment stable spelling, at the request boundary.
/// Anything stable-LOOKING that is not exactly this shape refuses
/// before any file is read; the engine's semantic para_id validation
/// judges the payload itself.
///
/// The ENGINE owns the predicate, and every verb — here and in the SDK
/// — asks it the same question. Two hand-written copies of one grammar
/// disagree eventually, and this pair did.
fn docx_stable_target_shape_valid(value : String) -> Bool {
  @office_docx.docx_stable_target_shape_valid(value)
}

///|
/// One explicit on/off property. Omission means UNTOUCHED, so the
/// grammar demands the caller say which of the three states they mean —
/// there is no bare `--bold` that could read as either set or toggle.
fn format_on_off_option(
  matches : @argparse.Matches,
  name : String,
) -> Bool? raise CliFailure {
  match optional_value(matches, name) {
    Some("on") => Some(true)
    Some("off") => Some(false)
    Some(other) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments",
          "--\{name} takes `on` or `off` (got '\{bounded_text(other, 40)}'); omit the flag to leave the property untouched",
        ),
      )
    None => None
  }
}

///|
/// `START:END` in paragraph-local units, half-open, as the read
/// surfaces report them.
fn format_range_option(
  matches : @argparse.Matches,
) -> (Int, Int)? raise CliFailure {
  guard optional_value(matches, "range") is Some(value) else { return None }
  guard value.find(":") is Some(colon) &&
    colon > 0 &&
    colon + 1 < value.length() else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "--range takes START:END in paragraph-local units, half-open, e.g. 0:5",
      ),
    )
  }
  let start = decimal_prefix_value(value[:colon].to_owned())
  let end = decimal_prefix_value(value[colon + 1:].to_owned())
  guard start is Some(from) && end is Some(to) && from >= 0 && to > from else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "--range takes START:END with 0 <= START < END, e.g. 0:5",
      ),
    )
  }
  Some((from, to))
}

///|
fn decimal_prefix_value(text : String) -> Int? {
  if text == "" || text.length() > 7 {
    return None
  }
  let mut value = 0
  for ch in text {
    guard ch is ('0'..='9') else { return None }
    value = value * 10 + (ch.to_int() - '0'.to_int())
  }
  Some(value)
}

///|
/// `--in` must be CANONICAL before it may act as a prefix: the engine's
/// prefix test is safe exactly because scanner ordinals are bracketed
/// (`p[1]` is not a prefix of `p[10]`), and a truncated or noncanonical
/// scope would silently widen the selection. A stable `p[id="…"]`
/// passes through — the engine resolves and validates it with its own
/// typed refusals.
fn format_validate_scope(scope : String) -> Unit raise CliFailure {
  if scope == "" {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "--in must name a body-relative subtree (p[3], tbl[1]) or a stable p[id=\"…\"]; an empty scope would match everything",
      ),
    )
  }
  if scope.has_prefix("p[id") {
    // Only the EXACT single-segment stable shape goes to the engine's
    // semantic validation; every other stable-looking spelling is
    // malformed grammar and refuses here, before any file is read.
    guard docx_stable_target_shape_valid(scope) else {
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments",
          "--in '\{bounded_text(scope, 80)}' is not the stable shape p[id=\"…\"]; pass the exact para_id spelling the read surfaces report",
        ),
      )
    }
    return
  }
  // Only the PATHS the DOCX scanner emits, as a state machine, not a
  // segment list: `tr[1]` cannot start a path, `p[1]/p[1]` and
  // `tbl[1]/tc[1]` order segments no document carries, and `--expect 0`
  // would bless any such typo as a successful no-op. Body level admits
  // a paragraph or a table; a table admits rows; a row admits cells; a
  // cell admits paragraphs or nested tables; a paragraph ends the path.
  let mut valid = true
  let mut state = "body"
  let mut cursor = 0
  while valid && cursor < scope.length() {
    let mut close = cursor
    while close < scope.length() && scope[close] != '/' {
      close += 1
    }
    let segment = scope[cursor:close].to_owned()
    match format_scope_segment_kind(segment) {
      Some(kind) =>
        state = match (state, kind) {
          ("body", "p") | ("cell", "p") => "terminal"
          ("body", "tbl") | ("cell", "tbl") => "table"
          ("table", "tr") => "row"
          ("row", "tc") => "cell"
          _ => {
            valid = false
            state
          }
        }
      None => valid = false
    }
    cursor = close + 1
  }
  if scope.has_suffix("/") {
    valid = false
  }
  guard valid else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments",
        "--in '\{bounded_text(scope, 80)}' is not a body-relative path the reader emits; pass the exact spelling the read surfaces report (p[3], tbl[1]/tr[1]/tc[1]/p[1]) or a stable p[id=\"…\"]",
      ),
    )
  }
}

///|
/// One scope segment: `kind[N]` with a canonical 1-based ordinal.
/// Returns the kind for the caller's state machine, or None when the
/// segment is not of that shape at all.
fn format_scope_segment_kind(segment : String) -> String? {
  guard segment.find("[") is Some(open) &&
    segment.has_suffix("]") &&
    open > 0 &&
    open + 1 < segment.length() - 1 else {
    return None
  }
  let kind = segment[:open].to_owned()
  guard kind is ("p" | "tbl" | "tr" | "tc") else { return None }
  let ordinal = segment[open + 1:segment.length() - 1].to_owned()
  guard ordinal[0] is ('1'..='9') else { return None }
  for ch in ordinal {
    guard ch is ('0'..='9') else { return None }
  }
  if ordinal.length() <= 6 {
    Some(kind)
  } else {
    None
  }
}

///|
fn format_property_json(value : Bool?) -> Json {
  match value {
    Some(state) => Json::boolean(state)
    None => Json::null()
  }
}

///|
fn format_report_json(
  file : String,
  output : String,
  text : String?,
  within : String?,
  range : (Int, Int)?,
  nth : Int?,
  bold : Bool?,
  italic : Bool?,
  underline : Bool?,
  color : String?,
  expect : Int?,
  allow_zero : Bool,
  dry_run : Bool,
  report : @office_docx.DocxFormatReport,
  changed : Bool,
  transaction~ : Json,
) -> Json {
  Json::object({
    "schema": Json::string("office.docx.format/1"),
    "file": Json::string(file),
    "format": Json::string("docx"),
    "output": Json::string(output),
    "mode": Json::string(report.mode()),
    "text": match text {
      Some(value) => Json::string(bounded_text(value, 160))
      None => Json::null()
    },
    "in": match within {
      Some(prefix) => Json::string(prefix)
      None => Json::null()
    },
    // When the scope was a stable p[id="…"], the scan path it resolved
    // to in THIS snapshot — both spellings, so the caller can re-anchor.
    "resolved_in": match report.resolved_within() {
      Some(path) => Json::string(path)
      None => Json::null()
    },
    "range": match range {
      Some((from, to)) => Json::string("\{from}:\{to}")
      None => Json::null()
    },
    "nth": match nth {
      Some(ordinal) => Json::number(ordinal.to_double())
      None => Json::null()
    },
    // The REQUEST, echoed as three-state properties: true set, false
    // cleared, null untouched.
    "bold": format_property_json(bold),
    "italic": format_property_json(italic),
    "underline": format_property_json(underline),
    "color": match color {
      Some(value) => Json::string(value)
      None => Json::null()
    },
    "expect": match expect {
      Some(count) => Json::number(count.to_double())
      None => Json::null()
    },
    "allow_zero": Json::boolean(allow_zero),
    "dry_run": Json::boolean(dry_run),
    // From the TRANSACTION, which knows whether bytes changed: a span
    // already carrying the request selects and plans nothing.
    "changed": Json::boolean(changed),
    "selected": Json::array(
      report.selected().map(ordinal => Json::number(ordinal.to_double())),
    ),
    "spans": Json::array(
      report
      .spans()
      .map(span => {
        Json::object({
          "path": Json::string(span.path()),
          "para_id": match span.para_id() {
            Some(id) => Json::string(id)
            None => Json::null()
          },
          "paragraph_anchor_status": Json::string(span.anchor_status()),
          "start": Json::number(span.start().to_double()),
          "end": Json::number(span.end().to_double()),
          "text": Json::string(bounded_text(span.text(), 160)),
        })
      }),
    ),
    "affected": Json::array(
      report.affected_paths().map(path => Json::string(path)),
    ),
    // The same paragraphs with their anchor judgments: after this
    // publish, the ordinal path may be the LAST time these paragraphs
    // are addressable by position, so the receipt hands the caller the
    // stable identity to re-anchor on.
    "affected_paragraphs": Json::array(
      report
      .affected()
      .map(entry => {
        Json::object({
          "path": Json::string(entry.path()),
          "para_id": match entry.para_id() {
            Some(id) => Json::string(id)
            None => Json::null()
          },
          "paragraph_anchor_status": Json::string(entry.anchor_status()),
        })
      }),
    ),
    "runs_changed": Json::number(report.runs_changed().to_double()),
    "runs_already_satisfied": Json::number(
      report.runs_already_satisfied().to_double(),
    ),
    "splits": Json::number(report.splits().to_double()),
    "byte_edits": Json::number(report.byte_edits().to_double()),
    "stories_scanned": Json::array([Json::string("/body")]),
    "transaction": transaction,
  })
}

///|
/// The reserve for what the preflight cannot measure yet — the
/// transaction record and the envelope's warnings — computed by
/// MEASURING a schema-accurate stub of the `office.transaction/2`
/// record this verb's transaction actually emits: the fixed fields with
/// the real paths, a validator-summary allowance, the one changed part
/// a format writes plus a small added/removed allowance, preservation's
/// unchanged COUNT (the record never lists the unchanged names), and an
/// allowance for the fixed publication warnings. A blanket constant
/// refused reports that would in fact have fit.
fn format_report_transaction_reserve(file : String, output : String) -> Int {
  let validator_entries : Array[Json] = []
  for _ in 0..<6 {
    validator_entries.push(
      Json::object({
        "name": Json::string("office-docx-format-readback-and-longer"),
        "passed": Json::boolean(true),
        "finding_count": Json::number(0),
      }),
    )
  }
  let stub : Json = Json::object({
    "schema": Json::string("office.transaction/2"),
    "format": Json::string("docx"),
    "input": Json::string(file),
    "output": Json::string(output),
    "mode": Json::string("path-based-atomic-replace"),
    "dry_run": Json::boolean(false),
    "changed": Json::boolean(true),
    "committed": Json::boolean(true),
    "original_size": Json::number(9007199254740991.0),
    "result_size": Json::number(9007199254740991.0),
    "replaced_existing": Json::boolean(true),
    "overwritten_size": Json::number(9007199254740991.0),
    "validations": Json::array(validator_entries),
    "preservation": Json::object({
      "whole_file_identical": Json::boolean(false),
      "manifest_enforced": Json::boolean(true),
      "changed": Json::array([Json::string("word/document.xml")]),
      "added": Json::array([
        Json::string("word/an-added-part-allowance.xml"),
        Json::string("word/another-added-part-allowance.xml"),
      ]),
      "removed": Json::array([
        Json::string("word/a-removed-part-allowance.xml"),
        Json::string("word/another-removed-part-allowance.xml"),
      ]),
      "unchanged_count": Json::number(9007199254740991.0),
      "zip_metadata_policy": Json::string("preserve-required-normalize-rest"),
    }),
  })
  let warnings_stub : Json = Json::array([
    Json::object({
      "code": Json::string("office.transaction.path_based_commit_semantics"),
      "message": Json::string(
        "publication uses path-based commit semantics on this platform: " +
        output,
      ),
    }),
    Json::object({
      "code": Json::string("office.transaction.path_based_commit_semantics"),
      "message": Json::string(
        "a second allowance entry for a platform publication warning: " + output,
      ),
    }),
  ])
  stub.stringify().length() + warnings_stub.stringify().length() + 2048
}

///|
fn ensure_format_report_budget(
  record : Json,
  maximum : Int,
) -> Unit raise @transaction.TransactionError {
  ignore(bounded_docx_payload(record, [], maximum)) catch {
    _ =>
      raise @transaction.transaction_failure(
        "office.format.report_too_large", "the JSON report for this format would exceed the output ceiling, so nothing was written; re-run without --json for the human summary, or narrow the selection with --in, --nth, or --range",
      )
  }
}

///|
async fn run_format(matches : @argparse.Matches) -> Unit {
  reject_duplicate_scalar_options(matches, [
    "text", "in", "range", "nth", "bold", "italic", "underline", "color", "expect",
  ])
  let file = required_value(matches, "file")
  let output = required_value(matches, "out")
  let mode = office_validate_output_mode(matches)
  guard file.to_lower().has_suffix(".docx") else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "office format requires a .docx document",
      ),
    )
  }
  guard output.to_lower().has_suffix(".docx") else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "the destination must end in .docx to match the edited format",
      ),
    )
  }
  let bold = format_on_off_option(matches, "bold")
  let italic = format_on_off_option(matches, "italic")
  let underline = format_on_off_option(matches, "underline")
  let color = optional_value(matches, "color")
  guard bold is Some(_) ||
    italic is Some(_) ||
    underline is Some(_) ||
    color is Some(_) else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "format requires at least one property: --bold, --italic, --underline (on|off), or --color RRGGBB",
      ),
    )
  }
  let text = match optional_value(matches, "text") {
    Some("") =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "--text must be non-empty; an empty needle matches every position",
        ),
      )
    other => other
  }
  let within = optional_value(matches, "in")
  match within {
    Some(scope) => format_validate_scope(scope)
    None => ()
  }
  let range = format_range_option(matches)
  // Exactly ONE selector: text finds occurrences, range names units.
  match (text, range) {
    (Some(_), Some(_)) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "--text and --range are different selectors; pass exactly one",
        ),
      )
    (None, None) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "format requires a selector: --text TEXT, or --range START:END with --in naming one paragraph",
        ),
      )
    _ => ()
  }
  match (range, within) {
    (Some(_), None) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "--range needs --in naming the ONE paragraph its units index into",
        ),
      )
    (Some(_), Some(scope)) => {
      // The scope must NAME A PARAGRAPH: a subtree like tbl[1] has no
      // unit axis for the range to index into, and the contradiction
      // refuses before any file is read.
      let last_segment = match scope.rev_find("/") {
        Some(slash) => scope[slash + 1:].to_owned()
        None => scope
      }
      guard last_segment.has_prefix("p[") else {
        raise CliFailure(
          @lib.protocol_error(
            "office.invalid_arguments",
            "--range needs --in ending at a paragraph (p[3], tbl[1]/tr[1]/tc[1]/p[1], or p[id=\"…\"]); '\{bounded_text(scope, 80)}' names no paragraph",
          ),
        )
      }
    }
    _ => ()
  }
  if range is Some(_) && optional_value(matches, "nth") is Some(_) {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "--nth orders TEXT occurrences; a --range names its units directly, so the two cannot combine",
      ),
    )
  }
  let allow_zero = matches.flags.get_or_default("allow-zero", false)
  let dry_run = matches.flags.get_or_default("dry-run", false)
  let nth = match optional_value(matches, "nth") {
    Some(_) => Some(bounded_decimal_argument(matches, "nth", 1, 1, 1000))
    None => None
  }
  let expect = match optional_value(matches, "expect") {
    Some(_) => Some(bounded_decimal_argument(matches, "expect", 0, 0, 1000))
    None => None
  }
  if expect is Some(_) && allow_zero {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "--expect and --allow-zero contradict each other; --expect 0 asserts zero on its own",
      ),
    )
  }
  // The property grammar is validated by the ENGINE (colour shape, the
  // nothing-touched refusal): the CLI passes the request through and
  // maps the typed refusal.
  let format = @docx.docx_direct_format(bold?, italic?, underline?, color?) catch {
    Unsupported(message~) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments",
          "invalid format request: \{message}",
        ),
      )
    _ =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "invalid format request",
        ),
      )
  }
  let options = @transaction.transaction_options(
    file,
    output_path=output,
    dry_run~,
    overwrite=matches.flags.get_or_default("overwrite", false),
  ) catch {
    error => raise transaction_failure(error)
  }
  // In JSON modes the report IS the deliverable, so its budget is part
  // of the transaction: refuse BEFORE publication if it cannot fit.
  let preflight : ((@office_docx.DocxFormatReport) -> Unit raise)? = match
    mode {
    Human => None
    JsonDocument | JsonLines =>
      Some((report : @office_docx.DocxFormatReport) => {
        ensure_format_report_budget(
          format_report_json(
            file,
            output,
            text,
            within,
            range,
            nth,
            bold,
            italic,
            underline,
            color,
            expect,
            allow_zero,
            dry_run,
            report,
            true,
            transaction=Json::null(),
          ),
          docx_cli_default_max_output_chars -
          format_report_transaction_reserve(file, output),
        )
      })
  }
  let result = @office_docx.transact_docx_format(
    options,
    format~,
    text?,
    within?,
    nth?,
    range?,
    expect?,
    allow_zero~,
    preflight?,
  ) catch {
    error => raise map_format_failure(error)
  }
  let record = format_report_json(
    file,
    output,
    text,
    within,
    range,
    nth,
    bold,
    italic,
    underline,
    color,
    expect,
    allow_zero,
    dry_run,
    result.report(),
    result.transaction().changed,
    transaction=result.transaction().to_json(),
  )
  match mode {
    JsonDocument | JsonLines => {
      let warnings = []
      for warning in result.transaction().warning_records() {
        warnings.push(warning)
      }
      let payload = bounded_docx_payload(
        record, warnings, docx_cli_default_max_output_chars,
      ) catch {
        _ =>
          raise CliFailure(
            @lib.protocol_error(
              "office.format.report_internal",
              "internal: the format report exceeded the output ceiling despite the preflight; the edit itself completed and \{output} is valid",
            ),
          )
      }
      println(checked_office_json_output(payload))
    }
    Human => {
      let verb = if dry_run { "would format" } else { "formatted" }
      println(
        "format: " +
        verb +
        " \{result.report().spans().length()} span(s) across \{result.report().affected_paths().length()} paragraph(s) -> " +
        human_text(output, 160),
      )
      for warning in result.transaction().warning_records() {
        println(
          "warning [\{human_text(warning.code, 160)}]: \{human_text(warning.message, 320)}",
        )
      }
    }
  }
}

///|
/// Map a transaction-layer failure to the CLI contract, preserving the
/// typed codes the transaction raises (`office.format.no_match`,
/// `office.format.expect_mismatch`, the planner's refusal slugs, and
/// the shared preflight's protection codes).
fn map_format_failure(error : Error) -> CliFailure {
  match error {
    CliFailure(_) as failure => failure
    @transaction.TransactionError(_) as failure => transaction_failure(failure)
    _ => unexpected_cli_failure("office.format.failed", "format", error)
  }
}