///|
priv enum JsonValue {
  JsonString(String)
  JsonNumber(Int)
  JsonDecimal(Double)
  JsonBool(Bool)
  JsonArray(Array[JsonValue])
  JsonObject(Array[(String, JsonValue)])
}

///|
fn error_text(error : PinyinError) -> String {
  match error {
    InvalidSeparator(reason) => "invalid_separator:" + reason
    InvalidSyllable(value, reason) => "invalid_syllable:" + value + ":" + reason
    UnknownCharacter(code) => "unknown_character:" + code.to_string()
    AmbiguousCharacter(code, count) =>
      "ambiguous_character:" + code.to_string() + ":" + count.to_string()
    InvalidSourceSpan(start, end) =>
      "invalid_source_span:" + start.to_string() + ":" + end.to_string()
    InvalidLexiconEntry(value, reason) =>
      "invalid_lexicon_entry:" + value + ":" + reason
    ConflictingLexiconEntry(value) => "conflicting_lexicon_entry:" + value
    InvalidLexiconText(reason) => "invalid_lexicon_text:" + reason
    InvalidBatchItem(value, reason) =>
      "invalid_batch_item:" + value + ":" + reason
    InvalidSearchDocument(value, reason) =>
      "invalid_search_document:" + value + ":" + reason
    InvalidSearchOptions(name, reason) =>
      "invalid_search_options:" + name + ":" + reason
    InvalidSearchQuery(reason) => "invalid_search_query:" + reason
    InvalidVariantLimit(limit) => "invalid_variant_limit:" + limit.to_string()
    InvalidConversionResult(reason) => "invalid_conversion_result:" + reason
    InvalidAlignment(reason) => "invalid_alignment:" + reason
    InvalidWeight(name, value) =>
      "invalid_weight:" + name + ":" + value.to_string()
    InvalidGeneratedData(reason) => "invalid_generated_data:" + reason
  }
}

///|
fn json_hex_digit(value : Int) -> String {
  let code = if value < 10 { 48 + value } else { 65 + value - 10 }
  code.to_uint16().unsafe_to_char().to_string()
}

///|
fn json_control_escape(code : Int) -> String {
  "\\u" +
  json_hex_digit(code / 4096 % 16) +
  json_hex_digit(code / 256 % 16) +
  json_hex_digit(code / 16 % 16) +
  json_hex_digit(code % 16)
}

///|
fn json_escape(value : String) -> String {
  let mut output = ""
  for code in text_code_points(value) {
    let escaped = match code {
      34 => "\\\""
      92 => "\\\\"
      8 => "\\b"
      9 => "\\t"
      10 => "\\n"
      12 => "\\f"
      13 => "\\r"
      _ =>
        if code >= 0 && code <= 31 {
          json_control_escape(code)
        } else {
          code_points_text([code])
        }
    }
    output = output + escaped
  }
  output
}

///|
fn json_encode(value : JsonValue) -> String {
  match value {
    JsonString(text) => "\"" + json_escape(text) + "\""
    JsonNumber(number) => number.to_string()
    JsonDecimal(number) => number.to_string()
    JsonBool(value) => if value { "true" } else { "false" }
    JsonArray(values) => {
      let mut output = "["
      for index = 0; index < values.length(); index = index + 1 {
        if index > 0 {
          output = output + ","
        }
        output = output + json_encode(values[index])
      }
      output + "]"
    }
    JsonObject(fields) => {
      let mut output = "{"
      for index = 0; index < fields.length(); index = index + 1 {
        if index > 0 {
          output = output + ","
        }
        let (name, field_value) = fields[index]
        output = output +
          "\"" +
          json_escape(name) +
          "\":" +
          json_encode(field_value)
      }
      output + "}"
    }
  }
}

///|
fn batch_item_json(item : BatchItemResult) -> JsonValue {
  let source = item.item()
  match item.outcome() {
    BatchSucceeded(converted) =>
      JsonObject([
        ("id", JsonString(source.identifier())),
        ("source", JsonString(source.source())),
        ("status", JsonString("ok")),
        ("rendered", JsonString(converted.rendered())),
      ])
    BatchFailed(error) =>
      JsonObject([
        ("id", JsonString(source.identifier())),
        ("source", JsonString(source.source())),
        ("status", JsonString("error")),
        ("error", JsonString(error_text(error))),
      ])
  }
}

///|
fn batch_summary_json(summary : ConversionSummary) -> JsonValue {
  JsonObject([
    ("total", JsonNumber(summary.total())),
    ("succeeded", JsonNumber(summary.succeeded())),
    ("failed", JsonNumber(summary.failed())),
    ("unknown_failures", JsonNumber(summary.unknown_failures())),
    ("ambiguous_failures", JsonNumber(summary.ambiguous_failures())),
  ])
}

///|
/// Produces a stable line-oriented report in caller input order.
pub fn batch_report_text(result : BatchResult) -> String {
  let summary = result.summary()
  let mut output = "items=" +
    summary.total().to_string() +
    "\nsucceeded=" +
    summary.succeeded().to_string() +
    "\nfailed=" +
    summary.failed().to_string()
  for item in result.items() {
    output = output + "\n[" + item.item().identifier() + "] "
    match item.outcome() {
      BatchSucceeded(converted) =>
        output = output + "ok: " + converted.rendered()
      BatchFailed(error) => output = output + "error: " + error_text(error)
    }
  }
  output
}

///|
/// Produces stable compact JSON using a structured encoder.
pub fn batch_report_json(result : BatchResult) -> String {
  let item_values : Array[JsonValue] = []
  for item in result.items() {
    item_values.push(batch_item_json(item))
  }
  json_encode(
    JsonObject([
      ("summary", batch_summary_json(result.summary())),
      ("items", JsonArray(item_values)),
    ]),
  )
}

///|
fn json_string_array(values : Array[String]) -> JsonValue {
  let output : Array[JsonValue] = []
  for value in values {
    output.push(JsonString(value))
  }
  JsonArray(output)
}

///|
fn token_kind_text(kind : TokenKind) -> String {
  match kind {
    PinyinToken => "pinyin"
    LiteralToken => "literal"
  }
}

///|
fn decision_source_text(source : DecisionSource) -> String {
  match source {
    PhraseLexicon => "phrase_lexicon"
    UnicodeReading => "unicode_reading"
    UnknownPreserved => "unknown_preserved"
  }
}

///|
fn converted_token_json(token : ConvertedToken) -> JsonValue {
  JsonObject([
    ("original", JsonString(token.original())),
    ("rendered", JsonString(token.rendered())),
    ("kind", JsonString(token_kind_text(token.kind()))),
    ("decision", JsonString(decision_source_text(token.decision_source()))),
    ("start", JsonNumber(token.span().start())),
    ("end", JsonNumber(token.span().end())),
    ("candidates", json_string_array(token.candidates())),
  ])
}

///|
fn conversion_summary_json(stats : ConversionStats) -> JsonValue {
  JsonObject([
    ("source_scalars", JsonNumber(stats.source_scalars())),
    ("output_tokens", JsonNumber(stats.output_tokens())),
    ("pinyin_tokens", JsonNumber(stats.pinyin_tokens())),
    ("literal_tokens", JsonNumber(stats.literal_tokens())),
    ("phrase_decisions", JsonNumber(stats.phrase_decisions())),
    ("unicode_decisions", JsonNumber(stats.unicode_decisions())),
    ("preserved_unknown", JsonNumber(stats.preserved_unknown())),
    ("diagnostics", JsonNumber(stats.diagnostics())),
  ])
}

///|
fn diagnostic_json(value : Diagnostic) -> JsonValue {
  let fields : Array[(String, JsonValue)] = [
    ("code", JsonString(value.code())),
    ("message", JsonString(value.message())),
  ]
  match value.span() {
    Some(span) => {
      fields.push(("start", JsonNumber(span.start())))
      fields.push(("end", JsonNumber(span.end())))
    }
    None => ()
  }
  JsonObject(fields)
}

///|
/// Emits conversion tokens, decisions, candidates, spans, and diagnostics.
pub fn conversion_report_json(result : ConversionResult) -> String {
  let tokens : Array[JsonValue] = []
  for token in result.tokens() {
    tokens.push(converted_token_json(token))
  }
  let diagnostics : Array[JsonValue] = []
  for value in result.diagnostics() {
    diagnostics.push(diagnostic_json(value))
  }
  json_encode(
    JsonObject([
      ("source", JsonString(result.source())),
      ("rendered", JsonString(result.rendered())),
      ("summary", conversion_summary_json(analyze_conversion(result))),
      ("tokens", JsonArray(tokens)),
      ("diagnostics", JsonArray(diagnostics)),
    ]),
  )
}

///|
fn reading_evidence_json(value : ScalarReadingEvidence) -> JsonValue {
  JsonObject([
    ("code_point", JsonNumber(value.code_point())),
    ("original", JsonString(value.original())),
    ("start", JsonNumber(value.span().start())),
    ("end", JsonNumber(value.span().end())),
    ("known", JsonBool(value.is_known())),
    ("ambiguous", JsonBool(value.is_ambiguous())),
    ("candidates", json_string_array(value.candidates())),
  ])
}

///|
/// Emits source coverage and per-scalar Unicode reading candidates.
pub fn reading_inspection_report_json(inspection : ReadingInspection) -> String {
  let entries : Array[JsonValue] = []
  for value in inspection.entries() {
    entries.push(reading_evidence_json(value))
  }
  json_encode(
    JsonObject([
      (
        "summary",
        JsonObject([
          ("total_scalars", JsonNumber(inspection.total_scalars())),
          ("known_scalars", JsonNumber(inspection.known_scalars())),
          ("unknown_scalars", JsonNumber(inspection.unknown_scalars())),
          ("ambiguous_scalars", JsonNumber(inspection.ambiguous_scalars())),
        ]),
      ),
      ("entries", JsonArray(entries)),
    ]),
  )
}

///|
fn search_hit_json(hit : SearchHit) -> JsonValue {
  let explanation = hit.explanation()
  JsonObject([
    ("id", JsonString(hit.identifier())),
    ("source", JsonString(hit.source())),
    ("position", JsonNumber(hit.original_position())),
    ("score", JsonDecimal(explanation.score())),
    ("exact", JsonDecimal(explanation.exact_component())),
    ("prefix", JsonDecimal(explanation.prefix_component())),
    ("initials", JsonDecimal(explanation.initials_component())),
    ("fuzzy", JsonDecimal(explanation.fuzzy_component())),
    ("distance", JsonDecimal(explanation.distance().cost())),
  ])
}

///|
/// Emits ranked hits and all score components in stable order.
pub fn search_hits_report_json(
  query : String,
  hits : Array[SearchHit],
) -> String {
  let values : Array[JsonValue] = []
  for hit in hits {
    values.push(search_hit_json(hit))
  }
  json_encode(
    JsonObject([
      ("query", JsonString(query)),
      ("count", JsonNumber(hits.length())),
      ("hits", JsonArray(values)),
    ]),
  )
}