///|
/// Version 1 of the machine-readable report. Independent of debug enum output.
pub(all) struct ReportJsonV1 {
  schema_version : Int32
  baseline_id : String
  case_count : Int32
  engine_count : Int32
  compatible : Bool
  runs : Array[EngineRunJsonV1]
  findings : Array[FindingJsonV1]
} derive(ToJson)

///|
pub(all) struct EngineRunJsonV1 {
  case_id : String
  engine_id : String
  outcome : EngineOutcomeJsonV1
} derive(ToJson)

///|
/// Status is compile_rejected, execution_failed, no_match, or matched.
/// Error and observation are always present on the wire, using null when absent.
pub(all) struct EngineOutcomeJsonV1 {
  status : String
  error : String?
  observation : MatchJsonV1?
}

///|
/// Captures exclude group zero; null means an unmatched capture.
/// Spans remain zero-based, end-exclusive StringView code-unit offsets.
pub(all) struct MatchJsonV1 {
  text : String
  start : Int32
  end : Int32
  captures : Array[String?]
}

///|
/// Baseline and actual preserve the observations behind a finding.
/// Null means the corresponding run is absent from a caller-constructed report.
pub(all) struct FindingJsonV1 {
  case_id : String
  engine_id : String
  kind : String
  detail : String
  baseline : EngineOutcomeJsonV1?
  actual : EngineOutcomeJsonV1?
}

///|
// Option's generic ToJson encoding is not the report wire format. Construct
// nullable slots explicitly to retain null keys and scalar capture strings.
pub impl ToJson for EngineOutcomeJsonV1 with fn to_json(self) {
  {
    "status": ToJson::to_json(self.status),
    "error": match self.error {
      None => null
      Some(message) => ToJson::to_json(message)
    },
    "observation": match self.observation {
      None => null
      Some(observation) => ToJson::to_json(observation)
    },
  }
}

///|
pub impl ToJson for MatchJsonV1 with fn to_json(self) {
  {
    "text": ToJson::to_json(self.text),
    "start": ToJson::to_json(self.start),
    "end": ToJson::to_json(self.end),
    "captures": ToJson::to_json(
      self.captures.map(capture => {
        match capture {
          None => null
          Some(text) => ToJson::to_json(text)
        }
      }),
    ),
  }
}

///|
pub impl ToJson for FindingJsonV1 with fn to_json(self) {
  {
    "case_id": ToJson::to_json(self.case_id),
    "engine_id": ToJson::to_json(self.engine_id),
    "kind": ToJson::to_json(self.kind),
    "detail": ToJson::to_json(self.detail),
    "baseline": match self.baseline {
      None => null
      Some(outcome) => ToJson::to_json(outcome)
    },
    "actual": match self.actual {
      None => null
      Some(outcome) => ToJson::to_json(outcome)
    },
  }
}

///|
/// Take a v1 snapshot without compiling patterns or calling adapters.
pub fn CompatibilityReport::json_v1(self : CompatibilityReport) -> ReportJsonV1 {
  let runs = self.runs.map(run => EngineRunJsonV1::{
    case_id: run.case_id,
    engine_id: run.engine_id,
    outcome: outcome_json_v1(run.outcome),
  })
  let findings = self.findings.map(finding => FindingJsonV1::{
    case_id: finding.case_id,
    engine_id: finding.engine_id,
    kind: finding_kind_name(finding.kind),
    detail: finding.detail,
    baseline: find_json_outcome(runs, finding.case_id, self.baseline_id),
    actual: find_json_outcome(runs, finding.case_id, finding.engine_id),
  })
  {
    schema_version: 1,
    baseline_id: self.baseline_id,
    case_count: self.case_count,
    engine_count: self.engine_count,
    compatible: self.is_compatible(),
    runs,
    findings,
  }
}

///|
/// Serialize a compatibility report using the version 1 wire schema.
pub fn CompatibilityReport::format_json(self : CompatibilityReport) -> String {
  self.json_v1().stringify()
}

///|
pub fn ReportJsonV1::stringify(self : ReportJsonV1) -> String {
  let output = StringBuilder()
  write_report_json(ToJson::to_json(self), output)
  output.to_string()
}

///|
// Sort every object's keys explicitly rather than relying on Map iteration.
// Arrays retain contract order; the core JSON library owns scalar encoding.
fn write_report_json(value : Json, output : StringBuilder) -> Unit {
  match value {
    Object(members) => {
      let keys = members.iter().map(pair => pair.0).to_array()
      keys.sort_by((left, right) => left.lexical_compare(right))
      output.write_char('{')
      for index, key in keys {
        if index > 0 {
          output.write_char(',')
        }
        output.write_string(ToJson::to_json(key).stringify())
        output.write_char(':')
        write_report_json(members[key], output)
      }
      output.write_char('}')
    }
    Array(values) => {
      output.write_char('[')
      for index, element in values {
        if index > 0 {
          output.write_char(',')
        }
        write_report_json(element, output)
      }
      output.write_char(']')
    }
    _ => output.write_string(value.stringify())
  }
}

///|
fn outcome_json_v1(outcome : EngineOutcome) -> EngineOutcomeJsonV1 {
  match outcome {
    CompileRejected(message) =>
      { status: "compile_rejected", error: Some(message), observation: None, }
    ExecutionFailed(message) =>
      { status: "execution_failed", error: Some(message), observation: None, }
    Completed(None) => { status: "no_match", error: None, observation: None, }
    Completed(Some(observation)) =>
      {
        status: "matched",
        error: None,
        observation: Some({
          text: observation.text,
          start: observation.start,
          end: observation.end,
          captures: observation.captures.copy(),
        }),
      }
  }
}

///|
fn find_json_outcome(
  runs : Array[EngineRunJsonV1],
  case_id : String,
  engine_id : String,
) -> EngineOutcomeJsonV1? {
  for run in runs {
    if run.case_id == case_id && run.engine_id == engine_id {
      return Some(run.outcome)
    }
  }
  None
}

///|
pub extend ReportJsonV1 with ToJson::{to_json}

///|
pub extend EngineRunJsonV1 with ToJson::{to_json}

///|
pub extend EngineOutcomeJsonV1 with ToJson::{to_json}

///|
pub extend MatchJsonV1 with ToJson::{to_json}

///|
pub extend FindingJsonV1 with ToJson::{to_json}