// `office replace` — the transactional find-and-replace verb (N2b).
//
// The CLI half owns REQUEST policy: flag validation, the contradictory
// flag pair, the control-character rejection on new text, and the
// output contract. Selection and refusal semantics live in the engine;
// publication and readback live in the transaction. Nothing here
// decides what is editable.

///|
fn replace_command() -> @argparse.Command {
  let summary = match @lib.find_capability_command("replace") {
    Some(command) => command.summary
    None =>
      "Replace literal text in a DOCX, refusing wherever an edit would not survive"
  }
  Command(
    "replace",
    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",
        about="literal text to replace — never a regular expression or wildcard; matched over the reader PROJECTION, so it spans run boundaries",
      ),
      OptionArg(
        "with",
        long="with",
        about="replacement text; empty deletes the match. Control characters including \\t and \\n are rejected — no silent break or tab synthesis",
      ),
      OptionArg(
        "in",
        long="in",
        about="restrict to a body-relative subtree, e.g. p[3] or tbl[1]",
      ),
      OptionArg(
        "nth",
        long="nth",
        about="select ONE candidate by the ordinal `office find` reports (1-based, counted over ALL candidates including restricted ones)",
      ),
      OptionArg(
        "expect",
        long="expect",
        about="assert exactly N replacements are selected; a mismatch refuses before anything is written",
      ),
    ],
    flags=[
      FlagArg(
        "allow-zero",
        long="allow-zero",
        about="treat zero matches 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(),
    ],
  )
}

///|
/// Reject control characters in replacement text at the REQUEST
/// boundary. v1 makes no structural text: a `\t` or `\n` in `--with`
/// would either be entity-escaped into literal control characters or
/// silently become break/tab elements, and both are surprises. The
/// engine itself permits XML-valid tab/LF/CR — this is verb policy, the
/// same split `set_run_text` uses.
fn replace_reject_control_text(
  text : String,
  flag : String,
) -> Unit raise CliFailure {
  for character in text {
    let code = character.to_int()
    if code < 0x20 {
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments",
          "\{flag} must not contain control characters (found U+\{code}); v1 neither matches nor makes structural breaks and tabs from text — use `office find` to locate atom content",
        ),
      )
    }
  }
}

///|
fn replace_report_json(
  file : String,
  output : String,
  needle : String,
  replacement : String,
  within : String?,
  nth : Int?,
  expect : Int?,
  allow_zero : Bool,
  dry_run : Bool,
  report : @office_docx.DocxFindReplaceReport,
  changed : Bool,
  transaction~ : Json,
) -> Json {
  Json::object({
    "schema": Json::string("office.docx.replace/1"),
    "file": Json::string(file),
    "format": Json::string("docx"),
    "output": Json::string(output),
    "text": Json::string(bounded_text(needle, 160)),
    "with": Json::string(bounded_text(replacement, 160)),
    "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()
    },
    "nth": match nth {
      Some(ordinal) => Json::number(ordinal.to_double())
      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 — the
    // receipt's replaced count cannot say: replacing text with itself
    // selects a candidate and plans no bytes.
    "changed": Json::boolean(changed),
    "selected": Json::array(
      report.selected().map(ordinal => Json::number(ordinal.to_double())),
    ),
    "replaced": Json::number(report.replaced().to_double()),
    "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()),
        })
      }),
    ),
    // The matches payload the dry-run contract promises: the selected
    // candidates in find's own entry shape, ranges and runs included —
    // emitted on every run, since a real run's caller deserves the same
    // visibility. Bounded at 100 entries: the candidate CEILING bounds
    // count, not bytes, and a long needle at high multiplicity would
    // otherwise carry megabytes of match text into the report.
    "matches": {
      let shown = if report.matches().length() > 100 {
        100
      } else {
        report.matches().length()
      }
      let entries : Array[Json] = []
      for index in 0.. 100),
    "stories_scanned": Json::array([Json::string("/body")]),
    "transaction": transaction,
  })
}

///|
/// Slack reserved for the fields the preflight cannot know yet: the
/// transaction report (fixed shape — validator names, sizes, and the
/// touched part paths; a body replace changes one part) serialized where
/// the preflight measured `null`. Worst case under current limits: two
/// 4096-character paths escape to ~48 KiB, the changed part path to
/// ~6 KiB, plus the fixed structure — roughly 56 KiB, inside the
/// reserve with headroom.
let replace_report_transaction_slack : Int = 64 * 1024

///|
/// Refuse a replace whose JSON report would exceed the output ceiling.
/// Raised as a transaction failure because it runs INSIDE the
/// transaction, before publication — nothing is written on refusal.
fn ensure_replace_report_budget(
  record : Json,
  maximum : Int,
) -> Unit raise @transaction.TransactionError {
  ignore(bounded_docx_payload(record, [], maximum)) catch {
    _ =>
      raise @transaction.transaction_failure(
        "office.replace.report_too_large", "the JSON report for this replace would exceed the output ceiling, so nothing was written; re-run without --json for the human summary, or narrow the scope with --in or --nth",
      )
  }
}

///|
async fn run_replace(matches : @argparse.Matches) -> Unit {
  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 replace 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 needle = match optional_value(matches, "text") {
    Some(value) if value != "" => value
    Some(_) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "replace requires a non-empty --text; an empty needle matches every position",
        ),
      )
    None =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "replace requires --text with the literal text to look for",
        ),
      )
  }
  let replacement = match optional_value(matches, "with") {
    Some(value) => value
    None =>
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "replace requires --with (pass an empty value to delete the match)",
        ),
      )
  }
  // The needle too: a control character in `--text` can match a
  // STRUCTURAL atom (a tab projects as U+0009), and replacing that atom
  // is exactly the structural mutation v1 forbids from text.
  replace_reject_control_text(needle, "--text")
  replace_reject_control_text(replacement, "--with")
  // `--in` is NOT run through `format_validate_scope` here, unlike
  // `format` and `find`. A stable `p[id="…"]` scope still refuses typed
  // — the engine asks the same shape predicate this file does — but a
  // non-canonical ORDINAL path (`p[3]/tr[1]`) reaches the prefix
  // matcher, matches nothing, and with `--allow-zero` publishes a
  // byte-identical output. Narrowing it here once rejected paths the
  // reader itself emits and an empty `--in`, so the widening is being
  // done properly under issue #525 rather than re-attempted inside this
  // one.
  let within = optional_value(matches, "in")
  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
  }
  // The contradiction is rejected HERE, before any file is read:
  // `--expect N` demands exactly N replacements while `--allow-zero`
  // permits none, so together they only agree when N is zero, which
  // `--expect 0` already says on its own.
  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",
      ),
    )
  }
  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: the preflight builds the record as it will be
  // emitted (transaction fields still unknown — measured as null and
  // paid for by the slack) and refuses BEFORE publication if it cannot
  // fit. Human mode prints no report and takes no preflight.
  let preflight : ((@office_docx.DocxFindReplaceReport) -> Unit raise)? = match
    mode {
    Human => None
    JsonDocument | JsonLines =>
      Some((report : @office_docx.DocxFindReplaceReport) => {
        ensure_replace_report_budget(
          replace_report_json(
            file,
            output,
            needle,
            replacement,
            within,
            nth,
            expect,
            allow_zero,
            dry_run,
            report,
            true,
            transaction=Json::null(),
          ),
          docx_cli_default_max_output_chars - replace_report_transaction_slack,
        )
      })
  }
  let result = @office_docx.transact_docx_find_replace(
    options,
    needle~,
    replacement~,
    within?,
    nth?,
    expect?,
    allow_zero~,
    preflight?,
  ) catch {
    error => raise map_replace_failure(error)
  }
  let record = replace_report_json(
    file,
    output,
    needle,
    replacement,
    within,
    nth,
    expect,
    allow_zero,
    dry_run,
    result.report(),
    result.transaction().changed,
    transaction=result.transaction().to_json(),
  )
  match mode {
    // Both JSON modes emit through the bounded pipeline the read
    // commands share. The in-transaction preflight already guaranteed
    // the record fits (it measured this exact record with the
    // transaction fields as null, against the ceiling minus the slack
    // those fields are bounded by), so a failure here is an accounting
    // bug, not a user outcome — the message says so, and names the
    // already-published output.
    JsonDocument | JsonLines => {
      let payload = bounded_docx_payload(
        record,
        [],
        docx_cli_default_max_output_chars,
      ) catch {
        _ =>
          raise CliFailure(
            @lib.protocol_error(
              "office.replace.report_internal",
              "internal: the replace 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 replace" } else { "replaced" }
      println(
        "replace: " +
        verb +
        " \{result.report().replaced()} occurrence(s) across \{result.report().affected_paths().length()} paragraph(s) -> " +
        human_text(output, 160),
      )
    }
  }
}

///|
/// Map a transaction-layer failure to the CLI contract, preserving the
/// typed codes the transaction raises (`office.replace.no_match`,
/// `office.replace.expect_mismatch`, refusal reasons from the engine).
fn map_replace_failure(error : Error) -> CliFailure {
  match error {
    CliFailure(_) as failure => failure
    @transaction.TransactionError(_) as failure => transaction_failure(failure)
    _ => unexpected_cli_failure("office.replace.failed", "replace", error)
  }
}