//! Theme-store catalog and file-serving endpoints.
//!
//! The store is filesystem-driven: the operator drops theme `.zip`
//! archives (the same format the engine's theme importer consumes)
//! into the directory named by the `THEME_STORE_DIR` environment
//! variable (default `theme_store/`), plus an optional `<id>.png`
//! preview per theme. [`ThemeStore::scan`] runs once at startup —
//! adding a theme means dropping the files and restarting the server.
//!
//! Endpoints (all public, no auth — the catalog is free):
//!
//! - `GET /api/themes` — [`ThemeCatalogResponse`] JSON
//! - `GET /api/themes/{id}/download` — the theme `.zip`
//! - `GET /api/themes/{id}/preview` — the preview PNG (404 when absent)

use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};

use axum::Json;
use axum::extract::{Path as UrlPath, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use tracing::{info, warn};

use crate::AppState;
use crate::error::AppError;

/// Environment variable naming the theme-store directory.
pub const THEME_STORE_DIR_ENV: &str = "THEME_STORE_DIR";

/// Default theme-store directory, relative to the server working dir —
/// same convention as the `avatars/` upload directory.
pub const DEFAULT_THEME_STORE_DIR: &str = "theme_store";

/// Archives larger than this are skipped at scan time with a warning.
/// Mirrors the engine importer's `MAX_ARCHIVE_BYTES` (20 MiB) — a zip
/// whose *compressed* size already exceeds the client's uncompressed
/// limit can never import, so serving it only wastes bandwidth.
pub const MAX_STORE_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024;

/// Upper bound on the bytes read out of an archive's `theme.ron`
/// entry at scan time, guarding the catalog scan against a
/// zip-bombed manifest. Real manifests are a few KB.
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;

/// `theme.ron`'s outer shape, `meta` block only — the 53 face entries
/// are irrelevant to the catalog and are validated client-side by the
/// importer at install time.
#[derive(Debug, Deserialize)]
struct ManifestMetaOnly {
    meta: StoreThemeMeta,
}

/// Mirror of the `meta` block of a theme manifest. The canonical
/// definition is `solitaire_engine::theme::ThemeMeta`; the server
/// cannot depend on the engine crate (it links Bevy), so the shape is
/// re-declared here. Keep the two in lockstep.
#[derive(Debug, Deserialize)]
struct StoreThemeMeta {
    id: String,
    name: String,
    author: String,
    version: String,
    card_aspect: (u32, u32),
}

/// In-memory catalog built by scanning the theme-store directory once
/// at startup. Shared via [`AppState`].
#[derive(Debug, Default)]
pub struct ThemeStore {
    entries: Vec<ThemeCatalogEntry>,
    zip_paths: HashMap<String, PathBuf>,
    preview_paths: HashMap<String, PathBuf>,
}

impl ThemeStore {
    /// An empty store — used by tests and when the store directory is
    /// absent (the server runs fine without a theme store).
    pub fn empty() -> Self {
        Self::default()
    }

    /// Scans `dir` for `*.zip` theme archives and builds the catalog.
    ///
    /// Best-effort per archive: unreadable files, oversized archives,
    /// missing/malformed manifests, and duplicate ids are skipped with
    /// a warning rather than failing startup — one bad upload must not
    /// take the store (or the server) down.
    pub fn scan(dir: &Path) -> Self {
        let mut store = Self::default();
        let read_dir = match std::fs::read_dir(dir) {
            Ok(read_dir) => read_dir,
            Err(e) => {
                info!(
                    "theme store: directory {} not readable ({e}); serving an empty catalog",
                    dir.display()
                );
                return store;
            }
        };

        let mut zips: Vec<PathBuf> = read_dir
            .flatten()
            .map(|entry| entry.path())
            .filter(|path| path.extension().is_some_and(|ext| ext == "zip"))
            .collect();
        // Deterministic catalog regardless of directory iteration order.
        zips.sort();

        for zip_path in zips {
            match scan_archive(&zip_path) {
                Ok((meta, size_bytes, sha256)) => {
                    if store.zip_paths.contains_key(&meta.id) {
                        warn!(
                            "theme store: skipping {} — duplicate theme id {:?}",
                            zip_path.display(),
                            meta.id
                        );
                        continue;
                    }
                    let preview_path = dir.join(format!("{}.png", meta.id));
                    let preview_url = if preview_path.is_file() {
                        store.preview_paths.insert(meta.id.clone(), preview_path);
                        Some(format!("/api/themes/{}/preview", meta.id))
                    } else {
                        None
                    };
                    store.entries.push(ThemeCatalogEntry {
                        download_url: format!("/api/themes/{}/download", meta.id),
                        preview_url,
                        id: meta.id.clone(),
                        name: meta.name,
                        author: meta.author,
                        version: meta.version,
                        card_aspect: meta.card_aspect,
                        size_bytes,
                        sha256,
                    });
                    store.zip_paths.insert(meta.id, zip_path);
                }
                Err(reason) => {
                    warn!("theme store: skipping {} — {reason}", zip_path.display());
                }
            }
        }

        store.entries.sort_by(|a, b| a.name.cmp(&b.name));
        info!("theme store: serving {} theme(s)", store.entries.len());
        store
    }

    /// Catalog entries, sorted by display name.
    pub fn entries(&self) -> &[ThemeCatalogEntry] {
        &self.entries
    }
}

/// Reads one archive: enforces the size cap, hashes the bytes, and
/// extracts + validates the manifest's `meta` block.
fn scan_archive(zip_path: &Path) -> Result<(StoreThemeMeta, u64, String), String> {
    let size_bytes = std::fs::metadata(zip_path)
        .map_err(|e| format!("cannot stat: {e}"))?
        .len();
    if size_bytes > MAX_STORE_ARCHIVE_BYTES {
        return Err(format!(
            "{size_bytes} bytes exceeds the {MAX_STORE_ARCHIVE_BYTES}-byte store limit"
        ));
    }

    let bytes = std::fs::read(zip_path).map_err(|e| format!("cannot read: {e}"))?;
    let sha256 = hex_digest(&bytes);

    let mut archive =
        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip archive: {e}"))?;
    let manifest_bytes = {
        let entry = archive
            .by_name("theme.ron")
            .map_err(|e| format!("no theme.ron in archive: {e}"))?;
        let mut buf = Vec::new();
        entry
            .take(MAX_MANIFEST_BYTES)
            .read_to_end(&mut buf)
            .map_err(|e| format!("cannot read theme.ron: {e}"))?;
        buf
    };

    let parsed: ManifestMetaOnly =
        ron::de::from_bytes(&manifest_bytes).map_err(|e| format!("malformed theme.ron: {e}"))?;
    let meta = parsed.meta;
    if meta.id.is_empty() {
        return Err("theme id is empty".to_string());
    }
    if meta.id.contains('/') || meta.id.contains('\\') || meta.id.contains("..") {
        return Err(format!("theme id {:?} is not filesystem-safe", meta.id));
    }
    if meta.card_aspect.0 == 0 || meta.card_aspect.1 == 0 {
        return Err("card_aspect contains a zero".to_string());
    }
    Ok((meta, size_bytes, sha256))
}

/// Lowercase-hex SHA-256 of `bytes`.
fn hex_digest(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    let mut out = String::with_capacity(digest.len() * 2);
    for byte in digest {
        out.push_str(&format!("{byte:02x}"));
    }
    out
}

/// `GET /api/themes` — the full catalog.
pub async fn list(State(state): State<AppState>) -> Json<ThemeCatalogResponse> {
    Json(ThemeCatalogResponse {
        themes: state.theme_store.entries().to_vec(),
    })
}

/// `GET /api/themes/{id}/download` — the theme archive bytes.
pub async fn download(
    State(state): State<AppState>,
    UrlPath(id): UrlPath<String>,
) -> Result<Response, AppError> {
    let path = state
        .theme_store
        .zip_paths
        .get(&id)
        .ok_or_else(|| AppError::NotFound("theme not found".into()))?;
    let bytes = tokio::fs::read(path)
        .await
        .map_err(|e| AppError::Internal(format!("theme archive unreadable: {e}")))?;
    Ok((
        [
            (header::CONTENT_TYPE, "application/zip".to_string()),
            (
                header::CONTENT_DISPOSITION,
                format!("attachment; filename=\"{id}.zip\""),
            ),
        ],
        bytes,
    )
        .into_response())
}

/// `GET /api/themes/{id}/preview` — the preview PNG, when the store
/// ships one for this theme.
pub async fn preview(
    State(state): State<AppState>,
    UrlPath(id): UrlPath<String>,
) -> Result<Response, AppError> {
    let path = state
        .theme_store
        .preview_paths
        .get(&id)
        .ok_or_else(|| AppError::NotFound("theme preview not found".into()))?;
    let bytes = tokio::fs::read(path)
        .await
        .map_err(|e| AppError::Internal(format!("theme preview unreadable: {e}")))?;
    Ok(([(header::CONTENT_TYPE, "image/png".to_string())], bytes).into_response())
}

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

    /// Minimal valid manifest for id/name pairs — the scan only reads
    /// the `meta` block, so no face entries are needed.
    fn manifest(id: &str, name: &str) -> String {
        format!(
            r#"(
    meta: (
        id: "{id}",
        name: "{name}",
        author: "Test Author",
        version: "1.0.0",
        card_aspect: (2, 3),
    ),
    faces: {{}},
    back: "back.svg",
)"#
        )
    }

    fn write_zip(dir: &Path, file_name: &str, manifest: &str) -> PathBuf {
        let path = dir.join(file_name);
        let file = std::fs::File::create(&path).expect("create zip");
        let mut writer = zip::ZipWriter::new(file);
        let options = zip::write::SimpleFileOptions::default();
        writer.start_file("theme.ron", options).expect("start");
        writer.write_all(manifest.as_bytes()).expect("write");
        writer.finish().expect("finish");
        path
    }

    #[test]
    fn scan_of_missing_dir_yields_empty_catalog() {
        let store = ThemeStore::scan(Path::new("/nonexistent/theme/store"));
        assert!(store.entries().is_empty());
    }

    #[test]
    fn scan_builds_sorted_catalog_with_hashes() {
        let dir = tempfile::tempdir().expect("tempdir");
        let zebra = write_zip(dir.path(), "b.zip", &manifest("zebra", "Zebra"));
        write_zip(dir.path(), "a.zip", &manifest("aurora", "Aurora"));

        let store = ThemeStore::scan(dir.path());
        let entries = store.entries();
        assert_eq!(entries.len(), 2);
        // Sorted by display name, not filename.
        assert_eq!(entries[0].id, "aurora");
        assert_eq!(entries[1].id, "zebra");
        assert_eq!(entries[1].download_url, "/api/themes/zebra/download");
        assert_eq!(entries[1].preview_url, None);

        let zebra_bytes = std::fs::read(&zebra).expect("read zip");
        assert_eq!(entries[1].sha256, hex_digest(&zebra_bytes));
        assert_eq!(entries[1].size_bytes, zebra_bytes.len() as u64);
    }

    #[test]
    fn scan_picks_up_preview_png_keyed_by_theme_id() {
        let dir = tempfile::tempdir().expect("tempdir");
        write_zip(dir.path(), "neon.zip", &manifest("neon", "Neon"));
        std::fs::write(dir.path().join("neon.png"), b"png-bytes").expect("write png");

        let store = ThemeStore::scan(dir.path());
        assert_eq!(
            store.entries()[0].preview_url.as_deref(),
            Some("/api/themes/neon/preview")
        );
        assert!(store.preview_paths.contains_key("neon"));
    }

    #[test]
    fn scan_skips_duplicate_ids_and_keeps_first() {
        let dir = tempfile::tempdir().expect("tempdir");
        write_zip(dir.path(), "1_first.zip", &manifest("dupe", "First"));
        write_zip(dir.path(), "2_second.zip", &manifest("dupe", "Second"));

        let store = ThemeStore::scan(dir.path());
        assert_eq!(store.entries().len(), 1);
        assert_eq!(store.entries()[0].name, "First");
    }

    #[test]
    fn scan_skips_malformed_and_unsafe_manifests() {
        let dir = tempfile::tempdir().expect("tempdir");
        // Not a zip at all.
        std::fs::write(dir.path().join("junk.zip"), b"not a zip").expect("write");
        // Path-separator id must be rejected — it would become a URL
        // path segment and a client install directory name.
        write_zip(dir.path(), "evil.zip", &manifest("../evil", "Evil"));
        // Valid one to prove the scan carries on past failures.
        write_zip(dir.path(), "ok.zip", &manifest("ok", "Ok"));

        let store = ThemeStore::scan(dir.path());
        assert_eq!(store.entries().len(), 1);
        assert_eq!(store.entries()[0].id, "ok");
    }
}
