use node_semver::{Range, Version};
use turbopath::{AbsoluteSystemPath, RelativeUnixPath};

use crate::{
    package_json::PackageJson,
    package_manager::{Error, PackageManager},
};

pub const LOCKFILE: &str = "yarn.lock";

pub struct YarnDetector<'a> {
    repo_root: &'a AbsoluteSystemPath,
    found: bool,
}

impl<'a> YarnDetector<'a> {
    pub fn new(repo_root: &'a AbsoluteSystemPath) -> Self {
        Self {
            repo_root,
            found: false,
        }
    }

    fn detect_from_lockfile(&self) -> Result<PackageManager, Error> {
        let yarn_lockfile = self.repo_root.join_component(LOCKFILE);
        let contents = String::from_utf8(yarn_lockfile.read()?)?;
        Self::detect_from_lockfile_contents(&contents)
            .ok_or(Error::UnrecognizedYarnLockfile(yarn_lockfile))
    }

    fn detect_from_lockfile_contents(contents: &str) -> Option<PackageManager> {
        if contents.lines().any(is_berry_metadata_line) {
            Some(PackageManager::Berry)
        } else if contents.lines().any(is_yarn1_header_line) {
            Some(PackageManager::Yarn)
        } else {
            None
        }
    }

    pub fn detect_berry_or_yarn(version: &Version) -> Result<PackageManager, Error> {
        let berry_constraint: Range = ">=2.0.0-0".parse().expect("valid version");
        if berry_constraint.satisfies(version) {
            Ok(PackageManager::Berry)
        } else {
            Ok(PackageManager::Yarn)
        }
    }
}

fn is_berry_metadata_line(line: &str) -> bool {
    let line = line
        .strip_prefix('\u{feff}')
        .unwrap_or(line)
        .trim_end_matches('\r');
    let Some(after_key) = line.strip_prefix("__metadata:") else {
        return false;
    };
    after_key
        .chars()
        .next()
        .map(|ch| ch.is_ascii_whitespace() || ch == '{')
        .unwrap_or(true)
}

fn is_yarn1_header_line(line: &str) -> bool {
    let line = line.strip_prefix('\u{feff}').unwrap_or(line).trim();
    let Some(comment) = line.strip_prefix('#') else {
        return false;
    };
    comment.trim().eq_ignore_ascii_case("yarn lockfile v1")
}

pub(crate) fn prune_patches<R: AsRef<RelativeUnixPath>>(
    package_json: &PackageJson,
    patches: &[R],
) -> PackageJson {
    let mut pruned_json = package_json.clone();
    let patches = patches
        .iter()
        .map(|patch_path| patch_path.as_ref().to_string())
        .collect::<Vec<_>>();

    if let Some(existing_patches) = &mut pruned_json.resolutions {
        existing_patches.retain(|_, resolution| {
            // Keep any non-patch resolution entries
            !resolution.ends_with(".patch")
                // Keep any patch resolutions if they end with a patch file path
                || patches.iter().any(|patch| resolution.ends_with(patch))
        })
    }

    pruned_json
}

impl Iterator for YarnDetector<'_> {
    type Item = Result<PackageManager, Error>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.found {
            return None;
        }
        self.found = true;

        let yarn_lockfile = self.repo_root.join_component(LOCKFILE);

        if yarn_lockfile.exists() {
            Some(self.detect_from_lockfile())
        } else {
            None
        }
    }
}

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

    use anyhow::Result;
    use serde_json::json;
    use turbopath::{AbsoluteSystemPath, RelativeUnixPathBuf};

    use super::{LOCKFILE, YarnDetector, prune_patches};
    use crate::{
        package_json::PackageJson,
        package_manager::{Error, PackageManager},
    };

    #[test]
    fn test_detect_yarn() -> Result<()> {
        let package_manager = YarnDetector::detect_berry_or_yarn(&"1.22.10".parse()?)?;
        assert_eq!(package_manager, PackageManager::Yarn);

        let package_manager = YarnDetector::detect_berry_or_yarn(&"2.22.10".parse()?)?;
        assert_eq!(package_manager, PackageManager::Berry);

        Ok(())
    }

    #[test]
    fn test_detects_lockfile_format() {
        assert_eq!(
            YarnDetector::detect_from_lockfile_contents(
                "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn \
                 lockfile v1\n"
            ),
            Some(PackageManager::Yarn)
        );
        assert_eq!(
            YarnDetector::detect_from_lockfile_contents(
                "# This file is generated by running \"yarn install\" inside your \
                 project.\n\n__metadata:\n  version: 6\n"
            ),
            Some(PackageManager::Berry)
        );
        assert_eq!(
            YarnDetector::detect_from_lockfile_contents("  __metadata:\n  version: 6\n"),
            None
        );
    }

    #[test]
    fn test_detects_lockfile_without_executing_yarn_path() -> Result<()> {
        let tmpdir = tempfile::tempdir()?;
        let repo_root = AbsoluteSystemPath::from_std_path(tmpdir.path())?;
        repo_root
            .join_component(LOCKFILE)
            .create_with_contents("__metadata:\n  version: 6\n  cacheKey: 8\n")?;
        repo_root
            .join_component(".yarnrc.yml")
            .create_with_contents("yarnPath: ./malicious.cjs\n")?;

        let package_manager = YarnDetector::new(repo_root).next().unwrap()?;
        assert_eq!(package_manager, PackageManager::Berry);

        Ok(())
    }

    #[test]
    fn test_errors_on_unrecognized_lockfile() -> Result<()> {
        let tmpdir = tempfile::tempdir()?;
        let repo_root = AbsoluteSystemPath::from_std_path(tmpdir.path())?;
        repo_root
            .join_component(LOCKFILE)
            .create_with_contents("not a yarn lockfile\n")?;

        let err = YarnDetector::new(repo_root).next().unwrap().unwrap_err();
        assert!(matches!(err, Error::UnrecognizedYarnLockfile(_)));

        Ok(())
    }

    #[test]
    fn test_patch_pruning() {
        let package_json: PackageJson = PackageJson::from_value(json!({
            "name": "pnpm-patches",
            "resolutions": {
                "foo@1.0.0": "patch:foo@npm%3A1.0.0#./.yarn/patches/foo-npm-1.0.0-time.patch",
                "bar@1.2.3": "patch:bar@npm%3A1.2.3#./.yarn/patches/bar-npm-1.2.3-time.patch",
                "baz": "1.0.0",
            }
        }))
        .unwrap();
        let patches =
            vec![RelativeUnixPathBuf::new(".yarn/patches/foo-npm-1.0.0-time.patch").unwrap()];
        let pruned = prune_patches(&package_json, &patches);
        assert_eq!(
            pruned.resolutions.as_ref(),
            Some(
                [
                    (
                        "foo@1.0.0",
                        "patch:foo@npm%3A1.0.0#./.yarn/patches/foo-npm-1.0.0-time.patch"
                    ),
                    // Should be kept as it isn't a patch, but a version override
                    ("baz", "1.0.0")
                ]
                .iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect::<BTreeMap<_, _>>()
            )
            .as_ref()
        );
    }
}
