use super::{MatrixError, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

const POLICY_PATH: &str = "ci/dependency-policy.json";
const REWRITE_PATH: &str = "RUSTREWRITE.md";

#[derive(Debug, Deserialize)]
struct DependencyPolicy {
    schema: u32,
    msrv: String,
    current: String,
    reviewed_on: String,
    review_by: String,
    direct: Vec<DirectPolicy>,
    approved_duplicates: Vec<DuplicatePolicy>,
}

#[derive(Debug, Deserialize)]
struct DirectPolicy {
    name: String,
    versions: Vec<String>,
    purpose: String,
    maintenance: String,
    transitive_cost: String,
    native: bool,
    rewrite_anchor: String,
}

#[derive(Debug, Deserialize)]
struct DuplicatePolicy {
    name: String,
    versions: Vec<String>,
    reason: String,
}

#[derive(Debug, Default, Serialize)]
struct ObservedDirect {
    versions: BTreeSet<String>,
    consumers: BTreeSet<String>,
    scopes: BTreeSet<String>,
}

struct ObservedGraph {
    direct: BTreeMap<String, ObservedDirect>,
    resolved_external_packages: usize,
    duplicates: BTreeMap<String, BTreeSet<String>>,
}

#[derive(Debug, Serialize)]
struct DependencyEvidence<'a> {
    schema: u32,
    msrv: &'a str,
    current: &'a str,
    reviewed_on: &'a str,
    review_by: &'a str,
    recorded_unix_seconds: u64,
    direct_dependencies: &'a BTreeMap<String, ObservedDirect>,
    direct_dependency_count: usize,
    resolved_external_package_count: usize,
    approved_duplicates: &'a BTreeMap<String, BTreeSet<String>>,
    native_dependencies: Vec<&'a str>,
    status: &'static str,
}

/// Audits the complete resolved dependency graph against its reviewed policy.
///
/// # Errors
///
/// Returns an error for unreviewed direct crates or versions, stale duplicate
/// approvals, wildcard or non-registry dependencies, incomplete rationale, or
/// a metadata/evidence I/O failure.
pub fn audit_dependencies(root: &Path, evidence: &Path) -> Result<()> {
    if evidence.exists() {
        return Err(MatrixError::new(format!(
            "{} already exists; preserve or remove it before rerunning the audit",
            evidence.display()
        )));
    }
    let policy: DependencyPolicy = serde_json::from_slice(&fs::read(root.join(POLICY_PATH))?)?;
    validate_policy(&policy, &fs::read_to_string(root.join(REWRITE_PATH))?)?;
    let metadata = cargo_metadata(root)?;
    let observed = observe(&metadata)?;
    compare_direct(&policy.direct, &observed.direct)?;
    compare_duplicates(&policy.approved_duplicates, &observed.duplicates)?;

    let native_dependencies = policy
        .direct
        .iter()
        .filter(|dependency| dependency.native)
        .map(|dependency| dependency.name.as_str())
        .collect();
    let evidence_record = DependencyEvidence {
        schema: 1,
        msrv: &policy.msrv,
        current: &policy.current,
        reviewed_on: &policy.reviewed_on,
        review_by: &policy.review_by,
        recorded_unix_seconds: SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|_| MatrixError::new("system clock predates Unix epoch"))?
            .as_secs(),
        direct_dependencies: &observed.direct,
        direct_dependency_count: observed.direct.len(),
        resolved_external_package_count: observed.resolved_external_packages,
        approved_duplicates: &observed.duplicates,
        native_dependencies,
        status: "ok",
    };
    if let Some(parent) = evidence.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(evidence)?;
    serde_json::to_writer_pretty(&mut file, &evidence_record)?;
    file.write_all(b"\n")?;
    file.sync_all()?;
    Ok(())
}

fn validate_policy(policy: &DependencyPolicy, rewrite: &str) -> Result<()> {
    if policy.schema != 1 || policy.msrv != "1.96.0" || policy.current != "stable" {
        return Err(MatrixError::new(
            "dependency policy must use schema 1, MSRV 1.96.0, and current stable",
        ));
    }
    if !date_shape(&policy.reviewed_on) || !date_shape(&policy.review_by) {
        return Err(MatrixError::new(
            "dependency review dates must use YYYY-MM-DD",
        ));
    }
    let mut names = BTreeSet::new();
    for dependency in &policy.direct {
        if dependency.name.trim().is_empty()
            || !names.insert(dependency.name.as_str())
            || dependency.versions.is_empty()
            || dependency.purpose.trim().is_empty()
            || dependency.rewrite_anchor.trim().is_empty()
            || !rewrite.contains(&dependency.rewrite_anchor)
            || !matches!(
                dependency.maintenance.as_str(),
                "active" | "stable" | "monitored-native"
            )
            || !matches!(
                dependency.transitive_cost.as_str(),
                "low" | "medium" | "high"
            )
        {
            return Err(MatrixError::new(format!(
                "dependency {} has incomplete or invalid review metadata",
                dependency.name
            )));
        }
        unique_versions(&dependency.name, &dependency.versions)?;
    }
    let mut duplicate_names = BTreeSet::new();
    for duplicate in &policy.approved_duplicates {
        if duplicate.name.trim().is_empty()
            || !duplicate_names.insert(duplicate.name.as_str())
            || duplicate.versions.len() < 2
            || duplicate.reason.trim().len() < 20
        {
            return Err(MatrixError::new(format!(
                "duplicate {} needs unique versions and a substantive reason",
                duplicate.name
            )));
        }
        unique_versions(&duplicate.name, &duplicate.versions)?;
    }
    Ok(())
}

fn date_shape(value: &str) -> bool {
    value.len() == 10
        && value.as_bytes()[4] == b'-'
        && value.as_bytes()[7] == b'-'
        && value
            .bytes()
            .enumerate()
            .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
}

fn unique_versions(name: &str, versions: &[String]) -> Result<()> {
    let unique = versions.iter().map(String::as_str).collect::<BTreeSet<_>>();
    if unique.len() != versions.len() || unique.iter().any(|version| version.trim().is_empty()) {
        return Err(MatrixError::new(format!(
            "dependency {name} versions must be nonempty and unique"
        )));
    }
    Ok(())
}

fn cargo_metadata(root: &Path) -> Result<Value> {
    let output = Command::new(super::cargo_program())
        .args([
            "metadata",
            "--locked",
            "--all-features",
            "--format-version",
            "1",
        ])
        .current_dir(root)
        .output()?;
    if !output.status.success() {
        return Err(MatrixError::new(format!(
            "cargo metadata failed during dependency audit: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        )));
    }
    Ok(serde_json::from_slice(&output.stdout)?)
}

fn observe(metadata: &Value) -> Result<ObservedGraph> {
    let packages = metadata["packages"]
        .as_array()
        .ok_or_else(|| MatrixError::new("cargo metadata has no package array"))?;
    let mut package_by_id = BTreeMap::new();
    let mut all_external = BTreeMap::<String, BTreeSet<String>>::new();
    for package in packages {
        let id = string(package, "id")?;
        let name = string(package, "name")?;
        let version = string(package, "version")?;
        let source = package["source"].as_str();
        if let Some(source) = source {
            if !source.starts_with("registry+https://github.com/rust-lang/crates.io-index") {
                return Err(MatrixError::new(format!(
                    "package {name} {version} uses unapproved source {source}"
                )));
            }
            all_external
                .entry(name.to_owned())
                .or_default()
                .insert(version.to_owned());
        }
        if package["dependencies"]
            .as_array()
            .is_some_and(|dependencies| {
                dependencies.iter().any(|dependency| {
                    dependency["source"].is_string() && dependency["req"].as_str() == Some("*")
                })
            })
        {
            return Err(MatrixError::new(format!(
                "package {name} contains a wildcard dependency"
            )));
        }
        package_by_id.insert(
            id.to_owned(),
            (name.to_owned(), version.to_owned(), source.is_some()),
        );
    }

    let nodes = metadata["resolve"]["nodes"]
        .as_array()
        .ok_or_else(|| MatrixError::new("cargo metadata has no resolve nodes"))?;
    let mut direct = BTreeMap::<String, ObservedDirect>::new();
    for node in nodes {
        let id = string(node, "id")?;
        let Some((consumer, _, false)) = package_by_id.get(id) else {
            continue;
        };
        let dependencies = node["deps"]
            .as_array()
            .ok_or_else(|| MatrixError::new("cargo metadata node has no dependencies"))?;
        for dependency in dependencies {
            let package_id = string(dependency, "pkg")?;
            let Some((name, version, true)) = package_by_id.get(package_id) else {
                continue;
            };
            let observed = direct.entry(name.clone()).or_default();
            observed.versions.insert(version.clone());
            observed.consumers.insert(consumer.clone());
            for kind in dependency["dep_kinds"].as_array().into_iter().flatten() {
                observed
                    .scopes
                    .insert(kind["kind"].as_str().unwrap_or("normal").to_owned());
            }
        }
    }
    let duplicates = all_external
        .iter()
        .filter(|(_, versions)| versions.len() > 1)
        .map(|(name, versions)| (name.clone(), versions.clone()))
        .collect();
    Ok(ObservedGraph {
        direct,
        resolved_external_packages: all_external.values().map(BTreeSet::len).sum(),
        duplicates,
    })
}

fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
    value[key]
        .as_str()
        .ok_or_else(|| MatrixError::new(format!("cargo metadata field {key} is not a string")))
}

fn compare_direct(
    policy: &[DirectPolicy],
    observed: &BTreeMap<String, ObservedDirect>,
) -> Result<()> {
    let expected = policy
        .iter()
        .map(|dependency| {
            (
                dependency.name.as_str(),
                dependency.versions.iter().cloned().collect::<BTreeSet<_>>(),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let expected_names = expected.keys().copied().collect::<BTreeSet<_>>();
    let observed_names = observed.keys().map(String::as_str).collect::<BTreeSet<_>>();
    if expected_names != observed_names {
        return Err(MatrixError::new(format!(
            "direct dependency policy mismatch; expected {expected_names:?}, observed {observed_names:?}"
        )));
    }
    for (name, versions) in expected {
        if observed[name].versions != versions {
            return Err(MatrixError::new(format!(
                "direct dependency {name} version mismatch; expected {versions:?}, observed {:?}",
                observed[name].versions
            )));
        }
    }
    Ok(())
}

fn compare_duplicates(
    policy: &[DuplicatePolicy],
    observed: &BTreeMap<String, BTreeSet<String>>,
) -> Result<()> {
    let expected = policy
        .iter()
        .map(|duplicate| {
            (
                duplicate.name.clone(),
                duplicate.versions.iter().cloned().collect::<BTreeSet<_>>(),
            )
        })
        .collect::<BTreeMap<_, _>>();
    if expected != *observed {
        return Err(MatrixError::new(format!(
            "resolved duplicate policy mismatch; expected {expected:?}, observed {observed:?}"
        )));
    }
    Ok(())
}

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

    #[test]
    fn checked_in_dependency_policy_matches_the_locked_graph() {
        let root = super::super::workspace_root(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
        let evidence = std::env::temp_dir().join(format!(
            "metacrate-dependency-audit-{}.json",
            std::process::id()
        ));
        let _ = fs::remove_file(&evidence);
        audit_dependencies(&root, &evidence).unwrap();
        let contents = fs::read_to_string(&evidence).unwrap();
        assert!(contents.contains("\"status\": \"ok\""));
        fs::remove_file(evidence).unwrap();
    }
}
