///|
/// Streaming scanner for input that arrives in chunks: log tailing, socket
/// reads, or prompt fragments. Findings carry absolute offsets into the whole
/// stream, never into the current chunk.
///
/// The scanner keeps an `overlap`-sized tail of unseen data so secrets that
/// straddle a chunk boundary are still detected. Correctness holds for any
/// secret whose full span is no longer than `overlap`; longer spans can be
/// split and missed, so pick `overlap` above the largest expected secret (PEM
/// private-key blocks are the usual ceiling; the default fits 8 KB keys).
struct ChunkScanner {
  config : ScanConfig
  overlap : Int
  mut buffer : StringBuilder
  mut base : Int
  mut finished : Bool
  mut emitted_until : Int
}

///|
/// Findings can start after their trigger text: assignments need the key,
/// bearer and basic auth need the header word, URLs need the scheme. The
/// window must retain this much text before the first unsettled finding or
/// re-scanning a sliding window loses the match.
const CHUNK_CONTEXT_MARGIN : Int = 32

///|
/// Build a chunked scanner. `overlap` is clamped to at least 64 code units.
pub fn ChunkScanner::new(
  config? : ScanConfig = ScanConfig::standard(),
  overlap? : Int = 8192,
) -> ChunkScanner {
  {
    config,
    overlap: if overlap < 64 {
      64
    } else {
      overlap
    },
    buffer: StringBuilder(),
    base: 0,
    finished: false,
    emitted_until: 0,
  }
}

///|
/// Consume one chunk and return every finding whose end position is settled:
/// at least `overlap` code units of later data exist, so no future chunk can
/// extend or start a match overlapping it.
pub fn ChunkScanner::push(
  self : ChunkScanner,
  chunk : String,
) -> Array[Finding] {
  if self.finished || chunk.length() == 0 {
    return []
  }
  self.buffer.write_string(chunk)
  let window = self.buffer.to_string()
  // to_string hands off the backing array when it is exactly full, so the
  // builder must be re-seeded from the materialized window in every path.
  self.buffer = StringBuilder()
  self.buffer.write_string(window)
  if window.length() <= self.overlap {
    return []
  }
  let limit = window.length() - self.overlap
  let findings = scan_with_config(window, self.config)
  let settled : Array[Finding] = []
  let mut drop = limit
  for finding in findings {
    if finding.end <= limit {
      let abs_start = self.base + finding.start
      // The window can retreat behind the context margin, re-showing an
      // already-emitted match; dedup keeps it single-reported.
      if abs_start >= self.emitted_until {
        settled.push({
          kind: finding.kind,
          start: abs_start,
          end: self.base + finding.end,
          confidence: finding.confidence,
        })
        self.emitted_until = self.base + finding.end
      }
      drop = finding.end
    } else {
      // Keep the finding plus its trigger context alive until it settles.
      let keep_from = if finding.start < CHUNK_CONTEXT_MARGIN {
        0
      } else {
        finding.start - CHUNK_CONTEXT_MARGIN
      }
      if keep_from < drop {
        drop = keep_from
      }
      break
    }
  }
  self.base += drop
  let kept = window[drop:].to_owned()
  self.buffer = StringBuilder()
  self.buffer.write_string(kept)
  settled
}

///|
/// Push complete log lines with their newline included and collect settled
/// findings. Equivalent to one push per line, but a single buffer append.
pub fn ChunkScanner::push_lines(
  self : ChunkScanner,
  lines : Array[String],
) -> Array[Finding] {
  let batch = StringBuilder()
  for line in lines {
    batch.write_string(line)
    batch.write_char(Int::unsafe_to_char(10))
  }
  self.push(batch.to_string())
}

///|
/// Flush the remaining tail and close the stream. Chunks pushed afterwards
/// are ignored.
pub fn ChunkScanner::finish(self : ChunkScanner) -> Array[Finding] {
  if self.finished {
    return []
  }
  self.finished = true
  let window = self.buffer.to_string()
  self.buffer = StringBuilder()
  self.buffer.write_string(window)
  let findings = scan_with_config(window, self.config)
  let out : Array[Finding] = []
  for finding in findings {
    if self.base + finding.start >= self.emitted_until {
      out.push({
        kind: finding.kind,
        start: self.base + finding.start,
        end: self.base + finding.end,
        confidence: finding.confidence,
      })
    }
  }
  self.buffer = StringBuilder()
  self.base = 0
  out
}