//! Unattended-install answer-file store (v0.5.2).
//!
//! Operators upload the answer file their installer expects — a RHEL/
//! Fedora **Kickstart**, a Debian **Preseed**, an Ubuntu **Autoinstall**
//! cloud-init user-data, or a Windows **answer file** (`autounattend.xml`)
//! — and OpenPXE serves it on demand to the booting machine. Files live
//! in their own directory (`<unattended_dir>/`), deliberately *not* under
//! `iso_dir`, so they never appear in the ISO listing or the PXE menu.
//!
//! Storage mirrors [`crate::store::IsoStore`]: in-memory map authoritative
//! for the process, sidecar `*.meta.json` on disk is the source of truth on
//! restart. The raw answer file sits beside it as `<id>.file`.
//!
//! Templating is applied at *serve* time, not store time — see
//! [`render_template`]. The stored bytes are exactly what the operator
//! uploaded; per-host hostname/IP/MAC values are substituted into a copy
//! when the file is fetched for a specific client.

use crate::store::slugify_str;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;

/// Disk + memory cap for one answer file. Kickstarts/preseeds/cloud-init
/// configs are a few KB; 1 MiB is a comfortable ceiling that still bounds
/// abuse.
pub const MAX_UNATTENDED_BYTES: usize = 1024 * 1024;

/// Which installer the answer file targets. Drives the kernel-argument
/// injection in the boot chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnattendedKind {
    /// RHEL / Fedora / CentOS / AlmaLinux / Rocky — `inst.ks=<url>`.
    Kickstart,
    /// Debian / older Ubuntu — `auto=true priority=critical url=<url>`.
    Preseed,
    /// Ubuntu 20.04+ Subiquity autoinstall — cloud-init NoCloud:
    /// `autoinstall ds=nocloud-net;s=<url>/`.
    Autoinstall,
    /// Windows Setup answer file (`autounattend.xml`). Served, not
    /// auto-injected (Windows reads it from media/USB, not a kernel arg).
    AnswerFile,
    /// Couldn't classify — stored + served, no auto-injection.
    #[default]
    Unknown,
}

impl UnattendedKind {
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            UnattendedKind::Kickstart => "Kickstart",
            UnattendedKind::Preseed => "Preseed",
            UnattendedKind::Autoinstall => "Autoinstall",
            UnattendedKind::AnswerFile => "Answer file",
            UnattendedKind::Unknown => "Unknown",
        }
    }
}

/// Lowercase file extension (no dot), or `None` if there isn't one.
fn ext_lower(filename: &str) -> Option<String> {
    std::path::Path::new(filename)
        .extension()
        .and_then(|e| e.to_str())
        .map(str::to_ascii_lowercase)
}

/// Classify an upload from its filename + a peek at its content. Best
/// effort: extension first, then a content sniff to disambiguate the
/// `.cfg` case (both Kickstart and Preseed use it).
#[must_use]
pub fn classify(filename: &str, content: &[u8]) -> UnattendedKind {
    let lower_name = filename.to_ascii_lowercase();
    let ext = ext_lower(filename);
    let text = String::from_utf8_lossy(&content[..content.len().min(8192)]);
    let looks_preseed = text.contains("d-i ") || text.contains("preseed/");
    let looks_kickstart = text.contains("%packages")
        || text.contains("\nlang ")
        || text.contains("\nkeyboard ")
        || text.contains("bootloader --")
        || text.starts_with("install");
    let looks_cloud_init = text.contains("autoinstall")
        || text.contains("#cloud-config")
        || text.contains("version: 1");

    match ext.as_deref() {
        Some("ks") => return UnattendedKind::Kickstart,
        Some("seed") => return UnattendedKind::Preseed,
        Some("xml") => return UnattendedKind::AnswerFile,
        Some("yaml" | "yml") => return UnattendedKind::Autoinstall,
        Some("cfg") => {
            return if looks_kickstart && !looks_preseed {
                UnattendedKind::Kickstart
            } else {
                UnattendedKind::Preseed
            };
        }
        _ => {}
    }
    if lower_name == "user-data" {
        return UnattendedKind::Autoinstall;
    }
    // No recognised extension — fall back to content sniffing.
    if looks_cloud_init {
        UnattendedKind::Autoinstall
    } else if looks_kickstart {
        UnattendedKind::Kickstart
    } else if looks_preseed {
        UnattendedKind::Preseed
    } else {
        UnattendedKind::Unknown
    }
}

/// True if the filename carries an extension we accept for upload. We
/// also accept the bare `user-data` name (cloud-init NoCloud convention).
#[must_use]
pub fn is_accepted_filename(filename: &str) -> bool {
    if filename.trim().eq_ignore_ascii_case("user-data") {
        return true;
    }
    matches!(
        ext_lower(filename).as_deref(),
        Some("ks" | "cfg" | "seed" | "yaml" | "yml" | "xml")
    )
}

/// Sidecar metadata for a stored answer file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnattendedMeta {
    /// URL-safe slug, unique within the store.
    pub id: String,
    /// Original upload filename, shown in the UI.
    pub filename: String,
    pub kind: UnattendedKind,
    pub size_bytes: u64,
    #[serde(with = "time::serde::rfc3339")]
    pub uploaded_at: OffsetDateTime,
}

#[derive(Debug, Default)]
struct Inner {
    files: HashMap<String, UnattendedMeta>,
}

/// In-memory + on-disk answer-file registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct UnattendedStore {
    dir: Arc<PathBuf>,
    inner: Arc<RwLock<Inner>>,
}

impl UnattendedStore {
    #[must_use]
    pub fn new(dir: PathBuf) -> Self {
        Self {
            dir: Arc::new(dir),
            inner: Arc::new(RwLock::new(Inner::default())),
        }
    }

    pub async fn ensure_dir(&self) -> Result<()> {
        tokio::fs::create_dir_all(self.dir.as_path()).await?;
        Ok(())
    }

    /// Scan the directory on startup, loading every `*.meta.json` sidecar.
    pub async fn load_from_disk(&self) -> Result<()> {
        self.ensure_dir().await?;
        let mut entries = tokio::fs::read_dir(self.dir.as_path()).await?;
        while let Some(e) = entries.next_entry().await? {
            let p = e.path();
            let is_meta = p
                .file_name()
                .and_then(|s| s.to_str())
                .is_some_and(|n| n.ends_with(".meta.json"));
            if !is_meta {
                continue;
            }
            if let Ok(text) = tokio::fs::read_to_string(&p).await {
                if let Ok(meta) = serde_json::from_str::<UnattendedMeta>(&text) {
                    self.inner.write().files.insert(meta.id.clone(), meta);
                }
            }
        }
        Ok(())
    }

    fn data_path(&self, id: &str) -> PathBuf {
        self.dir.join(format!("{id}.file"))
    }

    fn meta_path(&self, id: &str) -> PathBuf {
        self.dir.join(format!("{id}.meta.json"))
    }

    /// Mint a unique slug from the upload filename's stem.
    fn unique_id(&self, filename: &str) -> String {
        let stem = filename.rsplit_once('.').map_or(filename, |(s, _)| s);
        let base = {
            let s = slugify_str(stem);
            if s.is_empty() {
                "unattended".to_string()
            } else {
                s
            }
        };
        let g = self.inner.read();
        if !g.files.contains_key(&base) {
            return base;
        }
        for n in 1.. {
            let candidate = format!("{base}-{n}");
            if !g.files.contains_key(&candidate) {
                return candidate;
            }
        }
        unreachable!("u64 ids exhausted")
    }

    /// Store an uploaded answer file. Validates type + size, classifies,
    /// writes the bytes + a sidecar, and returns the new metadata.
    pub async fn add(&self, filename: &str, bytes: &[u8]) -> Result<UnattendedMeta> {
        if !is_accepted_filename(filename) {
            return Err(Error::Invalid(format!(
                "unsupported answer-file type '{filename}'. Accepted: .ks, .cfg, .seed, .yaml, .yml, .xml, user-data"
            )));
        }
        if bytes.len() > MAX_UNATTENDED_BYTES {
            return Err(Error::Invalid(format!(
                "answer file too large ({} bytes, max {MAX_UNATTENDED_BYTES})",
                bytes.len()
            )));
        }
        self.ensure_dir().await?;
        let kind = classify(filename, bytes);
        let id = self.unique_id(filename);
        let meta = UnattendedMeta {
            id: id.clone(),
            filename: filename.to_string(),
            kind,
            size_bytes: bytes.len() as u64,
            uploaded_at: OffsetDateTime::now_utc(),
        };
        // Atomic data write: tmp -> rename.
        let data = self.data_path(&id);
        let tmp = data.with_extension("file.tmp");
        tokio::fs::write(&tmp, bytes).await?;
        tokio::fs::rename(&tmp, &data).await?;
        let meta_text = serde_json::to_string_pretty(&meta).map_err(|e| Error::Other(e.into()))?;
        tokio::fs::write(self.meta_path(&id), meta_text).await?;
        self.inner.write().files.insert(id.clone(), meta.clone());
        tracing::info!(
            target: "openpxe::unattended",
            id = %id, file = %filename, kind = ?kind, size = bytes.len(),
            "unattended answer file stored"
        );
        Ok(meta)
    }

    #[must_use]
    pub fn list(&self) -> Vec<UnattendedMeta> {
        let g = self.inner.read();
        let mut v: Vec<_> = g.files.values().cloned().collect();
        v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
        v
    }

    #[must_use]
    pub fn get(&self, id: &str) -> Option<UnattendedMeta> {
        self.inner.read().files.get(id).cloned()
    }

    /// Read the raw stored bytes for `id`.
    pub async fn read(&self, id: &str) -> Result<Vec<u8>> {
        if !self.inner.read().files.contains_key(id) {
            return Err(Error::NotFound(format!("no unattended file '{id}'")));
        }
        let bytes = tokio::fs::read(self.data_path(id)).await?;
        Ok(bytes)
    }

    /// Remove a file + its sidecar. Returns true if something was removed.
    pub async fn remove(&self, id: &str) -> bool {
        let existed = self.inner.write().files.remove(id).is_some();
        if existed {
            let _ = tokio::fs::remove_file(self.data_path(id)).await;
            let _ = tokio::fs::remove_file(self.meta_path(id)).await;
        }
        existed
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.inner.read().files.len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// Substitute the per-host template tokens into an answer file at serve
/// time. Recognised tokens (case-sensitive, double-brace): `{{HOSTNAME}}`,
/// `{{IP}}`, `{{MAC}}`. Unset values render as an empty string so a
/// half-filled profile never leaves a literal `{{IP}}` in the file.
#[must_use]
pub fn render_template(
    content: &str,
    mac: Option<&str>,
    hostname: Option<&str>,
    ip: Option<&str>,
) -> String {
    content
        .replace("{{HOSTNAME}}", hostname.unwrap_or(""))
        .replace("{{IP}}", ip.unwrap_or(""))
        .replace("{{MAC}}", mac.unwrap_or(""))
}

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

    #[test]
    fn classify_by_extension() {
        assert_eq!(
            classify(" subiquity.yaml", b""),
            UnattendedKind::Autoinstall
        );
        assert_eq!(classify("ks.ks", b""), UnattendedKind::Kickstart);
        assert_eq!(classify("preseed.seed", b""), UnattendedKind::Preseed);
        assert_eq!(
            classify("autounattend.xml", b"<xml/>"),
            UnattendedKind::AnswerFile
        );
        assert_eq!(classify("user-data", b""), UnattendedKind::Autoinstall);
    }

    #[test]
    fn classify_cfg_by_content() {
        assert_eq!(
            classify("answer.cfg", b"d-i debian-installer/locale string en_US"),
            UnattendedKind::Preseed
        );
        assert_eq!(
            classify("answer.cfg", b"install\n%packages\n@core\n%end\n"),
            UnattendedKind::Kickstart
        );
    }

    #[test]
    fn accepted_filenames() {
        assert!(is_accepted_filename("a.ks"));
        assert!(is_accepted_filename("USER-DATA".to_lowercase().as_str()));
        assert!(is_accepted_filename("autounattend.XML"));
        assert!(!is_accepted_filename("evil.sh"));
        assert!(!is_accepted_filename("image.iso"));
    }

    #[test]
    fn template_substitutes_and_blanks_unset() {
        let body = "ip={{IP}} host={{HOSTNAME}} mac={{MAC}}";
        let out = render_template(body, Some("aa:bb"), Some("node1"), None);
        assert_eq!(out, "ip= host=node1 mac=aa:bb");
    }

    #[tokio::test]
    async fn add_list_read_remove_round_trip() {
        let dir = tempdir().unwrap();
        let s = UnattendedStore::new(dir.path().join("unattended"));
        let meta = s
            .add("rocky.ks", b"install\n%packages\n@core\n%end\n")
            .await
            .unwrap();
        assert_eq!(meta.kind, UnattendedKind::Kickstart);
        assert_eq!(s.len(), 1);
        let got = s.read(&meta.id).await.unwrap();
        assert!(got.starts_with(b"install"));
        // Survives a reload.
        let s2 = UnattendedStore::new(dir.path().join("unattended"));
        s2.load_from_disk().await.unwrap();
        assert!(s2.get(&meta.id).is_some());
        assert!(s2.remove(&meta.id).await);
        assert!(s2.get(&meta.id).is_none());
    }

    #[tokio::test]
    async fn rejects_bad_type_and_oversize() {
        let dir = tempdir().unwrap();
        let s = UnattendedStore::new(dir.path().join("unattended"));
        assert!(s.add("evil.sh", b"#!/bin/sh").await.is_err());
        let big = vec![b'x'; MAX_UNATTENDED_BYTES + 1];
        assert!(s.add("big.ks", &big).await.is_err());
    }

    #[tokio::test]
    async fn ids_are_unique() {
        let dir = tempdir().unwrap();
        let s = UnattendedStore::new(dir.path().join("unattended"));
        let a = s.add("ks.ks", b"install").await.unwrap();
        let b = s.add("ks.ks", b"install").await.unwrap();
        assert_ne!(a.id, b.id);
    }
}
