// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

//! Static asset serving endpoints (HTML pages, JS, CSS, favicon, logo).

use actix_web::http::header;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse, Responder};
use std::collections::HashMap;
use std::sync::OnceLock;

use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
use crate::server::status;

// ---------------------------------------------------------------------------
// Pre-compressed asset caches
// ---------------------------------------------------------------------------

macro_rules! define_gz_cache {
    ($fn_name:ident, $src:expr, $asset_name:literal) => {
        fn $fn_name() -> &'static GzCacheEntry {
            static CACHE: OnceLock<GzCacheEntry> = OnceLock::new();
            CACHE.get_or_init(|| gz_cache_entry($src.as_bytes(), $asset_name))
        }
    };
}

define_gz_cache!(gz_index_html, status::index_html(), "index.html");
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json");

fn generated_asset_cache() -> &'static HashMap<&'static str, GzCacheEntry> {
    static CACHE: OnceLock<HashMap<&'static str, GzCacheEntry>> = OnceLock::new();
    CACHE.get_or_init(|| {
        status::GENERATED_ASSETS
            .iter()
            .map(|(name, bytes)| (*name, gz_cache_entry(bytes, name)))
            .collect()
    })
}

// Vendored DSEG14 Classic font
// (binary woff2 — served directly, not through gz_cache)

// Vendored opus-decoder 0.7.11
define_gz_cache!(
    gz_opus_decoder_js,
    status::OPUS_DECODER_JS,
    "opus-decoder-0.7.11.min.js"
);

// Vendored Leaflet 1.9.4
define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js");
define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");

// ---------------------------------------------------------------------------
// HTML page routes (all serve the SPA index)
// ---------------------------------------------------------------------------

#[get("/")]
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/map")]
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/digital-modes")]
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/recorder")]
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/settings")]
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/about")]
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
    let c = gz_index_html();
    static_asset_response(&req, "text/html; charset=utf-8", c)
}

// ---------------------------------------------------------------------------
// Favicon & logo
// ---------------------------------------------------------------------------

#[get("/favicon.ico")]
pub(crate) async fn favicon() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(FAVICON_BYTES)
}

#[get("/favicon.png")]
pub(crate) async fn favicon_png() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(FAVICON_BYTES)
}

#[get("/logo.png")]
pub(crate) async fn logo() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(LOGO_BYTES)
}

// ---------------------------------------------------------------------------
// CSS
// ---------------------------------------------------------------------------

#[get("/style.css")]
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
    let c = gz_style_css();
    static_asset_response(&req, "text/css; charset=utf-8", c)
}

#[get("/themes.css")]
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
    let c = gz_themes_css();
    static_asset_response(&req, "text/css; charset=utf-8", c)
}

// Generated filenames are supplied only by build.rs and resolved through this
// allowlist. Unknown names and unsupported MIME types never reach the file system.
fn generated_content_type(filename: &str) -> Option<&'static str> {
    match filename.rsplit_once('.').map(|(_, extension)| extension) {
        Some("js") => Some("application/javascript; charset=utf-8"),
        Some("css") => Some("text/css; charset=utf-8"),
        Some("json" | "map") => Some("application/json; charset=utf-8"),
        Some("wasm") => Some("application/wasm"),
        _ => None,
    }
}

#[get("/{filename}")]
pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -> impl Responder {
    let filename = path.into_inner();
    let Some(content_type) = generated_content_type(&filename) else {
        return HttpResponse::NotFound().finish();
    };
    let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
        return HttpResponse::NotFound().finish();
    };
    static_asset_response(&req, content_type, entry)
}

#[get("/images/{filename}")]
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
    let filename = path.into_inner();
    // Reject path traversal attempts.
    if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
        return HttpResponse::BadRequest().body("invalid filename");
    }
    if !filename.ends_with(".png") {
        return HttpResponse::BadRequest().body("only .png files are accessible");
    }
    let dir = dirs::cache_dir()
        .unwrap_or_else(|| std::path::PathBuf::from(".cache"))
        .join("trx-rs")
        .join("wefax");
    let file_path = dir.join(&filename);
    match std::fs::read(&file_path) {
        Ok(data) => HttpResponse::Ok()
            .insert_header((header::CONTENT_TYPE, "image/png"))
            .insert_header((header::CACHE_CONTROL, "public, max-age=86400"))
            .body(data),
        Err(_) => HttpResponse::NotFound().body("image not found"),
    }
}

#[get("/bandplan.json")]
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
    let c = gz_bandplan_json();
    static_asset_response(&req, "application/json; charset=utf-8", c)
}

// ---------------------------------------------------------------------------
// Vendored DSEG14 Classic font
// ---------------------------------------------------------------------------

#[get("/vendor/dseg14-classic-latin-400-normal.woff2")]
pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "font/woff2"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(status::DSEG14_CLASSIC_WOFF2)
}

#[get("/vendor/opus-decoder-0.7.11.min.js")]
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
    let c = gz_opus_decoder_js();
    static_asset_response(&req, "application/javascript; charset=utf-8", c)
}

// ---------------------------------------------------------------------------
// Vendored Leaflet 1.9.4
// ---------------------------------------------------------------------------

#[get("/vendor/leaflet.js")]
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
    let c = gz_leaflet_js();
    static_asset_response(&req, "application/javascript; charset=utf-8", c)
}

#[get("/vendor/leaflet.css")]
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
    let c = gz_leaflet_css();
    static_asset_response(&req, "text/css; charset=utf-8", c)
}

#[get("/vendor/marker-icon.png")]
pub(crate) async fn leaflet_marker_icon() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(status::LEAFLET_MARKER_ICON)
}

#[get("/vendor/marker-icon-2x.png")]
pub(crate) async fn leaflet_marker_icon_2x() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(status::LEAFLET_MARKER_ICON_2X)
}

#[get("/vendor/marker-shadow.png")]
pub(crate) async fn leaflet_marker_shadow() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(status::LEAFLET_MARKER_SHADOW)
}

#[get("/vendor/layers.png")]
pub(crate) async fn leaflet_layers() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(status::LEAFLET_LAYERS)
}

#[get("/vendor/layers-2x.png")]
pub(crate) async fn leaflet_layers_2x() -> impl Responder {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(status::LEAFLET_LAYERS_2X)
}

// ---------------------------------------------------------------------------
// Vendored APRS symbol sprites
// ---------------------------------------------------------------------------

fn embedded_png(bytes: &'static [u8]) -> HttpResponse {
    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, "image/png"))
        .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
        .body(bytes)
}

#[get("/vendor/aprs-symbols-24-0.png")]
pub(crate) async fn aprs_symbols_primary() -> impl Responder {
    embedded_png(status::APRS_SYMBOLS_PRIMARY)
}

#[get("/vendor/aprs-symbols-24-0-2x.png")]
pub(crate) async fn aprs_symbols_primary_2x() -> impl Responder {
    embedded_png(status::APRS_SYMBOLS_PRIMARY_2X)
}

#[get("/vendor/aprs-symbols-24-1.png")]
pub(crate) async fn aprs_symbols_alternate() -> impl Responder {
    embedded_png(status::APRS_SYMBOLS_ALTERNATE)
}

#[get("/vendor/aprs-symbols-24-1-2x.png")]
pub(crate) async fn aprs_symbols_alternate_2x() -> impl Responder {
    embedded_png(status::APRS_SYMBOLS_ALTERNATE_2X)
}

#[get("/vendor/aprs-symbols-24-2.png")]
pub(crate) async fn aprs_symbols_overlay() -> impl Responder {
    embedded_png(status::APRS_SYMBOLS_OVERLAY)
}

#[get("/vendor/aprs-symbols-24-2-2x.png")]
pub(crate) async fn aprs_symbols_overlay_2x() -> impl Responder {
    embedded_png(status::APRS_SYMBOLS_OVERLAY_2X)
}

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

    #[test]
    fn generated_asset_manifest_is_an_explicit_safe_allowlist() {
        assert!(!status::GENERATED_ASSETS.is_empty());
        for (name, bytes) in status::GENERATED_ASSETS {
            assert!(!bytes.is_empty(), "generated asset {name} is empty");
            assert!(!name.contains('/') && !name.contains('\\') && !name.contains(".."));
            assert!(generated_content_type(name).is_some());
        }
        assert!(generated_asset_cache().contains_key("app.js"));
        assert!(!generated_asset_cache().contains_key("../app.js"));
    }

    #[test]
    fn generated_asset_mime_types_are_restricted() {
        assert_eq!(
            generated_content_type("chunk.js"),
            Some("application/javascript; charset=utf-8")
        );
        assert_eq!(
            generated_content_type("chunk.wasm"),
            Some("application/wasm")
        );
        assert_eq!(generated_content_type("secret.txt"), None);
    }

    /// Reads the width and height out of a PNG IHDR chunk.
    fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
        assert_eq!(&bytes[12..16], b"IHDR", "first chunk is not IHDR");
        let width = u32::from_be_bytes(bytes[16..20].try_into().expect("IHDR width"));
        let height = u32::from_be_bytes(bytes[20..24].try_into().expect("IHDR height"));
        (width, height)
    }

    /// The browser computes sprite cell offsets from a 16x6 grid of 24px cells,
    /// so a sheet at any other size would silently shift every APRS symbol.
    #[test]
    fn aprs_symbol_sheets_match_the_sprite_grid_the_frontend_assumes() {
        for (name, bytes) in [
            ("aprs-symbols-24-0", status::APRS_SYMBOLS_PRIMARY),
            ("aprs-symbols-24-1", status::APRS_SYMBOLS_ALTERNATE),
            ("aprs-symbols-24-2", status::APRS_SYMBOLS_OVERLAY),
        ] {
            assert_eq!(
                png_dimensions(bytes),
                (384, 144),
                "{name} is not a 16x6 grid"
            );
        }
        for (name, bytes) in [
            ("aprs-symbols-24-0-2x", status::APRS_SYMBOLS_PRIMARY_2X),
            ("aprs-symbols-24-1-2x", status::APRS_SYMBOLS_ALTERNATE_2X),
            ("aprs-symbols-24-2-2x", status::APRS_SYMBOLS_OVERLAY_2X),
        ] {
            assert_eq!(
                png_dimensions(bytes),
                (768, 288),
                "{name} is not a 2x sheet"
            );
        }
    }
}
