//! HTTP client for the server's theme-store endpoints.
//!
//! Fetches the catalog (`GET /api/themes`) and downloads theme
//! archives, verifying each download's size and SHA-256 against the
//! catalog entry before returning the bytes. The caller (the engine's
//! theme-store UI) hands verified bytes to the theme importer, which
//! independently re-validates the archive's structure — the store
//! pipeline never trusts a byte the importer hasn't checked.
//!
//! Native-only: gated out on wasm32 alongside the other `reqwest`
//! consumers in this crate.

use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use thiserror::Error;

/// Hard cap on a theme archive download, matching the engine
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
/// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;

/// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)]
pub enum ThemeStoreError {
    /// The request could not be sent or the response body not read.
    #[error("network error: {0}")]
    Network(String),
    /// The server answered with a non-success status code.
    #[error("server returned HTTP {0}")]
    Http(u16),
    /// The catalog JSON did not parse.
    #[error("malformed catalog: {0}")]
    MalformedCatalog(String),
    /// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
    /// catalog-declared size.
    #[error("archive size {got} exceeds the expected {expected} bytes")]
    Oversized { expected: u64, got: u64 },
    /// The downloaded bytes do not hash to the catalog's SHA-256 —
    /// the file changed on the server or was corrupted in transit.
    #[error("archive checksum mismatch (expected {expected}, got {got})")]
    ChecksumMismatch { expected: String, got: String },
}

/// Client for one server's theme store.
///
/// Unauthenticated: the catalog and downloads are public endpoints,
/// so unlike `SolitaireServerClient` there is no token handling.
pub struct ThemeStoreClient {
    /// Base URL of the server, trailing slash stripped.
    base_url: String,
    client: reqwest::Client,
}

impl ThemeStoreClient {
    /// Construct a client for the server at `base_url`
    /// (e.g. `"https://solitaire.example.com"`).
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into().trim_end_matches('/').to_owned(),
            client: reqwest::Client::new(),
        }
    }

    /// Fetch the theme catalog, sorted by display name (server-side).
    pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
        let resp = self
            .client
            .get(format!("{}/api/themes", self.base_url))
            .send()
            .await
            .map_err(|e| ThemeStoreError::Network(e.to_string()))?;
        if !resp.status().is_success() {
            return Err(ThemeStoreError::Http(resp.status().as_u16()));
        }
        let catalog: ThemeCatalogResponse = resp
            .json()
            .await
            .map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
        Ok(catalog.themes)
    }

    /// Download `entry`'s archive and verify it against the catalog's
    /// size and SHA-256. Returns the verified `.zip` bytes, ready for
    /// the engine's theme importer.
    pub async fn download_theme(
        &self,
        entry: &ThemeCatalogEntry,
    ) -> Result<Vec<u8>, ThemeStoreError> {
        // The catalog itself could name an absurd size; refuse before
        // buffering anything.
        if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
            return Err(ThemeStoreError::Oversized {
                expected: MAX_THEME_DOWNLOAD_BYTES,
                got: entry.size_bytes,
            });
        }
        let resp = self
            .client
            .get(format!("{}{}", self.base_url, entry.download_url))
            .send()
            .await
            .map_err(|e| ThemeStoreError::Network(e.to_string()))?;
        if !resp.status().is_success() {
            return Err(ThemeStoreError::Http(resp.status().as_u16()));
        }
        let bytes = resp
            .bytes()
            .await
            .map_err(|e| ThemeStoreError::Network(e.to_string()))?;
        verify_archive(&bytes, entry)?;
        Ok(bytes.to_vec())
    }
}

/// Checks downloaded `bytes` against the catalog `entry`'s declared
/// size and SHA-256. Pure so it can be unit-tested without a server.
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
    if bytes.len() as u64 != entry.size_bytes {
        return Err(ThemeStoreError::Oversized {
            expected: entry.size_bytes,
            got: bytes.len() as u64,
        });
    }
    let got = hex_digest(bytes);
    // Case-insensitive: the server emits lowercase hex, but a
    // hand-authored catalog mirror shouldn't fail on case alone.
    if !got.eq_ignore_ascii_case(&entry.sha256) {
        return Err(ThemeStoreError::ChecksumMismatch {
            expected: entry.sha256.clone(),
            got,
        });
    }
    Ok(())
}

/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
/// encoding in `solitaire_server::theme_store`.
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
}

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

    fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
        ThemeCatalogEntry {
            id: "neon".into(),
            name: "Neon".into(),
            author: "Test".into(),
            version: "1.0.0".into(),
            card_aspect: (2, 3),
            size_bytes: bytes.len() as u64,
            sha256: hex_digest(bytes),
            download_url: "/api/themes/neon/download".into(),
            preview_url: None,
        }
    }

    #[test]
    fn verify_accepts_matching_bytes() {
        let bytes = b"theme archive bytes";
        assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
    }

    #[test]
    fn verify_accepts_uppercase_catalog_hash() {
        let bytes = b"theme archive bytes";
        let mut entry = entry_for(bytes);
        entry.sha256 = entry.sha256.to_uppercase();
        assert!(verify_archive(bytes, &entry).is_ok());
    }

    #[test]
    fn verify_rejects_size_mismatch() {
        let bytes = b"theme archive bytes";
        let mut entry = entry_for(bytes);
        entry.size_bytes += 1;
        assert!(matches!(
            verify_archive(bytes, &entry),
            Err(ThemeStoreError::Oversized { .. })
        ));
    }

    #[test]
    fn verify_rejects_checksum_mismatch() {
        let bytes = b"theme archive bytes";
        let mut entry = entry_for(bytes);
        entry.sha256 = "0".repeat(64);
        assert!(matches!(
            verify_archive(bytes, &entry),
            Err(ThemeStoreError::ChecksumMismatch { .. })
        ));
    }
}
