use std::path::{Path, PathBuf};

use anyhow::Result;
use semver::Comparator;

use crate::core::{
    config::AlurConfig,
    pkg_json::{DeclaredPackageManagerSpec, PackageJson, package_json_path, read_package_json},
    types::{DetectionResult, DetectionSource, PackageManager},
};

const LOCKFILES: &[(&str, PackageManager)] = &[
    ("bun.lockb", PackageManager::Bun),
    ("bun.lock", PackageManager::Bun),
    ("pnpm-lock.yaml", PackageManager::Pnpm),
    ("yarn.lock", PackageManager::Yarn),
    ("package-lock.json", PackageManager::Npm),
    ("npm-shrinkwrap.json", PackageManager::Npm),
    ("deno.lock", PackageManager::Deno),
    ("deno.json", PackageManager::Deno),
    ("deno.jsonc", PackageManager::Deno),
];

const INSTALL_METADATA: &[(&str, PackageManager)] = &[
    ("node_modules/.deno", PackageManager::Deno),
    ("node_modules/.pnpm", PackageManager::Pnpm),
    ("node_modules/.yarn-state.yml", PackageManager::YarnBerry),
    ("node_modules/.yarn_integrity", PackageManager::Yarn),
    ("node_modules/.package-lock.json", PackageManager::Npm),
    (".pnp.cjs", PackageManager::YarnBerry),
    (".pnp.js", PackageManager::YarnBerry),
];

#[derive(Debug, Clone)]
pub(crate) struct NearestPackage {
    pub root: PathBuf,
    pub package_json_path: PathBuf,
    pub manifest: PackageJson,
}

#[derive(Debug, Clone)]
pub(crate) struct ScannedAncestor {
    pub dir: PathBuf,
    pub manifest: Option<PackageJson>,
}

#[derive(Debug, Clone)]
pub(crate) struct ProjectDiscovery {
    pub ancestors: Vec<ScannedAncestor>,
    pub nearest_package: Option<NearestPackage>,
    pub bin_dirs: Vec<PathBuf>,
    pub detection: DetectionResult,
    pub has_yarn_pnp_loader: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScanMode {
    Full,
    LocalBinsOnly,
}

impl ProjectDiscovery {
    pub(crate) fn scan(cwd: &Path, config: &AlurConfig, mode: ScanMode) -> Result<Self> {
        let mut ancestors = Vec::new();
        let mut nearest_package = None;
        let mut bin_dirs = Vec::new();
        let mut has_lock = false;
        let mut resolved_detection = None;
        let mut has_yarn_pnp_loader = false;

        for dir in cwd.ancestors() {
            let dir = dir.to_path_buf();
            let should_detect = resolved_detection.is_none() || !has_lock;
            let lockfile_pm = should_detect
                .then(|| detect_lockfile_in_dir(&dir))
                .flatten();
            has_lock |= lockfile_pm.is_some();
            has_yarn_pnp_loader |= yarn_pnp_loader_exists(&dir);

            let needs_manifest = matches!(mode, ScanMode::Full)
                && (nearest_package.is_none() || (should_detect && resolved_detection.is_none()));
            let manifest = if needs_manifest {
                read_package_json(&dir)?
            } else {
                None
            };

            if matches!(mode, ScanMode::Full)
                && nearest_package.is_none()
                && let Some(manifest) = manifest.clone()
            {
                nearest_package = Some(NearestPackage {
                    root: dir.clone(),
                    package_json_path: package_json_path(&dir),
                    manifest,
                });
            }

            if should_detect && resolved_detection.is_none() {
                resolved_detection = match mode {
                    ScanMode::Full => manifest
                        .as_ref()
                        .and_then(detect_package_manager_field)
                        .or_else(|| detection_from_lockfile(lockfile_pm, has_lock))
                        .or_else(|| manifest.as_ref().and_then(detect_dev_engines_field))
                        .or_else(|| detection_from_install_metadata(&dir, has_lock)),
                    ScanMode::LocalBinsOnly => detection_from_lockfile(lockfile_pm, has_lock)
                        .or_else(|| detection_from_install_metadata(&dir, has_lock)),
                };
            }

            collect_bin_dirs(&dir, &mut bin_dirs);
            ancestors.push(ScannedAncestor { dir, manifest });

            if scan_complete(
                mode,
                has_lock,
                &resolved_detection,
                nearest_package.is_some(),
            ) {
                break;
            }
        }

        let mut detection = resolved_detection
            .unwrap_or_else(|| fallback_detection_for_mode(mode, config, has_lock));
        detection.has_lock = has_lock;

        Ok(Self {
            ancestors,
            nearest_package,
            bin_dirs,
            detection,
            has_yarn_pnp_loader,
        })
    }
}

fn scan_complete(
    mode: ScanMode,
    has_lock: bool,
    resolved_detection: &Option<DetectionResult>,
    has_nearest_package: bool,
) -> bool {
    if !(has_lock && resolved_detection.is_some()) {
        return false;
    }

    match mode {
        ScanMode::Full => {
            has_nearest_package
                || resolved_detection
                    .as_ref()
                    .and_then(|detection| detection.agent)
                    == Some(PackageManager::Deno)
        }
        ScanMode::LocalBinsOnly => true,
    }
}

fn fallback_detection_for_mode(
    mode: ScanMode,
    config: &AlurConfig,
    has_lock: bool,
) -> DetectionResult {
    match mode {
        ScanMode::Full => fallback_detection(config, has_lock),
        ScanMode::LocalBinsOnly => config_only_detection(config, has_lock),
    }
}

pub(crate) fn node_modules_bin_dirs(cwd: &Path) -> Vec<PathBuf> {
    crate::core::profile::measure("local_bin.scan_dirs", || {
        let mut bin_dirs = Vec::new();
        for dir in cwd.ancestors() {
            collect_bin_dirs(dir, &mut bin_dirs);
        }
        bin_dirs
    })
}

pub(crate) fn resolve_declared_package_bin(
    ancestors: &[ScannedAncestor],
    bin_name: &str,
) -> Result<Option<PathBuf>> {
    for ancestor in ancestors {
        let manifest = match &ancestor.manifest {
            Some(manifest) => manifest.clone(),
            None => match read_package_json(&ancestor.dir)? {
                Some(manifest) => manifest,
                None => continue,
            },
        };
        let Some(relative) = manifest.bin_command_path(bin_name) else {
            continue;
        };
        let candidate = ancestor.dir.join(relative);
        if candidate.is_file() {
            return Ok(Some(candidate));
        }
    }

    Ok(None)
}

pub(crate) fn fallback_detection(config: &AlurConfig, has_lock: bool) -> DetectionResult {
    if let Some(agent) = config.default_package_manager {
        return DetectionResult {
            agent: Some(agent),
            has_lock,
            version_hint: None,
            source: DetectionSource::Config,
        };
    }

    if which::which("npm").is_ok() {
        return DetectionResult {
            agent: Some(PackageManager::Npm),
            has_lock,
            version_hint: None,
            source: DetectionSource::Fallback,
        };
    }

    DetectionResult {
        agent: None,
        has_lock,
        version_hint: None,
        source: DetectionSource::None,
    }
}

fn config_only_detection(config: &AlurConfig, has_lock: bool) -> DetectionResult {
    if let Some(agent) = config.default_package_manager {
        return DetectionResult {
            agent: Some(agent),
            has_lock,
            version_hint: None,
            source: DetectionSource::Config,
        };
    }

    DetectionResult {
        agent: None,
        has_lock,
        version_hint: None,
        source: DetectionSource::None,
    }
}

pub(crate) fn detect_lockfile_in_dir(dir: &Path) -> Option<PackageManager> {
    LOCKFILES
        .iter()
        .find_map(|(lockfile, pm)| dir.join(lockfile).exists().then_some(*pm))
}

pub(crate) fn detect_install_metadata_in_dir(dir: &Path) -> Option<PackageManager> {
    INSTALL_METADATA
        .iter()
        .find_map(|(entry, pm)| dir.join(entry).exists().then_some(*pm))
}

pub(crate) fn detect_package_manager_field(package_json: &PackageJson) -> Option<DetectionResult> {
    package_json
        .package_manager
        .as_deref()
        .and_then(parse_package_manager_field)
        .map(|(pm, version_hint)| DetectionResult {
            agent: Some(pm),
            has_lock: false,
            version_hint,
            source: DetectionSource::PackageManagerField,
        })
}

pub(crate) fn detect_dev_engines_field(package_json: &PackageJson) -> Option<DetectionResult> {
    package_json
        .dev_engines
        .as_ref()
        .and_then(|engines| engines.package_manager.as_ref())
        .and_then(|declared| match declared {
            DeclaredPackageManagerSpec::Single(entry) => {
                parse_declared_package_manager(entry.name.as_deref()?, entry.version.as_deref())
            }
            DeclaredPackageManagerSpec::Multiple(entries) => entries.iter().find_map(|entry| {
                parse_declared_package_manager(entry.name.as_deref()?, entry.version.as_deref())
            }),
        })
        .map(|(pm, version_hint)| DetectionResult {
            agent: Some(pm),
            has_lock: false,
            version_hint,
            source: DetectionSource::DevEnginesField,
        })
}

pub(crate) fn parse_package_manager_field(value: &str) -> Option<(PackageManager, Option<String>)> {
    let sanitized = value.trim().trim_start_matches(['^', '~']);
    if let Some((name, version)) = sanitized.split_once('@') {
        return parse_declared_package_manager(name, Some(version));
    }

    parse_declared_package_manager(sanitized, None)
}

fn parse_declared_package_manager(
    name: &str,
    version: Option<&str>,
) -> Option<(PackageManager, Option<String>)> {
    let lower = name
        .trim()
        .trim_start_matches(['^', '~'])
        .to_ascii_lowercase();
    let raw_version = version.map(str::trim).filter(|value| !value.is_empty());
    let parsed_version = raw_version.and_then(parse_version_hint);

    let mut pm = PackageManager::from_name(&lower)?;
    if pm == PackageManager::Yarn
        && (raw_version.is_some_and(|value| value.eq_ignore_ascii_case("berry"))
            || parsed_version
                .as_ref()
                .is_some_and(|version| version.major >= 2))
    {
        pm = PackageManager::YarnBerry;
    }

    let version_hint = if pm == PackageManager::YarnBerry
        && parsed_version
            .as_ref()
            .is_some_and(|version| version.berry_keyword)
    {
        None
    } else {
        parsed_version.map(|version| version.value)
    };

    Some((pm, version_hint))
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct VersionHint {
    value: String,
    major: u64,
    berry_keyword: bool,
}

fn parse_version_hint(value: &str) -> Option<VersionHint> {
    let trimmed = value.trim();
    if trimmed.eq_ignore_ascii_case("berry") {
        return Some(VersionHint {
            value: "berry".to_string(),
            major: 2,
            berry_keyword: true,
        });
    }

    let comparator_source = first_comparator(trimmed)?;
    let comparator_source = comparator_source
        .split_once('+')
        .map_or(comparator_source.as_str(), |(version, _)| version);
    let comparator = Comparator::parse(comparator_source).ok()?;
    let mut normalized = match (comparator.minor, comparator.patch) {
        (Some(minor), Some(patch)) => format!("{}.{}.{}", comparator.major, minor, patch),
        (Some(minor), None) => format!("{}.{}", comparator.major, minor),
        (None, _) => comparator.major.to_string(),
    };

    if !comparator.pre.is_empty() {
        normalized.push('-');
        normalized.push_str(comparator.pre.as_str());
    }

    Some(VersionHint {
        value: normalized,
        major: comparator.major,
        berry_keyword: false,
    })
}

fn first_comparator(value: &str) -> Option<String> {
    let mut parts = value.split_whitespace();
    let first = parts.next()?;

    if matches!(first, ">" | ">=" | "<" | "<=" | "=" | "~" | "^") {
        let version = parts.next()?;
        return Some(format!("{first}{version}"));
    }

    Some(first.to_string())
}

pub(crate) fn collect_bin_dirs(dir: &Path, bin_dirs: &mut Vec<PathBuf>) {
    for candidate in [
        dir.join("node_modules").join(".bin"),
        dir.join("node_modules")
            .join(".pnpm")
            .join("node_modules")
            .join(".bin"),
    ] {
        if candidate.is_dir() {
            bin_dirs.push(candidate);
        }
    }
}

fn detection_from_lockfile(
    lockfile_pm: Option<PackageManager>,
    has_lock: bool,
) -> Option<DetectionResult> {
    lockfile_pm.map(|pm| DetectionResult {
        agent: Some(pm),
        has_lock,
        version_hint: None,
        source: DetectionSource::Lockfile,
    })
}

fn detection_from_install_metadata(dir: &Path, has_lock: bool) -> Option<DetectionResult> {
    detect_install_metadata_in_dir(dir).map(|pm| DetectionResult {
        agent: Some(pm),
        has_lock,
        version_hint: None,
        source: DetectionSource::InstallMetadata,
    })
}

fn yarn_pnp_loader_exists(dir: &Path) -> bool {
    dir.join(".pnp.cjs").exists() || dir.join(".pnp.js").exists()
}

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

    #[test]
    fn finds_nearest_package_in_ancestors() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        let nested = root.join("packages").join("app");
        fs::create_dir_all(&nested).unwrap();
        fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();

        let discovery =
            ProjectDiscovery::scan(&nested, &AlurConfig::default(), ScanMode::Full).unwrap();
        let found = discovery.nearest_package.unwrap();
        assert_eq!(found.root, root);
    }

    #[test]
    fn bin_dirs_are_nearest_first() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        let nested = root.join("packages").join("app");
        fs::create_dir_all(root.join("node_modules").join(".bin")).unwrap();
        fs::create_dir_all(nested.join("node_modules").join(".bin")).unwrap();

        let bins = node_modules_bin_dirs(&nested);
        assert_eq!(bins[0], nested.join("node_modules").join(".bin"));
        assert_eq!(bins[1], root.join("node_modules").join(".bin"));
    }

    #[test]
    fn resolves_declared_package_bin_from_scanned_ancestors() {
        let dir = tempfile::tempdir().unwrap();
        let pkg = dir.path().join("pkg");
        let nested = pkg.join("src");
        fs::create_dir_all(&nested).unwrap();
        fs::create_dir_all(pkg.join("bin")).unwrap();
        fs::write(
            pkg.join("package.json"),
            r#"{"name":"tooling","bin":{"hello":"bin/hello.js"}}"#,
        )
        .unwrap();
        fs::write(pkg.join("bin").join("hello.js"), "console.log('hi')").unwrap();

        let discovery =
            ProjectDiscovery::scan(&nested, &AlurConfig::default(), ScanMode::Full).unwrap();
        let resolved = resolve_declared_package_bin(&discovery.ancestors, "hello").unwrap();
        assert_eq!(resolved, Some(pkg.join("bin").join("hello.js")));
    }
}
