// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
// SPDX-FileCopyrightText: Author: Pierre Gronau <Pierre.Gronau@ndaal.eu>

//! Lightweight HTTP router built on [`matchit`].
//!
//! Same design as the ndaal `vulnerability-lookup-rs` web layer:
//! handlers receive [`http::request::Parts`] plus extracted path
//! parameters and return a complete response; every response passes
//! through the security-header middleware.

use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};

use bytes::Bytes;
use http::header::{self, HeaderName, HeaderValue};
use http::{Method, Response, StatusCode};
use http_body_util::Full;

use crate::app_state::AppState;

/// The `Alt-Svc` header value advertising HTTP/3, set once at start-up when a
/// QUIC listener is active so HTTPS clients can upgrade to h3.
static ALT_SVC: OnceLock<HeaderValue> = OnceLock::new();

/// Advertise an HTTP/3 endpoint on `port` via `Alt-Svc` on every response.
/// Call once before serving; later calls are ignored.
pub fn set_alt_svc_port(port: u16) {
    if let Ok(value) = HeaderValue::from_str(&format!("h3=\":{port}\"; ma=86400")) {
        let _ = ALT_SVC.set(value);
    }
}

/// The concrete body type used throughout the server.
pub type Body = Full<Bytes>;

/// The boxed future returned by route handlers.
pub type HandlerFuture = Pin<Box<dyn Future<Output = Response<Body>> + Send>>;

/// A route handler: shared state + request parts + path parameters.
pub type HandlerFn = Arc<
    dyn Fn(AppState, http::request::Parts, Vec<(String, String)>) -> HandlerFuture + Send + Sync,
>;

/// Successful route match: handler plus extracted path parameters.
pub type RouteMatch = (HandlerFn, Vec<(String, String)>);

/// Error raised when route registration fails (duplicate / conflicting
/// patterns) — surfaces at startup, never at request time.
#[derive(Debug)]
pub struct RouterBuildError(String);

impl fmt::Display for RouterBuildError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "router build error: {}", self.0)
    }
}

impl std::error::Error for RouterBuildError {}

/// Collects routes, then builds the shared router in one fallible step.
pub struct RouterBuilder {
    routes: Vec<(Method, String, HandlerFn)>,
    fallback: Option<HandlerFn>,
}

impl Default for RouterBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl RouterBuilder {
    /// Create an empty builder.
    #[must_use]
    pub fn new() -> Self {
        Self {
            routes: Vec::new(),
            fallback: None,
        }
    }

    /// Register a `GET` route. Path patterns use `matchit` syntax:
    /// `{name}` for named parameters, `{*rest}` for catch-all suffixes.
    #[must_use]
    pub fn get(mut self, path: &str, handler: HandlerFn) -> Self {
        self.routes.push((Method::GET, path.to_owned(), handler));
        self
    }

    /// Set the handler used when no route matches (the 404 page).
    #[must_use]
    pub fn fallback(mut self, handler: HandlerFn) -> Self {
        self.fallback = Some(handler);
        self
    }

    /// Build the shared router.
    ///
    /// # Errors
    ///
    /// Returns [`RouterBuildError`] when two registered patterns
    /// conflict — a programming error caught at startup.
    pub fn build(self, state: AppState) -> Result<SharedRouter, RouterBuildError> {
        let mut trees: HashMap<Method, matchit::Router<HandlerFn>> = HashMap::new();
        for (method, path, handler) in self.routes {
            trees
                .entry(method)
                .or_default()
                .insert(&path, handler)
                .map_err(|e| RouterBuildError(format!("{path}: {e}")))?;
        }
        Ok(SharedRouter {
            inner: Arc::new(RouterInner {
                trees,
                fallback: self.fallback,
                state,
            }),
        })
    }
}

struct RouterInner {
    trees: HashMap<Method, matchit::Router<HandlerFn>>,
    fallback: Option<HandlerFn>,
    state: AppState,
}

/// A cheaply cloneable handle shared across connections and tasks.
#[derive(Clone)]
pub struct SharedRouter {
    inner: Arc<RouterInner>,
}

impl SharedRouter {
    /// Dispatch an incoming hyper request to the matching handler.
    pub async fn handle(&self, req: http::Request<hyper::body::Incoming>) -> Response<Body> {
        let (parts, _body) = req.into_parts();
        self.handle_parts(parts).await
    }

    /// Dispatch a request (as [`http::request::Parts`]) to the matching
    /// handler. `HEAD` is served through the `GET` tree (RFC 9110 §9.1;
    /// hyper suppresses the body on the wire), `405` with an `Allow`
    /// header when the path exists under another method, and the
    /// fallback (404 page) otherwise.
    pub async fn handle_parts(&self, parts: http::request::Parts) -> Response<Body> {
        let method = if parts.method == Method::HEAD {
            Method::GET
        } else {
            parts.method.clone()
        };
        let path = parts.uri.path().to_owned();
        let state = self.inner.state.clone();
        let response = if let Some((handler, params)) = self.match_handler(&method, &path) {
            handler(state, parts, params).await
        } else if self.path_matches_other_method(&method, &path) {
            method_not_allowed()
        } else if let Some(fallback) = &self.inner.fallback {
            fallback(state, parts, Vec::new()).await
        } else {
            text_response(StatusCode::NOT_FOUND, "Not Found")
        };
        apply_security_headers(response)
    }

    /// Look up the handler registered for `(method, path)`.
    fn match_handler(&self, method: &Method, path: &str) -> Option<RouteMatch> {
        let tree = self.inner.trees.get(method)?;
        let matched = tree.at(path).ok()?;
        let params: Vec<(String, String)> = matched
            .params
            .iter()
            .map(|(k, v)| (k.to_owned(), v.to_owned()))
            .collect();
        Some((Arc::clone(matched.value), params))
    }

    /// Does `path` match a route registered under a different method?
    fn path_matches_other_method(&self, method: &Method, path: &str) -> bool {
        self.inner
            .trees
            .iter()
            .any(|(m, tree)| m != method && tree.at(path).is_ok())
    }
}

/// Wrap an async handler function into a boxed [`HandlerFn`].
#[must_use]
pub fn handler_fn<F, Fut>(f: F) -> HandlerFn
where
    F: Fn(AppState, http::request::Parts, Vec<(String, String)>) -> Fut + Send + Sync + 'static,
    Fut: Future<Output = Response<Body>> + Send + 'static,
{
    Arc::new(move |state, parts, params| Box::pin(f(state, parts, params)))
}

/// Wrap a synchronous handler function into a boxed [`HandlerFn`].
#[must_use]
pub fn sync_handler_fn<F>(f: F) -> HandlerFn
where
    F: Fn(AppState, http::request::Parts, Vec<(String, String)>) -> Response<Body>
        + Send
        + Sync
        + 'static,
{
    Arc::new(move |state, parts, params| Box::pin(std::future::ready(f(state, parts, params))))
}

/// `405` with the `Allow` header required by RFC 9110 §15.5.6 — every
/// route in this server is `GET` (and therefore `HEAD`).
fn method_not_allowed() -> Response<Body> {
    let mut response = text_response(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed");
    response
        .headers_mut()
        .insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
    response
}

// ---------------------------------------------------------------------------
// Security headers
// ---------------------------------------------------------------------------

/// Baseline security response headers, applied to every response.
///
/// The header set is carried over from the ndaal
/// `vulnerability-lookup-rs` middleware (OWASP ASVS V14.4.x):
/// clickjacking (`X-Frame-Options`, CSP `frame-ancestors`),
/// MIME sniffing, referrer minimisation, powerful-API deny list and
/// cross-origin isolation. The CSP allows inline styles/scripts because
/// the Askama templates ship inline `<style>` / `<script>` blocks.
/// `Strict-Transport-Security` is included: the listener is TLS-only
/// (rustls, TLS 1.3), matching the reference middleware.
fn apply_security_headers(mut response: Response<Body>) -> Response<Body> {
    let pairs: [(HeaderName, &'static str); 9] = [
        (header::X_FRAME_OPTIONS, "DENY"),
        (
            header::STRICT_TRANSPORT_SECURITY,
            "max-age=63072000; includeSubDomains; preload",
        ),
        // script-src is 'self': the only scripts are the vendored
        // bootstrap.bundle.min.js and the external theme switcher
        // (/static/js/theme.js) — no inline scripts, so no 'unsafe-inline'
        // and no per-script hash to maintain. style-src keeps 'unsafe-inline'
        // for Bootstrap's inline style hooks and the single inline <style>
        // block in the page layout.
        (
            header::CONTENT_SECURITY_POLICY,
            "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; \
             script-src 'self'; \
             font-src 'self' data:; connect-src 'self'; \
             frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'",
        ),
        (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
        (header::REFERRER_POLICY, "no-referrer"),
        (
            HeaderName::from_static("permissions-policy"),
            "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), \
             microphone=(), payment=(), usb=(), interest-cohort=()",
        ),
        (
            HeaderName::from_static("cross-origin-opener-policy"),
            "same-origin",
        ),
        (
            HeaderName::from_static("cross-origin-resource-policy"),
            "same-origin",
        ),
        (
            HeaderName::from_static("cross-origin-embedder-policy"),
            "require-corp",
        ),
    ];
    let headers = response.headers_mut();
    for (name, value) in pairs {
        headers.insert(name, HeaderValue::from_static(value));
    }
    // Advertise the HTTP/3 endpoint when a QUIC listener is active.
    if let Some(alt_svc) = ALT_SVC.get() {
        headers.insert(HeaderName::from_static("alt-svc"), alt_svc.clone());
    }
    response
}

// ---------------------------------------------------------------------------
// Request helpers
// ---------------------------------------------------------------------------

/// Extract a named path parameter, percent-decoded.
#[must_use]
pub fn path_param(params: &[(String, String)], name: &str) -> Option<String> {
    let raw = params
        .iter()
        .find(|(k, _)| k == name)
        .map(|(_, v)| v.as_str())?;
    Some(percent_decode_utf8(raw).unwrap_or_else(|| raw.to_owned()))
}

/// Percent-decode an ASCII string. Returns `None` when the decoded
/// bytes are not valid UTF-8 or an escape is malformed.
#[must_use]
pub fn percent_decode_utf8(s: &str) -> Option<String> {
    let bytes = s.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut iter = bytes.iter().copied();
    while let Some(b) = iter.next() {
        if b == b'%' {
            match (iter.next(), iter.next()) {
                (Some(hi), Some(lo)) => {
                    let hi = hex_digit(hi)?;
                    let lo = hex_digit(lo)?;
                    out.push(hi.checked_shl(4).unwrap_or(0) | lo);
                },
                (Some(hi), None) => {
                    out.push(b'%');
                    out.push(hi);
                },
                _ => out.push(b'%'),
            }
        } else {
            out.push(b);
        }
    }
    String::from_utf8(out).ok()
}

/// Decode a single ASCII hex digit into its nibble value.
const fn hex_digit(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b.saturating_sub(b'0')),
        b'a'..=b'f' => Some(b.saturating_sub(b'a').saturating_add(10)),
        b'A'..=b'F' => Some(b.saturating_sub(b'A').saturating_add(10)),
        _ => None,
    }
}

/// Parse query-string parameters into a deserialisable struct.
///
/// # Errors
///
/// Returns an error string when the parameters cannot be deserialised
/// into the target type `T`.
pub fn parse_query<T: serde::de::DeserializeOwned>(uri: &http::Uri) -> Result<T, String> {
    let query = uri.query().unwrap_or_default();
    let map: serde_json::Map<String, serde_json::Value> = query
        .split('&')
        .filter(|s| !s.is_empty())
        .filter_map(|pair| {
            let (k, v) = pair.split_once('=')?;
            Some((
                k.to_owned(),
                serde_json::Value::String(decode_query_value(v)),
            ))
        })
        .collect();
    serde_json::from_value(serde_json::Value::Object(map))
        .map_err(|e| format!("query parse error: {e}"))
}

/// Percent-decode one query parameter value (`+` becomes a space).
#[must_use]
pub fn decode_query_value(input: &str) -> String {
    let plus_normalised = input.replace('+', " ");
    percent_decode_utf8(&plus_normalised).unwrap_or(plus_normalised)
}

// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------

/// Build a response with a static content type.
fn response_with(status: StatusCode, content_type: &'static str, body: Bytes) -> Response<Body> {
    let mut response = Response::new(Full::new(body));
    *response.status_mut() = status;
    response
        .headers_mut()
        .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
    response
}

/// Plain-text response.
#[must_use]
pub fn text_response(status: StatusCode, body: impl Into<Bytes>) -> Response<Body> {
    response_with(status, "text/plain; charset=utf-8", body.into())
}

/// HTML response with `Cache-Control: no-store` (dynamic pages must not
/// be served stale after a rebuild; embedded `/static/` assets carry
/// their own cache policy).
#[must_use]
pub fn html_response(status: StatusCode, body: impl Into<Bytes>) -> Response<Body> {
    let mut response = response_with(status, "text/html; charset=utf-8", body.into());
    response
        .headers_mut()
        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
    response
}

/// JSON response from a pre-serialised string.
#[must_use]
pub fn json_response(status: StatusCode, body: impl Into<Bytes>) -> Response<Body> {
    response_with(status, "application/json; charset=utf-8", body.into())
}

#[cfg(test)]
mod tests {
    #![allow(clippy::missing_panics_doc)] // tests assert; panicking is the failure mode

    use super::*;

    #[test]
    fn percent_decode_happy_path() {
        assert_eq!(
            percent_decode_utf8("GC.1.1%20test").as_deref(),
            Some("GC.1.1 test")
        );
        assert_eq!(percent_decode_utf8("a%2Fb").as_deref(), Some("a/b"));
        assert_eq!(
            percent_decode_utf8("erh%C3%B6ht").as_deref(),
            Some("erhöht")
        );
    }

    #[test]
    fn percent_decode_rejects_malformed() {
        assert!(percent_decode_utf8("%ZZ").is_none());
        assert!(
            percent_decode_utf8("%Ff").is_none(),
            "lone 0xFF is not UTF-8"
        );
        assert_eq!(percent_decode_utf8("end%").as_deref(), Some("end%"));
        assert_eq!(percent_decode_utf8("end%2").as_deref(), Some("end%2"));
    }

    #[test]
    fn path_param_decodes_escapes() {
        let params = vec![("id".to_owned(), "GC.1.1".to_owned())];
        assert_eq!(path_param(&params, "id"), Some("GC.1.1".to_owned()));
        assert_eq!(path_param(&params, "missing"), None);
    }

    #[test]
    fn query_values_decode_plus_and_percent() {
        assert_eq!(decode_query_value("hello+world"), "hello world");
        assert_eq!(decode_query_value("erh%C3%B6ht"), "erhöht");
    }

    #[test]
    fn parse_query_reads_struct() {
        #[derive(serde::Deserialize)]
        struct Q {
            q: Option<String>,
        }
        let uri: http::Uri = "http://localhost/search?q=hello%20world"
            .parse()
            .expect("valid uri");
        let q: Q = parse_query(&uri).expect("query parses");
        assert_eq!(q.q.as_deref(), Some("hello world"));
    }

    #[test]
    fn security_headers_cover_required_set() {
        let response = apply_security_headers(text_response(StatusCode::OK, "ok"));
        for name in [
            "x-frame-options",
            "content-security-policy",
            "x-content-type-options",
            "strict-transport-security",
            "referrer-policy",
            "permissions-policy",
            "cross-origin-opener-policy",
            "cross-origin-resource-policy",
            "cross-origin-embedder-policy",
        ] {
            assert!(
                response.headers().contains_key(name),
                "missing security header: {name}"
            );
        }
        assert_eq!(
            response
                .headers()
                .get("x-frame-options")
                .and_then(|v| v.to_str().ok()),
            Some("DENY")
        );
    }

    #[test]
    fn csp_pins_frame_ancestors_and_object_src() {
        let response = apply_security_headers(text_response(StatusCode::OK, "ok"));
        let csp = response
            .headers()
            .get("content-security-policy")
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default();
        assert!(csp.contains("frame-ancestors 'none'"));
        assert!(csp.contains("object-src 'none'"));
        assert!(csp.contains("base-uri 'none'"));
    }

    #[test]
    fn html_response_is_no_store() {
        let response = html_response(StatusCode::OK, "<p>ok</p>");
        assert_eq!(
            response
                .headers()
                .get("cache-control")
                .and_then(|v| v.to_str().ok()),
            Some("no-store")
        );
    }
}
