//! Response-wide HTTP security headers.
//!
//! Applied as a tower layer outside every other middleware so inner handlers
//! can still override any header by setting their own value first — the
//! defaults here only fill in what's missing.

use axum::extract::Request;
use axum::http::{HeaderMap, HeaderName, HeaderValue, header};
use axum::middleware::Next;
use axum::response::Response;

use crate::csp;

pub async fn layer(req: Request, next: Next) -> Response {
    let is_https = request_is_https(&req);
    let mut response = next.run(req).await;
    apply_defaults(response.headers_mut(), is_https);
    apply_csp(response.headers_mut());
    response
}

fn apply_csp(headers: &mut HeaderMap) {
    // Report-only while we tune the policy: browsers evaluate violations and log
    // them to the console without blocking any resources. Switch back to the
    // enforcing `content-security-policy` header once the policy is clean.
    if let Ok(value) = HeaderValue::from_str(csp::policy()) {
        headers
            .entry(HeaderName::from_static(
                "content-security-policy-report-only",
            ))
            .or_insert(value);
    }
}

fn apply_defaults(headers: &mut HeaderMap, is_https: bool) {
    // Always-on security posture. Static values, no per-request logic.
    set_default(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff");
    set_default(headers, header::X_FRAME_OPTIONS, "DENY");
    set_default(
        headers,
        header::REFERRER_POLICY,
        "strict-origin-when-cross-origin",
    );
    set_default(
        headers,
        HeaderName::from_static("cross-origin-opener-policy"),
        "same-origin",
    );
    set_default(
        headers,
        HeaderName::from_static("cross-origin-resource-policy"),
        "same-origin",
    );
    set_default(
        headers,
        HeaderName::from_static("permissions-policy"),
        PERMISSIONS_POLICY,
    );
    set_default(
        headers,
        HeaderName::from_static("x-download-options"),
        "noopen",
    );
    set_default(
        headers,
        HeaderName::from_static("x-permitted-cross-domain-policies"),
        "none",
    );
    // Legacy header; current OWASP guidance is to disable the reflected-XSS
    // filter (it has known bypasses and CSP is the proper replacement).
    set_default(headers, HeaderName::from_static("x-xss-protection"), "0");

    // Conservative cache defaults. Routes that deliberately want to cache
    // (hashed static assets, public GETs) set their own Cache-Control before
    // this middleware runs. When they have, we must not also stamp the no-cache
    // pair: `Pragma: no-cache` next to a long-lived `Cache-Control: immutable`
    // is contradictory, and browsers resolve it by revalidating on every load.
    // Since these assets carry no ETag/Last-Modified, that revalidation
    // degrades into a full re-download each time. Apply the no-store/no-cache
    // defaults only to responses that haven't opted into caching; a present
    // Cache-Control is the signal that the handler chose its own policy.
    if !headers.contains_key(header::CACHE_CONTROL) {
        headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
        headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
    }
    set_default(headers, header::VARY, "Accept-Encoding");

    // HSTS is a no-op over plain HTTP per RFC 6797, but only emit it on
    // connections we can actually verify came in over TLS — direct HTTPS or
    // a reverse proxy that honored X-Forwarded-Proto. Prevents a misconfigured
    // proxy from accidentally shipping an HSTS header for a host that isn't
    // actually HTTPS-terminated.
    if is_https {
        set_default(
            headers,
            HeaderName::from_static("strict-transport-security"),
            "max-age=63072000; includeSubDomains",
        );
    }
}

const PERMISSIONS_POLICY: &str = "\
accelerometer=(), \
autoplay=(), \
camera=(), \
display-capture=(), \
encrypted-media=(), \
fullscreen=(), \
geolocation=(), \
gyroscope=(), \
magnetometer=(), \
microphone=(), \
midi=(), \
payment=(), \
picture-in-picture=(), \
publickey-credentials-get=(), \
screen-wake-lock=(), \
usb=(), \
web-share=(), \
xr-spatial-tracking=()\
";

fn set_default(headers: &mut HeaderMap, name: HeaderName, value: &'static str) {
    if !headers.contains_key(&name) {
        headers.insert(name, HeaderValue::from_static(value));
    }
}

fn request_is_https(req: &Request) -> bool {
    if let Some(proto) = req
        .headers()
        .get("x-forwarded-proto")
        .and_then(|v| v.to_str().ok())
    {
        // X-Forwarded-Proto may be a comma-separated list if the request went
        // through multiple proxies; the leftmost value reflects the origin.
        let first = proto.split(',').next().unwrap_or(proto).trim();
        if first.eq_ignore_ascii_case("https") {
            return true;
        }
    }
    req.uri().scheme_str() == Some("https")
}

#[cfg(test)]
mod tests {
    use axum::body::Body;
    use axum::http::{Request as HttpRequest, Response};

    use super::*;

    fn req(uri: &str, extra_headers: &[(&str, &str)]) -> Request {
        let mut builder = HttpRequest::builder().uri(uri).method("GET");
        for (k, v) in extra_headers {
            builder = builder.header(*k, *v);
        }
        builder.body(Body::empty()).unwrap()
    }

    fn headers_after(req: &Request, seeded: &[(&str, &str)]) -> HeaderMap {
        let is_https = request_is_https(req);
        let mut response: Response<Body> = Response::new(Body::empty());
        for (k, v) in seeded {
            response.headers_mut().insert(
                HeaderName::from_bytes(k.as_bytes()).unwrap(),
                HeaderValue::from_str(v).unwrap(),
            );
        }
        apply_defaults(response.headers_mut(), is_https);
        response.into_parts().0.headers
    }

    #[test]
    fn core_headers_are_applied() {
        let headers = headers_after(&req("/", &[]), &[]);
        assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
        assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
        assert_eq!(
            headers.get("referrer-policy").unwrap(),
            "strict-origin-when-cross-origin"
        );
        assert_eq!(
            headers.get("cross-origin-opener-policy").unwrap(),
            "same-origin"
        );
        assert_eq!(
            headers.get("cross-origin-resource-policy").unwrap(),
            "same-origin"
        );
        assert!(headers.contains_key("permissions-policy"));
        assert_eq!(headers.get("x-download-options").unwrap(), "noopen");
        assert_eq!(
            headers.get("x-permitted-cross-domain-policies").unwrap(),
            "none"
        );
        assert_eq!(headers.get("x-xss-protection").unwrap(), "0");
        assert_eq!(headers.get("cache-control").unwrap(), "no-store");
        assert_eq!(headers.get("pragma").unwrap(), "no-cache");
        assert_eq!(headers.get("vary").unwrap(), "Accept-Encoding");
    }

    #[test]
    fn csp_is_report_only_not_enforced() {
        let mut headers = HeaderMap::new();
        apply_csp(&mut headers);
        assert!(headers.contains_key("content-security-policy-report-only"));
        assert!(!headers.contains_key("content-security-policy"));
    }

    #[test]
    fn existing_cache_control_is_not_overridden() {
        // Static assets set their own cache-control with long immutability.
        // The middleware default must not clobber it, and must not stamp a
        // contradictory `Pragma: no-cache` alongside it — that combination
        // forces browsers to revalidate (and, absent validators, re-download)
        // supposedly-immutable assets on every load.
        let headers = headers_after(&req("/assets/app-abc.js", &[]), &[(
            "cache-control",
            "public, max-age=31536000, immutable",
        )]);
        assert_eq!(
            headers.get("cache-control").unwrap(),
            "public, max-age=31536000, immutable"
        );
        assert!(
            !headers.contains_key("pragma"),
            "cacheable responses must not carry Pragma: no-cache"
        );
    }

    #[test]
    fn hsts_is_added_when_x_forwarded_proto_is_https() {
        let headers = headers_after(&req("/", &[("x-forwarded-proto", "https")]), &[]);
        assert_eq!(
            headers.get("strict-transport-security").unwrap(),
            "max-age=63072000; includeSubDomains"
        );
    }

    #[test]
    fn hsts_is_skipped_on_plain_http() {
        let headers = headers_after(&req("/", &[]), &[]);
        assert!(!headers.contains_key("strict-transport-security"));

        let headers = headers_after(&req("/", &[("x-forwarded-proto", "http")]), &[]);
        assert!(!headers.contains_key("strict-transport-security"));
    }

    #[test]
    fn hsts_reads_leftmost_value_of_chained_x_forwarded_proto() {
        // When a request flows through multiple proxies, the leftmost value
        // represents the original client → edge connection.
        let headers = headers_after(&req("/", &[("x-forwarded-proto", "https, http")]), &[]);
        assert!(headers.contains_key("strict-transport-security"));

        let headers = headers_after(&req("/", &[("x-forwarded-proto", "http, https")]), &[]);
        assert!(!headers.contains_key("strict-transport-security"));
    }
}
