//! Startup verification of vendored htmx assets against `htmx.lock.toml`.
//!
//! Called once during server boot when the workspace rendering profile is
//! `htmx-ssr` (or any route resolves to it). On mismatch, a `tracing::warn!`
//! is emitted per file; the server keeps running so the operator can roll
//! forward without an outage.

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

use serde::Deserialize;
use sha2::{Digest, Sha384};

/// Path within the project root where the lockfile is expected.
pub const LOCKFILE_RELATIVE_PATH: &str = "templates/shared/htmx/htmx.lock.toml";

/// Schema of `templates/shared/htmx/htmx.lock.toml`.
#[derive(Debug, Deserialize)]
pub struct HtmxLockfile {
    pub runtime: HtmxFileEntry,
    #[serde(default)]
    pub extensions: Vec<HtmxExtensionEntry>,
}

#[derive(Debug, Deserialize)]
pub struct HtmxFileEntry {
    #[allow(dead_code)]
    pub package: Option<String>,
    pub version: String,
    #[allow(dead_code)]
    pub source_url: String,
    pub file: String,
    pub sha384: String,
    pub size_bytes: u64,
}

#[derive(Debug, Deserialize)]
pub struct HtmxExtensionEntry {
    pub name: String,
    #[allow(dead_code)]
    pub attribute: String,
    pub version: String,
    #[allow(dead_code)]
    pub source_url: String,
    pub file: String,
    pub sha384: String,
    pub size_bytes: u64,
}

/// One verification finding per file.
#[derive(Debug, PartialEq)]
pub struct VerificationFinding {
    pub file: String,
    pub kind: FindingKind,
}

#[derive(Debug, PartialEq)]
pub enum FindingKind {
    Missing,
    SizeMismatch { expected: u64, actual: u64 },
    HashMismatch { expected: String, actual: String },
}

impl std::fmt::Display for VerificationFinding {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self.kind {
            FindingKind::Missing => write!(f, "{}: missing on disk", self.file),
            FindingKind::SizeMismatch { expected, actual } => write!(
                f,
                "{}: size mismatch (expected {} bytes, got {})",
                self.file, expected, actual
            ),
            FindingKind::HashMismatch { expected, actual } => write!(
                f,
                "{}: sha384 mismatch (expected {}, got {})",
                self.file, expected, actual
            ),
        }
    }
}

/// Load the lockfile from disk and verify every entry it references.
///
/// Returns an empty vec when every file matches its recorded size and hash.
/// Per-file findings are logged via `tracing::warn!` regardless of the
/// caller's handling.
pub fn verify_against_lockfile(htmx_dir: &Path) -> std::io::Result<Vec<VerificationFinding>> {
    let lock_path = htmx_dir.join("htmx.lock.toml");
    let content = std::fs::read_to_string(&lock_path)?;
    let lock: HtmxLockfile = toml::from_str(&content)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;

    let mut findings = Vec::new();

    check_entry(
        &htmx_dir.join(&lock.runtime.file),
        &lock.runtime.file,
        lock.runtime.size_bytes,
        &lock.runtime.sha384,
        &mut findings,
    );
    tracing::info!(
        version = %lock.runtime.version,
        "htmx runtime present"
    );

    for ext in &lock.extensions {
        check_entry(
            &htmx_dir.join(&ext.file),
            &ext.file,
            ext.size_bytes,
            &ext.sha384,
            &mut findings,
        );
        tracing::debug!(
            extension = %ext.name,
            version = %ext.version,
            "htmx extension present"
        );
    }

    for finding in &findings {
        tracing::warn!(file = %finding.file, finding = %finding, "htmx asset verification failed");
    }

    Ok(findings)
}

/// Convenience wrapper that runs verification rooted at the project root.
pub fn verify_default() -> std::io::Result<Vec<VerificationFinding>> {
    let dir = PathBuf::from(crate::utils::resolve_from_root("templates/shared/htmx"));
    verify_against_lockfile(&dir)
}

fn check_entry(
    path: &Path,
    declared_name: &str,
    expected_size: u64,
    expected_hash: &str,
    findings: &mut Vec<VerificationFinding>,
) {
    let metadata = match std::fs::metadata(path) {
        Ok(m) => m,
        Err(_) => {
            findings.push(VerificationFinding {
                file: declared_name.to_owned(),
                kind: FindingKind::Missing,
            });
            return;
        }
    };
    let actual_size = metadata.len();
    if actual_size != expected_size {
        findings.push(VerificationFinding {
            file: declared_name.to_owned(),
            kind: FindingKind::SizeMismatch {
                expected: expected_size,
                actual: actual_size,
            },
        });
        return;
    }
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(_) => {
            findings.push(VerificationFinding {
                file: declared_name.to_owned(),
                kind: FindingKind::Missing,
            });
            return;
        }
    };
    let mut hasher = Sha384::new();
    hasher.update(&bytes);
    let actual_hash = hex::encode(hasher.finalize());
    if actual_hash != expected_hash {
        findings.push(VerificationFinding {
            file: declared_name.to_owned(),
            kind: FindingKind::HashMismatch {
                expected: expected_hash.to_owned(),
                actual: actual_hash,
            },
        });
    }
}

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

    #[test]
    fn verifies_real_lockfile_against_vendored_assets() {
        // Resolves to repo's templates/shared/htmx which was vendored under L1/L2.
        let dir = PathBuf::from(crate::utils::resolve_from_root("templates/shared/htmx"));
        if !dir.join("htmx.lock.toml").exists() {
            // Skip in environments where assets haven't been vendored yet.
            return;
        }
        let findings = verify_against_lockfile(&dir).expect("verify");
        assert!(
            findings.is_empty(),
            "expected vendored assets to match lockfile, got: {:?}",
            findings
        );
    }

    #[test]
    fn missing_file_is_reported() {
        let tmp = tempfile::tempdir().expect("create tempdir");
        std::fs::write(
            tmp.path().join("htmx.lock.toml"),
            r#"
[runtime]
package = "htmx.org"
version = "0.0.0"
source_url = ""
file = "absent.js"
sha384 = "deadbeef"
size_bytes = 0
"#,
        )
        .expect("write lockfile");
        let findings = verify_against_lockfile(tmp.path()).expect("verify");
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].kind, FindingKind::Missing);
    }
}
