//! Asset serving helpers for custom URI scheme handlers.
//!
//! Platform implementations (iOS `WKURLSchemeHandler`, Android `WebViewClient`,
//! desktop custom-scheme handler) call [`serve_asset`] from their request
//! callback to resolve a path against a verified [`ProductAssets`] bundle.

use crate::mime::*;
use crate::state::ProductAssets;

/// Serve a single asset from a `ProductAssets` bundle.
///
/// Handles path normalization, traversal rejection, MIME detection,
/// index.html defaulting, and SPA fallback for extensionless paths.
///
/// If `inject` is `Some((csp, script_body))`, HTML index responses have
/// the CSP `<meta>` tag and a `<script>` block injected into `<head>`.
/// Pass raw JavaScript for `script_body` (e.g. `SANDBOX_LOCKDOWN_SCRIPT`);
/// the `<script>` tags are added automatically. Pass `""` to skip script
/// injection. See [`crate::inject_into_head`] for details.
///
/// Returns `(http_status, content_type, body)`.
///
/// # Security: CSP response header
///
/// The injected `<meta>` CSP tag only governs the HTML document itself.
/// Callers **must** also set the `Content-Security-Policy` HTTP response
/// header on the **HTML document response** (index and SPA-fallback paths).
/// Use [`crate::csp_for_scheme()`] to build the header value. Sub-resource
/// responses (JS, CSS, images) do not need their own CSP header — the
/// document-level policy governs them.
///
/// ```text
/// let chain_access = false;
/// let approved: &[&str] = &[];
/// let csp = csp_for_scheme("dotapp", chain_access, approved);
/// let (status, content_type, body) = serve_asset(&assets, path, Some((&csp, SANDBOX_LOCKDOWN_SCRIPT)));
///
/// // Platform response builder (e.g. WKURLSchemeHandler, Android WebViewClient):
/// response.set_status(status);
/// response.set_header("Content-Type", &content_type);
/// if content_type.starts_with("text/html") {
///     response.set_header("Content-Security-Policy", &csp);  // mandatory on HTML responses
/// }
/// response.set_body(body);
/// ```
///
/// # What callers must handle separately
///
/// - **CSP response header** on HTML document responses (see above)
/// - **`crossorigin` attribute stripping** — strip `crossorigin` from
///   `<script>` and `<link>` elements in served HTML to avoid CORS preflight
///   errors for same-scheme resources (platform-specific)
/// - **Do NOT set CORS headers** — custom URI schemes provide origin
///   isolation; CORS headers are meaningless and imply a broader permission
///   model than exists (see [`crate::csp_for_scheme()`] docs)
pub fn serve_asset(
    assets: &ProductAssets,
    raw_path: &str,
    inject: Option<(&str, &str)>,
) -> (u16, String, Vec<u8>) {
    // Strip query string and fragment before any path processing.
    let path = raw_path.split('?').next().unwrap_or(raw_path);
    let path = path.split('#').next().unwrap_or(path);

    // Normalize: strip leading slash, default empty path to index.html.
    let path = path.strip_prefix('/').unwrap_or(path);
    let path = if path.is_empty() { "index.html" } else { path };

    // Guard against unreasonably long paths before further processing.
    if path.len() > 4096 {
        return (414, "text/plain".into(), b"Path too long".to_vec());
    }

    // Traversal and encoding check — must pass before any asset lookup.
    if !is_safe_asset_path(path) {
        return (403, "text/plain".into(), b"Forbidden path".to_vec());
    }

    // Index document — applies to both "index.html" and "index.htm".
    if path == "index.html" || path == "index.htm" {
        let body = apply_inject(&assets.index_html, inject);
        return (200, assets.index_mime.clone(), body);
    }

    // Sub-resource lookup — exact path match in the bundle map.
    if let Some(data) = assets.resources.get(path) {
        return (200, mime_for_path(path).into(), data.clone());
    }

    // SPA fallback: extensionless paths (e.g. `/about`, `/settings`) serve
    // the index document so client-side routing handles them.
    if !path.contains('.') {
        let body = apply_inject(&assets.index_html, inject);
        return (200, assets.index_mime.clone(), body);
    }

    (
        404,
        "text/plain".into(),
        format!("Not found: {path}").into_bytes(),
    )
}

/// Apply optional CSP + script injection to an HTML document body.
///
/// Returns a clone of the original bytes when `inject` is `None` or when
/// injection fails due to invalid UTF-8 (the latter is a graceful fallback).
fn apply_inject(html: &[u8], inject: Option<(&str, &str)>) -> Vec<u8> {
    match inject {
        Some((csp, script_body)) => crate::security::inject_into_head(html, csp, script_body)
            .unwrap_or_else(|_| html.to_vec()),
        None => html.to_vec(),
    }
}

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

    fn make_assets(index_html: &[u8], resources: HashMap<String, Vec<u8>>) -> ProductAssets {
        ProductAssets {
            index_html: index_html.to_vec(),
            index_mime: "text/html; charset=utf-8".to_string(),
            resources,
        }
    }

    fn simple_assets() -> ProductAssets {
        let mut resources = HashMap::new();
        resources.insert("app.js".into(), b"console.log('app')".to_vec());
        resources.insert("style.css".into(), b"body{margin:0}".to_vec());
        resources.insert("assets/logo.png".into(), vec![0u8; 16]);
        make_assets(b"<html><head></head><body></body></html>", resources)
    }

    // ------------------------------------------------------------------
    // Index document
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_empty_path_returns_index() {
        let assets = simple_assets();
        let (status, mime, body) = serve_asset(&assets, "/", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/html; charset=utf-8");
        assert_eq!(body, assets.index_html);
    }

    #[test]
    fn test_serve_index_html_explicit() {
        let assets = simple_assets();
        let (status, mime, _) = serve_asset(&assets, "/index.html", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/html; charset=utf-8");
    }

    #[test]
    fn test_serve_index_htm_explicit() {
        let assets = simple_assets();
        let (status, mime, _) = serve_asset(&assets, "index.htm", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/html; charset=utf-8");
    }

    #[test]
    fn test_serve_index_without_leading_slash() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "index.html", None);
        assert_eq!(status, 200);
    }

    // ------------------------------------------------------------------
    // Sub-resource lookup
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_js_resource() {
        let assets = simple_assets();
        let (status, mime, body) = serve_asset(&assets, "/app.js", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/javascript; charset=utf-8");
        assert_eq!(body, b"console.log('app')");
    }

    #[test]
    fn test_serve_css_resource() {
        let assets = simple_assets();
        let (status, mime, _) = serve_asset(&assets, "/style.css", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/css; charset=utf-8");
    }

    #[test]
    fn test_serve_nested_resource() {
        let assets = simple_assets();
        let (status, mime, _) = serve_asset(&assets, "/assets/logo.png", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "image/png");
    }

    // ------------------------------------------------------------------
    // Query strings and fragments are stripped
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_strips_query_string() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "/app.js?v=1.2.3", None);
        assert_eq!(status, 200);
    }

    #[test]
    fn test_serve_strips_fragment() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "/app.js#section", None);
        assert_eq!(status, 200);
    }

    #[test]
    fn test_serve_strips_query_and_fragment() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "/app.js?v=1#top", None);
        assert_eq!(status, 200);
    }

    #[test]
    fn test_serve_index_with_query() {
        let assets = simple_assets();
        let (status, mime, _) = serve_asset(&assets, "/?foo=bar", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/html; charset=utf-8");
    }

    // ------------------------------------------------------------------
    // Traversal rejection
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_rejects_dot_dot_traversal() {
        let assets = simple_assets();
        let (status, _, body) = serve_asset(&assets, "/../../etc/passwd", None);
        assert_eq!(status, 403);
        assert_eq!(body, b"Forbidden path");
    }

    #[test]
    fn test_serve_rejects_percent_encoded_traversal() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "/%2e%2e/etc/passwd", None);
        assert_eq!(status, 403);
    }

    #[test]
    fn test_serve_rejects_null_byte() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "/file\0.js", None);
        assert_eq!(status, 403);
    }

    #[test]
    fn test_serve_rejects_windows_drive() {
        let assets = simple_assets();
        let (status, _, _) = serve_asset(&assets, "/C:secret.txt", None);
        assert_eq!(status, 403);
    }

    // ------------------------------------------------------------------
    // Path too long
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_rejects_path_too_long() {
        let assets = simple_assets();
        let long_path = format!("/{}", "a".repeat(5000));
        let (status, _, body) = serve_asset(&assets, &long_path, None);
        assert_eq!(status, 414);
        assert_eq!(body, b"Path too long");
    }

    // ------------------------------------------------------------------
    // SPA fallback for extensionless paths
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_spa_fallback_for_extensionless_path() {
        let assets = simple_assets();
        let (status, mime, body) = serve_asset(&assets, "/about", None);
        assert_eq!(status, 200, "extensionless path must fall back to index");
        assert_eq!(mime, "text/html; charset=utf-8");
        assert_eq!(body, assets.index_html);
    }

    #[test]
    fn test_serve_spa_fallback_deep_path() {
        let assets = simple_assets();
        let (status, mime, _) = serve_asset(&assets, "/settings/profile", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/html; charset=utf-8");
    }

    #[test]
    fn test_serve_404_for_missing_file_with_extension() {
        let assets = simple_assets();
        let (status, _, body) = serve_asset(&assets, "/missing.js", None);
        assert_eq!(status, 404);
        let body_str = String::from_utf8(body).unwrap();
        assert!(
            body_str.contains("missing.js"),
            "404 body must name the missing path"
        );
    }

    // ------------------------------------------------------------------
    // Injection
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_inject_csp_into_index() {
        let assets = simple_assets();
        let csp = "default-src 'self' dotapp:";
        let (status, _, body) = serve_asset(&assets, "/", Some((csp, "")));
        assert_eq!(status, 200);
        let out = String::from_utf8(body).unwrap();
        assert!(
            out.contains(r#"<meta http-equiv="Content-Security-Policy""#),
            "CSP meta must be injected into index"
        );
    }

    #[test]
    fn test_serve_inject_csp_into_spa_fallback() {
        let assets = simple_assets();
        let csp = "default-src 'self' dotapp:";
        let (status, _, body) = serve_asset(&assets, "/dashboard", Some((csp, "")));
        assert_eq!(status, 200);
        let out = String::from_utf8(body).unwrap();
        assert!(
            out.contains(r#"<meta http-equiv="Content-Security-Policy""#),
            "CSP meta must be injected for SPA fallback"
        );
    }

    #[test]
    fn test_serve_no_injection_for_sub_resources() {
        let assets = simple_assets();
        // Sub-resources (JS, CSS) must never have the CSP meta injected.
        let (status, _, body) = serve_asset(&assets, "/app.js", Some(("default-src 'self'", "")));
        assert_eq!(status, 200);
        assert_eq!(
            body, b"console.log('app')",
            "JS body must be returned verbatim"
        );
    }

    // ------------------------------------------------------------------
    // index_mime is used for the index document (not hardcoded)
    // ------------------------------------------------------------------

    #[test]
    fn test_serve_uses_index_mime_from_assets() {
        let assets = ProductAssets {
            index_html: b"<html></html>".to_vec(),
            index_mime: "text/html; charset=iso-8859-1".to_string(),
            resources: HashMap::new(),
        };
        let (status, mime, _) = serve_asset(&assets, "/", None);
        assert_eq!(status, 200);
        assert_eq!(mime, "text/html; charset=iso-8859-1");
    }

    #[test]
    fn test_serve_uses_index_mime_for_spa_fallback() {
        let assets = ProductAssets {
            index_html: b"<html></html>".to_vec(),
            index_mime: "text/html; charset=iso-8859-1".to_string(),
            resources: HashMap::new(),
        };
        let (_, mime, _) = serve_asset(&assets, "/about", None);
        assert_eq!(mime, "text/html; charset=iso-8859-1");
    }
}
