//! Smart resolution of node-app dependency repos for `node-app dev` in
//! platform-repo mode.
//!
//! Given a list of dep names (e.g. `["esp32-bridge", "discovery"]`), produce
//! a list of source-directory paths the dev orchestrator can build & stage
//! into the daemon's pre_start_dev_dir. Each name is resolved in this order
//! (first hit wins):
//!
//! 1. **Explicit `--dep PATH`** whose manifest name matches → use as-is.
//! 2. **Source-map hit** (`.node-app/sources.lock` entry with a live path).
//! 3. **Sibling** at `<parent>/node-app-<name>` (the developer's local checkout).
//! 4. **Global cache** at `$XDG_CACHE_HOME/node-app/deps/<name>/` (best-effort
//!    `git fetch && reset --hard origin/HEAD` to track upstream).
//! 5. **Clone** `econ-v1/node-app-<name>` into the cache (via `gh` when
//!    authenticated, else `git clone https://`).
//! 6. Fail with an actionable error pointing at all five attempts.
//!
//! The source map is rewritten atomically after a successful pass.

use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::SystemTime;

use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};

use super::host::gh;
use crate::tui::{self, LogEntry, LogSource, LogTx, TuiEvent};

const GITHUB_ORG: &str = "econ-v1";
const SOURCES_LOCK_REL: &str = ".node-app/sources.lock";

/// Schema version baked into the lockfile — bump if the on-disk format changes
/// in a non-backwards-compatible way so old lockfiles are rejected cleanly.
const LOCK_SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Serialize, Deserialize, Default)]
struct SourcesLock {
    schema_version: u32,
    #[serde(default)]
    dep: Vec<LockEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct LockEntry {
    name: String,
    source_path: String,
    #[serde(default)]
    git_rev: String,
    #[serde(default)]
    last_resolved: String,
}

/// Resolve dep source directories for the given names.
///
/// `explicit_dep_paths` are paths the user passed via `--dep PATH`; whichever
/// dep manifest name matches `app_names[i]` overrides auto-resolution for
/// that entry. Other entries in `explicit_dep_paths` are not returned here
/// (they're already in the caller's list and will be staged anyway).
pub fn resolve(
    project_root: &Path,
    app_names: &[String],
    explicit_dep_paths: &[PathBuf],
    log_tx: Option<&LogTx>,
) -> Result<Vec<PathBuf>> {
    if app_names.is_empty() {
        return Ok(Vec::new());
    }

    // Build name → path map for explicit overrides so we can skip resolution.
    let mut overridden: std::collections::HashMap<String, PathBuf> =
        std::collections::HashMap::new();
    for p in explicit_dep_paths {
        if let Ok(name) = read_manifest_name(p) {
            overridden.insert(name, p.clone());
        }
    }

    let lock_path = project_root.join(SOURCES_LOCK_REL);
    let mut lock = read_lock(&lock_path).unwrap_or_default();
    if lock.schema_version == 0 {
        lock.schema_version = LOCK_SCHEMA_VERSION;
    }

    let mut resolved: Vec<PathBuf> = Vec::with_capacity(app_names.len());
    let mut seen = std::collections::HashSet::new();
    for name in app_names {
        if !seen.insert(name.clone()) {
            tui::sys_log(log_tx, format!("⚠ duplicate dep '{}' in platform-depends — skipping", name));
            continue;
        }

        if overridden.contains_key(name) {
            tui::sys_log(log_tx, format!("✓ dep '{}' from --dep override", name));
            // Explicit deps are already in the caller's --dep list; do not duplicate.
            continue;
        }

        let path = resolve_one(name, &mut lock, project_root, log_tx)
            .with_context(|| format!("resolve dep '{}'", name))?;
        resolved.push(path);
    }

    write_lock_atomic(&lock_path, &lock)
        .with_context(|| format!("write {}", lock_path.display()))?;

    Ok(resolved)
}

fn resolve_one(
    name: &str,
    lock: &mut SourcesLock,
    project_root: &Path,
    log_tx: Option<&LogTx>,
) -> Result<PathBuf> {
    // 1. Source-map hit.
    if let Some(entry) = lock.dep.iter().find(|d| d.name == name) {
        let p = PathBuf::from(&entry.source_path);
        if validate_dep_dir(&p, name) {
            tui::sys_log(log_tx, format!("✓ dep '{}' from sources.lock ({})", name, p.display()));
            return Ok(p);
        }
        tui::sys_log(log_tx, format!(
            "⚠ sources.lock entry for '{}' is stale ({} missing) — re-resolving",
            name, p.display()
        ));
    }

    // 2. Sibling lookup.
    if let Some(sibling) = find_sibling(project_root, name) {
        tui::sys_log(log_tx, format!("✓ dep '{}' from sibling: {}", name, sibling.display()));
        record(lock, name, &sibling);
        return Ok(sibling);
    }

    // 3. Cache directory.
    let cache_path = cache_dep_dir(name)?;
    if validate_dep_dir(&cache_path, name) {
        tui::sys_log(log_tx, format!("→ updating cached dep '{}' at {}…", name, cache_path.display()));
        let _ = git_pull_best_effort(&cache_path, log_tx);
        record(lock, name, &cache_path);
        return Ok(cache_path);
    }

    // 4. Clone.
    tui::sys_log(log_tx, format!("→ cloning {}/node-app-{} → {}", GITHUB_ORG, name, cache_path.display()));
    if let Some(parent) = cache_path.parent() {
        fs::create_dir_all(parent).ok();
    }
    let slug = format!("{}/node-app-{}", GITHUB_ORG, name);
    clone_repo(&slug, &cache_path, log_tx)
        .with_context(|| {
            format!(
                "could not resolve 'node-app-{name}'; tried sources.lock, sibling \
                 ../node-app-{name}, cache {}, and clone of {slug}.\n\
                 Pass --dep PATH or remove this entry from infra/debian/platform-depends.",
                cache_path.display()
            )
        })?;
    if !validate_dep_dir(&cache_path, name) {
        bail!(
            "cloned '{slug}' to {} but the result doesn't look like a node-app \
             (no manifest.json with name='{name}').",
            cache_path.display()
        );
    }
    record(lock, name, &cache_path);
    Ok(cache_path)
}

fn record(lock: &mut SourcesLock, name: &str, path: &Path) {
    let git_rev = git_rev_at(path).unwrap_or_default();
    let last_resolved = iso8601_now();
    let source_path = path
        .canonicalize()
        .unwrap_or_else(|_| path.to_path_buf())
        .to_string_lossy()
        .into_owned();

    if let Some(entry) = lock.dep.iter_mut().find(|d| d.name == name) {
        entry.source_path = source_path;
        entry.git_rev = git_rev;
        entry.last_resolved = last_resolved;
    } else {
        lock.dep.push(LockEntry {
            name: name.to_string(),
            source_path,
            git_rev,
            last_resolved,
        });
    }
}

fn read_lock(path: &Path) -> Option<SourcesLock> {
    let text = fs::read_to_string(path).ok()?;
    serde_json::from_str(&text).ok()
}

fn write_lock_atomic(path: &Path, lock: &SourcesLock) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("mkdir -p {}", parent.display()))?;
    }
    let tmp = path.with_extension("lock.tmp");
    let text = serde_json::to_string_pretty(lock).context("serialize sources.lock")?;
    fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?;
    fs::rename(&tmp, path).with_context(|| format!("rename → {}", path.display()))?;
    Ok(())
}

/// Validate that `dir` contains a `manifest.json` whose `name` field matches
/// the expected dep name (so we don't mis-stage a same-named directory).
fn validate_dep_dir(dir: &Path, expected_name: &str) -> bool {
    let manifest_path = dir.join("manifest.json");
    let Ok(text) = fs::read_to_string(&manifest_path) else {
        return false;
    };
    let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
        return false;
    };
    value
        .get("name")
        .and_then(|v| v.as_str())
        .map(|n| n == expected_name)
        .unwrap_or(false)
}

fn read_manifest_name(dir: &Path) -> Result<String> {
    let path = dir.join("manifest.json");
    let text = fs::read_to_string(&path)
        .with_context(|| format!("read {}", path.display()))?;
    let v: serde_json::Value = serde_json::from_str(&text)
        .with_context(|| format!("parse {}", path.display()))?;
    v.get("name")
        .and_then(|x| x.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| anyhow!("{} has no 'name' field", path.display()))
}

fn find_sibling(project_root: &Path, name: &str) -> Option<PathBuf> {
    let parent = project_root.parent()?;
    let candidate = parent.join(format!("node-app-{}", name));
    if validate_dep_dir(&candidate, name) {
        return Some(candidate);
    }
    None
}

fn cache_dep_dir(name: &str) -> Result<PathBuf> {
    Ok(cache_root()?.join("deps").join(name))
}

fn cache_root() -> Result<PathBuf> {
    if let Ok(c) = std::env::var("XDG_CACHE_HOME") {
        if !c.is_empty() {
            return Ok(PathBuf::from(c).join("node-app"));
        }
    }
    let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("$HOME not set"))?;
    Ok(PathBuf::from(home).join(".cache/node-app"))
}

fn clone_repo(slug: &str, dest: &Path, log_tx: Option<&LogTx>) -> Result<()> {
    if dest.exists() {
        fs::remove_dir_all(dest)
            .with_context(|| format!("remove stale {}", dest.display()))?;
    }
    // Prefer gh when available + authenticated (handles private repos);
    // fall back to public https clone otherwise.
    if gh::gh_auth_ok() {
        let mut cmd = Command::new("gh");
        cmd.args(["repo", "clone", slug]).arg(dest);
        let label = format!("gh repo clone {slug}");
        if run_with_log_sink(cmd, &label, log_tx).is_ok() {
            return Ok(());
        }
        // gh failed (e.g. no read access) — fall through to https as a last try.
    }
    let url = format!("https://github.com/{slug}.git");
    let mut cmd = Command::new("git");
    cmd.args(["clone", "--depth", "1", "--progress", &url]).arg(dest);
    let label = format!("git clone {url}");
    run_with_log_sink(cmd, &label, log_tx)?;
    Ok(())
}

fn git_pull_best_effort(dir: &Path, log_tx: Option<&LogTx>) -> Result<()> {
    // Only attempt if there's actually a .git directory; non-git source
    // paths (rare — manually placed) are left alone.
    if !dir.join(".git").exists() {
        return Ok(());
    }
    let mut fetch = Command::new("git");
    fetch.args(["fetch", "--progress"]).current_dir(dir);
    let _ = run_with_log_sink(fetch, "git fetch", log_tx);

    let mut reset = Command::new("git");
    reset.args(["reset", "--hard", "origin/HEAD"]).current_dir(dir);
    let _ = run_with_log_sink(reset, "git reset --hard origin/HEAD", log_tx);
    Ok(())
}

/// Spawn `cmd`, route stdout/stderr line-by-line into the TUI log sink when
/// `log_tx` is Some, otherwise inherit so the user sees output on the terminal
/// (no-TUI / `--once` / piped invocations). Blocks until the child exits.
fn run_with_log_sink(mut cmd: Command, label: &str, log_tx: Option<&LogTx>) -> Result<()> {
    if let Some(tx) = log_tx {
        cmd.stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let mut child = cmd
            .spawn()
            .with_context(|| format!("spawn `{label}`"))?;

        // Spawn two reader threads so the child's pipe buffers don't fill up
        // and block it. Each forwards lines to the TUI sink with the same
        // LogSource so they interleave in the Build pane.
        let stdout = child.stdout.take().expect("piped");
        let stderr = child.stderr.take().expect("piped");
        let tx1 = tx.clone();
        let t1 = std::thread::spawn(move || {
            for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                let _ = tx1.send(TuiEvent::Log(LogEntry {
                    source: LogSource::Build,
                    line,
                }));
            }
        });
        let tx2 = tx.clone();
        let t2 = std::thread::spawn(move || {
            for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                let _ = tx2.send(TuiEvent::Log(LogEntry {
                    source: LogSource::Build,
                    line,
                }));
            }
        });

        let status = child
            .wait()
            .with_context(|| format!("wait `{label}`"))?;
        t1.join().ok();
        t2.join().ok();

        if !status.success() {
            bail!("`{label}` exited {}", status);
        }
        Ok(())
    } else {
        cmd.stdin(Stdio::null())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());
        let status = cmd
            .status()
            .with_context(|| format!("spawn `{label}`"))?;
        if !status.success() {
            bail!("`{label}` exited {}", status);
        }
        Ok(())
    }
}

fn git_rev_at(dir: &Path) -> Option<String> {
    let out = Command::new("git")
        .args(["rev-parse", "HEAD"])
        .current_dir(dir)
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8(out.stdout).ok()?;
    Some(s.trim().to_string())
}

fn iso8601_now() -> String {
    // RFC3339-like UTC timestamp without pulling in chrono. We don't need
    // sub-second precision; minute-level resolution is enough for a "last
    // resolved" hint humans skim.
    let secs = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let (year, month, day, hour, min, sec) = unix_to_ymdhms(secs);
    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, hour, min, sec
    )
}

/// Naive UTC unix-timestamp decomposition. Good enough for human-readable
/// lockfile timestamps; correct for the gregorian range we care about.
fn unix_to_ymdhms(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
    let days = (secs / 86400) as i64;
    let rem = (secs % 86400) as u32;
    let hour = rem / 3600;
    let min = (rem % 3600) / 60;
    let sec = rem % 60;

    // Civil-from-days algorithm (Howard Hinnant). Epoch = 1970-01-01.
    let z = days + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = (z - era * 146097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if m <= 2 { y + 1 } else { y };
    (year, m as u32, d as u32, hour, min, sec)
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    fn write_app_manifest(dir: &Path, name: &str) {
        fs::create_dir_all(dir).unwrap();
        let m = serde_json::json!({
            "manifest_version": 1,
            "name": name,
            "version": "0.1.0",
            "app_type": "bun"
        });
        fs::write(dir.join("manifest.json"), m.to_string()).unwrap();
    }

    #[test]
    fn validate_dep_dir_matches_name() {
        let tmp = TempDir::new().unwrap();
        write_app_manifest(tmp.path(), "discovery");
        assert!(validate_dep_dir(tmp.path(), "discovery"));
        assert!(!validate_dep_dir(tmp.path(), "wrong-name"));
    }

    #[test]
    fn validate_dep_dir_missing_manifest() {
        let tmp = TempDir::new().unwrap();
        assert!(!validate_dep_dir(tmp.path(), "anything"));
    }

    #[test]
    fn read_manifest_name_works() {
        let tmp = TempDir::new().unwrap();
        write_app_manifest(tmp.path(), "esp32-bridge");
        assert_eq!(read_manifest_name(tmp.path()).unwrap(), "esp32-bridge");
    }

    #[test]
    fn find_sibling_returns_match() {
        // Layout: /tmp/root/{platform,node-app-discovery}
        let tmp = TempDir::new().unwrap();
        let platform = tmp.path().join("platform");
        fs::create_dir_all(&platform).unwrap();
        let sibling = tmp.path().join("node-app-discovery");
        write_app_manifest(&sibling, "discovery");

        let found = find_sibling(&platform, "discovery").unwrap();
        assert_eq!(found.canonicalize().unwrap(), sibling.canonicalize().unwrap());
    }

    #[test]
    fn find_sibling_rejects_wrong_name() {
        let tmp = TempDir::new().unwrap();
        let platform = tmp.path().join("platform");
        fs::create_dir_all(&platform).unwrap();
        let sibling = tmp.path().join("node-app-discovery");
        write_app_manifest(&sibling, "different-name"); // manifest mismatch

        assert!(find_sibling(&platform, "discovery").is_none());
    }

    #[test]
    fn lockfile_roundtrip() {
        let tmp = TempDir::new().unwrap();
        let lock_path = tmp.path().join(".node-app/sources.lock");
        let mut lock = SourcesLock {
            schema_version: 1,
            dep: vec![LockEntry {
                name: "discovery".into(),
                source_path: "/tmp/x".into(),
                git_rev: "abc".into(),
                last_resolved: "2026-05-16T00:00:00Z".into(),
            }],
        };
        write_lock_atomic(&lock_path, &lock).unwrap();
        let parsed = read_lock(&lock_path).unwrap();
        assert_eq!(parsed.dep.len(), 1);
        assert_eq!(parsed.dep[0].name, "discovery");
        // Update + rewrite.
        lock.dep[0].git_rev = "def".into();
        write_lock_atomic(&lock_path, &lock).unwrap();
        let parsed = read_lock(&lock_path).unwrap();
        assert_eq!(parsed.dep[0].git_rev, "def");
    }

    #[test]
    fn resolve_uses_explicit_override() {
        let tmp = TempDir::new().unwrap();
        let platform = tmp.path().join("platform");
        fs::create_dir_all(&platform).unwrap();
        // Explicit --dep override
        let override_path = tmp.path().join("fork-of-discovery");
        write_app_manifest(&override_path, "discovery");
        // Sibling that SHOULD be ignored.
        let sibling = tmp.path().join("node-app-discovery");
        write_app_manifest(&sibling, "discovery");

        let out = resolve(
            &platform,
            &["discovery".to_string()],
            &[override_path.clone()],
            None,
        )
        .unwrap();
        // Override is already in the caller's --dep list; resolver should
        // skip auto-resolution and return an empty list.
        assert!(out.is_empty());
    }

    #[test]
    fn resolve_picks_sibling_over_clone() {
        let tmp = TempDir::new().unwrap();
        let platform = tmp.path().join("platform");
        fs::create_dir_all(&platform).unwrap();
        let sibling = tmp.path().join("node-app-discovery");
        write_app_manifest(&sibling, "discovery");

        let out = resolve(&platform, &["discovery".to_string()], &[], None).unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(
            out[0].canonicalize().unwrap(),
            sibling.canonicalize().unwrap()
        );

        // Lockfile should now contain the dep.
        let parsed = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
        assert_eq!(parsed.dep.len(), 1);
        assert_eq!(parsed.dep[0].name, "discovery");
    }

    #[test]
    fn resolve_stale_lockfile_falls_through_to_sibling() {
        let tmp = TempDir::new().unwrap();
        let platform = tmp.path().join("platform");
        fs::create_dir_all(&platform).unwrap();
        // Pre-seed a lockfile pointing at a non-existent dir.
        let bogus = tmp.path().join("ghost");
        let lock = SourcesLock {
            schema_version: 1,
            dep: vec![LockEntry {
                name: "discovery".into(),
                source_path: bogus.to_string_lossy().into_owned(),
                git_rev: "x".into(),
                last_resolved: "2026-05-16T00:00:00Z".into(),
            }],
        };
        write_lock_atomic(&platform.join(SOURCES_LOCK_REL), &lock).unwrap();
        // Provide a sibling we can fall through to.
        let sibling = tmp.path().join("node-app-discovery");
        write_app_manifest(&sibling, "discovery");

        let out = resolve(&platform, &["discovery".to_string()], &[], None).unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(
            out[0].canonicalize().unwrap(),
            sibling.canonicalize().unwrap()
        );
    }

    #[test]
    fn unix_epoch_decomposes_correctly() {
        // Epoch boundary.
        let (y, mo, d, h, mi, s) = unix_to_ymdhms(0);
        assert_eq!((y, mo, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
        // 2024-01-01T00:00:00Z (well-known fence-post, includes 2020 leap).
        let (y, mo, d, h, mi, s) = unix_to_ymdhms(1_704_067_200);
        assert_eq!((y, mo, d, h, mi, s), (2024, 1, 1, 0, 0, 0));
        // 2024-02-29T12:34:56Z — leap-day sanity.
        let (y, mo, d, h, mi, s) = unix_to_ymdhms(1_709_210_096);
        assert_eq!((y, mo, d, h, mi, s), (2024, 2, 29, 12, 34, 56));
    }
}
