//! AIRGAP5 — the target directory layout.
//!
//! Design (`.nornir/airgap-install.md` §2): a **data dir** (fast-moving data)
//! plus a **software dir** partitioned by airgap **version** (date-stamped, e.g.
//! `software/20250505/`), with **each product under the version**
//! (`software/<YYYYMMDD>/<product>`). Upgrades are an **atomic symlink swap**:
//! `software/current` points at the active version dir, and swapping it is a
//! single `rename(2)` so a reader never sees a half-written tree. Everything is
//! **idempotent** — re-`prepare()` on a partial install just fills the gaps.

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

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

/// The target's prepared directory layout.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Layout {
    /// Fast-moving data root (e.g. `/var/lib`).
    pub data_dir: PathBuf,
    /// Per-product log root (e.g. `/var/log`). A product's logs live under
    /// `log_dir/<name>` — provisioned alongside its data dir at install time.
    pub log_dir: PathBuf,
    /// Software root; partitioned by airgap version below.
    pub software_dir: PathBuf,
    /// Airgap generation (e.g. "20250505"); software lives under
    /// `software_dir/<version>`.
    pub version: String,
}

impl Layout {
    /// A layout whose `version` is today's date stamp (`YYYYMMDD`), with explicit
    /// data / log / software roots (e.g. `/var/lib`, `/var/log`, `/opt/lake`).
    pub fn dated(
        data_dir: impl Into<PathBuf>,
        log_dir: impl Into<PathBuf>,
        software_dir: impl Into<PathBuf>,
    ) -> Self {
        Layout {
            data_dir: data_dir.into(),
            log_dir: log_dir.into(),
            software_dir: software_dir.into(),
            version: chrono::Utc::now().format("%Y%m%d").to_string(),
        }
    }

    /// The version dir: `software_dir/<version>`.
    pub fn version_dir(&self) -> PathBuf {
        self.software_dir.join(&self.version)
    }

    /// Where product `name`'s software is unpacked: `software_dir/<version>/<name>`.
    pub fn product_software(&self, name: &str) -> PathBuf {
        self.version_dir().join(name)
    }

    /// Where product `name`'s fast-moving data lives: `data_dir/<name>`.
    pub fn product_data(&self, name: &str) -> PathBuf {
        self.data_dir.join(name)
    }

    /// Where product `name`'s logs live: `log_dir/<name>`. Provisioned next to the
    /// product's data dir by [`Layout::prepare_product`] at install time.
    pub fn product_log(&self, name: &str) -> PathBuf {
        self.log_dir.join(name)
    }

    /// The `current` symlink path that points at the active version dir. Callers
    /// resolve software through this so an upgrade is a single atomic swap.
    pub fn current_link(&self) -> PathBuf {
        self.software_dir.join("current")
    }

    /// Prepare the data + log + `software/<version>` ROOT dirs on disk
    /// (idempotent: existing dirs are left untouched). Does NOT touch the
    /// `current` symlink — call [`Layout::activate`] to swap it after the version's
    /// software is unpacked. Per-PRODUCT `data/<name>` + `log/<name>` dirs are
    /// created by [`Layout::prepare_product`].
    pub fn prepare(&self) -> Result<()> {
        std::fs::create_dir_all(&self.data_dir)
            .with_context(|| format!("create data dir {}", self.data_dir.display()))?;
        std::fs::create_dir_all(&self.log_dir)
            .with_context(|| format!("create log dir {}", self.log_dir.display()))?;
        std::fs::create_dir_all(self.version_dir())
            .with_context(|| format!("create version dir {}", self.version_dir().display()))?;
        Ok(())
    }

    /// Provision product `name`'s per-product tree: its software dir under the
    /// active version (`software/<version>/<name>`) plus BOTH a `data/<name>` and a
    /// `log/<name>` dir — the AIRGAP5 product-install contract ("create data dir +
    /// create log dir"). Calls [`Layout::prepare`] first, so the roots exist too.
    /// Fully idempotent: re-running on a partial install just fills the gaps and
    /// never clobbers an existing data/log dir. Returns the three created paths in
    /// `(software, data, log)` order for the caller to record.
    pub fn prepare_product(&self, name: &str) -> Result<(PathBuf, PathBuf, PathBuf)> {
        self.prepare()?;
        let software = self.product_software(name);
        let data = self.product_data(name);
        let log = self.product_log(name);
        std::fs::create_dir_all(&software)
            .with_context(|| format!("create product software dir {}", software.display()))?;
        std::fs::create_dir_all(&data)
            .with_context(|| format!("create product data dir {}", data.display()))?;
        std::fs::create_dir_all(&log)
            .with_context(|| format!("create product log dir {}", log.display()))?;
        Ok((software, data, log))
    }

    /// Atomically point `software/current` at this layout's version dir — the
    /// upgrade swap. Implemented as write-new-symlink-to-temp + `rename`, which
    /// is atomic on the same filesystem, so a concurrent reader either sees the
    /// old target or the new one, never a missing/partial link. Idempotent.
    #[cfg(unix)]
    pub fn activate(&self) -> Result<()> {
        use std::os::unix::fs::symlink;
        let link = self.current_link();
        let tmp = self.software_dir.join(".current.tmp");
        let _ = std::fs::remove_file(&tmp);
        // Point at the relative version name so the tree is relocatable.
        symlink(&self.version, &tmp)
            .with_context(|| format!("create temp symlink {}", tmp.display()))?;
        std::fs::rename(&tmp, &link)
            .with_context(|| format!("atomic-swap current → {}", self.version))?;
        Ok(())
    }

    #[cfg(not(unix))]
    pub fn activate(&self) -> Result<()> {
        // Windows has no atomic symlink swap in std; record the active version in
        // a small marker file instead (junctions need elevation).
        std::fs::write(self.software_dir.join("CURRENT"), &self.version)
            .context("write CURRENT marker")?;
        Ok(())
    }

    /// The version the `current` symlink resolves to, if any (the active gen).
    #[cfg(unix)]
    pub fn active_version(&self) -> Option<String> {
        std::fs::read_link(self.current_link())
            .ok()
            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
    }

    #[cfg(not(unix))]
    pub fn active_version(&self) -> Option<String> {
        std::fs::read_to_string(self.software_dir.join("CURRENT")).ok()
    }
}

/// Resolve a product's software path THROUGH the `current` symlink — what a
/// running service uses so it always follows the active generation.
pub fn current_product_path(software_dir: &Path, product: &str) -> PathBuf {
    software_dir.join("current").join(product)
}

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

    /// LAW 1 (inject-assert): `prepare()` creates the version-dated, per-product
    /// dirs; the path is `software/<version>/<product>` and data is `data/<p>`.
    #[test]
    fn prepare_makes_version_dated_per_product_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250505".into(),
        };
        l.prepare().unwrap();

        assert!(l.data_dir.is_dir(), "data dir created");
        assert!(l.log_dir.is_dir(), "log dir created");
        assert!(l.version_dir().is_dir(), "software/<version> created");
        assert_eq!(
            l.product_software("doris"),
            tmp.path().join("software/20250505/doris")
        );
        assert_eq!(l.product_data("doris"), tmp.path().join("data/doris"));
        assert_eq!(l.product_log("doris"), tmp.path().join("log/doris"));

        // Idempotent: a second prepare() does not error.
        l.prepare().unwrap();
    }

    /// LAW 1: `prepare_product` provisions ALL THREE per-product dirs — software
    /// under the active version, a `data/<name>`, and a `log/<name>` — and is
    /// idempotent (a second call over existing dirs, even with data written into
    /// them, is a no-op that never clobbers).
    #[test]
    fn prepare_product_creates_software_data_and_log_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250505".into(),
        };

        let (sw, data, log) = l.prepare_product("holger").unwrap();
        assert_eq!(sw, tmp.path().join("software/20250505/holger"));
        assert_eq!(data, tmp.path().join("data/holger"));
        assert_eq!(log, tmp.path().join("log/holger"));
        assert!(sw.is_dir(), "software/<version>/<name> created");
        assert!(data.is_dir(), "data/<name> created");
        assert!(log.is_dir(), "log/<name> created");

        // Write into data + log, then re-run: idempotent, contents preserved.
        std::fs::write(data.join("state.db"), b"rows").unwrap();
        std::fs::write(log.join("app.log"), b"line").unwrap();
        l.prepare_product("holger").unwrap();
        assert_eq!(std::fs::read(data.join("state.db")).unwrap(), b"rows");
        assert_eq!(std::fs::read(log.join("app.log")).unwrap(), b"line");
    }

    /// LAW 1: the atomic symlink swap — `activate()` points `current` at the
    /// version; a SECOND version swaps it atomically; `active_version()` tracks it.
    #[cfg(unix)]
    #[test]
    fn atomic_symlink_swap_on_upgrade() {
        let tmp = tempfile::tempdir().unwrap();
        let sw = tmp.path().join("software");

        let v1 = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: sw.clone(),
            version: "20250505".into(),
        };
        v1.prepare().unwrap();
        std::fs::write(v1.product_software("holger").join("../holger.bin"), b"v1").ok();
        v1.activate().unwrap();
        assert_eq!(v1.active_version().as_deref(), Some("20250505"));
        assert!(v1.current_link().exists(), "current resolves");

        // Upgrade: prepare a NEW version dir and atomically swap `current`.
        let v2 = Layout {
            version: "20250606".into(),
            ..v1.clone()
        };
        v2.prepare().unwrap();
        v2.activate().unwrap();
        assert_eq!(v2.active_version().as_deref(), Some("20250606"), "swapped");
        // The old version dir is untouched (rollback target).
        assert!(sw.join("20250505").is_dir(), "old gen retained for rollback");
        // current now resolves through the new gen.
        assert_eq!(
            current_product_path(&sw, "holger"),
            sw.join("current/holger")
        );
    }

    /// `Layout::dated` stamps an 8-digit YYYYMMDD version, and all path builders
    /// compose off it (pure path math — no disk).
    #[test]
    fn dated_version_is_yyyymmdd_and_paths_compose() {
        let l = Layout::dated("/var/lib", "/var/log", "/opt/sw");
        assert_eq!(l.version.len(), 8, "YYYYMMDD: {}", l.version);
        assert!(l.version.chars().all(|c| c.is_ascii_digit()));
        assert_eq!(l.version_dir(), Path::new("/opt/sw").join(&l.version));
        assert_eq!(
            l.product_software("holger"),
            Path::new("/opt/sw").join(&l.version).join("holger")
        );
        assert_eq!(l.product_data("holger"), Path::new("/var/lib/holger"));
        assert_eq!(l.product_log("holger"), Path::new("/var/log/holger"));
        assert_eq!(l.current_link(), Path::new("/opt/sw/current"));
    }

    /// `active_version()` is None before any `activate()`, and a `Layout`
    /// round-trips through serde (a product embeds it in its airgap bundle).
    #[cfg(unix)]
    #[test]
    fn active_version_none_before_activate_and_serde_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250101".into(),
        };
        l.prepare().unwrap();
        assert_eq!(l.active_version(), None, "no current link yet");

        let back: Layout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap();
        assert_eq!(back, l);
    }

    /// Re-`activate()`-ing the SAME version is idempotent — the link still resolves
    /// there (the atomic temp-symlink swap tolerates an existing target).
    #[cfg(unix)]
    #[test]
    fn reactivate_same_version_is_idempotent() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250101".into(),
        };
        l.prepare().unwrap();
        l.activate().unwrap();
        l.activate().unwrap();
        assert_eq!(l.active_version().as_deref(), Some("20250101"));
        assert!(l.current_link().exists());
    }
}
