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

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

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

pub(crate) struct RepoStatusEntry {
    pub path: RelativeUnixPathBuf,
    pub is_delete: bool,
}

impl GitRepo {
    #[tracing::instrument(skip(self, root_path, hashes))]
    pub(crate) fn append_git_status(
        &self,
        root_path: &AbsoluteSystemPath,
        pkg_prefix: &RelativeUnixPathBuf,
        hashes: &mut GitHashes,
    ) -> Result<Vec<RelativeUnixPathBuf>, Error> {
        let mut git = Command::new(self.bin.as_std_path())
            .args([
                "status",
                "--untracked-files",
                "--no-renames",
                "-z",
                "--",
                ".",
            ])
            .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 status"))?;
        let mut stderr = git
            .stderr
            .take()
            .ok_or_else(|| Error::git_error("failed to get stderr for git status"))?;
        let parse_result = read_status(stdout, root_path, pkg_prefix, hashes);
        wait_for_success(git, &mut stderr, "git status", root_path, parse_result)
    }
}

fn read_status<R: Read>(
    reader: R,
    root_path: &AbsoluteSystemPath,
    pkg_prefix: &RelativeUnixPathBuf,
    hashes: &mut GitHashes,
) -> Result<Vec<RelativeUnixPathBuf>, Error> {
    let mut to_hash = Vec::new();
    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_status(&buffer)?;
        let path = require_git_path(entry.filename, "git status path")?;
        if entry.is_delete {
            let path = path.strip_prefix(pkg_prefix).map_err(|_| {
                Error::git_error(format!(
                    "'git status --untracked-files --no-renames -z -- .' run in {root_path} found \
                     a deleted file {path} that did not have the expected prefix: {pkg_prefix}"
                ))
            })?;
            hashes.remove(&path);
        } else {
            to_hash.push(path);
        }
        buffer.clear();
    }
    Ok(to_hash)
}

#[allow(dead_code)]
fn read_status_raw<R: Read>(reader: R) -> Result<Vec<RepoStatusEntry>, Error> {
    let mut entries = Vec::new();
    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_status(&buffer)?;
        let path = require_git_path(entry.filename, "git status path")?;
        entries.push(RepoStatusEntry {
            path,
            is_delete: entry.is_delete,
        });
        buffer.clear();
    }
    Ok(entries)
}

struct StatusEntry<'a> {
    filename: &'a [u8],
    is_delete: bool,
}

fn parse_status(i: &[u8]) -> Result<StatusEntry<'_>, Error> {
    match nom::combinator::all_consuming(nom_parse_status)(i).finish() {
        Ok((_, tup)) => Ok(tup),
        Err(e) => Err(Error::git_error(format!(
            "failed to parse git-status: {}",
            String::from_utf8_lossy(e.input)
        ))),
    }
}

fn nom_parse_status(i: &[u8]) -> nom::IResult<&[u8], StatusEntry<'_>> {
    let (i, x) = nom::bytes::complete::take(1usize)(i)?;
    let (i, y) = nom::bytes::complete::take(1usize)(i)?;
    let (i, _) = nom::character::complete::space1(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,
        StatusEntry {
            filename,
            is_delete: x[0] == b'D' || y[0] == b'D',
        },
    ))
}

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

    use turbopath::{AbsoluteSystemPathBuf, RelativeUnixPathBuf, RelativeUnixPathBufTestExt};

    use super::read_status;
    use crate::{Error, GitHashes, OidHash};

    #[test]
    fn test_status() {
        let root_path = AbsoluteSystemPathBuf::cwd().unwrap();
        let tests: &[(&str, &str, (&str, bool))] = &[
            ("AD my-pkg/package.json\0", "my-pkg", ("package.json", true)),
            (
                // no trailing NUL
                "AD some-pkg/package.json",
                "some-pkg",
                ("package.json", true),
            ),
            ("M  package.json\0", "", ("package.json", false)),
            ("A  some-pkg/some-file\0", "some-pkg", ("some-file", false)),
            (
                "M  some-pkg/file with spaces\0",
                "some-pkg",
                ("file with spaces", false),
            ),
        ];
        for (input, prefix, (expected_filename, expect_delete)) in tests {
            let prefix = RelativeUnixPathBuf::new(*prefix).unwrap();
            let mut hashes = to_hash_map(&[(expected_filename, "some-hash")]);
            let to_hash = read_status(input.as_bytes(), &root_path, &prefix, &mut hashes).unwrap();
            if *expect_delete {
                assert_eq!(hashes.len(), 0, "input: {input}");
            } else {
                assert_eq!(to_hash.len(), 1, "input: {input}");
                let expected = prefix.join(&RelativeUnixPathBuf::new(*expected_filename).unwrap());
                assert_eq!(to_hash[0], expected);
            }
        }
    }

    #[test]
    fn test_status_rejects_non_utf8_filename() {
        let root_path = AbsoluteSystemPathBuf::cwd().unwrap();
        let prefix = RelativeUnixPathBuf::new("").unwrap();
        let mut hashes = GitHashes::new();
        let err = read_status(
            b"M  bad-\xff\0".as_slice(),
            &root_path,
            &prefix,
            &mut hashes,
        )
        .unwrap_err();

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

    fn to_hash_map(pairs: &[(&str, &str)]) -> GitHashes {
        HashMap::from_iter(pairs.iter().map(|(path, hash)| {
            let padded = format!("{:0<40}", hash);
            (
                RelativeUnixPathBuf::new(*path).unwrap(),
                OidHash::from_hex_str(&padded),
            )
        }))
    }
}
