///|
/// Indices are zero-based into BatchDelta.baseline/current; None marks absence.
/// Findings use current spans for introduced items and baseline spans for resolved.
pub struct FileDelta {
  baseline_index : Int32?
  current_index : Int32?
  introduced : Array[Diagnostic]
  resolved : Array[Diagnostic]
  retained : Int32
}

///|
/// Current-file order followed by unmatched baseline files in baseline order.
pub struct BatchDelta {
  baseline : BatchReport
  current : BatchReport
  files : Array[FileDelta]
}

///|
fn checked_batch_snapshot(
  batch : BatchReport,
  label : String,
) -> Result[BatchReport, String] {
  if batch.initial_lock_timeout_ms is Some(n) && n < 0 {
    return Err("\{label}: invalid initial timeout")
  }
  let files = []
  for i, file in batch.files {
    let input = file.migration
    if !valid_unicode(input.path) || input.source != file.report.source {
      return Err(
        "\{label} file \{i}: invalid Unicode path or mismatched source",
      )
    }
    if input.execution == Unknown ||
      (input.max_lock_timeout_ms is Some(n) && n <= 0) {
      return Err(
        "\{label} file \{i}: unknown execution or invalid timeout policy",
      )
    }
    match validate_report(file.report, "\{label} file \{i}") {
      Ok(_) => ()
      Err(e) => return Err(e)
    }
    if !file.report.complete {
      return Err(
        "\{label} file \{i}: analysis is incomplete; a batch baseline requires complete coverage",
      )
    }
    files.push({ migration: input, report: report_snapshot(file.report), })
  }
  Ok({ files, initial_lock_timeout_ms: batch.initial_lock_timeout_ms, })
}

///|
fn file_delta(
  before : Int32?,
  after : Int32?,
  delta : ReportDelta,
) -> FileDelta {
  {
    baseline_index: before,
    current_index: after,
    introduced: delta.introduced,
    resolved: delta.resolved,
    retained: delta.retained,
  }
}

///|
/// Match exact path/context keys, then duplicate occurrences in source order.
/// Context includes shared initial timeout. Changed paths/policies are removals
/// and additions; SQL source itself is compared using compare_reports. The caller
/// must select trusted reports from the same project and rule version.
///
/// ```mbt check
/// test {
///   let inputs = [
///     @migration_lens.Migration::new(
///       "a.sql",
///       "DROP INDEX a;",
///       execution=SeparateStatements,
///     ),
///   ]
///   let batch = @migration_lens.review_migrations(inputs[:])
///   let delta = @migration_lens.compare_batches(batch, batch).unwrap()
///   assert_eq(delta.files[0].retained, 1)
///   assert_eq(delta.exit_code(), 0)
/// }
/// ```
pub fn compare_batches(
  baseline : BatchReport,
  current : BatchReport,
) -> Result[BatchDelta, String] {
  let baseline = match checked_batch_snapshot(baseline, "Baseline") {
    Ok(b) => b
    Err(e) => return Err(e)
  }
  let current = match checked_batch_snapshot(current, "Current") {
    Ok(b) => b
    Err(e) => return Err(e)
  }
  let queues : Map[String, Array[Int32]] = Map([])
  for i, file in baseline.files {
    let key = migration_context(
      file.migration,
      initial_lock_timeout_ms?=baseline.initial_lock_timeout_ms,
    ).stringify()
    match queues.get(key) {
      Some(indices) => indices.push(i)
      None => queues[key] = [i]
    }
  }
  let cursors : Map[String, Int32] = Map([])
  let used : Map[Int32, Unit] = Map([])
  let files = []
  let empty : Report = {
    source: "",
    statements: 0,
    complete: true,
    diagnostics: [],
  }
  for i, file in current.files {
    let key = migration_context(
      file.migration,
      initial_lock_timeout_ms?=current.initial_lock_timeout_ms,
    ).stringify()
    let cursor = cursors.get(key).unwrap_or(0)
    let before = match queues.get(key) {
      Some(indices) => indices.get(cursor)
      None => None
    }
    let previous = match before {
      Some(index) => {
        cursors[key] = cursor + 1
        used[index] = ()
        baseline.files[index].report
      }
      None => empty
    }
    let delta = match compare_reports(previous, file.report) {
      Ok(d) => d
      Err(e) => return Err(e)
    }
    files.push(file_delta(before, Some(i), delta))
  }
  for i, file in baseline.files {
    if !used.contains(i) {
      let delta = match compare_reports(file.report, empty) {
        Ok(d) => d
        Err(e) => return Err(e)
      }
      files.push(file_delta(Some(i), None, delta))
    }
  }
  Ok({ baseline, current, files, })
}

///|
/// Baseline gate only: retained findings remain in the full current snapshot.
pub fn BatchDelta::exit_code(self : BatchDelta) -> Int32 {
  if self.files.any(file => !file.introduced.is_empty()) {
    1
  } else {
    0
  }
}

///|
fn delta_index_json(index : Int32?) -> Json {
  match index {
    Some(i) => Json(i)
    None => Json::null()
  }
}

///|
pub fn BatchDelta::to_json(self : BatchDelta) -> Json {
  let files : Array[Json] = self.files.map(file => {
    "baseline_index": delta_index_json(file.baseline_index),
    "current_index": delta_index_json(file.current_index),
    "introduced": Json(file.introduced),
    "resolved": Json(file.resolved),
    "retained": file.retained,
  })
  {
    "format_version": 1,
    "baseline": self.baseline.to_json(),
    "current": self.current.to_json(),
    "files": Json(files),
    "exit_code": self.exit_code(),
  }
}

///|
pub fn BatchDelta::to_text(self : BatchDelta) -> String {
  let out = StringBuilder()
  out.write_string(
    "MigrationLens batch delta: \{self.files.length()} entries; exit=\{self.exit_code()}\n",
  )
  for file in self.files {
    let before = match file.baseline_index {
      Some(i) => Some(self.baseline.files[i])
      None => None
    }
    let after = match file.current_index {
      Some(i) => Some(self.current.files[i])
      None => None
    }
    let path = match after {
      Some(f) => f.migration.path
      None => before.unwrap().migration.path
    }
    out.write_string(
      "== \{path} baseline=\{Repr(file.baseline_index)} current=\{Repr(file.current_index)}; \{file.retained} retained ==\n",
    )
    if after is Some(f) {
      let report : Report = {
        source: f.report.source,
        statements: f.report.statements,
        complete: true,
        diagnostics: file.introduced,
      }
      out.write_string("Introduced (current source):\n" + report.to_text())
    }
    if before is Some(f) {
      let report : Report = {
        source: f.report.source,
        statements: f.report.statements,
        complete: true,
        diagnostics: file.resolved,
      }
      out.write_string("Resolved (baseline source):\n" + report.to_text())
    }
  }
  out.to_string()
}