///|
/// Broadcast an envelope to all sinks.
fn emit(
  sinks : Array[&@core.MessageSink],
  envelope : @cucumber_messages.Envelope,
) -> Unit {
  for sink in sinks {
    sink.on_message(envelope)
  }
}

///|
/// Write formatter output to the configured destination.
fn write_formatter_output(
  entry : FormatterEntry,
  strict~ : Bool = false,
) -> Unit raise SyncExecutionError {
  let content = entry.sink.output()
  if content.length() == 0 {
    return
  }
  match entry.dest {
    Stdout => println(content)
    Stderr => write_host_output("/dev/stderr", content + "\n", strict~)
    File(path) => write_host_output(path, content, strict~)
  }
}

///|
fn write_host_output(
  path : String,
  content : String,
  strict~ : Bool,
) -> Unit raise SyncExecutionError {
  @fs.write_string_to_file(path, content) catch {
    @fs.IOError(message) =>
      if strict {
        raise SyncExecutionError::HostIoFailed(
          operation="write",
          path~,
          message~,
        )
      }
  }
}

///|
fn make_meta_envelope() -> @cucumber_messages.Envelope {
  let json : Json = {
    "meta": {
      "protocolVersion": Json("25.0.1"),
      "implementation": { "name": Json("moonspec"), "version": Json("0.4.0") },
      "runtime": { "name": Json("moonbit") },
      "os": { "name": Json("unknown") },
      "cpu": { "name": Json("unknown") },
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
/// Cucumber Messages media type for a Gherkin source, based on its URI.
fn gherkin_media_type(uri : String) -> String {
  if uri.has_suffix(".feature.md") {
    "text/x.cucumber.gherkin+markdown"
  } else {
    "text/x.cucumber.gherkin+plain"
  }
}

///|
fn make_source_envelope(
  uri : String,
  data : String,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "source": {
      "uri": Json(uri),
      "data": Json(data),
      "mediaType": Json(gherkin_media_type(uri)),
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_run_started_envelope(id : String) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testRunStarted": {
      "timestamp": { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) },
      "id": Json(id),
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_run_finished_envelope(
  success : Bool,
  run_id : String,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testRunFinished": {
      "success": Json(success),
      "timestamp": { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) },
      "testRunStartedId": Json(run_id),
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_case_started_envelope(
  id : String,
  test_case_id : String,
  attempt~ : Int32 = 0,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testCaseStarted": {
      "attempt": Json(attempt),
      "id": Json(id),
      "testCaseId": Json(test_case_id),
      "timestamp": { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) },
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_case_finished_envelope(
  test_case_started_id : String,
  will_be_retried~ : Bool = false,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testCaseFinished": {
      "testCaseStartedId": Json(test_case_started_id),
      "timestamp": { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) },
      "willBeRetried": Json(will_be_retried),
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_step_started_envelope(
  test_case_started_id : String,
  test_step_id : String,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testStepStarted": {
      "testCaseStartedId": Json(test_case_started_id),
      "testStepId": Json(test_step_id),
      "timestamp": { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) },
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_step_finished_envelope(
  test_case_started_id : String,
  test_step_id : String,
  status : String,
  message : String?,
) -> @cucumber_messages.Envelope {
  let result_json : Map[String, Json] = Map([])
  result_json["duration"] = {
    "seconds": Json((0 : Int32)),
    "nanos": Json((0 : Int32)),
  }
  result_json["status"] = Json(status)
  match message {
    Some(msg) => result_json["message"] = Json(msg)
    None => ()
  }
  let json : Json = {
    "testStepFinished": {
      "testCaseStartedId": Json(test_case_started_id),
      "testStepId": Json(test_step_id),
      "testStepResult": Json(result_json),
      "timestamp": { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) },
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_attachment_envelope(
  body : String,
  content_encoding : String,
  media_type : String,
  file_name : String?,
  test_case_started_id : String?,
  test_step_id : String?,
) -> @cucumber_messages.Envelope {
  let json_map : Map[String, Json] = Map([])
  json_map["body"] = Json(body)
  json_map["contentEncoding"] = Json(content_encoding)
  json_map["mediaType"] = Json(media_type)
  match file_name {
    Some(f) => json_map["fileName"] = Json(f)
    None => ()
  }
  match test_case_started_id {
    Some(id) => json_map["testCaseStartedId"] = Json(id)
    None => ()
  }
  match test_step_id {
    Some(id) => json_map["testStepId"] = Json(id)
    None => ()
  }
  json_map["timestamp"] = (
    { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) } : Json)
  let json : Json = { "attachment": Json(json_map) }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_external_attachment_envelope(
  url : String,
  media_type : String,
  test_case_started_id : String?,
  test_step_id : String?,
) -> @cucumber_messages.Envelope {
  let json_map : Map[String, Json] = Map([])
  json_map["url"] = Json(url)
  json_map["mediaType"] = Json(media_type)
  match test_case_started_id {
    Some(id) => json_map["testCaseStartedId"] = Json(id)
    None => ()
  }
  match test_step_id {
    Some(id) => json_map["testStepId"] = Json(id)
    None => ()
  }
  json_map["timestamp"] = (
    { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) } : Json)
  let json : Json = { "externalAttachment": Json(json_map) }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
/// Drain pending attachments from a run-level hook and emit as envelopes.
fn emit_run_hook_attachments(
  sinks : Array[&@core.MessageSink],
  attachments : Array[@core.PendingAttachment],
  test_run_hook_started_id : String,
) -> Unit {
  for att in attachments {
    match att {
      @core.PendingAttachment::Embedded(
        body~,
        encoding~,
        media_type~,
        file_name~
      ) => {
        let enc_str = encoding.to_json().stringify()
        let enc_clean = enc_str[1:enc_str.length() - 1].to_owned()
        let json_map : Map[String, Json] = Map([])
        json_map["body"] = Json(body)
        json_map["contentEncoding"] = Json(enc_clean)
        json_map["mediaType"] = Json(media_type)
        match file_name {
          Some(f) => json_map["fileName"] = Json(f)
          None => ()
        }
        json_map["testRunHookStartedId"] = Json(test_run_hook_started_id)
        json_map["timestamp"] = (
          { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) } : Json)
        let json : Json = { "attachment": Json(json_map) }
        let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
          _ => continue
        }
        emit(sinks, envelope)
      }
      @core.PendingAttachment::External(url~, media_type~) => {
        let json_map : Map[String, Json] = Map([])
        json_map["url"] = Json(url)
        json_map["mediaType"] = Json(media_type)
        json_map["testRunHookStartedId"] = Json(test_run_hook_started_id)
        json_map["timestamp"] = (
          { "seconds": Json((0 : Int32)), "nanos": Json((0 : Int32)) } : Json)
        let json : Json = { "externalAttachment": Json(json_map) }
        let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
          _ => continue
        }
        emit(sinks, envelope)
      }
    }
  }
}

///|
/// Map a StepStatus to a cucumber-messages status string.
fn step_status_to_string(status : StepStatus) -> String {
  match status {
    StepStatus::Passed => "PASSED"
    StepStatus::Failed(_) => "FAILED"
    StepStatus::Skipped(_) => "SKIPPED"
    StepStatus::Undefined => "UNDEFINED"
    StepStatus::Pending => "PENDING"
  }
}

///|
/// Map a StepStatus to a failure message (if any).
fn step_status_message(status : StepStatus) -> String? {
  match status {
    StepStatus::Failed(msg) => Some(msg)
    StepStatus::Skipped(reason) => reason
    _ => None
  }
}

///|
/// Mapping from pickle to its test case ID and test step IDs.
priv struct TestCaseMapping {
  test_case_id : String
  test_step_ids : Array[String]
  regular_step_ids : Array[String]
}

///|
/// Extract test case mappings from test case envelopes, keyed by pickle ID.
fn extract_test_case_mappings(
  tc_envelopes : Array[@cucumber_messages.Envelope],
) -> Map[String, TestCaseMapping] {
  let mappings : Map[String, TestCaseMapping] = Map([])
  for env in tc_envelopes {
    match env {
      @cucumber_messages.Envelope::TestCase(tc) => {
        let step_ids : Array[String] = []
        let regular_step_ids : Array[String] = []
        for ts in tc.testSteps {
          step_ids.push(ts.id)
          if ts.pickleStepId is Some(_) {
            regular_step_ids.push(ts.id)
          }
        }
        mappings[tc.pickleId] = {
          test_case_id: tc.id,
          test_step_ids: step_ids,
          regular_step_ids,
        }
      }
      _ => ()
    }
  }
  mappings
}

///|
/// Parse a retry count from pickle tags.
///
/// Scans for a tag matching `@retry(N)` where N is a non-negative integer.
/// Returns `Some(n)` for the first match, or `None` if no retry tag is found.
/// Invalid formats like `@retry()`, `@retry(abc)`, or `@retry(-1)` are ignored.
///
/// ```
/// parse_retry_tag(["@smoke", "@retry(3)"]) // => Some(3)
/// parse_retry_tag(["@smoke", "@slow"])      // => None
/// ```
fn parse_retry_tag(tags : Array[String]) -> Int32? {
  let max_retries = 100
  for tag in tags {
    let n : Int32 = lexmatch tag with longest {
      re"^@retry\([0-9]+\)$" as matched => {
        let digits = matched["@retry(".length():matched.length() - 1]
        let parsed = @string.parse_int(digits) catch { _ => 0 }
        if parsed > max_retries {
          max_retries
        } else {
          parsed
        }
      }
      _ => continue
    }
    return Some(n)
  }
  None
}

///|
/// Find the line of the scenario, or of the Examples row, that a pickle was
/// compiled from.
fn pickle_line(
  cache : FeatureCache,
  pickle : @cucumber_messages.Pickle,
) -> Int32? {
  guard cache.get(pickle.uri) is Some(feature) else { return None }
  guard pickle.astNodeIds.get(0) is Some(scenario_id) else { return None }
  let scenarios : Array[@gherkin.Scenario] = []
  for child in feature.children {
    match child {
      Scenario(s) => scenarios.push(s)
      Rule(rule) =>
        for rule_child in rule.children {
          if rule_child is Scenario(s) {
            scenarios.push(s)
          }
        }
      _ => ()
    }
  }
  for scenario in scenarios {
    if scenario.id != scenario_id {
      continue
    }
    guard pickle.astNodeIds.get(1) is Some(row_id) else {
      return Some(scenario.location.line)
    }
    for examples in scenario.examples {
      for row in examples.table_body {
        if row.id == row_id {
          return Some(row.location.line)
        }
      }
    }
    return Some(scenario.location.line)
  }
  None
}

///|
/// Collect MoonspecError for each non-passing scenario, with every step.
fn collect_scenario_errors(
  result : RunResult,
  locations : Map[String, String],
) -> Array[@core.MoonspecError] {
  let errors : Array[@core.MoonspecError] = []
  for feature in result.features {
    for scenario in feature.scenarios {
      match scenario.status {
        ScenarioStatus::Passed | ScenarioStatus::Skipped(_) => continue
        _ => {
          let steps : Array[@core.StepReport] = []
          let step_errors : Array[@core.MoonspecError] = []
          for step in scenario.steps {
            let problem : @core.MoonspecError? = match
              (step.status, step.diagnostic) {
              (_, Some(@core.UndefinedStep(..) as e)) => Some(e)
              (_, Some(@core.PendingStep(..) as e)) => Some(e)
              (_, Some(@core.MoonspecError::StepFailed(..) as e)) => Some(e)
              (StepStatus::Failed(message), _) =>
                match
                  @core.step_failed_error(
                    step=step.text,
                    keyword=step.keyword,
                    message~,
                  ) {
                  @core.MoonspecError::StepFailed(..) as e => Some(e)
                  _ => None
                }
              _ => None
            }
            match (problem, step.status) {
              (Some(e), _) => {
                steps.push(Problem(e))
                step_errors.push(e)
              }
              (None, StepStatus::Passed) =>
                steps.push(Passed(keyword=step.keyword, step=step.text))
              (None, _) =>
                steps.push(Skipped(keyword=step.keyword, step=step.text))
            }
          }
          errors.push(
            @core.scenario_failed_error(
              scenario=scenario.scenario_name,
              feature=feature.name,
              location=locations.get(scenario.pickle_id),
              steps~,
              errors=step_errors,
            ),
          )
        }
      }
    }
  }
  errors
}

///|
fn format_run_summary(
  summary : RunSummary,
  parse_errors~ : Int32 = 0,
) -> String {
  let parts : Array[String] = []
  if parse_errors > 0 {
    let noun = if parse_errors == 1 { "parse error" } else { "parse errors" }
    parts.push(parse_errors.to_string() + " " + noun)
  }
  if summary.failed > 0 {
    parts.push(summary.failed.to_string() + " failed")
  }
  if summary.undefined > 0 {
    parts.push(summary.undefined.to_string() + " undefined")
  }
  if summary.pending > 0 {
    parts.push(summary.pending.to_string() + " pending")
  }
  if summary.retried > 0 {
    parts.push(summary.retried.to_string() + " retried")
  }
  parts.push(summary.total_scenarios.to_string() + " total")
  parts.join(", ")
}

///|
/// Build a PickleResult for a scenario that should be skipped.
///
/// Emits `TestCaseStarted`, per-step `TestStepStarted`/`TestStepFinished`,
/// and `TestCaseFinished` envelopes when sinks are present. All steps are
/// marked `SKIPPED` with the given reason.
fn make_skipped_pickle_result(
  pickle : @cucumber_messages.Pickle,
  tags : Array[String],
  reason : String,
  mapping : TestCaseMapping?,
  test_case_id : String,
  test_step_ids : Array[String],
  sinks : Array[&@core.MessageSink],
  id_gen : IdGenerator,
) -> PickleResult {
  let step_results : Array[StepResult] = pickle.steps.map(fn(step) {
    let keyword = match step.type_ {
      Some(@cucumber_messages.PickleStepType::Context) => "Given "
      Some(@cucumber_messages.PickleStepType::Action) => "When "
      Some(@cucumber_messages.PickleStepType::Outcome) => "Then "
      _ => "* "
    }
    {
      text: step.text,
      keyword,
      status: StepStatus::Skipped(Some(reason)),
      duration_ms: 0L,
      diagnostic: None,
    }
  })
  // Emit TestCaseStarted/Finished if sinks present
  match mapping {
    Some(_) => {
      let tcs_id = id_gen.next("tcs")
      if sinks.length() > 0 {
        emit(sinks, make_test_case_started_envelope(tcs_id, test_case_id))
        let mut si = 0
        for _step in pickle.steps {
          let ts_id = if si < test_step_ids.length() {
            test_step_ids[si]
          } else {
            ""
          }
          si += 1
          emit(sinks, make_test_step_started_envelope(tcs_id, ts_id))
          emit(
            sinks,
            make_test_step_finished_envelope(
              tcs_id,
              ts_id,
              "SKIPPED",
              Some(reason),
            ),
          )
        }
        emit(sinks, make_test_case_finished_envelope(tcs_id))
      }
    }
    None => ()
  }
  let result : ScenarioResult = {
    feature_name: pickle.uri,
    scenario_name: pickle.name,
    pickle_id: pickle.id,
    tags,
    steps: step_results,
    status: ScenarioStatus::Skipped(Some(reason)),
    duration_ms: 0L,
  }
  { result, was_retried: false, }
}

///|
/// Execute a single pickle with a fresh world, with optional retry.
///
/// When `retries > 0` or the pickle has a `@retry(N)` tag, failed scenarios are
/// re-executed synchronously. Each attempt creates a fresh
/// World instance via `factory()` and emits its own `TestCaseStarted`/
/// `TestCaseFinished` envelope pair with the correct `attempt` number and
/// `willBeRetried` flag.
///
/// The `@retry(N)` tag takes priority over the global `retries` parameter.
/// Returns a `PickleResult` containing the final attempt's `ScenarioResult` and
/// a flag indicating whether the scenario was retried.
fn[W : @core.World] execute_pickle(
  factory : () -> W,
  pickle : @cucumber_messages.Pickle,
  sinks~ : Array[&@core.MessageSink] = [],
  tc_mappings~ : Map[String, TestCaseMapping] = {},
  id_gen~ : IdGenerator = IdGenerator::new(),
  retries~ : Int32 = 0,
  dry_run~ : Bool = false,
  skip_tags~ : Array[String] = [],
) -> PickleResult raise Error {
  let tags = pickle.tags.map(fn(t) { t.name })
  let tag_retries = parse_retry_tag(tags)
  let max_retries = match tag_retries {
    Some(n) => n
    None => retries
  }
  let mapping = tc_mappings.get(pickle.id)
  let test_case_id = match mapping {
    Some(m) => m.test_case_id
    None => ""
  }
  let test_step_ids = match mapping {
    Some(m) => m.test_step_ids
    None => []
  }
  // Check if scenario should be skipped via tags
  let skip_reason = @config.skip_reason(tags, skip_tags)
  if skip_reason is Some(reason) {
    let regular_step_ids = match mapping {
      Some(m) => m.regular_step_ids
      None => []
    }
    return make_skipped_pickle_result(
      pickle, tags, reason, mapping, test_case_id, regular_step_ids, sinks, id_gen,
    )
  }
  for current_attempt = 0 {
    let world = factory()
    let setup = @core.Setup::new()
    @core.World::configure(world, setup)
    let registry = setup.step_registry()
    let hook_registry = setup.hook_registry()
    // Emit TestCaseStarted
    let tcs_id = match mapping {
      Some(_) => {
        let tcs_id = id_gen.next("tcs")
        if sinks.length() > 0 {
          emit(
            sinks,
            make_test_case_started_envelope(
              tcs_id,
              test_case_id,
              attempt=current_attempt,
            ),
          )
        }
        Some(tcs_id)
      }
      None => None
    }
    let result = execute_scenario(
      registry,
      feature_name=pickle.uri,
      scenario_name=pickle.name,
      pickle_id=pickle.id,
      tags~,
      steps=pickle.steps,
      hook_registry~,
      sinks~,
      test_case_started_id=tcs_id.unwrap_or(""),
      test_step_ids~,
      dry_run~,
    )
    // Only retry on actual failures — not Undefined, Pending, or Skipped
    let is_failed = result.status == ScenarioStatus::Failed
    let has_retries_left = !dry_run && current_attempt < max_retries
    // Emit TestCaseFinished
    match tcs_id {
      Some(id) =>
        if sinks.length() > 0 {
          emit(
            sinks,
            make_test_case_finished_envelope(
              id,
              will_be_retried=is_failed && has_retries_left,
            ),
          )
        }
      None => ()
    }
    if !is_failed || !has_retries_left {
      break { result, was_retried: current_attempt > 0, }
    }
    continue current_attempt + 1
  }
}

///|
/// Group scenario results by feature URI and look up feature names from cache.
fn group_by_feature(
  results : Array[ScenarioResult],
  cache : FeatureCache,
) -> Array[FeatureResult] {
  let groups : Map[String, Array[ScenarioResult]] = Map([])
  let order : Array[String] = []
  for r in results {
    let key = r.feature_name
    match groups.get(key) {
      Some(arr) => arr.push(r)
      None => {
        groups.set(key, [r])
        order.push(key)
      }
    }
  }
  let feature_results : Array[FeatureResult] = []
  for uri in order {
    let scenarios = match groups.get(uri) {
      Some(arr) => arr
      None => []
    }
    let name = match cache.get(uri) {
      Some(f) => f.name
      None => uri
    }
    feature_results.push({ name, scenarios, duration_ms: 0L, })
  }
  feature_results
}

///|
fn compute_summary(
  features : Array[FeatureResult],
  retried~ : Int32 = 0,
) -> RunSummary {
  let mut total = 0
  let mut passed = 0
  let mut failed = 0
  let mut undefined = 0
  let mut pending = 0
  let mut skipped = 0
  for f in features {
    for s in f.scenarios {
      total = total + 1
      match s.status {
        ScenarioStatus::Passed => passed = passed + 1
        ScenarioStatus::Failed => failed = failed + 1
        ScenarioStatus::Undefined => undefined = undefined + 1
        ScenarioStatus::Pending => pending = pending + 1
        ScenarioStatus::Skipped(_) => skipped = skipped + 1
      }
    }
  }
  {
    total_scenarios: total,
    passed,
    failed,
    undefined,
    pending,
    skipped,
    retried,
    duration_ms: 0L,
  }
}