#[derive(Debug, Eq, PartialEq)]
pub struct Line {
    pub old_number: String,
    pub new_number: String,
    pub text: String,
    pub kind: &'static str,
}

pub struct Parsed {
    pub lines: Vec<Line>,
    pub columns: usize,
}

pub fn parse_file(diff: &str, path: &str) -> Parsed {
    let section = sections(diff)
        .into_iter()
        .find(|section| section_path(section).as_deref() == Some(path))
        .unwrap_or("");
    if section.is_empty() {
        let text = format!("No diff data is available for {path}.");
        return Parsed {
            columns: text.chars().count(),
            lines: vec![Line {
                old_number: String::new(),
                new_number: String::new(),
                text,
                kind: "header",
            }],
        };
    }
    let mut old = 0;
    let mut new = 0;
    let mut columns = 0;
    let lines = section
        .lines()
        .map(|text| {
            columns = columns.max(display_columns(text));
            let (old_number, new_number, kind) = if text.starts_with("@@") {
                if let Some((old_start, new_start)) = hunk_starts(text) {
                    old = old_start;
                    new = new_start;
                }
                (String::new(), String::new(), "hunk")
            } else if text.starts_with('+') && !text.starts_with("+++") {
                let number = new.to_string();
                new += 1;
                (String::new(), number, "addition")
            } else if text.starts_with('-') && !text.starts_with("---") {
                let number = old.to_string();
                old += 1;
                (number, String::new(), "removal")
            } else if text.starts_with(' ') {
                let numbers = (old.to_string(), new.to_string());
                old += 1;
                new += 1;
                (numbers.0, numbers.1, "context")
            } else {
                (String::new(), String::new(), "header")
            };
            Line {
                old_number,
                new_number,
                text: text.into(),
                kind,
            }
        })
        .collect();
    Parsed { lines, columns }
}

fn sections(diff: &str) -> Vec<&str> {
    let mut starts: Vec<_> = diff
        .match_indices("diff --git ")
        .map(|(index, _)| index)
        .collect();
    starts.push(diff.len());
    starts
        .windows(2)
        .map(move |bounds| &diff[bounds[0]..bounds[1]])
        .collect()
}

fn section_path(section: &str) -> Option<String> {
    section
        .lines()
        .find_map(|line| line.strip_prefix("+++ "))
        .filter(|path| *path != "/dev/null")
        .or_else(|| {
            section
                .lines()
                .find_map(|line| line.strip_prefix("--- "))
                .filter(|path| *path != "/dev/null")
        })
        .map(|path| {
            decode_path(path)
                .trim_start_matches("a/")
                .trim_start_matches("b/")
                .into()
        })
}

fn decode_path(path: &str) -> String {
    let path = path.trim_matches('"');
    let mut bytes = Vec::with_capacity(path.len());
    let mut chars = path.bytes();
    while let Some(byte) = chars.next() {
        if byte != b'\\' {
            bytes.push(byte);
            continue;
        }
        let Some(escaped) = chars.next() else { break };
        match escaped {
            b'n' => bytes.push(b'\n'),
            b'r' => bytes.push(b'\r'),
            b't' => bytes.push(b'\t'),
            b'\\' | b'"' => bytes.push(escaped),
            b'0'..=b'7' => {
                let mut value = escaped - b'0';
                for _ in 0..2 {
                    let Some(next) = chars.next() else { break };
                    if !(b'0'..=b'7').contains(&next) {
                        bytes.push(value);
                        bytes.push(next);
                        value = 0;
                        break;
                    }
                    value = value * 8 + next - b'0';
                }
                if value != 0 {
                    bytes.push(value);
                }
            }
            other => bytes.push(other),
        }
    }
    String::from_utf8_lossy(&bytes).into_owned()
}

fn hunk_starts(line: &str) -> Option<(i32, i32)> {
    let mut parts = line.split_whitespace();
    (parts.next()? == "@@").then_some(())?;
    Some((
        range_start(parts.next()?, '-'),
        range_start(parts.next()?, '+'),
    ))
}

fn range_start(value: &str, prefix: char) -> i32 {
    value
        .strip_prefix(prefix)
        .and_then(|value| value.split(',').next())
        .and_then(|value| value.parse().ok())
        .unwrap_or_default()
}

fn display_columns(line: &str) -> usize {
    line.chars().fold(0, |column, character| {
        if character == '\t' {
            (column / 8 + 1) * 8
        } else {
            column + 1
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn selects_and_numbers_a_file_diff() {
        let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\
diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n";
        let parsed = parse_file(diff, "two");
        assert_eq!(parsed.lines[4].old_number, "4");
        assert_eq!(parsed.lines[4].new_number, "8");
        assert_eq!(parsed.lines[5].kind, "removal");
        assert_eq!(parsed.lines[6].kind, "addition");
        assert!(parsed.lines.iter().all(|line| !line.text.contains("old")));
    }

    #[test]
    fn decodes_git_quoted_paths() {
        let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n";
        assert_eq!(
            parse_file(diff, "café").lines.last().unwrap().kind,
            "addition"
        );
    }
}
