// FR-33 — RepairPlan: what was repaired, why, how risky it is, and
// whether the caller may act on it.
//
// FR-33, the *why*: `Repair` says `R005 at 1:21`; that is a diagnosis, not a
// decision, because the caller still has to know what R005 does to the text.
// A `PlanOp` adds the action the rule performed (remove, insert, replace,
// merge, …), whether information was lost, and a *class* of confidence.
//
// FR-33, the self-healing loop: `analyze` never raises. A gate that throws
// forces every caller to write a `try` around it and then re-derive the retry
// message from the error, which is exactly the code that gets written slightly
// differently in every integration. `analyze` returns the refusal inside the
// plan (decision `Invalid`/`LossyRepair`/`Ambiguous` plus `retry_hint`), so the
// loop is:
//
//   let plan = @mend.analyze(arguments, options=@mend.Options::for_agent())
//   if plan.is_executable() { execute(plan.value) } else { send(plan.prompt()) }
//
// Confidence is deliberately a class, not a probability: JSONMend has no
// calibration data, and a number that looks like a probability but is not one
// is worse than a label. `certain` means the value is provably unchanged;
// `probable` means the repair is the only one the rules allow but it did
// invent, drop or truncate content; `ambiguous` means two readings exist.

///|
/// What a repair did to the text (FR-33).
pub(all) enum PlanAction {
  /// Nothing was rewritten (`R001`, `R002`: a fence or a comment was dropped).
  Remove
  /// Something was added (`R006`, `R007`, `R008`, `R009`, `R010`).
  Insert
  /// A token was rewritten in place (`R003`, `R011`, `R012`, `R015`).
  Replace
  /// A token was re-spelled without changing what it denotes (`R003`, `R011`,
  /// `R012`, `R013`, `R014`, `R022`).
  Normalize
  /// Two values became one (`R018`, `R019`).
  Merge
  /// A bare word was turned into a string by adding quotes (`R004`).
  Wrap
}

///|
/// Explicit `Eq` implementation for `PlanAction`.
pub impl Eq for PlanAction with fn equal(self : PlanAction, other : PlanAction) -> Bool {
  self.name() == other.name()
}

///|
/// Explicit `Eq` extension for `PlanAction`.
pub extend PlanAction with Eq::{not_equal, equal}

///|
/// Stable lowercase name, e.g. `remove`.
pub fn PlanAction::name(self : PlanAction) -> String {
  match self {
    Remove => "remove"
    Insert => "insert"
    Replace => "replace"
    Normalize => "normalize"
    Merge => "merge"
    Wrap => "wrap"
  }
}

///|
/// The action a rule performs (FR-33). Total by construction: adding a
/// `RepairCode` without a case here is a compile error.
pub fn plan_action(code : RepairCode) -> PlanAction {
  match code {
    StripFence => Remove
    StripComment => Remove
    NormalizeQuote => Normalize
    QuoteBareWord => Wrap
    RemoveExtraComma => Remove
    InsertComma => Insert
    InsertColon => Insert
    FillNull => Insert
    CloseContainer => Insert
    CloseString => Insert
    FixNumber => Normalize
    ConvertLiteral => Replace
    EscapeControl => Normalize
    FixEscape => Normalize
    MismatchedCloser => Replace
    DropUnexpected => Remove
    DropExtraCloser => Remove
    MergeDuplicateKey => Merge
    ConcatStrings => Merge
    DropEllipsis => Remove
    SkipProse => Remove
    NormalizeLiteral => Normalize
  }
}

///|
/// One repair, described as an operation on the input (FR-33).
pub(all) struct PlanOp {
  /// `R` code, e.g. `R005`.
  rule : String
  /// Stable name of the rule, e.g. `RemoveExtraComma`.
  name : String
  /// What the rule did.
  action : PlanAction
  /// Where in the input.
  position : Position
  severity : Severity
  /// The second reading, when there is one.
  ambiguity : Ambiguity
  /// `certain`, `probable` or `ambiguous` (see the file header).
  confidence_class : String
  /// `1.0` for `certain`, `0.75` for `probable`, `0.5` for `ambiguous`. A class
  /// label with a numeric spelling for machine consumers, not a probability.
  confidence : Double
}

///|
/// Explicit `Eq` implementation for `PlanOp`.
pub impl Eq for PlanOp with fn equal(self : PlanOp, other : PlanOp) -> Bool {
  self.rule == other.rule && self.position.offset == other.position.offset
}

///|
/// Explicit `Eq` extension for `PlanOp`.
pub extend PlanOp with Eq::{not_equal, equal}

///|
/// Whether the operation threw information away: true for a `Warning` record.
pub fn PlanOp::information_loss(self : PlanOp) -> Bool {
  self.severity is Warning
}

///|
/// One repair record as a plan operation (FR-33).
pub fn PlanOp::of_repair(r : Repair) -> PlanOp {
  let (class, value) = if r.ambiguity.is_ambiguous() {
    ("ambiguous", 0.5)
  } else if r.severity is Warning {
    ("probable", 0.75)
  } else {
    ("certain", 1.0)
  }
  {
    rule: r.code.code(),
    name: r.code.name(),
    action: plan_action(r.code),
    position: r.position,
    severity: r.severity,
    ambiguity: r.ambiguity,
    confidence_class: class,
    confidence: value,
  }
}

///|
/// The full analysis of one input: the repairs, the risk, the decision and the
/// refusal, if any (FR-33, FR-33).
pub(all) struct RepairPlan {
  /// Repaired text; empty when the input produced no value.
  text : String
  /// The parsed value, when the caller can use it. `None` on a refusal.
  value : Json?
  /// One entry per repair record, in the order they happened.
  repairs : Array[PlanOp]
  /// Whether the repair log was cut short by `max_repairs`.
  repairs_truncated : Bool
  exactness : Exactness
  /// Whether the input was already well-formed JSON and the only thing JSONMend
  /// had to decide was what its repeated member names mean (Q-26).
  valid_json : Bool
  /// Whether any repair admitted a second reading (FR-27).
  ambiguous : Bool
  completeness : Completeness
  /// What the caller may do (FR-34).
  decision : ExecutionDecision
  /// The refusal as feedback for the model, when there was one (FR-28).
  retry_hint : RetryHint?
  /// `E` code of the refusal, or `""`.
  error_code : String
  /// One line about the refusal, or `""`.
  error_message : String
  /// First input fraction, in per mille, at which the document needed no
  /// further closing, or `-1` when it was not measured (`analyze(…, readiness=step)`).
  readiness_permille : Int32
  /// Declared members of the skeleton the value was checked against, or `0`
  /// when no skeleton was given (FR-36).
  schema_total : Int32
  /// Declared members that hold a value of the declared kind.
  schema_satisfied : Int32
  /// Required members that the value does not provide, as dotted paths.
  schema_missing : Array[String]
}

///|
/// Whether the caller may execute the plan (FR-34).
pub fn RepairPlan::is_executable(self : RepairPlan) -> Bool {
  self.decision.is_safe() && self.value is Some(_)
}

///|
/// What a model should do differently, one sentence per rule that fired
/// (FR-33). Ordered by first occurrence and de-duplicated, because a truncated
/// document fires `R010` and `R009` and the two sentences are one instruction.
pub fn RepairPlan::suggestions(self : RepairPlan) -> Array[String] {
  let out : Array[String] = []
  for op in self.repairs {
    let advice = advice_for(op)
    if advice != "" && !out.contains(advice) {
      out.push(advice)
    }
  }
  out
}

///|
/// The instruction one operation implies (FR-33).
///
/// A second reading is more specific than the rule that produced it: `R006`
/// between two elements usually means "you forgot a comma", but when the two
/// strings may equally be one concatenated string the useful sentence is the
/// ambiguity, not the rule.
pub fn advice_for(op : PlanOp) -> String {
  match op.ambiguity {
    AdjacentStrings =>
      "Separate the two adjacent strings with a comma, or merge them into one string."
    ConcatOperator =>
      "Remove the `+`, or merge the two strings into one string."
    BareWord => "Quote the bare word, or spell it as a JSON literal."
    TrailingContent => "Return exactly one JSON value."
    Unambiguous => rule_advice(code_of_name(op.name))
  }
}

///|
/// The rule a stable name refers to; `RepairCode::name` is total, so this
/// inverse is used only to avoid carrying the enum in the plan as well.
fn code_of_name(name : String) -> RepairCode {
  let codes : Array[RepairCode] = [
    StripFence,
    StripComment,
    NormalizeQuote,
    QuoteBareWord,
    RemoveExtraComma,
    InsertComma,
    InsertColon,
    FillNull,
    CloseContainer,
    CloseString,
    FixNumber,
    ConvertLiteral,
    EscapeControl,
    FixEscape,
    MismatchedCloser,
    DropUnexpected,
    DropExtraCloser,
    MergeDuplicateKey,
    ConcatStrings,
    DropEllipsis,
    SkipProse,
    NormalizeLiteral,
  ]
  for c in codes {
    if c.name() == name {
      return c
    }
  }
  CloseContainer
}

///|
/// The instruction a refusal implies for the model that produced the input
/// (FR-28, FR-33). Empty for a rule with nothing specific to say.
fn rule_advice(code : RepairCode) -> String {
  match code {
    CloseString => "Do not truncate string values."
    CloseContainer => "Close every object and array you open."
    MismatchedCloser | DropExtraCloser | DropUnexpected =>
      "Emit well-nested JSON: every `{` needs its `}` and every `[` its `]`."
    InsertComma | RemoveExtraComma =>
      "Fix the commas: one between members and elements, none before a closer."
    InsertColon => "Write every object member as `\"key\": value`."
    FillNull => "Do not leave a value out after `:`."
    QuoteBareWord => "Quote every string, including member names."
    NormalizeQuote => "Use double quotes, not single quotes, around strings."
    FixNumber | NormalizeLiteral | ConvertLiteral =>
      "Use JSON values only: `true`, `false`, `null`, and numbers without a leading `+` or a trailing dot."
    EscapeControl | FixEscape =>
      "Escape control characters and stray backslashes inside strings."
    MergeDuplicateKey => "Do not repeat an object member."
    ConcatStrings => "Do not concatenate strings with `+`."
    StripFence | SkipProse | StripComment | DropEllipsis =>
      "Return JSON only: no code fences, comments, prose or `...`."
  }
}

///|
/// Feedback ready to be sent back to the model that produced the input
/// (FR-28, FR-33).
///
/// A refusal carries a `RetryHint`, which names the position and quotes the
/// window. A plan that was *accepted* but is not executable — a lossy repair
/// under `permissive()` — has no hint, because nothing was refused; the prompt
/// is then derived from the rules that fired, which is the same instruction a
/// refusal would have produced. A plan that was accepted and is executable
/// returns a sentence saying so, so that a caller never has to branch.
pub fn RepairPlan::prompt(self : RepairPlan) -> String {
  match self.retry_hint {
    Some(h) => h.to_prompt()
    None =>
      if self.is_executable() {
        "The output was accepted; no retry is needed."
      } else {
        let sb = StringBuilder()
        sb.write_string("JSONMend refused the output (")
        sb.write_string(self.decision.name())
        if self.error_message != "" {
          sb.write_string(": ")
          sb.write_string(self.error_message)
        }
        sb.write_string(").")
        for line in self.suggestions() {
          sb.write_string("\n")
          sb.write_string(line)
        }
        sb.write_string(
          "\nRe-emit the arguments as valid JSON with exactly one reading.",
        )
        sb.to_string()
      }
  }
}

///|
/// A one line summary for logs: `lossy_repair exactness=lossy repairs=2`.
pub fn RepairPlan::summary(self : RepairPlan) -> String {
  let sb = StringBuilder()
  sb.write_string(self.decision.name())
  sb.write_string(" exactness=")
  sb.write_string(self.exactness.name())
  sb.write_string(" repairs=")
  sb.write_string(self.repairs.length().to_string())
  if self.ambiguous {
    sb.write_string(" ambiguous")
  }
  if self.readiness_permille >= 0 {
    sb.write_string(" syntax-ready=")
    sb.write_string(self.readiness_permille.to_string())
    sb.write_string("/1000")
  }
  if self.schema_total > 0 {
    sb.write_string(" schema=")
    sb.write_string(self.schema_satisfied.to_string())
    sb.write_string("/")
    sb.write_string(self.schema_total.to_string())
  }
  sb.to_string()
}

///|
/// The plan for an accepted result.
fn plan_of_final(
  fin : FinalResult,
  input : String,
  step : Int32,
  schema : Schema?,
) -> RepairPlan {
  let ops : Array[PlanOp] = []
  for r in fin.repairs {
    ops.push(PlanOp::of_repair(r))
  }
  let readiness = if step > 0 {
    syntax_readiness_permille(input, step~)
  } else {
    -1
  }
  let verdict = check_schema(schema, fin.value)
  let decision = decide(ops.length(), fin.exactness, fin.ambiguous)
  {
    text: fin.text,
    value: Some(fin.value),
    repairs: ops,
    repairs_truncated: fin.repairs_truncated,
    exactness: fin.exactness,
    valid_json: input_was_valid_json(ops),
    ambiguous: fin.ambiguous,
    completeness: fin.completeness,
    decision: verdict.decision(decision),
    retry_hint: None,
    error_code: if verdict.ok {
      ""
    } else {
      "E070"
    },
    error_message: verdict.message,
    readiness_permille: readiness,
    schema_total: verdict.total,
    schema_satisfied: verdict.satisfied,
    schema_missing: verdict.missing,
  }
}

///|
/// Whether the input was already well-formed JSON, so that the only thing
/// JSONMend had to decide was what a repeated member name means (Q-26).
///
/// `R018` is the rule that fires on text a strict JSON reader accepts: RFC 8259
/// permits a repeated name and leaves "which one wins" to the consumer. Every
/// other rule fires on text that is not JSON yet. So "no repair at all" and
/// "only duplicate-key normalisation" are exactly the cases in which
/// *syntactically valid* and *safe to execute* come apart, which is the
/// distinction this field exists to report. The claim is checked against the
/// independent corpus by `code/tests/eval/run_independent.py`.
fn input_was_valid_json(ops : Array[PlanOp]) -> Bool {
  for op in ops {
    if op.rule != "R018" {
      return false
    }
  }
  true
}

///|
/// Whether the value JSONMend hands over was produced by rewriting the input:
/// `true` as soon as any repair was recorded, including the ones that only
/// normalise notation.
pub fn RepairPlan::normalized(self : RepairPlan) -> Bool {
  self.repairs.length() > 0
}

///|
/// Which kind of second reading the plan admits (Q-26).
///
/// `semantic` — two values became one (`merge`), so two consumers that disagree
/// about repeated member names would end up with different values; the plan
/// cannot be executed without choosing for them.
/// `syntactic` — the notation could be read two ways, but the value is the same
/// under both readings.
/// `none` — one reading only.
pub fn RepairPlan::ambiguity_kind(self : RepairPlan) -> String {
  let mut syntactic = false
  for op in self.repairs {
    match op.action {
      // A merge is only a second *meaning* when the two values differ. Merging
      // `{"a":1,"a":1}` loses nothing and both readings give the same value, so
      // the record is Info and this is not an ambiguity at all.
      Merge =>
        if op.information_loss() {
          return "semantic"
        } else if op.ambiguity.is_ambiguous() {
          syntactic = true
        }
      _ => if op.ambiguity.is_ambiguous() { syntactic = true }
    }
  }
  if syntactic {
    "syntactic"
  } else {
    "none"
  }
}

///|
/// `allowed` when the plan may be handed to a tool, `blocked` otherwise.
pub fn RepairPlan::execution(self : RepairPlan) -> String {
  if self.is_executable() {
    "allowed"
  } else {
    "blocked"
  }
}

///|
/// What a schema check adds to a plan: the counts, and the reason when the
/// value does not satisfy the skeleton (FR-36).
priv struct SchemaCheck {
  total : Int32
  satisfied : Int32
  missing : Array[String]
  ok : Bool
  message : String
}

///|
/// `SchemaCheck::decision`: the parse decision, downgraded to `Invalid` when the
/// skeleton is not satisfied.
fn SchemaCheck::decision(
  self : SchemaCheck,
  base : ExecutionDecision,
) -> ExecutionDecision {
  if self.ok {
    base
  } else {
    base.worse_of(Invalid)
  }
}

///|
/// Check a value against an optional skeleton, turning the two failure modes
/// (missing required member, wrong kind) into one sentence.
fn check_schema(schema : Schema?, value : Json) -> SchemaCheck {
  match schema {
    None => { total: 0, satisfied: 0, missing: [], ok: true, message: "", }
    Some(s) => {
      let v = s.evaluate(value)
      let sb = StringBuilder()
      if !v.ok {
        sb.write_string(
          "the value does not satisfy the declared skeleton: " +
          v.satisfied.to_string() +
          "/" +
          v.total.to_string() +
          " declared members",
        )
        if v.missing.length() > 0 {
          sb.write_string("; missing ")
          for i = 0; i < v.missing.length(); i = i + 1 {
            if i > 0 {
              sb.write_string(", ")
            }
            sb.write_string(v.missing[i])
          }
        }
        if v.mismatched.length() > 0 {
          sb.write_string("; wrong kind ")
          for i = 0; i < v.mismatched.length(); i = i + 1 {
            if i > 0 {
              sb.write_string(", ")
            }
            let m = v.mismatched[i]
            sb.write_string(
              m.path + " (expected " + m.expected + ", got " + m.got + ")",
            )
          }
        }
      }
      {
        total: v.total,
        satisfied: v.satisfied,
        missing: v.missing,
        ok: v.ok,
        message: sb.to_string(),
      }
    }
  }
}

///|
/// The plan for a refused input (FR-33). `E010` and `E011` carry the repair that
/// was refused, so the plan keeps it as an operation: the caller can show the
/// same "why" for a refusal as for an accepted repair.
fn plan_of_error(e : MendError, input : String) -> RepairPlan {
  let code = e.code()
  let refused = match e {
    StrictViolation(c, p, m) => Some((c, p, m, Unambiguous))
    AmbiguousRepair(c, p, a) => Some((c, p, a.message(), a))
    _ => None
  }
  let ops : Array[PlanOp] = []
  let mut exactness = Exact
  let mut ambiguous = false
  match refused {
    Some((c, p, m, a)) => {
      exactness = Lossy
      ambiguous = a.is_ambiguous()
      ops.push({
        rule: c.code(),
        name: c.name(),
        action: plan_action(c),
        position: p,
        severity: Warning,
        ambiguity: a,
        confidence_class: "refused",
        confidence: 0.0,
      })
      ignore(m)
    }
    None => ()
  }
  {
    text: "",
    value: None,
    repairs: ops,
    repairs_truncated: false,
    exactness,
    // A refusal is still a statement about the input: when the rule that was
    // refused is the duplicate-key normalisation, the input *was* well-formed
    // JSON and the refusal is about its meaning, not its syntax (Q-26).
    valid_json: match refused {
      Some((c, _, _, _)) => c is MergeDuplicateKey
      None => false
    },
    ambiguous,
    completeness: Empty,
    decision: decide_error(code),
    retry_hint: e.retry_hint(input~),
    error_code: code,
    error_message: e.to_string(),
    readiness_permille: -1,
    schema_total: 0,
    schema_satisfied: 0,
    schema_missing: [],
  }
}

///|
/// Analyse `input` under `options` (FR-33, FR-33).
///
/// Never raises: a refusal is a plan whose `decision` is not executable and
/// whose `retry_hint` carries the feedback. `readiness` is the sampling step of
/// the syntax readiness measurement; `0` (the default) skips it, because it
/// costs `O(n / step)` partial parses and most callers do not need it.
pub fn analyze(
  input : String,
  options? : Options,
  readiness? : Int32,
  schema? : Schema,
) -> RepairPlan {
  let opts = match options {
    Some(o) => o
    None => Options::default()
  }
  let step = match readiness {
    Some(v) => if v > 0 { v } else { 0 }
    None => 0
  }
  try {
    opts.validate()
    analyze_once(input, opts, step, schema)
  } catch {
    e => {
      let mut retried : RepairPlan? = None
      if opts.skip_prose && refused_no_json(e) && is_valid_json(input) {
        // `skip_prose` looks for the first `{` or `[`, because that is what a
        // chatty model puts the arguments in. A document whose root is a scalar
        // — `true`, `42`, `"ok"`, which RFC 8259 allows — has no such
        // character, so the whole input is skipped as prose and a perfectly
        // valid document is refused. When the input is in fact valid JSON, the
        // skip was wrong and is undone. The retry only happens on that refusal
        // and only for input a strict parser accepts, so prose is never
        // reinterpreted as a value.
        let again : Options = { ..opts, skip_prose: false, }
        try {
          retried = Some(analyze_once(input, again, step, schema))
        } catch {
          e2 => retried = Some(plan_of_error(e2, input))
        }
      }
      match retried {
        Some(p) => p
        None => plan_of_error(e, input)
      }
    }
  }
}

///|
/// Whether the refusal is "there is no JSON here", the only one that says
/// anything about `skip_prose`.
fn refused_no_json(e : MendError) -> Bool {
  match e {
    NoJsonFound(_) => true
    _ => false
  }
}

///|
/// One attempt at analysing `input` with `opts`.
fn analyze_once(
  input : String,
  opts : Options,
  step : Int32,
  schema : Schema?,
) -> RepairPlan raise MendError {
  let engine = Engine::make(opts)
  engine.feed_str(input)
  plan_of_final(engine.finish_engine(), input, step, schema)
}

///|
/// The same analysis for a stream that was fed incrementally (FR-33).
///
/// `input` is optional and only used to quote the window around a refused
/// position; the parser does not keep the text it consumed.
pub fn PartialParser::analyze(
  self : PartialParser,
  input? : String,
  schema? : Schema,
) -> RepairPlan {
  let text = match input {
    Some(t) => t
    None => ""
  }
  plan_of_final(self.finish(), text, 0, schema) catch {
    e => plan_of_error(e, text)
  }
}

///|
/// Render `self` as the `jsonmend explain` block (FR-33).
///
/// `input` is optional; when it is passed it is echoed under `Input:` truncated
/// to one line, so the block is self-contained in a log.
pub fn RepairPlan::explain_text(self : RepairPlan, input? : String) -> String {
  let sb = StringBuilder()
  sb.write_string("JSONMend Analysis\n")
  sb.write_string(
    "────────────────────────────────\n",
  )
  match input {
    Some(text) => {
      sb.write_string("Input:\n  ")
      sb.write_string(one_line(text))
      sb.write_string("\n")
    }
    None => ()
  }
  sb.write_string("\nRepair:\n")
  if self.repairs.length() == 0 {
    if self.error_code == "" {
      sb.write_string("  none (valid JSON)\n")
    } else {
      sb.write_string("  none\n")
    }
  }
  for op in self.repairs {
    sb.write_string("  ")
    sb.write_string(op.rule)
    sb.write_string(" ")
    sb.write_string(op.name)
    sb.write_string(" (")
    sb.write_string(op.action.name())
    sb.write_string(" at ")
    sb.write_string(op.position.line.to_string())
    sb.write_string(":")
    sb.write_string(op.position.column.to_string())
    sb.write_string(")")
    if op.ambiguity.is_ambiguous() {
      sb.write_string(" [ambiguous: ")
      sb.write_string(op.ambiguity.name())
      sb.write_string("]")
    }
    sb.write_string("\n")
  }
  sb.write_string("\nRisk:\n")
  sb.write_string("  exactness = ")
  sb.write_string(self.exactness.name())
  sb.write_string("\n  ambiguity = ")
  sb.write_string(if self.ambiguous { "yes" } else { "none" })
  sb.write_string("\n  information_loss = ")
  sb.write_string(if self.exactness is Lossy { "true" } else { "false" })
  sb.write_string("\n")
  if self.schema_total > 0 {
    sb.write_string("  schema = ")
    sb.write_string(self.schema_satisfied.to_string())
    sb.write_string("/")
    sb.write_string(self.schema_total.to_string())
    if self.schema_missing.length() > 0 {
      sb.write_string(" (missing ")
      for i = 0; i < self.schema_missing.length(); i = i + 1 {
        if i > 0 {
          sb.write_string(", ")
        }
        sb.write_string(self.schema_missing[i])
      }
      sb.write_string(")")
    }
    sb.write_string("\n")
  }
  sb.write_string("\nDecision:\n  ")
  sb.write_string(self.decision.label())
  sb.write_string("\n")
  if self.decision.is_safe() {
    sb.write_string("\nRepaired:\n  ")
    sb.write_string(one_line(self.text))
    sb.write_string("\n")
  } else {
    sb.write_string("\nSuggested retry:\n")
    for line in self.prompt().split("\n") {
      sb.write_string("  ")
      sb.write_string(line.to_owned())
      sb.write_string("\n")
    }
  }
  sb.to_string()
}

///|
/// The first line of `s`, so that a plan can be printed without echoing a whole
/// document into a log file.
fn one_line(s : String) -> String {
  let sb = StringBuilder()
  for c in s {
    if c.to_int() == 0x0A {
      sb.write_string("\\n")
    } else {
      sb.write_char(c)
    }
  }
  let out = sb.to_string()
  let max = 200
  if out.length() <= max {
    out
  } else {
    out[:max].to_owned() + "…"
  }
}

///|
/// Escape a string for the JSON documents this file emits.
fn json_escape(s : String) -> String {
  let sb = StringBuilder()
  for c in s {
    let n = c.to_int()
    if n == 0x22 {
      sb.write_string("\\\"")
    } else if n == 0x5C {
      sb.write_string("\\\\")
    } else if n == 0x0A {
      sb.write_string("\\n")
    } else if n == 0x0D {
      sb.write_string("\\r")
    } else if n == 0x09 {
      sb.write_string("\\t")
    } else if n < 0x20 {
      sb.write_string("\\u00")
      let hi = n >> 4
      let lo = n & 15
      sb.write_char(hex_digit(hi))
      sb.write_char(hex_digit(lo))
    } else {
      sb.write_char(c)
    }
  }
  sb.to_string()
}

///|
fn hex_digit(n : Int32) -> Char {
  if n < 10 {
    Int32::unsafe_to_char(0x30 + n)
  } else {
    Int32::unsafe_to_char(0x61 + (n - 10))
  }
}

///|
/// `"key":"value"` for an already escaped value.
fn kv(key : String, value : String) -> String {
  "\"" + key + "\":\"" + value + "\""
}

///|
/// Machine readable form of the plan, for `jsonmend explain --json` and for the
/// browser demo. Field names are part of the contract.
pub fn RepairPlan::to_json(self : RepairPlan) -> String {
  let sb = StringBuilder()
  sb.write_string("{")
  sb.write_string(kv("decision", self.decision.name()))
  sb.write_string(",")
  sb.write_string(kv("label", self.decision.label()))
  sb.write_string(",")
  sb.write_string(kv("exactness", self.exactness.name()))
  sb.write_string(",\"ambiguous\":")
  sb.write_string(self.ambiguous.to_string())
  sb.write_string(",\"information_loss\":")
  sb.write_string((self.exactness is Lossy).to_string())
  // Q-26: "syntactically valid" is not "safe to execute", and the pair
  // `valid_json` / `normalized` is what says so. A document can be valid JSON
  // and still be blocked, because a repeated member name has no single meaning.
  sb.write_string(",\"valid_json\":")
  sb.write_string(self.valid_json.to_string())
  sb.write_string(",\"normalized\":")
  sb.write_string(self.normalized().to_string())
  sb.write_string(",")
  sb.write_string(kv("ambiguity", self.ambiguity_kind()))
  sb.write_string(",")
  sb.write_string(kv("execution", self.execution()))
  sb.write_string(",\"executable\":")
  sb.write_string(self.is_executable().to_string())
  sb.write_string(",\"syntax_ready_permille\":")
  sb.write_string(self.readiness_permille.to_string())
  sb.write_string(",\"schema_total\":")
  sb.write_string(self.schema_total.to_string())
  sb.write_string(",\"schema_satisfied\":")
  sb.write_string(self.schema_satisfied.to_string())
  sb.write_string(",\"schema_missing\":[")
  for i = 0; i < self.schema_missing.length(); i = i + 1 {
    if i > 0 {
      sb.write_string(",")
    }
    sb.write_string("\"" + json_escape(self.schema_missing[i]) + "\"")
  }
  sb.write_string("]")
  sb.write_string(",\"error\":")
  if self.error_code == "" {
    sb.write_string("null")
  } else {
    sb.write_string("{")
    sb.write_string(kv("code", self.error_code))
    sb.write_string(",")
    sb.write_string(kv("message", json_escape(self.error_message)))
    sb.write_string("}")
  }
  sb.write_string(",\"repairs\":[")
  for i = 0; i < self.repairs.length(); i = i + 1 {
    let op = self.repairs[i]
    if i > 0 {
      sb.write_string(",")
    }
    sb.write_string("{")
    sb.write_string(kv("rule", op.rule))
    sb.write_string(",")
    sb.write_string(kv("name", op.name))
    sb.write_string(",")
    sb.write_string(kv("action", op.action.name()))
    sb.write_string(",\"offset\":")
    sb.write_string(op.position.offset.to_string())
    sb.write_string(",\"line\":")
    sb.write_string(op.position.line.to_string())
    sb.write_string(",\"column\":")
    sb.write_string(op.position.column.to_string())
    sb.write_string(",")
    sb.write_string(
      kv(
        "severity",
        match op.severity {
          Info => "info"
          Warning => "warning"
        },
      ),
    )
    sb.write_string(",")
    sb.write_string(kv("ambiguity", op.ambiguity.name()))
    sb.write_string(",")
    sb.write_string(kv("confidence", op.confidence_class))
    sb.write_string(",\"confidence_value\":")
    sb.write_string(op.confidence.to_string())
    sb.write_string(",\"information_loss\":")
    sb.write_string(op.information_loss().to_string())
    sb.write_string("}")
  }
  sb.write_string("],\"retry\":")
  match self.retry_hint {
    Some(h) =>
      sb.write_string(
        "{" +
        kv("code", h.error_code) +
        "," +
        kv("prompt", json_escape(h.to_prompt())) +
        "}",
      )
    None => sb.write_string("null")
  }
  sb.write_string(",\"repairs_truncated\":")
  sb.write_string(self.repairs_truncated.to_string())
  // The value itself, so a caller that has the right to execute does not have to
  // parse the text again to get it.
  sb.write_string(",\"value\":")
  match self.value {
    Some(v) => sb.write_string(v.stringify())
    None => sb.write_string("null")
  }
  sb.write_string(",\"text\":")
  sb.write_string("\"" + json_escape(self.text) + "\"")
  sb.write_string("}")
  sb.to_string()
}