///|
/// Error raised to signal a failed attempt that should be retried.
priv suberror RetryableFailure {
  RetryableFailure(ScenarioResult)
}

///|
/// 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) -> Unit {
  let content = entry.sink.output()
  if content.length() == 0 {
    return
  }
  match entry.dest {
    Stdout => println(content)
    Stderr =>
      @fs.write_string_to_file("/dev/stderr", content + "\n") catch {
        _ => ()
      }
    File(path) => @fs.write_string_to_file(path, content) catch { _ => () }
  }
}

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

///|
fn make_source_envelope(
  uri : String,
  data : String,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "source": {
      "uri": uri.to_json(),
      "data": data.to_json(),
      "mediaType": "text/x.cucumber.gherkin+plain".to_json(),
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_run_started_envelope(id : String) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testRunStarted": {
      "timestamp": {
        "seconds": (0 : Int).to_json(),
        "nanos": (0 : Int).to_json(),
      },
      "id": id.to_json(),
    },
  }
  @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": success.to_json(),
      "timestamp": {
        "seconds": (0 : Int).to_json(),
        "nanos": (0 : Int).to_json(),
      },
      "testRunStartedId": run_id.to_json(),
    },
  }
  @json.from_json(json) catch {
    _ => panic()
  }
}

///|
fn make_test_case_started_envelope(
  id : String,
  test_case_id : String,
  attempt? : Int = 0,
) -> @cucumber_messages.Envelope {
  let json : Json = {
    "testCaseStarted": {
      "attempt": attempt.to_json(),
      "id": id.to_json(),
      "testCaseId": test_case_id.to_json(),
      "timestamp": {
        "seconds": (0 : Int).to_json(),
        "nanos": (0 : Int).to_json(),
      },
    },
  }
  @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": test_case_started_id.to_json(),
      "timestamp": {
        "seconds": (0 : Int).to_json(),
        "nanos": (0 : Int).to_json(),
      },
      "willBeRetried": will_be_retried.to_json(),
    },
  }
  @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": test_case_started_id.to_json(),
      "testStepId": test_step_id.to_json(),
      "timestamp": {
        "seconds": (0 : Int).to_json(),
        "nanos": (0 : Int).to_json(),
      },
    },
  }
  @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] = {}
  result_json["duration"] = {
    "seconds": (0 : Int).to_json(),
    "nanos": (0 : Int).to_json(),
  }
  result_json["status"] = status.to_json()
  match message {
    Some(msg) => result_json["message"] = msg.to_json()
    None => ()
  }
  let json : Json = {
    "testStepFinished": {
      "testCaseStartedId": test_case_started_id.to_json(),
      "testStepId": test_step_id.to_json(),
      "testStepResult": result_json.to_json(),
      "timestamp": {
        "seconds": (0 : Int).to_json(),
        "nanos": (0 : Int).to_json(),
      },
    },
  }
  @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] = {}
  json_map["body"] = body.to_json()
  json_map["contentEncoding"] = content_encoding.to_json()
  json_map["mediaType"] = media_type.to_json()
  match file_name {
    Some(f) => json_map["fileName"] = f.to_json()
    None => ()
  }
  match test_case_started_id {
    Some(id) => json_map["testCaseStartedId"] = id.to_json()
    None => ()
  }
  match test_step_id {
    Some(id) => json_map["testStepId"] = id.to_json()
    None => ()
  }
  json_map["timestamp"] = (
    { "seconds": (0 : Int).to_json(), "nanos": (0 : Int).to_json() } : Json)
  let json : Json = { "attachment": json_map.to_json() }
  @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] = {}
  json_map["url"] = url.to_json()
  json_map["mediaType"] = media_type.to_json()
  match test_case_started_id {
    Some(id) => json_map["testCaseStartedId"] = id.to_json()
    None => ()
  }
  match test_step_id {
    Some(id) => json_map["testStepId"] = id.to_json()
    None => ()
  }
  json_map["timestamp"] = (
    { "seconds": (0 : Int).to_json(), "nanos": (0 : Int).to_json() } : Json)
  let json : Json = { "externalAttachment": json_map.to_json() }
  @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.substring(start=1, end=enc_str.length() - 1)
        let json_map : Map[String, Json] = {}
        json_map["body"] = body.to_json()
        json_map["contentEncoding"] = enc_clean.to_json()
        json_map["mediaType"] = media_type.to_json()
        match file_name {
          Some(f) => json_map["fileName"] = f.to_json()
          None => ()
        }
        json_map["testRunHookStartedId"] = test_run_hook_started_id.to_json()
        json_map["timestamp"] = (
          { "seconds": (0 : Int).to_json(), "nanos": (0 : Int).to_json() } :
          Json)
        let json : Json = { "attachment": json_map.to_json() }
        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] = {}
        json_map["url"] = url.to_json()
        json_map["mediaType"] = media_type.to_json()
        json_map["testRunHookStartedId"] = test_run_hook_started_id.to_json()
        json_map["timestamp"] = (
          { "seconds": (0 : Int).to_json(), "nanos": (0 : Int).to_json() } :
          Json)
        let json : Json = { "externalAttachment": json_map.to_json() }
        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]
}

///|
/// 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] = {}
  for env in tc_envelopes {
    match env {
      @cucumber_messages.Envelope::TestCase(tc) => {
        let step_ids : Array[String] = []
        for ts in tc.testSteps {
          step_ids.push(ts.id)
        }
        mappings[tc.pickleId] = { test_case_id: tc.id, test_step_ids: 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]) -> Int? {
  let max_retries = 100
  for tag in tags {
    let n : Int = lexmatch tag with longest {
      ("@retry" "[(]" ("[0-9]+" as digits) "[)]") => {
        let parsed = @strconv.parse_int(digits.to_string()) catch { _ => 0 }
        if parsed > max_retries {
          max_retries
        } else {
          parsed
        }
      }
      _ => continue
    }
    return Some(n)
  }
  None
}

///|
/// Check if a pickle's tags match any configured skip tag.
///
/// Scans tags for matches against the skip tag list. Supports two formats:
/// - Bare tag: `@skip` → reason is the tag name without `@` (e.g., `"skip"`)
/// - Tag with reason: `@skip("reason")` → extracts the quoted reason string
///
/// Returns `Some(reason)` for the first match, or `None` if no skip tag found.
/// Invalid reason formats (empty parens, unquoted) fall back to the tag name.
///
/// ```
/// parse_skip_tag(["@skip(\"flaky\")"], ["@skip"]) // => Some("flaky")
/// parse_skip_tag(["@smoke"], ["@skip"])            // => None
/// ```
fn parse_skip_tag(tags : Array[String], skip_tags : Array[String]) -> String? {
  for tag in tags {
    let (name, reason) : (String, String?) = lexmatch tag with longest {
      ("@" ("[a-zA-Z_][a-zA-Z0-9_]*" as n) "[(]\"" ("[^\"]+" as r) "\"[)]") =>
        (n.to_string(), Some(r.to_string()))
      ("@" ("[a-zA-Z_][a-zA-Z0-9_]*" as n) "[(]" "[^)]*" "[)]") =>
        (n.to_string(), None)
      ("@" ("[a-zA-Z_][a-zA-Z0-9_]*" as n)) => (n.to_string(), None)
      _ => continue
    }
    let full_tag = "@" + name
    for skip in skip_tags {
      if full_tag == skip {
        return Some(reason.unwrap_or(name))
      }
    }
  }
  None
}

///|
/// Run all features and collect results.
///
/// A fresh world is created per scenario via `factory()` for isolation.
/// When `parallel` is greater than 0, pickles are executed concurrently using
/// `@async.all()` with bounded concurrency. Otherwise, pickles run sequentially.
/// Lifecycle hooks registered via `Setup` are called automatically when present.
pub async fn[W : @core.World] run(
  factory : () -> W,
  options : RunOptions,
) -> RunResult {
  let features = options.features()
  let sinks = options.get_sinks()
  let tag_expr = options.get_tag_expr()
  let scenario_name = options.get_scenario_name()
  let cache = FeatureCache::new()
  if sinks.length() > 0 {
    emit(sinks, make_meta_envelope())
  }
  let all_parse_errors : Array[ParseErrorInfo] = []
  for source in features {
    if sinks.length() > 0 {
      let (uri, data) = match source {
        FeatureSource::Text(path, content) => (path, content)
        FeatureSource::File(path) => {
          let content = @fs.read_file_to_string(path) catch { _ => "" }
          (path, content)
        }
        FeatureSource::Parsed(path, _) => (path, "")
      }
      emit(sinks, make_source_envelope(uri, data))
    }
    let errors = cache.load_from_source(source)
    all_parse_errors.append(errors)
  }
  // Emit GherkinDocument envelopes
  if sinks.length() > 0 {
    for entry in cache.features() {
      let uri = entry.0
      let json : Json = {
        "gherkinDocument": {
          "uri": uri.to_json(),
          "comments": ([] : Array[Int]).to_json(),
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  // Emit ParseError envelopes
  if sinks.length() > 0 {
    for pe in all_parse_errors {
      let source_ref : Map[String, Json] = {}
      source_ref["uri"] = pe.uri.to_json()
      match pe.line {
        Some(line) => source_ref["location"] = { "line": line.to_json() }
        None => ()
      }
      let json : Json = {
        "parseError": {
          "source": source_ref.to_json(),
          "message": pe.message.to_json(),
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  let pickles = compile_pickles(cache)
  // Emit Pickle envelopes
  if sinks.length() > 0 {
    for pickle in pickles {
      let json : Json = { "pickle": pickle.to_json() }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  let filter = PickleFilter::new()
  let filter = if tag_expr != "" { filter.with_tags(tag_expr) } else { filter }
  let filter = if scenario_name != "" {
    filter.with_names([scenario_name])
  } else {
    filter
  }
  let filtered = filter.apply(pickles)
  // Build shared registry for envelope metadata
  let setup = @core.Setup::new()
  let world0 = factory()
  @core.World::configure(world0, setup)
  let registry = setup.step_registry()
  let custom_param_types = setup.custom_param_types()
  // Emit StepDefinition envelopes
  let id_gen = IdGenerator::new()
  if sinks.length() > 0 {
    let sd_envelopes = build_step_definition_envelopes(registry)
    for env in sd_envelopes {
      emit(sinks, env)
    }
  }
  // Emit ParameterType envelopes for custom parameter types
  if sinks.length() > 0 {
    for cpt in custom_param_types {
      let regex_json : Array[Json] = cpt.patterns.map(fn(p) { p.to_json() })
      let json : Json = {
        "parameterType": {
          "id": id_gen.next_param_type_id().to_json(),
          "name": cpt.name.to_json(),
          "regularExpressions": regex_json.to_json(),
          "preferForRegularExpressionMatch": false.to_json(),
          "useForSnippets": true.to_json(),
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  // Emit Hook envelopes
  if sinks.length() > 0 {
    let hook_reg = setup.hook_registry()
    for hook in hook_reg.hooks() {
      let source_ref : Map[String, Json] = {}
      match hook.source {
        Some(src) => {
          match src.uri {
            Some(uri) => source_ref["uri"] = uri.to_json()
            None => ()
          }
          match src.line {
            Some(line) => source_ref["location"] = { "line": line.to_json() }
            None => ()
          }
        }
        None => ()
      }
      let hook_type_str = match hook.type_ {
        @core.HookType::BeforeTestRun => "BEFORE_TEST_RUN"
        @core.HookType::AfterTestRun => "AFTER_TEST_RUN"
        @core.HookType::BeforeTestCase => "BEFORE_TEST_CASE"
        @core.HookType::AfterTestCase => "AFTER_TEST_CASE"
        @core.HookType::BeforeTestStep => "BEFORE_TEST_STEP"
        @core.HookType::AfterTestStep => "AFTER_TEST_STEP"
      }
      let json : Json = {
        "hook": {
          "id": hook.id.to_json(),
          "sourceReference": source_ref.to_json(),
          "type": hook_type_str.to_json(),
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  // Test planning phase
  let tc_mappings : Map[String, TestCaseMapping] = if sinks.length() > 0 {
    let tc_envelopes = build_test_cases(
      registry,
      setup.hook_registry(),
      filtered,
      id_gen,
    )
    for env in tc_envelopes {
      emit(sinks, env)
    }
    extract_test_case_mappings(tc_envelopes)
  } else {
    {}
  }
  // Emit TestRunStarted
  let run_id = id_gen.next("tr")
  if sinks.length() > 0 {
    emit(sinks, make_test_run_started_envelope(run_id))
  }
  // Execute before_test_run hooks
  let before_run_hooks = setup
    .hook_registry()
    .by_type(@core.HookType::BeforeTestRun)
  for hook in before_run_hooks {
    let trhs_id = id_gen.next("trhs")
    if sinks.length() > 0 {
      let json : Json = {
        "testRunHookStarted": {
          "id": trhs_id.to_json(),
          "testRunStartedId": run_id.to_json(),
          "hookId": hook.id.to_json(),
          "timestamp": {
            "seconds": (0 : Int).to_json(),
            "nanos": (0 : Int).to_json(),
          },
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
    let hook_ctx = @core.RunHookCtx::new()
    let result_status = try {
      match hook.handler {
        @core.HookHandler::RunHandler(h) => h(hook_ctx)
        _ => ()
      }
      "PASSED"
    } catch {
      _ => "FAILED"
    }
    if sinks.length() > 0 && hook_ctx.pending_attachments().length() > 0 {
      emit_run_hook_attachments(sinks, hook_ctx.pending_attachments(), trhs_id)
      hook_ctx.pending_attachments().clear()
    }
    if sinks.length() > 0 {
      let json : Json = {
        "testRunHookFinished": {
          "testRunHookStartedId": trhs_id.to_json(),
          "result": {
            "duration": {
              "seconds": (0 : Int).to_json(),
              "nanos": (0 : Int).to_json(),
            },
            "status": result_status.to_json(),
          },
          "timestamp": {
            "seconds": (0 : Int).to_json(),
            "nanos": (0 : Int).to_json(),
          },
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  let retries = options.get_retries()
  let dry_run = options.is_dry_run()
  let skip_tags = options.get_skip_tags()
  let paired_results = if options.is_parallel() {
    run_pickles_parallel(
      factory,
      filtered,
      max_concurrent=options.get_max_concurrent(),
      sinks~,
      tc_mappings~,
      id_gen~,
      retries~,
      dry_run~,
      skip_tags~,
    )
  } else {
    run_pickles_sequential(
      factory,
      filtered,
      sinks~,
      tc_mappings~,
      id_gen~,
      retries~,
      dry_run~,
      skip_tags~,
    )
  }
  let results : Array[ScenarioResult] = paired_results.map(fn(pr) { pr.result })
  let retried_count = paired_results
    .iter()
    .fold(init=0, fn(acc, pr) { if pr.was_retried { acc + 1 } else { acc } })
  // Execute after_test_run hooks
  let run_errors : Array[@core.HookError] = []
  for r in results {
    if r.status != ScenarioStatus::Passed {
      let msg = match
        r.steps
        .iter()
        .find_first(fn(s) {
          match s.status {
            StepStatus::Failed(_) => true
            _ => false
          }
        }) {
        Some(s) =>
          match s.status {
            StepStatus::Failed(m) => m
            _ => "scenario failed"
          }
        None => "scenario failed"
      }
      run_errors.push(
        @core.HookError::ScenarioFailed(
          feature_name=r.feature_name,
          scenario_name=r.scenario_name,
          message=msg,
        ),
      )
    }
  }
  let run_hook_result : @core.HookResult = if run_errors.length() > 0 {
    @core.HookResult::Failed(run_errors)
  } else {
    @core.HookResult::Passed
  }
  let after_run_hooks = setup
    .hook_registry()
    .by_type(@core.HookType::AfterTestRun)
  for hook in after_run_hooks {
    let trhs_id = id_gen.next("trhs")
    if sinks.length() > 0 {
      let json : Json = {
        "testRunHookStarted": {
          "id": trhs_id.to_json(),
          "testRunStartedId": run_id.to_json(),
          "hookId": hook.id.to_json(),
          "timestamp": {
            "seconds": (0 : Int).to_json(),
            "nanos": (0 : Int).to_json(),
          },
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
    let hook_ctx = @core.RunHookCtx::new()
    let result_status = try {
      match hook.handler {
        @core.HookHandler::RunAfterHandler(h) => h(hook_ctx, run_hook_result)
        _ => ()
      }
      "PASSED"
    } catch {
      _ => "FAILED"
    }
    if sinks.length() > 0 && hook_ctx.pending_attachments().length() > 0 {
      emit_run_hook_attachments(sinks, hook_ctx.pending_attachments(), trhs_id)
      hook_ctx.pending_attachments().clear()
    }
    if sinks.length() > 0 {
      let json : Json = {
        "testRunHookFinished": {
          "testRunHookStartedId": trhs_id.to_json(),
          "result": {
            "duration": {
              "seconds": (0 : Int).to_json(),
              "nanos": (0 : Int).to_json(),
            },
            "status": result_status.to_json(),
          },
          "timestamp": {
            "seconds": (0 : Int).to_json(),
            "nanos": (0 : Int).to_json(),
          },
        },
      }
      let envelope : @cucumber_messages.Envelope = @json.from_json(json) catch {
        _ => continue
      }
      emit(sinks, envelope)
    }
  }
  // Emit TestRunFinished
  if sinks.length() > 0 {
    let all_passed = results
      .iter()
      .all(fn(r) { r.status == ScenarioStatus::Passed })
    emit(sinks, make_test_run_finished_envelope(all_passed, run_id))
  }
  let feature_results = group_by_feature(results, cache)
  let summary = compute_summary(feature_results, retried=retried_count)
  // Write formatter output to configured destinations
  for entry in options.get_formatters() {
    write_formatter_output(entry)
  }
  { features: feature_results, summary, parse_errors: all_parse_errors }
}

///|
/// Run all features, raising MoonspecError on any failure.
///
/// This is the ergonomic test API. Use in generated tests and manual tests
/// where you want structured error output instead of manual result inspection.
pub async fn[W : @core.World] run_or_fail(
  factory : () -> W,
  options : RunOptions,
) -> Unit {
  let result = run(factory, options)
  if result.parse_errors.length() > 0 ||
    result.summary.failed > 0 ||
    result.summary.undefined > 0 ||
    result.summary.pending > 0 {
    let errors = collect_scenario_errors(result)
    let summary = format_run_summary(result.summary)
    raise @core.run_failed_error(summary~, errors~)
  }
}

///|
/// Collect MoonspecError for each non-passing scenario.
fn collect_scenario_errors(result : RunResult) -> Array[@core.MoonspecError] {
  let errors : Array[@core.MoonspecError] = []
  for feature in result.features {
    for scenario in feature.scenarios {
      match scenario.status {
        ScenarioStatus::Passed => continue
        _ => {
          let step_errors : Array[@core.MoonspecError] = []
          for step in scenario.steps {
            match step.diagnostic {
              Some(@core.UndefinedStep(..) as e) => step_errors.push(e)
              Some(@core.PendingStep(..) as e) => step_errors.push(e)
              Some(@core.MoonspecError::StepFailed(..) as e) =>
                step_errors.push(e)
              _ => ()
            }
          }
          errors.push(
            @core.scenario_failed_error(
              scenario=scenario.scenario_name,
              feature=feature.name,
              errors=step_errors,
            ),
          )
        }
      }
    }
  }
  errors
}

///|
fn format_run_summary(summary : RunSummary) -> String {
  let parts : Array[String] = []
  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(", ")
}

///|
/// Run pickles sequentially (the default path).
async fn[W : @core.World] run_pickles_sequential(
  factory : () -> W,
  pickles : Array[@cucumber_messages.Pickle],
  sinks? : Array[&@core.MessageSink] = [],
  tc_mappings? : Map[String, TestCaseMapping] = {},
  id_gen? : IdGenerator = IdGenerator::new(),
  retries? : Int = 0,
  dry_run? : Bool = false,
  skip_tags? : Array[String] = [],
) -> Array[PickleResult] {
  let results : Array[PickleResult] = []
  for pickle in pickles {
    let result = execute_pickle(
      factory,
      pickle,
      sinks~,
      tc_mappings~,
      id_gen~,
      retries~,
      dry_run~,
      skip_tags~,
    )
    results.push(result)
  }
  results
}

///|
/// 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 using `@async.retry(Immediate)`. 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.
async 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? : Int = 0,
  dry_run? : Bool = false,
  skip_tags? : Array[String] = [],
) -> PickleResult {
  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 = parse_skip_tag(tags, skip_tags)
  if skip_reason is Some(reason) {
    return make_skipped_pickle_result(
      pickle, tags, reason, mapping, test_case_id, test_step_ids, sinks, id_gen,
    )
  }
  let attempt : Ref[Int] = { val: 0 }
  let execute_attempt : async () -> ScenarioResult = () => {
    let current_attempt = attempt.val
    attempt.val += 1
    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 = 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 failed and retries remain, raise to trigger @async.retry
    if is_failed && has_retries_left {
      raise RetryableFailure(result)
    }
    result
  }
  let result = if max_retries > 0 && not(dry_run) {
    @async.retry(
      @async.Immediate,
      max_retry=max_retries,
      fatal_error=fn(e) {
        match e {
          RetryableFailure(_) => false
          _ => true
        }
      },
      execute_attempt,
    ) catch {
      RetryableFailure(r) => r
      _ => panic()
    }
  } else {
    execute_attempt()
  }
  let was_retried = attempt.val > 1
  { result, was_retried }
}

///|
/// 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]] = {}
  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? : Int = 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,
  }
}