///|
fn contains_string(values : Array[String], value : String) -> Bool {
  for item in values {
    if item == value {
      return true
    }
  }
  false
}

///|
fn normalize_output(text : String, policy : ComparePolicy) -> String {
  let ansi_normalized = if policy.strip_ansi_sgr {
    strip_ansi_sgr(text)
  } else {
    text
  }
  let normalized = if policy.normalize_line_endings {
    ansi_normalized
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  } else {
    ansi_normalized
  }
  let trailing_trimmed = if policy.trim_trailing_whitespace_per_line {
    trim_trailing_whitespace(normalized)
  } else {
    normalized
  }
  if policy.trim_one_final_newline && trailing_trimmed.has_suffix("\n") {
    trailing_trimmed[0:trailing_trimmed.length() - 1].to_owned()
  } else {
    trailing_trimmed
  }
}

///|
fn trim_trailing_whitespace(text : String) -> String {
  let lines : Array[String] = []
  for line in text.split("\n") {
    let characters = line.to_array()
    let mut end = characters.length()
    while end > 0 && (characters[end - 1] == ' ' || characters[end - 1] == '\t') {
      end -= 1
    }
    lines.push(String::from_iter(characters[0:end].iter()))
  }
  lines.join("\n")
}

///|
/// Remove ANSI CSI SGR style sequences, preserving other escape sequences.
fn strip_ansi_sgr(text : String) -> String {
  let characters = text.to_array()
  let output : Array[Char] = []
  let mut index = 0
  while index < characters.length() {
    if characters[index].to_int() == 27 &&
      index + 1 < characters.length() &&
      characters[index + 1] == '[' {
      let mut end = index + 2
      while end < characters.length() &&
            characters[end].to_int() >= 0x30 &&
            characters[end].to_int() <= 0x3F {
        end += 1
      }
      if end < characters.length() && characters[end] == 'm' {
        index = end + 1
      } else {
        output.push(characters[index])
        index += 1
      }
    } else {
      output.push(characters[index])
      index += 1
    }
  }
  String::from_iter(output.iter())
}

///|
/// Compare captured outcomes for one scenario. Missing, duplicate, or
/// undeclared target observations make the report inconclusive unless a
/// concrete divergence has already been observed.
pub fn compare_scenario(run : ScenarioRun) -> ParityReport {
  let missing_targets : Array[String] = []
  let unexpected_targets : Array[String] = []
  let duplicate_targets : Array[String] = []
  let diagnostics : Array[String] = []
  let seen_expected : Array[String] = []
  let compared_targets : Array[String] = []
  let differences : Array[FieldDifference] = []
  let seen_observations : Array[String] = []
  let mut reference : Observation? = None

  if run.schema_version != 1 {
    diagnostics.push("unsupported schema_version; expected 1")
  }
  if run.name.trim().is_empty() {
    diagnostics.push("scenario name must not be empty")
  }
  if run.expected_targets.length() < 2 {
    diagnostics.push("at least two expected targets are required")
  }

  for expected in run.expected_targets {
    if contains_string(seen_expected, expected) {
      if !contains_string(duplicate_targets, expected) {
        duplicate_targets.push(expected)
      }
    } else {
      seen_expected.push(expected)
      if !has_observation(run.observations, expected) {
        missing_targets.push(expected)
      } else {
        compared_targets.push(expected)
      }
    }
  }

  for observation in run.observations {
    if !contains_string(run.expected_targets, observation.target) {
      if !contains_string(unexpected_targets, observation.target) {
        unexpected_targets.push(observation.target)
      }
    }
    if contains_string(seen_observations, observation.target) {
      if !contains_string(duplicate_targets, observation.target) {
        duplicate_targets.push(observation.target)
      }
    } else {
      seen_observations.push(observation.target)
    }
  }

  for expected in run.expected_targets {
    match reference {
      None =>
        for observation in run.observations {
          if observation.target == expected {
            reference = Some(observation)
            break
          }
        }
      Some(_) => break
    }
  }

  match reference {
    None => ()
    Some(reference_observation) =>
      for expected in run.expected_targets {
        if expected != reference_observation.target {
          for observation in run.observations {
            if observation.target == expected {
              compare_observation_fields(
                reference_observation,
                observation,
                run.policy,
                differences,
                "parity",
              )
              break
            }
          }
        }
      }
  }

  let status = if differences.length() > 0 {
    Status::Divergent
  } else if missing_targets.length() > 0 ||
    unexpected_targets.length() > 0 ||
    duplicate_targets.length() > 0 ||
    diagnostics.length() > 0 {
    Status::Inconclusive
  } else {
    Status::Pass
  }
  {
    scenario: run.name,
    status,
    diagnostics,
    reference_target: reference.map(value => value.target),
    compared_targets,
    missing_targets,
    unexpected_targets,
    duplicate_targets,
    differences,
  }
}

///|
fn has_observation(observations : Array[Observation], target : String) -> Bool {
  for observation in observations {
    if observation.target == target {
      return true
    }
  }
  false
}

///|
fn compare_observation_fields(
  reference : Observation,
  observed : Observation,
  policy : ComparePolicy,
  differences : Array[FieldDifference],
  kind : String,
) -> Unit {
  if reference.exit_code != observed.exit_code {
    differences.push({
      target: observed.target,
      kind,
      field: "exit_code",
      path: None,
      reference: reference.exit_code.to_string(),
      observed: observed.exit_code.to_string(),
    })
  }
  let reference_stdout = normalize_output(reference.stdout, policy)
  let observed_stdout = normalize_output(observed.stdout, policy)
  if policy.compare_stdout_as_json {
    match
      (parse_json_output(reference_stdout), parse_json_output(observed_stdout)) {
      (Some(reference_json), Some(observed_json)) =>
        if !reference_json.equal(observed_json) {
          collect_json_differences(
            reference_json,
            observed_json,
            observed.target,
            kind,
            "",
            0,
            differences,
          )
        }
      _ =>
        if reference_stdout != observed_stdout {
          differences.push({
            target: observed.target,
            kind,
            field: "stdout",
            path: None,
            reference: reference_stdout,
            observed: observed_stdout,
          })
        }
    }
  } else if reference_stdout != observed_stdout {
    differences.push({
      target: observed.target,
      kind,
      field: "stdout",
      path: None,
      reference: reference_stdout,
      observed: observed_stdout,
    })
  }
  if policy.compare_stderr {
    let reference_stderr = normalize_output(reference.stderr, policy)
    let observed_stderr = normalize_output(observed.stderr, policy)
    if reference_stderr != observed_stderr {
      differences.push({
        target: observed.target,
        kind,
        field: "stderr",
        path: None,
        reference: reference_stderr,
        observed: observed_stderr,
      })
    }
  }
}

///|
fn parse_json_output(text : String) -> Json? {
  Some(@json.parse(text)) catch {
    _ => None
  }
}

///|
/// Collect deterministic JSON Pointer paths for nested differences.
fn collect_json_differences(
  reference : Json,
  observed : Json,
  target : String,
  kind : String,
  path : String,
  depth : Int,
  differences : Array[FieldDifference],
) -> Unit {
  if reference.equal(observed) {
    return
  }
  if depth >= 64 {
    differences.push({
      target,
      kind,
      field: "stdout_json",
      path: Some(if path == "" { "/" } else { path }),
      reference: "",
      observed: "",
    })
    return
  }
  match (reference, observed) {
    (Json::Object(reference_object), Json::Object(observed_object)) => {
      let keys = reference_object.keys().to_array()
      for key in observed_object.keys() {
        if !keys.contains(key) {
          keys.push(key)
        }
      }
      keys.sort()
      for key in keys {
        let child_path = path + "/" + escape_json_pointer_segment(key)
        match (reference_object.get(key), observed_object.get(key)) {
          (Some(reference_value), Some(observed_value)) =>
            collect_json_differences(
              reference_value,
              observed_value,
              target,
              kind,
              child_path,
              depth + 1,
              differences,
            )
          (Some(reference_value), None) =>
            push_json_difference(
              target,
              kind,
              child_path,
              reference_value,
              Json::string(""),
              differences,
            )
          (None, Some(observed_value)) =>
            push_json_difference(
              target,
              kind,
              child_path,
              Json::string(""),
              observed_value,
              differences,
            )
          (None, None) => ()
        }
      }
    }
    (Json::Array(reference_array), Json::Array(observed_array)) => {
      let shared_length = if reference_array.length() < observed_array.length() {
        reference_array.length()
      } else {
        observed_array.length()
      }
      let mut index = 0
      while index < shared_length {
        collect_json_differences(
          reference_array[index],
          observed_array[index],
          target,
          kind,
          path + "/" + index.to_string(),
          depth + 1,
          differences,
        )
        index += 1
      }
      if reference_array.length() != observed_array.length() {
        differences.push({
          target,
          kind,
          field: "stdout_json",
          path: Some(path + "/length"),
          reference: reference_array.length().to_string(),
          observed: observed_array.length().to_string(),
        })
      }
    }
    _ =>
      push_json_difference(target, kind, path, reference, observed, differences)
  }
}

///|
fn push_json_difference(
  target : String,
  kind : String,
  path : String,
  reference : Json,
  observed : Json,
  differences : Array[FieldDifference],
) -> Unit {
  differences.push({
    target,
    kind,
    field: "stdout_json",
    path: Some(if path == "" { "/" } else { path }),
    reference: reference.stringify(),
    observed: observed.stringify(),
  })
}

///|
fn escape_json_pointer_segment(segment : String) -> String {
  segment.replace_all(old="~", new="~0").replace_all(old="/", new="~1")
}

///|
/// Compare a batch of independent scenarios and aggregate their status.
pub fn compare_suite(runs : Array[ScenarioRun]) -> SuiteReport {
  aggregate_suite_reports(runs.map(run => compare_scenario(run)))
}

///|
/// Compare target parity and any committed expected-result contract.
/// An empty expectations array means parity-only mode.
pub fn compare_contract_scenario(contract : ContractScenario) -> ParityReport {
  let run = contract.scenario
  let base = compare_scenario(run)
  if contract.expectations.length() == 0 {
    return base
  }
  let differences = base.differences.copy()
  let diagnostics = base.diagnostics.copy()
  let seen_expectations : Array[String] = []
  for expectation in contract.expectations {
    if !contains_string(run.expected_targets, expectation.target) {
      diagnostics.push(
        "expectation target is not declared: " + expectation.target,
      )
    }
    if contains_string(seen_expectations, expectation.target) {
      diagnostics.push(
        "duplicate expected-result contract for " + expectation.target,
      )
    } else {
      seen_expectations.push(expectation.target)
      match find_observation(run.observations, expectation.target) {
        Some(actual) =>
          compare_observation_fields(
            expectation,
            actual,
            run.policy,
            differences,
            "expected-result",
          )
        None => ()
      }
    }
  }
  for target in run.expected_targets {
    if !contains_string(seen_expectations, target) {
      diagnostics.push("missing expected-result contract for " + target)
    }
  }
  let status = if differences.length() > 0 {
    Status::Divergent
  } else if diagnostics.length() > 0 || base.status is Status::Inconclusive {
    Status::Inconclusive
  } else {
    Status::Pass
  }
  {
    scenario: base.scenario,
    status,
    diagnostics,
    reference_target: base.reference_target,
    compared_targets: base.compared_targets,
    missing_targets: base.missing_targets,
    unexpected_targets: base.unexpected_targets,
    duplicate_targets: base.duplicate_targets,
    differences,
  }
}

///|
/// Aggregate scenarios that include per-target expected results.
pub fn compare_contract_suite(
  contracts : Array[ContractScenario],
) -> SuiteReport {
  aggregate_suite_reports(
    contracts.map(contract => compare_contract_scenario(contract)),
  )
}

///|
fn find_observation(
  observations : Array[Observation],
  target : String,
) -> Observation? {
  for observation in observations {
    if observation.target == target {
      return Some(observation)
    }
  }
  None
}

///|
fn aggregate_suite_reports(reports : Array[ParityReport]) -> SuiteReport {
  let mut passed = 0
  let mut divergent = 0
  let mut inconclusive = 0
  for report in reports {
    match report.status {
      Pass => passed += 1
      Divergent => divergent += 1
      Inconclusive => inconclusive += 1
    }
  }
  let status = if divergent > 0 {
    Status::Divergent
  } else if inconclusive > 0 || reports.length() == 0 {
    Status::Inconclusive
  } else {
    Status::Pass
  }
  {
    status,
    scenario_count: reports.length(),
    passed,
    divergent,
    inconclusive,
    reports,
  }
}