///|
/// The `office edit` command: literal find & replace over the text of an
/// EXISTING DOCX through a strict docx.edit/1 script. Distinct from
/// `office template` (which substitutes `{{key}}` placeholders from a
/// data document) and from `office annotate` (which touches only
/// comments): the needle is ordinary literal text, matched across run
/// boundaries, and the replacement is spliced into the source snapshot
/// byte-span by byte-span so all unrelated OOXML is preserved.

///|
/// Ceiling on the edit script document `office edit` will read.
let office_edit_max_script_bytes : Int = 4 * 1024 * 1024

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

///|
/// The office.docx.edit/1 record, shared by the success envelope and the
/// strict-refusal failure details.
fn edit_report_json(
  file : String,
  script_file : String,
  output : String,
  report : @office_docx.DocxEditReport,
  schema~ : String,
  transaction? : Json,
) -> Json {
  let v2 = schema == @office_docx.SCHEMA_DOCX_EDIT_V2
  let optional_text = (value : String?) => {
    match value {
      Some(text) => Json::string(bounded_text(text, 160))
      None => Json::null()
    }
  }
  let results = report.results.map(entry => {
    // A revision op has no needle and a replace_text op has no selector, so
    // each entry carries the whole key set with the inapplicable half null.
    // A fixed shape is what lets an agent read `results[]` without first
    // branching on `op`.
    let set_run = entry.op == "set_run_text"
    let revision = entry.op != "replace_text" && !set_run
    let fields : Map[String, Json] = {
      "op": Json::string(entry.op),
      "find": if entry.op == "replace_text" {
        Json::string(bounded_text(entry.find, 160))
      } else {
        Json::null()
      },
      "replace": if entry.op == "replace_text" {
        Json::string(bounded_text(entry.replace, 160))
      } else {
        Json::null()
      },
      "selector": if revision {
        Json::object({
          "id": optional_text(entry.selector_id),
          "author": optional_text(entry.selector_author),
          "type": optional_text(entry.selector_type),
          "all": Json::boolean(entry.selector_all),
        })
      } else {
        Json::null()
      },
      "matched": Json::number(entry.matched.to_double()),
      "replacements": Json::number(entry.replacements.to_double()),
      "resolved": Json::number(entry.resolved.to_double()),
    }
    fields["occurrence"] = match entry.occurrence {
      Some(ordinal) => Json::number(ordinal.to_double())
      None => Json::null()
    }
    if v2 {
      // the /2 result shape carries the addressed-op keys on EVERY
      // entry, null when inapplicable, so agents never branch on `op`
      // to learn the key set
      fields["at"] = optional_text(entry.at)
      // Present only when a stable p[id="…"] head resolved: the ordinal
      // address it landed on in this transaction's snapshot.
      match entry.resolved_at {
        Some(path) => fields["resolved_at"] = Json::string(path)
        None => ()
      }
      fields["expect"] = optional_text(entry.expect)
      fields["text"] = optional_text(entry.text)
    }
    Json::object(fields)
  })
  let fields : Map[String, Json] = {
    "schema": Json::string(
      if v2 {
        @office_docx.SCHEMA_DOCX_EDIT_RESULT_V2
      } else {
        @office_docx.SCHEMA_DOCX_EDIT_RESULT
      },
    ),
    "file": Json::string(bounded_text(file, 160)),
    "format": Json::string("docx"),
    "script_file": Json::string(bounded_text(script_file, 160)),
    "output": Json::string(bounded_text(output, 160)),
    "ops_applied": Json::number(report.ops_applied.to_double()),
    "replacements": Json::number(report.replacements.to_double()),
    "revisions_resolved": Json::number(report.revisions_resolved.to_double()),
    "results": Json::array(results),
    "unmatched": edit_findings_json(report.unmatched),
    "unmatched_total": Json::number(report.unmatched_total.to_double()),
    "unsupported": edit_findings_json(report.unsupported),
    "unsupported_total": Json::number(report.unsupported_total.to_double()),
    "conflicts": edit_findings_json(report.conflicts),
    "conflicts_total": Json::number(report.conflicts_total.to_double()),
    "locations": edit_findings_json(report.locations),
    "locations_truncated": Json::boolean(report.locations_truncated),
    "stories_scanned": Json::array(
      report.stories_scanned.map(story => Json::string(story)),
    ),
  }
  match transaction {
    Some(value) => fields["transaction"] = value
    None => ()
  }
  Json::object(fields)
}

///|
async fn run_edit(matches : @argparse.Matches) -> Unit {
  let file = required_value(matches, "file")
  let script_file = required_value(matches, "script")
  let output = required_value(matches, "out")
  let allow_unmatched = matches.flags.get_or_default("allow-unmatched", false)
  let mode = office_validate_output_mode(matches)
  guard file.to_lower().has_suffix(".docx") else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "office edit requires a .docx document",
      ),
    )
  }
  guard output.to_lower().has_suffix(".docx") else {
    raise CliFailure(
      @lib.protocol_error(
        "office.invalid_arguments", "--out must end in .docx to match the edited format",
      ),
    )
  }
  let script_bytes = read_bounded_file(
    script_file,
    office_edit_max_script_bytes,
    "office.edit.script_read_failed",
    "edit script",
    limit_code="office.edit.resource_limit",
  )
  let script_text = @utf8.decode(script_bytes, ignore_bom=true) catch {
    _ =>
      raise CliFailure(
        @lib.protocol_error(
          "office.edit.invalid_script", "edit script is not valid UTF-8",
        ),
      )
  }
  let script = @office_docx.parse_docx_edit_script(script_text) catch {
    DocxEditParseError(reason) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.edit.invalid_script",
          "invalid edit script: " + 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)
  }
  let result = @office_docx.transact_docx_edit(
    options,
    script,
    allow_unmatched~,
  ) catch {
    @office_docx.DocxEditRefused(report) => {
      // strict findings: precedence unsupported > conflicts > unmatched.
      // A script is entirely replace_text or entirely revision ops, so the
      // op family names the failure precisely rather than generically.
      let revisions = report.results.search_by(entry => {
          entry.op != "replace_text"
        })
        is Some(_)
      let (code, message) = if report.unsupported_total > 0 {
        if revisions {
          (
            "office.edit.unsupported_revision", "a selected tracked change is outside the resolvable set",
          )
        } else {
          (
            "office.edit.unsupported_context", "a match reaches content the edit cannot rewrite safely",
          )
        }
      } else if report.conflicts_total > 0 {
        if revisions {
          (
            "office.edit.conflicting_revisions", "two operations disagree about a tracked change, or one revision is nested in another",
          )
        } else {
          (
            "office.edit.overlapping_matches", "two operations match overlapping text",
          )
        }
      } else if revisions {
        (
          "office.edit.unmatched_revision", "a revision selector matched no tracked change",
        )
      } else {
        (
          "office.edit.unmatched_find", "an operation did not find the occurrences it requires",
        )
      }
      raise CliFailure(
        @lib.protocol_error(
          code,
          message,
          details=edit_report_json(
            file,
            script_file,
            output,
            report,
            schema=script.schema(),
          ),
        ),
      )
    }
    @transaction.TransactionError(..) as error =>
      raise transaction_failure(error)
    error if @async.is_being_cancelled() => raise error
    error =>
      raise unexpected_cli_failure(
        "office.edit.failed", "edit transaction", error,
      )
  }
  let record = edit_report_json(
    schema=script.schema(),
    file,
    script_file,
    output,
    result.report,
    transaction=result.transaction.to_json(),
  )
  match mode {
    JsonDocument => println(@lib.output_success(record).stringify(indent=2))
    JsonLines => println(@lib.output_success(record).stringify())
    Human => {
      // A script is entirely one op family, so the line reports the unit that
      // family actually produces rather than always printing both.
      let revisions = result.report.results.search_by(entry => {
          entry.op is ("accept_revision" | "reject_revision")
        })
        is Some(_)
      let done = if revisions {
        "\{result.report.revisions_resolved} revision(s) resolved"
      } else {
        "\{result.report.replacements} replacement(s)"
      }
      println(
        "edit: " +
        done +
        " across \{result.report.ops_applied} op(s) -> " +
        human_text(output, 160),
      )
    }
  }
}

///|
fn edit_command() -> @argparse.Command {
  let summary = match @lib.find_capability_command("edit") {
    Some(command) => command.summary
    None =>
      "Replace literal text in an existing DOCX through a strict edit script"
  }
  Command(
    "edit",
    about=summary,
    positionals=[
      PositionArg(
        "file",
        about="existing .docx package (never modified in place)",
        num_args=@argparse.ValueRange::single(),
      ),
      PositionArg(
        "script",
        about="docx.edit/1 JSON document",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
    options=[
      OptionArg(
        "out",
        long="out",
        about="destination .docx path (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-unmatched",
        long="allow-unmatched",
        about="report ops that found too few occurrences instead of failing",
      ),
      FlagArg("json", long="json", about="print office.output/1 JSON"),
      FlagArg("jsonl", long="jsonl", about="print one office.output/1 line"),
    ],
  )
}