///|
/// Incrementally audits terminal output that arrives in multiple chunks.
///
/// A scanner buffers only a trailing control sequence that may be completed by
/// a later chunk. Plain text and complete sequences are emitted immediately.
pub struct StreamScanner {
  policy : Policy
  mut pending : String
  mut received_length : Int
}

///|
/// Creates a streaming scanner with the strict default policy.
pub fn StreamScanner::new(policy? : Policy = Policy::strict()) -> StreamScanner {
  { policy, pending: "", received_length: 0 }
}

///|
/// Returns the number of characters currently buffered as a partial sequence.
pub fn StreamScanner::pending_length(self : StreamScanner) -> Int {
  self.pending.to_array().length()
}

///|
/// Returns the total number of characters fed since creation or the last reset.
pub fn StreamScanner::received_length(self : StreamScanner) -> Int {
  self.received_length
}

///|
/// Returns true when no partial terminal sequence is buffered.
pub fn StreamScanner::is_idle(self : StreamScanner) -> Bool {
  self.pending.length() == 0
}

///|
/// Feeds one output chunk into the scanner.
///
/// Finding offsets are relative to the complete logical stream, not to the
/// current chunk.
pub fn StreamScanner::feed(self : StreamScanner, chunk : String) -> ScanResult {
  let pending_length = self.pending.to_array().length()
  let combined_start = self.received_length - pending_length
  let combined = self.pending + chunk
  let chars = combined.to_array()
  self.received_length = self.received_length + chunk.to_array().length()
  let complete_end = stream_complete_end(chars, self.policy)
  let complete = String::from_array(chars[0:complete_end])
  self.pending = String::from_array(chars[complete_end:])
  shift_result_offsets(scan_with_policy(complete, self.policy), combined_start)
}

///|
/// Finalizes a logical stream.
///
/// Any buffered partial sequence is audited as truncated. The scanner is reset
/// after the result is produced and can be reused for another logical stream.
pub fn StreamScanner::finish(self : StreamScanner) -> ScanResult {
  let pending_length = self.pending.to_array().length()
  let pending_start = self.received_length - pending_length
  let result = shift_result_offsets(
    scan_with_policy(self.pending, self.policy),
    pending_start,
  )
  self.pending = ""
  self.received_length = 0
  result
}

///|
/// Discards buffered state without emitting a finding.
pub fn StreamScanner::reset(self : StreamScanner) -> Unit {
  self.pending = ""
  self.received_length = 0
}

///|
fn shift_result_offsets(result : ScanResult, delta : Int) -> ScanResult {
  if delta == 0 || result.findings.length() == 0 {
    result
  } else {
    let shifted : Array[Finding] = []
    for finding in result.findings {
      shifted.push({
        kind: finding.kind,
        severity: finding.severity,
        offset: finding.offset + delta,
        sequence: finding.sequence,
        message: finding.message,
      })
    }
    {
      text: result.text,
      findings: shifted,
      removed_count: result.removed_count,
    }
  }
}

///|
fn is_string_introducer(char : Char) -> Bool {
  char == ']' || char == 'P' || char == '_' || char == '^'
}

///|
fn is_c1_string_introducer(code : Int) -> Bool {
  code == 0x9d || code == 0x90 || code == 0x9f || code == 0x9e
}

///|
fn stream_csi_end(
  chars : Array[Char],
  start : Int,
  prefix_length : Int,
  policy : Policy,
) -> Int? {
  let length = chars.length()
  let limit = start + policy.max_sequence_length
  let mut cursor = start + prefix_length
  while cursor < length && cursor < limit && !is_csi_final(chars[cursor]) {
    cursor = cursor + 1
  }
  if cursor < length && cursor < limit {
    Some(cursor + 1)
  } else if cursor >= limit {
    Some(if limit < length { limit } else { length })
  } else {
    None
  }
}

///|
fn stream_string_end(
  chars : Array[Char],
  start : Int,
  prefix_length : Int,
  policy : Policy,
  allow_bel : Bool,
) -> Int? {
  let length = chars.length()
  let limit = start + policy.max_sequence_length
  let mut cursor = start + prefix_length
  while cursor < length && cursor < limit {
    if allow_bel && chars[cursor] == '\u{07}' {
      return Some(cursor + 1)
    }
    if chars[cursor].to_int() == 0x9c {
      return Some(cursor + 1)
    }
    if chars[cursor] == '\u{1b}' {
      if cursor + 1 >= length {
        return None
      }
      if chars[cursor + 1] == '\\' {
        return Some(cursor + 2)
      }
    }
    cursor = cursor + 1
  }
  if cursor >= limit {
    Some(if limit < length { limit } else { length })
  } else {
    None
  }
}

///|
fn stream_escape_end(chars : Array[Char], start : Int, policy : Policy) -> Int? {
  let length = chars.length()
  if start + 1 >= length {
    return None
  }
  let introducer = chars[start + 1]
  if introducer == '[' {
    stream_csi_end(chars, start, 2, policy)
  } else if is_string_introducer(introducer) {
    stream_string_end(chars, start, 2, policy, introducer == ']')
  } else if introducer == '(' ||
    introducer == ')' ||
    introducer == '*' ||
    introducer == '+' ||
    introducer == '-' ||
    introducer == '.' ||
    introducer == '/' ||
    introducer == '%' ||
    introducer == '#' {
    if start + 2 < length {
      Some(start + 3)
    } else {
      None
    }
  } else {
    Some(start + 2)
  }
}

///|
fn stream_c1_end(chars : Array[Char], start : Int, policy : Policy) -> Int? {
  let code = chars[start].to_int()
  if code == 0x9b {
    stream_csi_end(chars, start, 1, policy)
  } else if is_c1_string_introducer(code) {
    stream_string_end(chars, start, 1, policy, code == 0x9d)
  } else {
    Some(start + 1)
  }
}

///|
fn stream_complete_end(chars : Array[Char], policy : Policy) -> Int {
  let mut index = 0
  while index < chars.length() {
    let char = chars[index]
    let next = if char == '\u{1b}' {
      stream_escape_end(chars, index, policy)
    } else if char.to_int() >= 0x80 && char.to_int() <= 0x9f {
      stream_c1_end(chars, index, policy)
    } else {
      Some(index + 1)
    }
    match next {
      Some(end) => index = end
      None => return index
    }
  }
  chars.length()
}