// `tally-digest v1`:把「报告说了什么」和「报告对应的内容是什么」绑在一起。
//
// 分工(这是本层存在的理由,别把两者混成一层):
// * 报告(`tally-plan v3`)给人看:哪段保留/截断/丢弃、多少 token、为什么;
// * 摘要(`tally-digest v1`)给机器看:报告正文的 sha256 + 每段内容的 sha256。
//
// 于是「报告有没有被改过」「这段内容是不是报告里那一段」都能用两个文件回答,
// 不需要信任报告里的数字。
//
// 段内容只覆盖 kept / truncated:被丢弃的段从未进入窗口,它的字节不在计划里。
// 这一点写进格式,不假装能证明没发生过的事。

///|
pub(all) struct DigestEntry {
  index : Int
  /// `kept` / `truncated` / `dropped`
  action : String
  /// 段内容的 sha256 hex;`dropped` 为 `-`
  sha : String
} derive(Debug)

///|
pub(all) struct PlanDigest {
  report_sha : String
  plan_sha : String
  entries : Array[DigestEntry]
} derive(Debug)

///|
pub suberror DigestError {
  /// 不认识的头部(版本漂移时宁可报错,也不猜格式)
  BadHeader(String)
  /// 第 `line` 行格式不合法
  MalformedLine(Int, String)
  /// 报告正文与 `report` 行不一致
  ReportHashMismatch(String, String)
  /// 段行与 `plan` 行不一致
  PlanHashMismatch(String, String)
  /// 段数与报告里的段数不一致
  SegmentCountMismatch(Int, Int)
  /// 第 `index` 段的动作在报告与摘要里不一致
  SegmentActionMismatch(Int, String, String)
  /// 报告的 `used` 与逐段求和不等
  UsedMismatch(Int64, Int64)
  /// `window - reserve != input_limit`
  LimitMismatch(Int64, Int64)
}

///|
fn split_spaces(line : StringView) -> Array[String] {
  let out : Array[String] = []
  for p in line.split(" ") {
    if p.length() > 0 {
      out.push(p.to_owned())
    }
  }
  out
}

///|
fn parse_int_str(s : String) -> Int? {
  let chars = s.to_array()
  let n = chars.length()
  if n == 0 {
    return None
  }
  let neg = chars[0] == '-'
  let start = if neg { 1 } else { 0 }
  if start >= n {
    return None
  }
  let mut v = 0
  for i = start; i < n; i = i + 1 {
    let d = chars[i].to_int() - 0x30
    if d < 0 || d > 9 {
      return None
    }
    v = v * 10 + d
  }
  Some(if neg { -v } else { v })
}

///|
/// 把 `12->7` 拆成 (12, 7);截断行的两半都是非负整数。
fn split_arrow(s : String) -> (Int, Int)? {
  let chars = s.to_array()
  let n = chars.length()
  let mut at = -1
  for i = 0; i + 1 < n; i = i + 1 {
    if chars[i] == '-' && chars[i + 1] == '>' {
      at = i
      break
    }
  }
  if at < 0 {
    return None
  }
  let left = parse_int_str(String::from_array(chars[0:at].to_owned()))
  let right = parse_int_str(
    String::from_array(chars[at + 2:n].to_owned()),
  )
  match (left, right) {
    (Some(l), Some(r)) => Some((l, r))
    _ => None
  }
}

///|
fn plan_digest_for(entries : Array[DigestEntry]) -> String {
  let out : Array[Char] = []
  for e in entries {
    for c in (e.index.to_string() + " " + e.action + " " + e.sha + "\n").to_array() {
      out.push(c)
    }
  }
  sha256_hex(@utf8.encode(String::from_array(out)))
}

///|
/// 按段下标升序(插入排序,段数是个位到几十,别为它引依赖)。
fn sort_entries(entries : Array[DigestEntry]) -> Array[DigestEntry] {
  let out = entries.copy()
  let n = out.length()
  for i = 1; i < n; i = i + 1 {
    let cur = out[i]
    let mut j = i
    while j > 0 && out[j - 1].index > cur.index {
      out[j] = out[j - 1]
      j = j - 1
    }
    out[j] = cur
  }
  out
}

///|
/// 计算一份计划的摘要。`entries` 按段下标升序,与输入顺序无关。
pub fn Plan::digest(self : Plan) -> PlanDigest {
  let entries : Array[DigestEntry] = []
  for k in self.kept {
    entries.push({ index: k.index, action: "kept", sha: sha256_hex(k.bytes) })
  }
  for t in self.truncated {
    entries.push({
      index: t.index,
      action: "truncated",
      sha: sha256_hex(t.bytes),
    })
  }
  for d in self.dropped {
    entries.push({ index: d.index, action: "dropped", sha: "-" })
  }
  let sorted = sort_entries(entries)
  {
    report_sha: sha256_hex(@utf8.encode(self.render())),
    plan_sha: plan_digest_for(sorted),
    entries: sorted,
  }
}

///|
pub fn PlanDigest::render(self : PlanDigest) -> String {
  let out : Array[Char] = []
  for c in (
    "tally-digest v1\nreport " +
    self.report_sha +
    "\nplan " +
    self.plan_sha +
    "\n[segments]\n"
  ).to_array() {
    out.push(c)
  }
  for e in self.entries {
    for c in (e.index.to_string() + " " + e.action + " " + e.sha + "\n").to_array() {
      out.push(c)
    }
  }
  String::from_array(out)
}

///|
/// 摘要文本,供 CLI 落盘(与 `Plan::render` 并列的两个产物)。
pub fn Plan::render_digest(self : Plan) -> String {
  self.digest().render()
}

///|
/// 解析摘要文本。逐行给出错误位置,不做"尽力而为"的修复。
pub fn parse_digest(text : String) -> PlanDigest raise DigestError {
  let entries : Array[DigestEntry] = []
  let mut line_no = 0
  let mut report_sha = ""
  let mut plan_sha = ""
  let mut in_segments = false
  for raw in text.split("\n") {
    line_no = line_no + 1
    let line = raw.trim()
    if line.length() == 0 {
      continue
    }
    if line_no == 1 {
      if line != "tally-digest v1" {
        raise BadHeader(line.to_owned())
      }
      continue
    }
    let parts = split_spaces(line)
    if parts.length() == 0 {
      continue
    }
    if parts[0] == "[segments]" {
      in_segments = true
      continue
    }
    if in_segments {
      if parts.length() != 3 {
        raise MalformedLine(line_no, line.to_owned())
      }
      match parse_int_str(parts[0]) {
        None => raise MalformedLine(line_no, line.to_owned())
        Some(index) => {
          let action = parts[1]
          if action != "kept" && action != "truncated" && action != "dropped" {
            raise MalformedLine(line_no, line.to_owned())
          }
          if action == "dropped" && parts[2] != "-" {
            raise MalformedLine(line_no, line.to_owned())
          }
          if action != "dropped" && parts[2].length() != 64 {
            raise MalformedLine(line_no, line.to_owned())
          }
          entries.push({ index, action, sha: parts[2] })
        }
      }
    } else if parts[0] == "report" {
      if parts.length() != 2 || parts[1].length() != 64 {
        raise MalformedLine(line_no, line.to_owned())
      }
      report_sha = parts[1]
    } else if parts[0] == "plan" {
      if parts.length() != 2 || parts[1].length() != 64 {
        raise MalformedLine(line_no, line.to_owned())
      }
      plan_sha = parts[1]
    } else {
      raise MalformedLine(line_no, line.to_owned())
    }
  }
  if report_sha == "" || plan_sha == "" {
    raise MalformedLine(0, "missing report/plan digest line")
  }
  { report_sha, plan_sha, entries }
}

///|
priv struct ReportedSegment {
  index : Int
  action : String
  tokens : Int64
}

///|
priv struct ReportFacts {
  window : Int64
  reserve : Int64
  limit : Int64
  used : Int64
  segments : Array[ReportedSegment]
}

///|
/// 解析 `tally-plan v2` / `v3`。只取 verify 需要的字段;未知行一律报错,
/// 免得"报告格式漂移了但校验还说 ok"。
fn parse_report(report : String) -> ReportFacts raise DigestError {
  let mut line_no = 0
  let mut window : Int64 = 0L
  let mut reserve : Int64 = 0L
  let mut limit : Int64 = 0L
  let mut used : Int64 = 0L
  let mut section = ""
  // v2 与 v3 都读得懂:v3 多 `[pools]` 一节,`[truncated]` 行尾多 `omitted=`。
  let mut version = 2
  let segments : Array[ReportedSegment] = []
  for raw in report.split("\n") {
    line_no = line_no + 1
    let line = raw.trim()
    if line.length() == 0 {
      continue
    }
    if line_no == 1 {
      if line == "tally-plan v3" {
        version = 3
      } else if line == "tally-plan v2" {
        version = 2
      } else {
        raise BadHeader(line.to_owned())
      }
      continue
    }
    let parts = split_spaces(line)
    if parts.length() == 0 {
      continue
    }
    if parts[0] == "[kept]" || parts[0] == "[truncated]" || parts[0] == "[dropped]" || parts[0] == "[pools]" {
      section = parts[0]
      continue
    }
    if section == "" {
      if parts[0] == "counter" {
        if parts.length() != 3 || parts[1].length() == 0 {
          raise MalformedLine(line_no, line.to_owned())
        }
        continue
      }
      if parts[0] == "overflow" {
        if parts.length() != 2 || (parts[1] != "true" && parts[1] != "false") {
          raise MalformedLine(line_no, line.to_owned())
        }
        continue
      }
      if parts[0] == "pinned" {
        if parts.length() != 2 || parse_int_str(parts[1]) == None {
          raise MalformedLine(line_no, line.to_owned())
        }
        continue
      }
      if parts.length() != 2 {
        raise MalformedLine(line_no, line.to_owned())
      }
      let v = match parse_int_str(parts[1]) {
        Some(x) => x.to_int64()
        None => raise MalformedLine(line_no, line.to_owned())
      }
      if parts[0] == "window" {
        window = v
      } else if parts[0] == "reserve" {
        reserve = v
      } else if parts[0] == "input_limit" {
        limit = v
      } else if parts[0] == "used" {
        used = v
      } else if parts[0] == "pinned" {
        ()
      } else {
        raise MalformedLine(line_no, line.to_owned())
      }
      continue
    }
    if section == "[pools]" {
      // `<池名> <配额> <需求> <占用> <借入> <借出>`:池名之外全是整数。
      if version != 3 || parts.length() != 6 {
        raise MalformedLine(line_no, line.to_owned())
      }
      for p = 1; p < 6; p = p + 1 {
        if parse_int_str(parts[p]) == None {
          raise MalformedLine(line_no, line.to_owned())
        }
      }
      continue
    }
    if section == "[kept]" {
      if parts.length() != 4 {
        raise MalformedLine(line_no, line.to_owned())
      }
      let index = match parse_int_str(parts[0]) {
        Some(x) => x
        None => raise MalformedLine(line_no, line.to_owned())
      }
      let tokens = match parse_int_str(parts[3]) {
        Some(x) => x.to_int64()
        None => raise MalformedLine(line_no, line.to_owned())
      }
      segments.push({ index, action: "kept", tokens })
    } else if section == "[truncated]" {
      let want = if version == 3 { 6 } else { 5 }
      if parts.length() != want {
        raise MalformedLine(line_no, line.to_owned())
      }
      if version == 3 && !parts[5].has_prefix("omitted=") {
        raise MalformedLine(line_no, line.to_owned())
      }
      let index = match parse_int_str(parts[0]) {
        Some(x) => x
        None => raise MalformedLine(line_no, line.to_owned())
      }
      let tokens = match split_arrow(parts[3]) {
        Some(pair) => {
          let (_, kept_tokens) = pair
          kept_tokens.to_int64()
        }
        None => raise MalformedLine(line_no, line.to_owned())
      }
      segments.push({ index, action: "truncated", tokens })
    } else {
      if parts.length() < 5 {
        raise MalformedLine(line_no, line.to_owned())
      }
      let index = match parse_int_str(parts[0]) {
        Some(x) => x
        None => raise MalformedLine(line_no, line.to_owned())
      }
      // 哨兵行(`index == -1`)的 tokens 是溢出量,不是某段的量。
      let tokens = match parse_int_str(parts[3]) {
        Some(x) => x.to_int64()
        None => raise MalformedLine(line_no, line.to_owned())
      }
      segments.push({ index, action: "dropped", tokens })
    }
  }
  { window, reserve, limit, used, segments }
}

///|
/// 把文件里的"编辑产物"归一到规范形:去 UTF-8 BOM、CRLF 归一成 LF、结尾只留一个换行。
///
/// 为什么需要:格式里只有 LF,但 Windows 控制台重定向会把 LF 写成 CRLF;
/// 编辑器(含 PowerShell 的 `Out-File -Encoding utf8`)还会在开头写 BOM。
/// 如果不归一,"文件在磁盘上"和"文件被哈希时"就是两份不同的字节,
/// 校验会在没被篡改的文件上失败——那是误报,比漏报更难查。
pub fn normalize_newlines(text : String) -> String {
  let chars = text.to_array()
  let out : Array[Char] = []
  let mut i = 0
  // BOM 不是正文:留着会让"第一行是 tally-digest v1"变成"第一行多了个隐形字符"。
  if chars.length() > 0 && chars[0] == '\u{FEFF}' {
    i = 1
  }
  while i < chars.length() {
    if chars[i] == '\r' && i + 1 < chars.length() && chars[i + 1] == '\n' {
      out.push('\n')
      i = i + 2
    } else {
      out.push(chars[i])
      i = i + 1
    }
  }
  // 结尾只留一个换行:`println(render())` 会在已经以 `\n` 结尾的正文后再补一个换行,
  // 那是打印行为,不是内容。多个空行也不携带信息,一并归一到规范形。
  while out.length() > 1 &&
    out[out.length() - 1] == '\n' &&
    out[out.length() - 2] == '\n' {
    let _ = out.pop()
  }
  String::from_array(out)
}

///|
/// 只做**自洽性**校验:报告没有被改过、摘要与报告对得上、`used` 与逐段求和相等。
/// 要证明"这份报告确实由这批输入和这套策略产出",还需要重跑一遍(CLI 的 `--vocab` 分支)。
pub fn verify_digest_pair(report : String, digest_text : String) -> Unit raise DigestError {
  let report = normalize_newlines(report)
  let digest_text = normalize_newlines(digest_text)
  let digest = parse_digest(digest_text)
  let facts = parse_report(report)
  let report_sha = sha256_hex(@utf8.encode(report))
  if report_sha != digest.report_sha {
    raise ReportHashMismatch(digest.report_sha, report_sha)
  }
  let plan_sha = plan_digest_for(digest.entries)
  if plan_sha != digest.plan_sha {
    raise PlanHashMismatch(digest.plan_sha, plan_sha)
  }
  if facts.limit != facts.window - facts.reserve {
    raise LimitMismatch(facts.window - facts.reserve, facts.limit)
  }
  // 哨兵行(index == -1)不是某一段:它进 plan 哈希("溢出过"这件事要可核对),
  // 但不参与段对账——两侧都按同一规则过滤,否则溢出计划的段数永远对不上。
  let real : Array[ReportedSegment] = []
  for s in facts.segments {
    if s.index >= 0 {
      real.push(s)
    }
  }
  let dig_real : Array[DigestEntry] = []
  for d in digest.entries {
    if d.index >= 0 {
      dig_real.push(d)
    }
  }
  if real.length() != dig_real.length() {
    raise SegmentCountMismatch(real.length(), dig_real.length())
  }
  // 报告按 kept → truncated → dropped 分节,摘要按段下标排序;对账前统一成下标序。
  for i = 1; i < real.length(); i = i + 1 {
    let cur = real[i]
    let mut j = i
    while j > 0 && real[j - 1].index > cur.index {
      real[j] = real[j - 1]
      j = j - 1
    }
    real[j] = cur
  }
  let mut sum : Int64 = 0L
  for i = 0; i < real.length(); i = i + 1 {
    let r = real[i]
    let d = dig_real[i]
    if r.index != d.index || r.action != d.action {
      raise SegmentActionMismatch(r.index, r.action, d.action)
    }
    // 只有 kept / truncated 的 token 计入 `used`;丢弃段的数字是"它有多大",不是"它占了多少"。
    if r.action != "dropped" {
      sum = sum + r.tokens
    }
  }
  if sum != facts.used {
    raise UsedMismatch(facts.used, sum)
  }
}

///|
/// 给 CLI / 日志用的可读消息。位置信息保留在字符串里,别在调用处再拼一遍。
pub fn DigestError::message(self : DigestError) -> String {
  match self {
    BadHeader(h) => "unrecognised header: " + h
    MalformedLine(n, t) =>
      "line " + n.to_string() + ": malformed: " + t
    ReportHashMismatch(expected, actual) =>
      "report sha256 mismatch: digest says " + expected + ", file is " + actual
    PlanHashMismatch(expected, actual) =>
      "segment digest mismatch: digest says " + expected + ", recomputed " + actual
    SegmentCountMismatch(report_n, digest_n) =>
      "segment count mismatch: report has " +
      report_n.to_string() +
      ", digest has " +
      digest_n.to_string()
    SegmentActionMismatch(index, report_action, digest_action) =>
      "segment " +
      index.to_string() +
      ": report says " +
      report_action +
      ", digest says " +
      digest_action
    UsedMismatch(report_used, sum) =>
      "used mismatch: report says " +
      report_used.to_string() +
      ", segments sum to " +
      sum.to_string()
    LimitMismatch(expected, actual) =>
      "input_limit mismatch: window - reserve = " +
      expected.to_string() +
      ", report says " +
      actual.to_string()
  }
}

///|
/// 不 `raise` 的校验通道:把 `DigestError` 折成一句带位置的话。
///
/// 存在的理由和 `fit_with_result` 一样——suberror 构造器不能经 `pub using` 再导出,
/// 只 import 门面包的使用者写不出 `catch { ReportHashMismatch(..) => .. }`。
/// 这里把「哪份文件、哪个位置、两个 sha256 各是什么」一次性给全。
pub fn verify_report(
  report : String,
  digest_text : String,
) -> Result[Unit, String] {
  let err : DigestError? = try {
    verify_digest_pair(report, digest_text)
    None
  } catch {
    BadHeader(h) => Some(BadHeader(h))
    MalformedLine(n, t) => Some(MalformedLine(n, t))
    ReportHashMismatch(a, b) => Some(ReportHashMismatch(a, b))
    PlanHashMismatch(a, b) => Some(PlanHashMismatch(a, b))
    SegmentCountMismatch(a, b) => Some(SegmentCountMismatch(a, b))
    SegmentActionMismatch(i, a, b) => Some(SegmentActionMismatch(i, a, b))
    UsedMismatch(a, b) => Some(UsedMismatch(a, b))
    LimitMismatch(a, b) => Some(LimitMismatch(a, b))
  }
  match err {
    None => Ok(())
    Some(e) => Err(e.message())
  }
}