// unified diff 解析器。
//
// 全部是纯函数:输入一段文本,输出结构化结果,失败则 raise 带行号的错误。
//
// 为什么用「按 @@ 声明的行数驱动」的状态机,而不是逐行看前缀?
// 因为前缀会骗人:一处被删掉的源码如果是 `-- foo`,它在 diff 里渲染成
// `--- foo`,看起来和文件头 `--- a/x` 一模一样。只有先读到 @@ 头、
// 拿到「这一块有几行」,才能判断接下来的 `--- foo` 属于 hunk 内容。

///|
/// 解析失败。`BadDiff(行号, 说明)`,行号从 1 开始,指向出错的源文本行。
pub suberror DiffError {
  BadDiff(Int, String)
} derive(Eq, Debug)

///|
pub extend DiffError with Eq::{equal, not_equal}

///|
pub extend DiffError with Debug::{to_repr}

///|
/// 从 `@@ -10,3 +10,4 @@ 函数名` 读出来的定位信息。
struct HunkHeader {
  old_start : Int
  old_count : Int
  new_start : Int
  new_count : Int
  heading : String
} derive(Eq, Debug)

///|
pub extend HunkHeader with Eq::{equal, not_equal}

///|
pub extend HunkHeader with Debug::{to_repr}

///|
/// 按行切分,兼容 `\n`、`\r\n`、`\r`。
/// 文本以换行结尾时不会多出一个空行。
pub fn split_lines(text : String) -> Array[String] {
  let normalized = text
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  let out : Array[String] = []
  for line in normalized.split("\n") {
    out.push(line.to_owned())
  }
  if out.length() > 0 && out[out.length() - 1] == "" {
    let _ = out.pop()
  }
  out
}

///|
/// 取字符区间 `[from, to)` 拼成字符串,下标越界会被夹到合法范围。
/// 后面所有下标操作都基于 `Array[Char]`,这样中文和 emoji 路径
/// 不会按 UTF-16 码元错位。
fn chars_slice(cs : Array[Char], from : Int, to : Int) -> String {
  let len = cs.length()
  let lo = if from < 0 { 0 } else if from > len { len } else { from }
  let hi = if to < lo { lo } else if to > len { len } else { to }
  String::from_array(cs[lo:hi])
}

///|
/// 取 `prefix` 之后的内容,例如 `after("--- a/f.c", "--- ")` 得到 `a/f.c`。
/// 前缀不匹配时原样返回。
fn after(line : String, prefix : String) -> String {
  match line.strip_prefix(prefix) {
    Some(rest) => rest.to_owned()
    None => line
  }
}

///|
fn is_space_at(cs : Array[Char], i : Int) -> Bool {
  i < cs.length() && cs[i] == ' '
}

///|
fn skip_spaces(cs : Array[Char], from : Int) -> Int {
  let mut i = from
  while is_space_at(cs, i) {
    i = i + 1
  }
  i
}

///|
/// 从 `from` 起读一串十进制数字,返回(值, 结束下标, 是否成功)。
/// 超过 9 位按失败处理,避免畸形输入把 `Int` 累加溢出。
fn read_digits(cs : Array[Char], from : Int) -> (Int, Int, Bool) {
  let mut i = from
  while i < cs.length() && cs[i] >= '0' && cs[i] <= '9' {
    i = i + 1
  }
  let width = i - from
  if width == 0 || width > 9 {
    (0, i, false)
  } else {
    let mut v = 0
    for k in from.. String {
  let cs = text.to_array()
  if cs.length() >= 2 && cs[0] == '"' && cs[cs.length() - 1] == '"' {
    chars_slice(cs, 1, cs.length() - 1)
  } else {
    text
  }
}

///|
/// 读一个路径 token。git 遇到含空格的路径会加双引号,
/// 所以这里自己做引号感知的切分,而不是按空格 split。
/// 返回(路径, 结束下标)。
fn read_path_token(cs : Array[Char], from : Int) -> (String, Int) {
  if from < cs.length() && cs[from] == '"' {
    let mut i = from + 1
    while i < cs.length() && cs[i] != '"' {
      i = i + 1
    }
    (chars_slice(cs, from + 1, i), i + 1)
  } else {
    let mut i = from
    while i < cs.length() && cs[i] != ' ' {
      i = i + 1
    }
    (chars_slice(cs, from, i), i)
  }
}

///|
/// 清理 `---` / `+++` 行里的路径:
/// 1. 丢掉制表符之后的时间戳(`--- a/f.c\t2026-01-01 10:00`)
/// 2. 去掉包裹的引号
/// 3. 去掉 git 的 `a/` `b/` 前缀
///
/// `/dev/null` 会原样保留,靠它判断新增还是删除。
pub fn clean_path(raw : String) -> String {
  let no_stamp = match raw.before("\t") {
    Some(head) => head.to_owned()
    None => raw
  }
  let unquoted = strip_surrounding_quotes(no_stamp)
  if unquoted.has_prefix("a/") {
    after(unquoted, "a/")
  } else if unquoted.has_prefix("b/") {
    after(unquoted, "b/")
  } else {
    unquoted
  }
}

///|
/// 读 `diff --git a/x b/y` 去掉前缀之后的两个路径。
pub fn read_two_paths(rest : String) -> (String, String) {
  let cs = rest.to_array()
  let (p1, i1) = read_path_token(cs, skip_spaces(cs, 0))
  let (p2, _) = read_path_token(cs, skip_spaces(cs, i1))
  (clean_path(p1), clean_path(p2))
}

///|
/// 逗号后面的计数;整段 `,N` 省略时按 GNU 规则当作 1。
fn read_count(
  cs : Array[Char],
  from : Int,
  line_no : Int,
  what : String,
) -> (Int, Int) raise DiffError {
  if from < cs.length() && cs[from] == ',' {
    let (n, next, ok) = read_digits(cs, from + 1)
    guard ok else { raise BadDiff(line_no, "\{what}不是数字") }
    (n, next)
  } else {
    (1, from)
  }
}

///|
/// 只裁空格,不裁制表符——函数名提示里可能带 tab。
fn trim_spaces(s : String) -> String {
  let cs = s.to_array()
  let mut from = 0
  while is_space_at(cs, from) {
    from = from + 1
  }
  let mut to = cs.length()
  while to > from && cs[to - 1] == ' ' {
    to = to - 1
  }
  chars_slice(cs, from, to)
}

///|
/// 解析 `@@ -old_start,old_count +new_start,new_count @@ heading`。
///
/// 省略的计数按 GNU 规则当作 1。计数为 0 表示纯插入/纯删除,
/// 此时起始行号含义是「在第几行之后插入」,原样保留不做换算。
pub fn parse_hunk_header(
  line : String,
  line_no : Int,
) -> HunkHeader raise DiffError {
  let cs = line.to_array()
  let len = cs.length()
  guard chars_slice(cs, 0, 2) == "@@" else {
    raise BadDiff(line_no, "hunk 头不是以 @@ 开头")
  }
  let mut i = skip_spaces(cs, 2)
  guard i < len && cs[i] == '-' else {
    raise BadDiff(line_no, "hunk 头缺少 `-` 起始行号")
  }
  let (old_start, next, ok) = read_digits(cs, i + 1)
  guard ok else {
    raise BadDiff(line_no, "hunk 头的旧文件行号不是数字")
  }
  let (old_count, i2) = read_count(
    cs, next, line_no, "hunk 头的旧文件行数",
  )
  i = skip_spaces(cs, i2)
  guard i < len && cs[i] == '+' else {
    raise BadDiff(line_no, "hunk 头缺少 `+` 起始行号")
  }
  let (new_start, next2, ok2) = read_digits(cs, i + 1)
  guard ok2 else {
    raise BadDiff(line_no, "hunk 头的新文件行号不是数字")
  }
  let (new_count, i3) = read_count(
    cs, next2, line_no, "hunk 头的新文件行数",
  )
  i = skip_spaces(cs, i3)
  // 结尾的 `@@` 之后是函数名提示;有些工具不写,留空即可。
  let heading = if chars_slice(cs, i, i + 2) == "@@" {
    trim_spaces(chars_slice(cs, i + 2, len))
  } else {
    ""
  }
  { old_start, old_count, new_start, new_count, heading, }
}

///|
/// 一个文件的游标。`pos` 指向还没读的那一行。
priv struct Reader {
  lines : Array[String]
  mut pos : Int
}

///|
fn Reader::at_end(self : Reader) -> Bool {
  self.pos >= self.lines.length()
}

///|
/// 越界时返回空串,调用方都要先判 `at_end`,所以不会掩盖错误。
fn Reader::peek(self : Reader) -> String {
  if self.at_end() {
    ""
  } else {
    self.lines[self.pos]
  }
}

///|
fn Reader::advance(self : Reader) -> Unit {
  if !self.at_end() {
    self.pos = self.pos + 1
  }
}

///|
/// 当前行的 1 基行号,用于报错定位。
fn Reader::line_no(self : Reader) -> Int {
  self.pos + 1
}

///|
fn Reader::starts_with(self : Reader, prefix : String) -> Bool {
  !self.at_end() && self.peek().has_prefix(prefix)
}

///|
/// 一行是不是某个文件记录的起点。git 只为改动过的文件写 `---` 头,
/// 纯权限变更没有 hunk,`--- a/x` 单独出现也算起点。
fn starts_new_entry(line : String) -> Bool {
  let prefixes : Array[String] = ["diff --git ", "--- ", "@@"]
  prefixes.any(fn(p) { line.has_prefix(p) })
}

///|
/// 把一份 unified diff 解析成结构化结果。
///
/// 空文本是合法的,得到 0 个文件——代码评审里「没有改动」不是错误。
/// 无法识别的杂散行(commit 说明、`version 1.0` 之类)会被跳过,
/// 但 hunk 行数对不上会报错,因为那意味着 diff 本身被截断或损坏了。
pub fn parse_diff(text : String) -> Diff raise DiffError {
  let r : Reader = { lines: split_lines(text), pos: 0, }
  let files : Array[FileDiff] = []
  while !r.at_end() {
    let line = r.peek()
    if starts_new_entry(line) {
      let f = parse_file(r)
      files.push(f)
    } else {
      r.advance()
    }
  }
  { files, }
}

///|
fn trim_suffix(s : String, suffix : String) -> String {
  match s.strip_suffix(suffix) {
    Some(head) => head.to_owned()
    None => s
  }
}

///|
/// `index` / `similarity index` 这类头只影响哈希值,对评审没有信息量。
fn is_noise_header(line : String) -> Bool {
  let prefixes : Array[String] = [
    "index ", "similarity index ", "dissimilarity index ",
  ]
  prefixes.any(fn(p) { line.has_prefix(p) })
}

///|
fn parse_file(r : Reader) -> FileDiff raise DiffError {
  let start_line = r.line_no()
  let mut old_path = ""
  let mut new_path = ""
  let mut status : FileStatus = Modified
  let mut binary = false
  let mut old_mode : String? = None
  let mut new_mode : String? = None
  let hunks : Array[Hunk] = []

  if r.starts_with("diff --git ") {
    let (a, b) = read_two_paths(after(r.peek(), "diff --git "))
    old_path = a
    new_path = b
    r.advance()
  }

  // git 扩展头,顺序不固定,逐条吃到不匹配为止
  while !r.at_end() {
    let line = r.peek()
    if line.has_prefix("new file mode ") {
      status = NewFile
      new_mode = Some(after(line, "new file mode "))
      r.advance()
    } else if line.has_prefix("deleted file mode ") {
      status = Deleted
      old_mode = Some(after(line, "deleted file mode "))
      r.advance()
    } else if line.has_prefix("old mode ") {
      old_mode = Some(after(line, "old mode "))
      r.advance()
    } else if line.has_prefix("new mode ") {
      new_mode = Some(after(line, "new mode "))
      r.advance()
    } else if line.has_prefix("rename from ") {
      old_path = clean_path(after(line, "rename from "))
      status = Renamed
      r.advance()
    } else if line.has_prefix("rename to ") {
      new_path = clean_path(after(line, "rename to "))
      status = Renamed
      r.advance()
    } else if line.has_prefix("copy from ") {
      old_path = clean_path(after(line, "copy from "))
      status = Copied
      r.advance()
    } else if line.has_prefix("copy to ") {
      new_path = clean_path(after(line, "copy to "))
      status = Copied
      r.advance()
    } else if line.has_prefix("Binary files ") {
      // Binary files a/x.png and b/x.png differ
      binary = true
      let parts = after(line, "Binary files ").split(" and ").to_array()
      if parts.length() >= 2 {
        old_path = clean_path(parts[0].to_owned())
        new_path = clean_path(trim_suffix(parts[1].to_owned(), " differ"))
      }
      r.advance()
    } else if line.has_prefix("GIT binary patch") {
      binary = true
      r.advance()
    } else if is_noise_header(line) {
      r.advance()
    } else {
      break
    }
  }

  if r.starts_with("--- ") {
    old_path = clean_path(after(r.peek(), "--- "))
    r.advance()
    if r.starts_with("+++ ") {
      new_path = clean_path(after(r.peek(), "+++ "))
      r.advance()
    }
  }

  // 靠 /dev/null 推断新增与删除,比只看 `new file mode` 覆盖面更广
  if old_path == "/dev/null" {
    status = NewFile
  } else if new_path == "/dev/null" {
    status = Deleted
  }

  while r.starts_with("@@") {
    let h = parse_hunk(r)
    hunks.push(h)
  }

  if hunks.is_empty() && !binary {
    if status == Modified && old_mode != new_mode {
      status = ModeOnly
    } else if old_path == "" && new_path == "" {
      raise BadDiff(start_line, "看起来不是一个 diff 记录")
    }
  }

  { old_path, new_path, status, binary, old_mode, new_mode, hunks, }
}

///|
/// 读一个 hunk。调用方保证游标正停在 `@@` 行上。
fn parse_hunk(r : Reader) -> Hunk raise DiffError {
  let header_line = r.line_no()
  let header = parse_hunk_header(r.peek(), header_line)
  let old_start = header.old_start
  let old_count = header.old_count
  let new_start = header.new_start
  let new_count = header.new_count
  let heading = header.heading
  r.advance()

  let mut old_left = old_count
  let mut new_left = new_count
  let mut old_ln = old_start
  let mut new_ln = new_start
  let lines : Array[Line] = []
  let mut no_newline_old = false
  let mut no_newline_new = false

  while true {
    let line = r.peek()
    let line_no = r.line_no()
    // `\ No newline` 不吃行数,而且可能出现在计数已经归零之后,
    // 所以必须优先于「计数读完」这个退出条件判断。
    if !r.at_end() && line.has_prefix("\\") {
      guard lines.length() > 0 else {
        raise BadDiff(line_no, "缺少换行符标记前面没有内容行")
      }
      match lines[lines.length() - 1].kind {
        Removed => no_newline_old = true
        Added => no_newline_new = true
        Context => {
          no_newline_old = true
          no_newline_new = true
        }
      }
      r.advance()
    } else if old_left == 0 && new_left == 0 {
      break
    } else if r.at_end() {
      raise BadDiff(
        header_line, "hunk 声明的行数还没读完就到了文本结尾",
      )
    } else if line.has_prefix("-") && old_left > 0 {
      lines.push({
        kind: Removed,
        content: after(line, "-"),
        old_lineno: Some(old_ln),
        new_lineno: None,
      })
      old_left = old_left - 1
      old_ln = old_ln + 1
      r.advance()
    } else if line.has_prefix("+") && new_left > 0 {
      lines.push({
        kind: Added,
        content: after(line, "+"),
        old_lineno: None,
        new_lineno: Some(new_ln),
      })
      new_left = new_left - 1
      new_ln = new_ln + 1
      r.advance()
    } else if (line.has_prefix(" ") || line == "") &&
      old_left > 0 &&
      new_left > 0 {
      // 有的工具会剥掉行尾空格,于是空上下文行就成了空串
      lines.push({
        kind: Context,
        content: after(line, " "),
        old_lineno: Some(old_ln),
        new_lineno: Some(new_ln),
      })
      old_left = old_left - 1
      new_left = new_left - 1
      old_ln = old_ln + 1
      new_ln = new_ln + 1
      r.advance()
    } else {
      raise BadDiff(line_no, "hunk 内容与 @@ 声明的行数不符")
    }
  }

  {
    old_start,
    old_count,
    new_start,
    new_count,
    heading,
    lines,
    no_newline_old,
    no_newline_new,
  }
}