// packages/loader.rs --- Require-name resolution against installed packages (T M7.7).

//! Loader (T M7.7, spec §sec:packages-future).
//!
//! Pure-Rust resolution layer that maps a Lua `require` name (e.g.
//! `"pmacs-magit"`, `"pmacs-magit.commit"`) to a concrete on-disk file
//! path within an installed package's tree, gated by the manifest's
//! [`PackageManifest::exports`] list.
//!
//! # Why a separate module
//!
//! The Lua-side searcher (in `lua_bindings.rs`) is short and stitches
//! mlua APIs together: load a chunk, set its environment, return it.
//! The decision logic — "given a require name and an installed
//! package roster, what file should we load (if any) and what error
//! should we emit (if not)?" — is plain Rust. Splitting it out lets
//! us unit-test every branch (entry, exported submodule, non-exported
//! submodule, package-not-installed, missing file) without standing
//! up a Lua state per case.
//!
//! # Resolution rules
//!
//! Let the require name split into `(head, tail)` on the first `.`:
//!
//! - `head == ""` → invalid require, fall through.
//! - No package with `install_basename() == head` → [`LookupOutcome::NotInstalled`].
//!   The Lua searcher returns a "not found" string; subsequent
//!   searchers (path-based, etc.) get a turn.
//! - `tail.is_empty()` (i.e. require name == basename, no dot) →
//!   [`LookupOutcome::EntryModule`] pointing at `manifest.entry`. The
//!   entry is **always** loadable; whether the basename appears in
//!   `exports` is the package author's choice but not enforced here.
//! - `tail` non-empty:
//!   - If the full require name (`head.tail`) is in `manifest.exports`
//!     → [`LookupOutcome::ExportedModule`] pointing at the conventional
//!     Lua-style path: `<install_path>/<tail with `.` → `/`>.lua`,
//!     falling back to `<install_path>/<tail with `.` → `/`>/init.lua`
//!     when the first form does not exist.
//!   - Otherwise → [`LookupOutcome::NotExported`] carrying the
//!     package name and the full sorted exports list, so the Lua
//!     searcher can produce a self-contained error message.
//!
//! Multiple installed packages can share a basename (a project-scope
//! install of the same name as a user-scope install). The Lua
//! searcher iterates the roster reverse-most-recent-first; this
//! module operates on whichever single [`InstalledPackage`] the
//! caller hands in, so collision policy is the searcher's concern,
//! not ours.

use std::path::PathBuf;

use super::installer::InstalledPackage;

/// Outcome of looking up a `require` name against one installed
/// package.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum LookupOutcome {
    /// The require name matches the package's entry module. The
    /// caller loads `entry_path` and sets the package's environment.
    EntryModule {
        /// Absolute path to the entry file (`install_path` joined
        /// with `manifest.entry`).
        entry_path: PathBuf,
    },
    /// The require name matches a submodule listed in `exports`.
    ExportedModule {
        /// Absolute path to the resolved file (one of `<dir>/x.lua`
        /// or `<dir>/x/init.lua` based on which exists; if neither
        /// exists at lookup time the caller surfaces the missing
        /// file as a load error).
        file_path: PathBuf,
        /// Which file form was selected. `Direct` for `<dir>/x.lua`,
        /// `InitDir` for `<dir>/x/init.lua`. `MissingBoth` is
        /// surfaced when neither file exists; the searcher converts
        /// this into a require error naming the package and the
        /// submodule.
        kind: ResolvedKind,
    },
    /// The require name's head matches the package basename, but the
    /// full name is not declared in the package's `exports`. The
    /// caller raises a require error with the available exports.
    NotExported {
        /// Package basename.
        package: String,
        /// Full require name as the user wrote it.
        requested: String,
        /// Sorted, deduped exports list for the error message.
        exports: Vec<String>,
    },
    /// No installed package matches the require name's head. The
    /// caller falls through to the next searcher (typically the
    /// path-based one).
    NotInstalled,
}

/// Which file form an exported-module lookup matched.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ResolvedKind {
    /// `<install_path>/<rel>.lua`.
    Direct,
    /// `<install_path>/<rel>/init.lua`.
    InitDir,
    /// Neither form exists; caller surfaces a missing-file error.
    MissingBoth,
}

/// Look up a `require` name against a single installed package.
///
/// `pkg.install_basename()` is the head this function compares against
/// the require name's first segment. See module docs for the full
/// resolution table.
#[must_use]
pub fn lookup_in_package(name: &str, pkg: &InstalledPackage) -> LookupOutcome {
    let basename = pkg.install_basename();
    let (head, tail) = match name.split_once('.') {
        Some((h, t)) => (h, t),
        None => (name, ""),
    };
    if head != basename {
        return LookupOutcome::NotInstalled;
    }

    if tail.is_empty() {
        // Bare basename require → entry module. Always allowed; the
        // entry is the package's "main" interface and listing it in
        // exports is permitted but not required.
        return LookupOutcome::EntryModule {
            entry_path: pkg.entry_path(),
        };
    }

    // Submodule require. Must be in exports.
    if !pkg.manifest.exports.iter().any(|e| e == name) {
        let mut exports = pkg.manifest.exports.clone();
        exports.sort();
        exports.dedup();
        return LookupOutcome::NotExported {
            package: basename.to_string(),
            requested: name.to_string(),
            exports,
        };
    }

    // Resolve `tail` (which may itself contain dots) to a relative
    // path under install_path.
    let rel = tail.replace('.', "/");
    let direct = pkg.install_path.join(format!("{rel}.lua"));
    let init_dir = pkg.install_path.join(&rel).join("init.lua");

    let kind = if direct.is_file() {
        ResolvedKind::Direct
    } else if init_dir.is_file() {
        ResolvedKind::InitDir
    } else {
        ResolvedKind::MissingBoth
    };
    let file_path = match kind {
        ResolvedKind::Direct | ResolvedKind::MissingBoth => direct,
        ResolvedKind::InitDir => init_dir,
    };
    LookupOutcome::ExportedModule { file_path, kind }
}

/// Walk the installed-packages list in reverse order (most-recent
/// first) and return the first match. Mirrors the Lua searcher's
/// "newer install wins on collision" semantics.
#[must_use]
pub fn lookup_in_roster(name: &str, roster: &[InstalledPackage]) -> LookupOutcome {
    for pkg in roster.iter().rev() {
        let outcome = lookup_in_package(name, pkg);
        if !matches!(outcome, LookupOutcome::NotInstalled) {
            return outcome;
        }
    }
    LookupOutcome::NotInstalled
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::packages::{InstallPin, InstallScope, PackageManifest, PackageName};
    use semver::{Version, VersionReq};
    use std::path::Path;
    use tempfile::TempDir;

    fn manifest(name: &str, exports: &[&str]) -> PackageManifest {
        PackageManifest {
            name: PackageName::new(name).unwrap(),
            version: Version::new(1, 0, 0),
            summary: "test".into(),
            pmacs_required: VersionReq::parse(">= 0.1.0").unwrap(),
            dependencies: vec![],
            conflicts: vec![],
            entry: PathBuf::from("init.lua"),
            exports: exports.iter().map(|s| (*s).to_string()).collect(),
        }
    }

    fn make_installed(
        name: &str,
        exports: &[&str],
        files: &[(&str, &str)],
    ) -> (TempDir, InstalledPackage) {
        let td = tempfile::tempdir().unwrap();
        let install_path = td.path().join(name);
        std::fs::create_dir_all(&install_path).unwrap();
        for (rel, body) in files {
            let p = install_path.join(rel);
            if let Some(parent) = p.parent() {
                std::fs::create_dir_all(parent).unwrap();
            }
            std::fs::write(&p, body).unwrap();
        }
        let pkg = InstalledPackage {
            manifest: manifest(name, exports),
            install_path,
            commit: "0".repeat(40),
            tag: "v1.0.0".into(),
            version: Version::new(1, 0, 0),
            scope: InstallScope::User,
            pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
        };
        (td, pkg)
    }

    #[test]
    fn entry_lookup_for_bare_basename() {
        let (_td, pkg) = make_installed("samplepkg", &[], &[("init.lua", "return {}")]);
        match lookup_in_package("samplepkg", &pkg) {
            LookupOutcome::EntryModule { entry_path } => {
                assert!(
                    entry_path.ends_with("samplepkg/init.lua"),
                    "got {entry_path:?}",
                );
            }
            other => panic!("expected EntryModule, got {other:?}"),
        }
    }

    #[test]
    fn exported_submodule_resolves_to_direct_file() {
        let (_td, pkg) = make_installed(
            "magit",
            &["magit.commit"],
            &[("init.lua", "return {}"), ("commit.lua", "return {}")],
        );
        match lookup_in_package("magit.commit", &pkg) {
            LookupOutcome::ExportedModule { file_path, kind } => {
                assert_eq!(kind, ResolvedKind::Direct);
                assert!(file_path.ends_with("magit/commit.lua"), "got {file_path:?}");
            }
            other => panic!("expected ExportedModule, got {other:?}"),
        }
    }

    #[test]
    fn exported_submodule_falls_back_to_init_dir() {
        let (_td, pkg) = make_installed(
            "magit",
            &["magit.commit"],
            &[("init.lua", "return {}"), ("commit/init.lua", "return {}")],
        );
        match lookup_in_package("magit.commit", &pkg) {
            LookupOutcome::ExportedModule { file_path, kind } => {
                assert_eq!(kind, ResolvedKind::InitDir);
                assert!(
                    file_path.ends_with("magit/commit/init.lua"),
                    "got {file_path:?}",
                );
            }
            other => panic!("expected ExportedModule, got {other:?}"),
        }
    }

    #[test]
    fn exported_submodule_with_neither_file_returns_missing_both() {
        let (_td, pkg) = make_installed("magit", &["magit.commit"], &[("init.lua", "return {}")]);
        match lookup_in_package("magit.commit", &pkg) {
            LookupOutcome::ExportedModule { kind, .. } => {
                assert_eq!(kind, ResolvedKind::MissingBoth);
            }
            other => panic!("expected ExportedModule, got {other:?}"),
        }
    }

    #[test]
    fn non_exported_submodule_returns_not_exported_with_list() {
        let (_td, pkg) = make_installed(
            "magit",
            &["magit.commit", "magit.diff"],
            &[("init.lua", "return {}"), ("internal.lua", "return {}")],
        );
        match lookup_in_package("magit.internal", &pkg) {
            LookupOutcome::NotExported {
                package,
                requested,
                exports,
            } => {
                assert_eq!(package, "magit");
                assert_eq!(requested, "magit.internal");
                assert_eq!(exports, vec!["magit.commit", "magit.diff"]);
            }
            other => panic!("expected NotExported, got {other:?}"),
        }
    }

    #[test]
    fn unrelated_require_name_returns_not_installed() {
        let (_td, pkg) = make_installed("magit", &[], &[("init.lua", "return {}")]);
        assert_eq!(
            lookup_in_package("totally-different", &pkg),
            LookupOutcome::NotInstalled,
        );
    }

    #[test]
    fn deeper_dotted_path_resolves_with_slashes() {
        let (_td, pkg) = make_installed(
            "magit",
            &["magit.diff.hunk"],
            &[("init.lua", "return {}"), ("diff/hunk.lua", "return {}")],
        );
        match lookup_in_package("magit.diff.hunk", &pkg) {
            LookupOutcome::ExportedModule { file_path, kind } => {
                assert_eq!(kind, ResolvedKind::Direct);
                assert!(
                    file_path.ends_with("magit/diff/hunk.lua"),
                    "got {file_path:?}",
                );
            }
            other => panic!("expected ExportedModule, got {other:?}"),
        }
    }

    #[test]
    fn roster_lookup_picks_most_recent_on_basename_collision() {
        // Two installs with the same basename; the second one should
        // win the lookup (mirrors the Lua searcher's project-scope-
        // overrides-user-scope semantics).
        let (_td_a, mut pkg_a) = make_installed("samplepkg", &[], &[("init.lua", "return 'a'")]);
        pkg_a.scope = InstallScope::User;
        let (_td_b, mut pkg_b) = make_installed("samplepkg", &[], &[("init.lua", "return 'b'")]);
        pkg_b.scope = InstallScope::Project {
            project_root: PathBuf::from("/tmp/proj"),
        };
        let _ = (Path::new("a"), Path::new("b"));

        let roster = vec![pkg_a.clone(), pkg_b.clone()];
        match lookup_in_roster("samplepkg", &roster) {
            LookupOutcome::EntryModule { entry_path } => {
                assert_eq!(entry_path, pkg_b.entry_path());
            }
            other => panic!("expected EntryModule, got {other:?}"),
        }
    }
}
