///|
fn marker_for(finding : Finding) -> String {
  "[termproof:\{finding.rule_id}:\{risk_name(finding.risk)}]"
}

///|
fn count_text_chars(input : String) -> Int {
  input.iter().count()
}

///|
fn push_bidi_findings(
  event : Event,
  policy : Policy,
  findings : Array[Finding],
  stats : Stats,
) -> Unit {
  let mut relative = 0
  for c in event.raw {
    if is_bidi_control(c) {
      let severity = Dangerous
      findings.push({
        rule_id: "TP-UNI-001",
        severity,
        risk: BidirectionalText,
        message: "Unicode bidirectional control can disguise the visual order of text",
        offset: event.offset + relative,
        sequence: visible(String::from_array([c])),
        decision: if policy.remove_bidi {
          if policy.mark_dangerous {
            Mark
          } else {
            Drop
          }
        } else {
          Keep
        },
      })
      stats.dangerous += 1
      if policy.remove_bidi {
        stats.dropped += 1
      }
    }
    relative += 1
  }
}

///|
fn update_severity_stats(stats : Stats, severity : Severity) -> Unit {
  match severity {
    Info => stats.info += 1
    Warning => stats.warnings += 1
    Dangerous => stats.dangerous += 1
    Critical => stats.critical += 1
  }
}

///|
/// Parse, classify, and sanitize terminal output according to `policy`.
///
/// `plain_text` never contains terminal controls. `safe_terminal` may retain SGR
/// or OSC 8 only when explicitly allowed by the selected policy.
pub fn audit(
  input : String,
  policy? : Policy = Policy::strict(),
) -> AuditResult {
  let events = parse(input)
  let findings : Array[Finding] = []
  let plain = StringBuilder::new(size_hint=input.length())
  let safe = StringBuilder::new(size_hint=input.length())
  let stats = empty_stats(input.iter().count())
  for event in events {
    if event.kind == Text {
      let clean = if policy.remove_bidi {
        strip_bidi_controls(event.raw)
      } else {
        event.raw
      }
      plain.write_string(clean)
      safe.write_string(clean)
      stats.text_chars += count_text_chars(clean)
      push_bidi_findings(event, policy, findings, stats)
    } else {
      stats.control_events += 1
      guard classify(event, policy) is Some(finding) else { continue }
      findings.push(finding)
      update_severity_stats(stats, finding.severity)
      match finding.decision {
        Keep => safe.write_string(event.raw)
        Drop => stats.dropped += 1
        Mark => {
          stats.marked += 1
          safe.write_string(marker_for(finding))
          plain.write_string(marker_for(finding))
        }
      }
    }
  }
  let mut failed = false
  for finding in findings {
    if severity_rank(finding.severity) >= severity_rank(policy.fail_at) {
      failed = true
    }
  }
  {
    plain_text: plain.to_string(),
    safe_terminal: safe.to_string(),
    events,
    findings,
    stats,
    failed,
  }
}

///|
pub fn sanitize(input : String) -> String {
  audit(input, policy=Policy::plain()).plain_text
}

///|
pub fn sanitize_for_ci(input : String) -> String {
  audit(input, policy=Policy::ci()).safe_terminal
}

///|
pub fn has_terminal_controls(input : String) -> Bool {
  for event in parse(input) {
    if event.kind != Text {
      return true
    }
  }
  false
}