//! `ctxpkg.lock` — pins remote installs per project (GL #406).
//!
//! Lives at `.lean-ctx/ctxpkg.lock` in the project root, TOML, sorted by
//! scoped name so diffs stay minimal and reviews stay sane. Records what was
//! installed, from where, and the artifact hash — enough to re-fetch and
//! re-verify the exact bytes later.

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

use serde::{Deserialize, Serialize};

pub const LOCKFILE_REL_PATH: &str = ".lean-ctx/ctxpkg.lock";

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Lockfile {
    #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
    pub packages: Vec<LockedPackage>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
    /// Scoped name, e.g. `@acme/auth-context`.
    pub name: String,
    pub version: String,
    /// SHA-256 of the downloaded artifact bytes (registry + local verified).
    pub artifact_sha256: String,
    /// Registry base URL the artifact came from.
    pub registry: String,
}

pub fn lockfile_path(project_root: &Path) -> PathBuf {
    project_root.join(LOCKFILE_REL_PATH)
}

pub fn load(project_root: &Path) -> Result<Lockfile, String> {
    let path = lockfile_path(project_root);
    if !path.exists() {
        return Ok(Lockfile::default());
    }
    let text = std::fs::read_to_string(&path).map_err(|e| format!("read ctxpkg.lock: {e}"))?;
    toml::from_str(&text).map_err(|e| format!("parse ctxpkg.lock: {e}"))
}

/// Insert or replace the entry for `entry.name`, keeping the file sorted.
pub fn upsert(project_root: &Path, entry: LockedPackage) -> Result<(), String> {
    let mut lock = load(project_root)?;
    lock.packages.retain(|p| p.name != entry.name);
    lock.packages.push(entry);
    lock.packages.sort_by(|a, b| a.name.cmp(&b.name));
    save(project_root, &lock)
}

fn save(project_root: &Path, lock: &Lockfile) -> Result<(), String> {
    let path = lockfile_path(project_root);
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir).map_err(|e| format!("create .lean-ctx: {e}"))?;
    }
    let header = "# ctxpkg.lock — generated by `lean-ctx pack install`; commit this file.\n";
    let body = toml::to_string_pretty(lock).map_err(|e| format!("render ctxpkg.lock: {e}"))?;
    std::fs::write(&path, format!("{header}{body}")).map_err(|e| format!("write ctxpkg.lock: {e}"))
}

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

    fn tmp_root() -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "ctxlock-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        std::fs::create_dir_all(&dir).expect("mkdir");
        dir
    }

    #[test]
    fn upsert_roundtrip_sorted_and_replacing() {
        let root = tmp_root();
        upsert(
            &root,
            LockedPackage {
                name: "@zeta/pkg".into(),
                version: "1.0.0".into(),
                artifact_sha256: "aa".into(),
                registry: "https://ctxpkg.com/api".into(),
            },
        )
        .expect("upsert 1");
        upsert(
            &root,
            LockedPackage {
                name: "@acme/pkg".into(),
                version: "2.0.0".into(),
                artifact_sha256: "bb".into(),
                registry: "https://ctxpkg.com/api".into(),
            },
        )
        .expect("upsert 2");
        // Replace the @zeta entry with a newer version.
        upsert(
            &root,
            LockedPackage {
                name: "@zeta/pkg".into(),
                version: "1.1.0".into(),
                artifact_sha256: "cc".into(),
                registry: "https://ctxpkg.com/api".into(),
            },
        )
        .expect("upsert 3");

        let lock = load(&root).expect("load");
        assert_eq!(lock.packages.len(), 2);
        assert_eq!(lock.packages[0].name, "@acme/pkg"); // sorted
        assert_eq!(lock.packages[1].version, "1.1.0"); // replaced
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn missing_lockfile_loads_empty() {
        let root = tmp_root();
        assert!(load(&root).expect("load").packages.is_empty());
        std::fs::remove_dir_all(&root).ok();
    }
}
