///|
/// A single engine observation normalized for cross-backend comparison.
/// Offsets are zero-based MoonBit StringView code-unit offsets.
pub(all) struct MatchObservation {
  text : String
  start : Int32
  end : Int32
  captures : Array[String?]
} derive(Eq, Debug)

///|
/// An opaque compiled pattern supplied by an engine adapter.
pub(all) struct CompiledMatcher {
  observe : (String) -> Result[MatchObservation?, String]
}

///|
/// An engine adapter. Implement `compile` by calling the engine under test;
/// this package never parses patterns or performs matching itself.
pub(all) struct EngineAdapter {
  id : String
  compile : (String) -> Result[CompiledMatcher, String]
}

///|
/// A pattern/input pair in a portable behavior contract.
pub(all) struct ContractCase {
  id : String
  pattern : String
  input : String
  expected : ExpectedOutcome?
  expected_span : (Int32, Int32)?
}

///|
pub extend MatchObservation with Eq::{equal, not_equal}

///|
pub extend MatchObservation with @moonbitlang/core/debug.Debug::{to_repr}

///|
/// Optional engine-independent expectation for one contract case.
pub(all) enum ExpectedOutcome {
  ExpectedNoMatch
  ExpectedMatch(text~ : String, captures~ : Array[String?])
} derive(Eq, Debug)

///|
pub extend ExpectedOutcome with Eq::{equal, not_equal}

///|
pub extend ExpectedOutcome with @moonbitlang/core/debug.Debug::{to_repr}

///|
/// Result of running one case against one engine.
pub(all) enum EngineOutcome {
  CompileRejected(String)
  ExecutionFailed(String)
  Completed(MatchObservation?)
} derive(Eq, Debug)

///|
pub extend EngineOutcome with Eq::{equal, not_equal}

///|
pub extend EngineOutcome with @moonbitlang/core/debug.Debug::{to_repr}

///|
/// A cross-engine or expectation difference.
pub(all) struct CompatibilityFinding {
  case_id : String
  engine_id : String
  kind : FindingKind
  detail : String
}

///|
/// Difference categories emitted by a contract run.
pub(all) enum FindingKind {
  CompileAcceptanceDiff
  ExecutionDiff
  MatchPresenceDiff
  MatchedTextDiff
  MatchSpanDiff
  CaptureDiff
  ExpectationFailed
} derive(Eq, Debug)

///|
pub extend FindingKind with Eq::{equal, not_equal}

///|
pub extend FindingKind with @moonbitlang/core/debug.Debug::{to_repr}

///|
/// Output rows are stable in case order, then adapter order.
pub(all) struct EngineRun {
  case_id : String
  engine_id : String
  outcome : EngineOutcome
}

///|
/// Aggregate result from a compatibility run.
pub(all) struct CompatibilityReport {
  baseline_id : String
  case_count : Int32
  engine_count : Int32
  runs : Array[EngineRun]
  findings : Array[CompatibilityFinding]
}

///|
/// Invalid or ambiguous compatibility-run configuration.
pub(all) enum ContractError {
  EmptyBaseline
  NoEngines
  EmptyEngineId
  DuplicateEngine(String)
  BaselineNotFound(String)
  DuplicateCase(String)
  EmptyCaseId
  InvalidExpectedSpan(String)
} derive(Eq, Debug)

///|
pub extend ContractError with Eq::{equal, not_equal}

///|
pub extend ContractError with @moonbitlang/core/debug.Debug::{to_repr}

///|
/// Build an adapter around a real regex implementation.
pub fn EngineAdapter::new(
  id~ : String,
  compile~ : (String) -> Result[CompiledMatcher, String],
) -> EngineAdapter {
  { id, compile, }
}

///|
/// Build the observation closure returned after a backend compiles a pattern.
pub fn CompiledMatcher::new(
  observe~ : (String) -> Result[MatchObservation?, String],
) -> CompiledMatcher {
  { observe, }
}

///|
/// Construct a contract case without an expected value.
pub fn ContractCase::new(
  id~ : String,
  pattern~ : String,
  input~ : String,
) -> ContractCase {
  { id, pattern, input, expected: None, expected_span: None, }
}

///|
/// Construct a contract case with an expected non-match.
pub fn ContractCase::expect_no_match(
  id~ : String,
  pattern~ : String,
  input~ : String,
) -> ContractCase {
  {
    id,
    pattern,
    input,
    expected: Some(ExpectedOutcome::ExpectedNoMatch),
    expected_span: None,
  }
}

///|
/// Construct a contract case with expected whole-match and capture text.
pub fn ContractCase::expect_match(
  id~ : String,
  pattern~ : String,
  input~ : String,
  text~ : String,
  captures~ : Array[String?],
) -> ContractCase {
  {
    id,
    pattern,
    input,
    expected: Some(ExpectedOutcome::ExpectedMatch(text~, captures~)),
    expected_span: None,
  }
}

///|
/// Construct a contract case that also asserts the normalized whole-match
/// span. Offsets use the adapter contract's zero-based StringView code units.
pub fn ContractCase::expect_match_span(
  id~ : String,
  pattern~ : String,
  input~ : String,
  text~ : String,
  captures~ : Array[String?],
  start~ : Int32,
  end~ : Int32,
) -> ContractCase {
  {
    id,
    pattern,
    input,
    expected: Some(ExpectedOutcome::ExpectedMatch(text~, captures~)),
    expected_span: Some((start, end)),
  }
}

///|
/// Run a suite against every supplied adapter and compare each result with a
/// designated baseline. The caller controls adapter order and case order.
pub fn run_contracts(
  cases : Array[ContractCase],
  engines : Array[EngineAdapter],
  baseline_id : String,
) -> Result[CompatibilityReport, ContractError] {
  if baseline_id == "" {
    return Err(ContractError::EmptyBaseline)
  }
  if engines.is_empty() {
    return Err(ContractError::NoEngines)
  }
  let mut baseline_index = -1
  for i, engine in engines {
    if engine.id == "" {
      return Err(ContractError::EmptyEngineId)
    }
    for j = 0; j < i; j = j + 1 {
      if engines[j].id == engine.id {
        return Err(ContractError::DuplicateEngine(engine.id))
      }
    }
    if engine.id == baseline_id {
      baseline_index = i
    }
  }
  if baseline_index < 0 {
    return Err(ContractError::BaselineNotFound(baseline_id))
  }
  for i, case in cases {
    if case.id == "" {
      return Err(ContractError::EmptyCaseId)
    }
    if case.expected_span is Some((start, end)) &&
      (start < 0 || end < start || end > case.input.length()) {
      return Err(ContractError::InvalidExpectedSpan(case.id))
    }
    for j = 0; j < i; j = j + 1 {
      if cases[j].id == case.id {
        return Err(ContractError::DuplicateCase(case.id))
      }
    }
  }
  let runs : Array[EngineRun] = []
  let findings : Array[CompatibilityFinding] = []
  let compilation_cache : Array[CompilationCacheEntry] = []
  for case in cases {
    let case_start = runs.length()
    for engine in engines {
      let outcome = match
        compile_cached(engine, case.pattern, compilation_cache) {
        Rejected(message) => EngineOutcome::CompileRejected(message)
        Ready(matcher) =>
          match (matcher.observe)(case.input) {
            Err(message) => EngineOutcome::ExecutionFailed(message)
            Ok(observation) => EngineOutcome::Completed(observation)
          }
      }
      runs.push({ case_id: case.id, engine_id: engine.id, outcome, })
    }
    let baseline = runs[case_start + baseline_index].outcome
    for engine_index, engine in engines {
      if engine_index != baseline_index {
        let actual = runs[case_start + engine_index].outcome
        compare_outcomes(case.id, engine.id, baseline, actual, findings)
      }
      check_expectation(
        case,
        engine.id,
        runs[case_start + engine_index].outcome,
        findings,
      )
    }
  }
  Ok({
    baseline_id,
    case_count: cases.length(),
    engine_count: engines.length(),
    runs,
    findings,
  })
}

///|
/// True when no engine differs from the baseline and every golden expectation
/// passed.
pub fn CompatibilityReport::is_compatible(self : CompatibilityReport) -> Bool {
  self.findings.is_empty()
}

///|
/// Number of cases evaluated by each configured engine.
pub fn CompatibilityReport::case_count(self : CompatibilityReport) -> Int32 {
  self.case_count
}

///|
/// Number of configured adapters in this run.
pub fn CompatibilityReport::engine_count(self : CompatibilityReport) -> Int32 {
  self.engine_count
}

///|
/// Stable one-line summary intended for CI output.
pub fn CompatibilityReport::summary(self : CompatibilityReport) -> String {
  "baseline=\{self.baseline_id} cases=\{self.case_count} engines=\{self.engine_count} findings=\{self.findings.length()} compatible=\{self.is_compatible()}"
}

///|
/// Stable text form of findings, preserving case and adapter order.
pub fn CompatibilityReport::format_findings(
  self : CompatibilityReport,
) -> String {
  let mut output = ""
  for finding in self.findings {
    if output != "" {
      output = output + "\n"
    }
    output = output +
      "\{finding.case_id} [\{finding.engine_id}] \{finding_kind_name(finding.kind)}: \{finding.detail}"
  }
  output
}

///|
fn finding_kind_name(kind : FindingKind) -> String {
  match kind {
    CompileAcceptanceDiff => "compile-acceptance"
    ExecutionDiff => "execution"
    MatchPresenceDiff => "match-presence"
    MatchedTextDiff => "matched-text"
    MatchSpanDiff => "match-span"
    CaptureDiff => "captures"
    ExpectationFailed => "expectation"
  }
}

///|
/// Compare a suite using MoonBit's official `moonbitlang/regexp` package.
pub fn moonbit_regexp_adapter() -> EngineAdapter {
  EngineAdapter::new(id="moonbitlang/regexp", compile=pattern => {
    try {
      let compiled = @regexp.compile(pattern)
      Ok(
        CompiledMatcher::new(observe=input => {
          let result = compiled.execute(input)
          if !result.matched() {
            Ok(None)
          } else {
            let whole = result.get(0).unwrap().to_owned()
            let start = result.before().length()
            let captures = []
            for index = 1; index < compiled.group_count(); index = index + 1 {
              captures.push(result.get(index).map(view => view.to_owned()))
            }
            Ok(
              Some({
                text: whole,
                start,
                end: start + whole.length(),
                captures,
              }),
            )
          }
        }),
      )
    } catch {
      @regexp.RegexpError(err~, source_fragment~) =>
        Err("\{err}: \{source_fragment}")
    }
  })
}

///|
fn compare_outcomes(
  case_id : String,
  engine_id : String,
  baseline : EngineOutcome,
  actual : EngineOutcome,
  findings : Array[CompatibilityFinding],
) -> Unit {
  match (baseline, actual) {
    (CompileRejected(_), Completed(_))
    | (CompileRejected(_), ExecutionFailed(_)) =>
      push_finding(
        case_id,
        engine_id,
        FindingKind::CompileAcceptanceDiff,
        "baseline rejected the pattern; candidate compiled it",
        findings,
      )
    (Completed(_), CompileRejected(_))
    | (ExecutionFailed(_), CompileRejected(_)) =>
      push_finding(
        case_id,
        engine_id,
        FindingKind::CompileAcceptanceDiff,
        "baseline compiled the pattern; candidate rejected it",
        findings,
      )
    (ExecutionFailed(_), Completed(_)) =>
      push_finding(
        case_id,
        engine_id,
        FindingKind::ExecutionDiff,
        "baseline execution failed; candidate completed",
        findings,
      )
    (Completed(_), ExecutionFailed(_)) =>
      push_finding(
        case_id,
        engine_id,
        FindingKind::ExecutionDiff,
        "baseline completed; candidate execution failed",
        findings,
      )
    (Completed(left), Completed(right)) =>
      compare_observations(case_id, engine_id, left, right, findings)
    _ => ()
  }
}

///|
fn compare_observations(
  case_id : String,
  engine_id : String,
  baseline : MatchObservation?,
  actual : MatchObservation?,
  findings : Array[CompatibilityFinding],
) -> Unit {
  match (baseline, actual) {
    (None, Some(_)) | (Some(_), None) =>
      push_finding(
        case_id,
        engine_id,
        FindingKind::MatchPresenceDiff,
        "one engine matched and the other did not",
        findings,
      )
    (Some(left), Some(right)) => {
      if left.text != right.text {
        push_finding(
          case_id,
          engine_id,
          FindingKind::MatchedTextDiff,
          "matched text differs",
          findings,
        )
      }
      if left.start != right.start || left.end != right.end {
        push_finding(
          case_id,
          engine_id,
          FindingKind::MatchSpanDiff,
          "whole-match span differs",
          findings,
        )
      }
      if left.captures != right.captures {
        push_finding(
          case_id,
          engine_id,
          FindingKind::CaptureDiff,
          "capture values differ",
          findings,
        )
      }
    }
    (None, None) => ()
  }
}

///|
fn check_expectation(
  case : ContractCase,
  engine_id : String,
  actual : EngineOutcome,
  findings : Array[CompatibilityFinding],
) -> Unit {
  match case.expected {
    None => ()
    Some(ExpectedOutcome::ExpectedNoMatch) =>
      if actual != EngineOutcome::Completed(None) {
        push_finding(
          case.id,
          engine_id,
          FindingKind::ExpectationFailed,
          "expected no match",
          findings,
        )
      }
    Some(ExpectedOutcome::ExpectedMatch(text~, captures~)) =>
      match actual {
        Completed(Some(observation)) => {
          if observation.text != text || observation.captures != captures {
            push_finding(
              case.id,
              engine_id,
              FindingKind::ExpectationFailed,
              "match or captures differ from the contract",
              findings,
            )
          }
          if case.expected_span is Some((start, end)) &&
            (observation.start != start || observation.end != end) {
            push_finding(
              case.id,
              engine_id,
              FindingKind::ExpectationFailed,
              "match span differs from the contract",
              findings,
            )
          }
        }
        _ =>
          push_finding(
            case.id,
            engine_id,
            FindingKind::ExpectationFailed,
            "expected a match",
            findings,
          )
      }
  }
}

///|
fn push_finding(
  case_id : String,
  engine_id : String,
  kind : FindingKind,
  detail : String,
  findings : Array[CompatibilityFinding],
) -> Unit {
  findings.push({ case_id, engine_id, kind, detail, })
}

///|
priv enum CachedCompilation {
  Ready(CompiledMatcher)
  Rejected(String)
}

///|
priv struct CompilationCacheEntry {
  engine_id : String
  pattern : String
  result : CachedCompilation
}

///|
fn compile_cached(
  engine : EngineAdapter,
  pattern : String,
  cache : Array[CompilationCacheEntry],
) -> CachedCompilation {
  for entry in cache {
    if entry.engine_id == engine.id && entry.pattern == pattern {
      return entry.result
    }
  }
  let result = match (engine.compile)(pattern) {
    Ok(matcher) => CachedCompilation::Ready(matcher)
    Err(message) => CachedCompilation::Rejected(message)
  }
  cache.push({ engine_id: engine.id, pattern, result, })
  result
}