use std::{
    io::{BufRead, BufReader, Read},
    process::{Command, Stdio},
};

use nom::Finish;
use turbopath::{AbsoluteSystemPathBuf, RelativeUnixPathBuf};

use crate::{
    Error, GitHashes, GitRepo, OidHash,
    git_path::{UnsupportedGitPath, parse_git_path, require_git_path},
    wait_for_success,
};

/// Sorted list of (path, hash) pairs from `git ls-tree`. Uses a `Vec` instead
/// of `BTreeMap` because git output is already sorted by pathname, giving us
/// free insertion order with better cache locality for the `partition_point`
/// range lookups performed in `RepoGitIndex::get_package_hashes`.
pub(crate) type SortedGitHashes = Vec<(RelativeUnixPathBuf, OidHash)>;

pub(crate) struct GitTreeState {
    pub hashes: SortedGitHashes,
    pub unsupported_paths: Vec<UnsupportedGitPath>,
}

impl GitTreeState {
    fn new() -> Self {
        Self {
            hashes: SortedGitHashes::new(),
            unsupported_paths: Vec::new(),
        }
    }
}

pub(crate) struct GitStatusState {
    pub entries: Vec<crate::status::RepoStatusEntry>,
    pub unsupported_paths: Vec<UnsupportedGitPath>,
}

impl GitStatusState {
    fn new() -> Self {
        Self {
            entries: Vec::new(),
            unsupported_paths: Vec::new(),
        }
    }
}

pub(crate) struct GitPathList {
    pub paths: Vec<RelativeUnixPathBuf>,
    pub unsupported_paths: Vec<UnsupportedGitPath>,
}

impl GitPathList {
    fn new() -> Self {
        Self {
            paths: Vec::new(),
            unsupported_paths: Vec::new(),
        }
    }
}

impl GitRepo {
    #[tracing::instrument(skip(self))]
    pub fn git_ls_tree(&self, root_path: &AbsoluteSystemPathBuf) -> Result<GitHashes, Error> {
        let mut hashes = GitHashes::new();
        let mut git = Command::new(self.bin.as_std_path())
            .args(["ls-tree", "-r", "-z", "HEAD"])
            .env("GIT_OPTIONAL_LOCKS", "0")
            .current_dir(root_path)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        let stdout = git
            .stdout
            .as_mut()
            .ok_or_else(|| Error::git_error("failed to get stdout for git ls-tree"))?;
        let mut stderr = git
            .stderr
            .take()
            .ok_or_else(|| Error::git_error("failed to get stderr for git ls-tree"))?;
        let parse_result = read_ls_tree(stdout, &mut hashes);
        wait_for_success(git, &mut stderr, "git ls-tree", root_path, parse_result)?;
        Ok(hashes)
    }

    /// Run `git ls-tree` once at the git repo root, returning all committed
    /// file hashes keyed by git-root-relative paths.
    #[tracing::instrument(skip(self))]
    pub fn git_ls_tree_repo_root(&self) -> Result<GitHashes, Error> {
        self.git_ls_tree(&self.root)
    }

    /// Run `git ls-tree -r HEAD -z` at the repo root, returning a sorted Vec
    /// suitable for binary-search range queries in `RepoGitIndex`.
    #[cfg(test)]
    #[tracing::instrument(skip(self))]
    pub(crate) fn git_ls_tree_repo_root_sorted(&self) -> Result<SortedGitHashes, Error> {
        let state = self.git_ls_tree_repo_root_sorted_with_unsupported()?;
        if let Some(path) = state.unsupported_paths.into_iter().next() {
            return Err(path.into_error());
        }
        Ok(state.hashes)
    }

    pub(crate) fn git_ls_tree_repo_root_sorted_with_unsupported(
        &self,
    ) -> Result<GitTreeState, Error> {
        let mut git = Command::new(self.bin.as_std_path())
            .args(["ls-tree", "-r", "-z", "HEAD"])
            .env("GIT_OPTIONAL_LOCKS", "0")
            .current_dir(&self.root)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        let stdout = git
            .stdout
            .as_mut()
            .ok_or_else(|| Error::git_error("failed to get stdout for git ls-tree"))?;
        let mut stderr = git
            .stderr
            .take()
            .ok_or_else(|| Error::git_error("failed to get stderr for git ls-tree"))?;
        let mut state = GitTreeState::new();
        let parse_result = read_ls_tree_sorted_with_unsupported(stdout, &mut state);
        wait_for_success(git, &mut stderr, "git ls-tree", &self.root, parse_result)?;
        Ok(state)
    }

    /// Run `git diff-index HEAD -z` to find modified/deleted files relative to
    /// HEAD. Returns sorted `RepoStatusEntry` entries.
    #[cfg(test)]
    #[tracing::instrument(skip(self))]
    pub(crate) fn git_diff_index_repo_root(
        &self,
    ) -> Result<Vec<crate::status::RepoStatusEntry>, Error> {
        let state = self.git_diff_index_repo_root_with_unsupported()?;
        if let Some(path) = state.unsupported_paths.into_iter().next() {
            return Err(path.into_error());
        }
        Ok(state.entries)
    }

    pub(crate) fn git_diff_index_repo_root_with_unsupported(
        &self,
    ) -> Result<GitStatusState, Error> {
        let mut git = Command::new(self.bin.as_std_path())
            .args([
                "diff-index",
                "HEAD",
                "-z",
                "--diff-filter=ADM",
                "--no-renames",
            ])
            .env("GIT_OPTIONAL_LOCKS", "0")
            .current_dir(&self.root)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        let stdout = git
            .stdout
            .as_mut()
            .ok_or_else(|| Error::git_error("failed to get stdout for git diff-index"))?;
        let mut stderr = git
            .stderr
            .take()
            .ok_or_else(|| Error::git_error("failed to get stderr for git diff-index"))?;
        let mut state = GitStatusState::new();
        let parse_result = read_diff_index_with_unsupported(stdout, &mut state);
        wait_for_success(git, &mut stderr, "git diff-index", &self.root, parse_result)?;
        state.entries.sort_by(|a, b| a.path.cmp(&b.path));
        Ok(state)
    }

    /// Run `git ls-files --others --exclude-standard -z` to find untracked
    /// files.
    #[cfg(test)]
    #[tracing::instrument(skip(self))]
    pub(crate) fn git_ls_files_untracked(&self) -> Result<Vec<RelativeUnixPathBuf>, Error> {
        let state = self.git_ls_files_untracked_with_unsupported()?;
        if let Some(path) = state.unsupported_paths.into_iter().next() {
            return Err(path.into_error());
        }
        Ok(state.paths)
    }

    pub(crate) fn git_ls_files_untracked_with_unsupported(&self) -> Result<GitPathList, Error> {
        let mut git = Command::new(self.bin.as_std_path())
            .args(["ls-files", "--others", "--exclude-standard", "-z"])
            .env("GIT_OPTIONAL_LOCKS", "0")
            .current_dir(&self.root)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        let stdout = git
            .stdout
            .as_mut()
            .ok_or_else(|| Error::git_error("failed to get stdout for git ls-files"))?;
        let mut stderr = git
            .stderr
            .take()
            .ok_or_else(|| Error::git_error("failed to get stderr for git ls-files"))?;
        let mut state = GitPathList::new();
        let parse_result = read_null_terminated_paths_with_unsupported(stdout, &mut state);
        wait_for_success(git, &mut stderr, "git ls-files", &self.root, parse_result)?;
        Ok(state)
    }
}

fn read_ls_tree<R: Read>(reader: R, hashes: &mut GitHashes) -> Result<(), Error> {
    let mut reader = BufReader::with_capacity(64 * 1024, reader);
    let mut buffer = Vec::new();
    while reader.read_until(b'\0', &mut buffer)? != 0 {
        let entry = parse_ls_tree(&buffer)?;
        let hash = std::str::from_utf8(entry.hash)
            .map_err(|e| Error::git_error(format!("invalid utf8 in ls-tree hash: {e}")))?;
        let path = require_git_path(entry.filename, "git ls-tree filename")?;
        hashes.insert(path, OidHash::from_hex_str(hash));
        buffer.clear();
    }
    Ok(())
}

#[cfg(test)]
pub(crate) fn read_ls_tree_sorted<R: Read>(
    reader: R,
    hashes: &mut SortedGitHashes,
) -> Result<(), Error> {
    let mut state = GitTreeState {
        hashes: std::mem::take(hashes),
        unsupported_paths: Vec::new(),
    };
    read_ls_tree_sorted_with_unsupported(reader, &mut state)?;
    if let Some(path) = state.unsupported_paths.into_iter().next() {
        return Err(path.into_error());
    }
    *hashes = state.hashes;
    Ok(())
}

fn read_ls_tree_sorted_with_unsupported<R: Read>(
    reader: R,
    state: &mut GitTreeState,
) -> Result<(), Error> {
    let mut reader = BufReader::with_capacity(64 * 1024, reader);
    let mut buffer = Vec::new();
    while reader.read_until(b'\0', &mut buffer)? != 0 {
        let entry = parse_ls_tree(&buffer)?;
        let hash = std::str::from_utf8(entry.hash)
            .map_err(|e| Error::git_error(format!("invalid utf8 in ls-tree hash: {e}")))?;
        match parse_git_path(entry.filename, "git ls-tree filename")? {
            Ok(path) => state.hashes.push((path, OidHash::from_hex_str(hash))),
            Err(path) => state.unsupported_paths.push(path),
        }
        buffer.clear();
    }
    debug_assert!(
        state.hashes.windows(2).all(|w| w[0].0 < w[1].0),
        "git ls-tree output should be sorted by pathname"
    );
    Ok(())
}

fn read_diff_index_with_unsupported<R: Read>(
    reader: R,
    state: &mut GitStatusState,
) -> Result<(), Error> {
    // git diff-index -z output: ":old_mode new_mode old_sha new_sha status\0path\0"
    // Each record is two null-terminated fields: the diff header and the path.
    let mut reader = BufReader::with_capacity(64 * 1024, reader);
    let mut header_buf = Vec::new();
    let mut path_buf = Vec::new();
    loop {
        header_buf.clear();
        if reader.read_until(b'\0', &mut header_buf)? == 0 {
            break;
        }
        path_buf.clear();
        if reader.read_until(b'\0', &mut path_buf)? == 0 {
            break;
        }
        // Trim trailing null
        if path_buf.last() == Some(&b'\0') {
            path_buf.pop();
        }
        let path = parse_git_path(&path_buf, "git diff-index path")?;

        // Status letter is the last non-null char in the header
        let header = header_buf.strip_suffix(b"\0").unwrap_or(&header_buf);
        let status_byte = header.last().copied().unwrap_or(b'M');
        let is_delete = status_byte == b'D';

        match path {
            Ok(path) => state
                .entries
                .push(crate::status::RepoStatusEntry { path, is_delete }),
            Err(path) => state.unsupported_paths.push(path),
        }
    }
    Ok(())
}

fn read_null_terminated_paths_with_unsupported<R: Read>(
    reader: R,
    state: &mut GitPathList,
) -> Result<(), Error> {
    let mut reader = BufReader::with_capacity(64 * 1024, reader);
    let mut buffer = Vec::new();
    while reader.read_until(b'\0', &mut buffer)? != 0 {
        // Trim trailing null
        if buffer.last() == Some(&b'\0') {
            buffer.pop();
        }
        if buffer.is_empty() {
            buffer.clear();
            continue;
        }
        match parse_git_path(&buffer, "git ls-files path")? {
            Ok(path) => state.paths.push(path),
            Err(path) => state.unsupported_paths.push(path),
        }
        buffer.clear();
    }
    Ok(())
}

struct LsTreeEntry<'a> {
    filename: &'a [u8],
    hash: &'a [u8],
}

fn parse_ls_tree(i: &[u8]) -> Result<LsTreeEntry<'_>, Error> {
    let mut parser = nom::combinator::all_consuming(nom_parse_ls_tree);
    match parser(i).finish() {
        Ok((_, entry)) => Ok(entry),
        Err(e) => Err(Error::git_error(format!(
            "failed to parse git-ls-tree: {}",
            String::from_utf8_lossy(e.input)
        ))),
    }
}

fn nom_parse_ls_tree(i: &[u8]) -> nom::IResult<&[u8], LsTreeEntry<'_>> {
    let (i, _) = nom::bytes::complete::is_not(" ")(i)?;
    let (i, _) = nom::character::complete::space1(i)?;
    let (i, _) = nom::bytes::complete::is_not(" ")(i)?;
    let (i, _) = nom::character::complete::space1(i)?;
    let (i, hash) = nom::bytes::complete::take(40usize)(i)?;
    let (i, _) = nom::bytes::complete::take(1usize)(i)?;
    let (i, filename) = nom::bytes::complete::is_not("\0")(i)?;
    // We explicitly support a missing terminator
    let (i, _) = nom::combinator::opt(nom::bytes::complete::tag(b"\0"))(i)?;
    Ok((i, LsTreeEntry { filename, hash }))
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use turbopath::RelativeUnixPathBuf;

    use crate::{Error, GitHashes, OidHash, ls_tree::read_ls_tree};

    fn to_hash_map(pairs: &[(&str, &str)]) -> GitHashes {
        HashMap::from_iter(pairs.iter().map(|(path, hash)| {
            (
                RelativeUnixPathBuf::new(*path).unwrap(),
                OidHash::from_hex_str(hash),
            )
        }))
    }

    fn to_sorted_hashes(pairs: &[(&str, &str)]) -> super::SortedGitHashes {
        pairs
            .iter()
            .map(|(path, hash)| {
                (
                    RelativeUnixPathBuf::new(*path).unwrap(),
                    OidHash::from_hex_str(hash),
                )
            })
            .collect()
    }

    // Verifies that read_ls_tree_sorted produces correct sorted Vec entries
    // from git ls-tree output.
    #[test]
    fn test_ls_tree_sorted() {
        let input = "100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\tpackage.json\x00100644 \
                     blob 5b999efa470b056e329b4c23a73904e0794bdc2f\tsrc/index.ts\x00100644 blob \
                     f44f57fff95196c5f7139dfa0b96875f1e9650a9\tsrc/utils.ts\0";

        let expected = to_sorted_hashes(&[
            ("package.json", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
            ("src/index.ts", "5b999efa470b056e329b4c23a73904e0794bdc2f"),
            ("src/utils.ts", "f44f57fff95196c5f7139dfa0b96875f1e9650a9"),
        ]);

        let mut hashes = super::SortedGitHashes::new();
        super::read_ls_tree_sorted(input.as_bytes(), &mut hashes).unwrap();
        assert_eq!(hashes, expected);

        // Verify entries are sorted (invariant needed for binary search)
        assert!(
            hashes.windows(2).all(|w| w[0].0 < w[1].0),
            "sorted Vec should maintain sorted order"
        );
    }

    // Verifies read_ls_tree_sorted handles all the edge cases that read_ls_tree
    // handles. Both parsers share the same `parse_ls_tree` function.
    #[test]
    fn test_ls_tree_sorted_edge_cases() {
        // Single entry without trailing NUL
        let input = "100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\tpackage.json";
        let mut hashes = super::SortedGitHashes::new();
        super::read_ls_tree_sorted(input.as_bytes(), &mut hashes).unwrap();
        assert_eq!(hashes.len(), 1);

        // Empty input
        let mut hashes = super::SortedGitHashes::new();
        super::read_ls_tree_sorted("".as_bytes(), &mut hashes).unwrap();
        assert_eq!(hashes.len(), 0);
    }

    #[test]
    fn test_ls_tree() {
        let tests: &[(&str, &[(&str, &str)])] = &[
            (
                "100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\tpackage.json\0",
                &[("package.json", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391")],
            ),
            (
                // missing nul byte
                "100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\tpackage.json",
                &[("package.json", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391")],
            ),
            (
                // We aren't attempting to use octal escapes here, it just looks like it
                #[allow(clippy::octal_escapes)]
                "100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\t\t\000100644 blob \
                 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\t\"\000100644 blob \
                 5b999efa470b056e329b4c23a73904e0794bdc2f\t\n\000100644 blob \
                 f44f57fff95196c5f7139dfa0b96875f1e9650a9\t.gitignore\000100644 blob \
                 33dbaf21275ca2a5f460249d941cbc27d5da3121\tREADME.md\000040000 tree \
                 7360f2d292aec95907cebdcbb412a6bf2bd10f8a\tapps\000100644 blob \
                 9ec2879b24ce2c817296eebe2cb3846f8e4751ea\tpackage.json\000040000 tree \
                 5759aadaea2cde55468a61e7104eb0a9d86c1d30\tpackages\000100644 blob \
                 33d0621ee2f4da4a2f6f6bdd51a42618d181e337\tturbo.json\000100644 blob \
                 579f273c9536d324c20b2e8f0d7fe4784ed0d9df\tfile with spaces\0",
                &[
                    ("\t", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
                    ("\"", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
                    ("\n", "5b999efa470b056e329b4c23a73904e0794bdc2f"),
                    (".gitignore", "f44f57fff95196c5f7139dfa0b96875f1e9650a9"),
                    ("README.md", "33dbaf21275ca2a5f460249d941cbc27d5da3121"),
                    ("apps", "7360f2d292aec95907cebdcbb412a6bf2bd10f8a"),
                    ("package.json", "9ec2879b24ce2c817296eebe2cb3846f8e4751ea"),
                    ("packages", "5759aadaea2cde55468a61e7104eb0a9d86c1d30"),
                    ("turbo.json", "33d0621ee2f4da4a2f6f6bdd51a42618d181e337"),
                    (
                        "file with spaces",
                        "579f273c9536d324c20b2e8f0d7fe4784ed0d9df",
                    ),
                ],
            ),
        ];
        for (input, expected) in tests {
            let input_bytes = input.as_bytes();
            let mut hashes = GitHashes::new();
            let expected = to_hash_map(expected);
            read_ls_tree(input_bytes, &mut hashes).unwrap();
            assert_eq!(hashes, expected);
        }
    }

    #[test]
    fn test_ls_tree_rejects_non_utf8_filename() {
        let input = b"100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\tbad-\xff\0";
        let mut hashes = GitHashes::new();
        let err = read_ls_tree(&input[..], &mut hashes).unwrap_err();

        match err {
            Error::UnsupportedGitPath { origin, path, .. } => {
                assert_eq!(origin, "git ls-tree filename");
                assert_eq!(path, "bad-\\xff");
            }
            _ => panic!("expected unsupported git path error"),
        }
    }
}
