use std::path::PathBuf;

use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_config::ServerSettingsBuilder;
use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
use fabro_server::server::RouterOptions;
use fabro_server::test_support::{
    TEST_DEV_TOKEN, TEST_SESSION_SECRET, test_app_state,
    test_app_state_with_runtime_settings_and_options,
};
use tower::ServiceExt;

use crate::helpers::{
    checked_response, response_json, response_status, response_text, settings_from_toml,
};

fn dev_token_enabled_auth_mode() -> AuthMode {
    let resolved = ServerSettingsBuilder::from_toml(
        r#"
_version = 1

[server.auth]
methods = ["dev-token"]
"#,
    )
    .expect("settings should resolve")
    .server;
    resolve_auth_mode_with_lookup(&resolved, |name| match name {
        "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()),
        "FABRO_DEV_TOKEN" => Some(TEST_DEV_TOKEN.to_string()),
        _ => None,
    })
    .expect("auth mode should resolve")
}

fn spa_fixture_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}

#[tokio::test]
async fn old_unversioned_routes_return_404() {
    let app = fabro_server::test_support::build_test_router(test_app_state());

    let cases = [(Method::POST, "/completions")];

    for (method, path) in cases {
        let req = Request::builder()
            .method(method.clone())
            .uri(path)
            .body(Body::empty())
            .unwrap();
        let response = app.clone().oneshot(req).await.unwrap();
        response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await;
    }
}

#[tokio::test]
async fn root_and_health_stay_at_root() {
    let app = fabro_server::test_support::build_test_router_with_options(
        test_app_state(),
        RouterOptions {
            static_asset_root: Some(spa_fixture_root()),
            ..RouterOptions::default()
        },
    );

    let root_req = Request::builder()
        .method("GET")
        .uri("/")
        .body(Body::empty())
        .unwrap();
    let root_response = app.clone().oneshot(root_req).await.unwrap();
    let root_html = response_text(root_response, StatusCode::OK, "GET /").await;
    assert!(root_html.contains("<div id=\"root\"></div>"));

    let health_req = Request::builder()
        .method("GET")
        .uri("/health")
        .body(Body::empty())
        .unwrap();
    let health_response = app.oneshot(health_req).await.unwrap();
    let health_body = response_json(health_response, StatusCode::OK, "GET /health").await;
    assert_eq!(health_body["status"], "ok");
    assert!(
        health_body.get("version").is_none(),
        "health endpoint should not expose version"
    );
}

#[tokio::test]
async fn install_routes_are_absent_in_normal_mode() {
    let app = fabro_server::test_support::build_test_router(test_app_state());

    let response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install")
                .header("accept", "text/html,application/xhtml+xml")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(response, StatusCode::NOT_FOUND, "GET /install").await;
}

#[tokio::test]
async fn api_v1_root_is_not_routed() {
    let app = fabro_server::test_support::build_test_router(test_app_state());

    let req = Request::builder()
        .method("GET")
        .uri("/api/v1/")
        .body(Body::empty())
        .unwrap();
    let response = app.oneshot(req).await.unwrap();
    response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/").await;
}

#[tokio::test]
async fn health_responds_at_versioned_path() {
    let app = fabro_server::test_support::build_test_router(test_app_state());

    let req = Request::builder()
        .method("GET")
        .uri("/api/v1/health")
        .body(Body::empty())
        .unwrap();
    let response = app.oneshot(req).await.unwrap();
    let body = response_json(response, StatusCode::OK, "GET /api/v1/health").await;
    assert_eq!(body["status"], "ok");
}

#[tokio::test]
async fn source_maps_are_not_served() {
    let app = fabro_server::test_support::build_test_router(test_app_state());

    let request = Request::builder()
        .method("GET")
        .uri("/assets/entry-abc123.js.map")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    response_status(
        response,
        StatusCode::NOT_FOUND,
        "GET /assets/entry-abc123.js.map",
    )
    .await;
}

#[tokio::test]
async fn web_enabled_serves_web_only_routes() {
    let auth_mode = dev_token_enabled_auth_mode();
    let app = fabro_server::server::build_router_with_options(
        test_app_state(),
        &auth_mode,
        RouterOptions {
            static_asset_root: Some(spa_fixture_root()),
            ..RouterOptions::default()
        },
    );

    let auth_me_request = Request::builder()
        .method("GET")
        .uri("/api/v1/auth/me")
        .body(Body::empty())
        .unwrap();
    let auth_me_response = app.clone().oneshot(auth_me_request).await.unwrap();
    response_status(
        auth_me_response,
        StatusCode::UNAUTHORIZED,
        "GET /api/v1/auth/me",
    )
    .await;

    // Browser-style navigation to an SPA route falls back to index.html.
    let setup_request = Request::builder()
        .method("GET")
        .uri("/setup")
        .header("accept", "text/html,application/xhtml+xml")
        .body(Body::empty())
        .unwrap();
    let setup_response = app.clone().oneshot(setup_request).await.unwrap();
    response_status(setup_response, StatusCode::OK, "GET /setup").await;

    // Same path without `Accept: text/html` (e.g. curl, fetch default) is
    // not a browser navigation and must not get the SPA HTML fallback.
    let setup_no_accept = Request::builder()
        .method("GET")
        .uri("/setup")
        .body(Body::empty())
        .unwrap();
    let setup_no_accept_response = app.clone().oneshot(setup_no_accept).await.unwrap();
    response_status(
        setup_no_accept_response,
        StatusCode::NOT_FOUND,
        "GET /setup",
    )
    .await;

    let setup_status_request = Request::builder()
        .method("GET")
        .uri("/api/v1/setup/status")
        .body(Body::empty())
        .unwrap();
    let setup_status_response = app.clone().oneshot(setup_status_request).await.unwrap();
    response_status(
        setup_status_response,
        StatusCode::NOT_FOUND,
        "GET /api/v1/setup/status",
    )
    .await;

    let setup_complete_request = Request::builder()
        .method("GET")
        .uri("/setup/complete")
        .body(Body::empty())
        .unwrap();
    let setup_complete_response = app.clone().oneshot(setup_complete_request).await.unwrap();
    response_status(
        setup_complete_response,
        StatusCode::NOT_FOUND,
        "GET /setup/complete",
    )
    .await;

    // Unregistered /api/* paths must always 404, even for browser-style
    // `Accept: text/html` requests — the SPA fallback never applies to
    // /api/. Guards against API typos silently rendering the UI shell.
    let api_miss = Request::builder()
        .method("GET")
        .uri("/api/v2/nonexistent")
        .header("accept", "text/html")
        .body(Body::empty())
        .unwrap();
    let api_miss_response = app.oneshot(api_miss).await.unwrap();
    response_status(
        api_miss_response,
        StatusCode::NOT_FOUND,
        "GET /api/v2/nonexistent",
    )
    .await;
}

#[tokio::test]
async fn security_headers_are_applied_to_all_responses() {
    let app = fabro_server::test_support::build_test_router_with_options(
        test_app_state(),
        RouterOptions {
            static_asset_root: Some(spa_fixture_root()),
            ..RouterOptions::default()
        },
    );

    // Plain HTTP: HSTS must NOT be present.
    let api_response = checked_response(
        app.clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/health")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap(),
        StatusCode::OK,
        "GET /health",
    )
    .await;
    let headers = api_response.headers();
    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!(headers.contains_key("permissions-policy"));
    assert_eq!(headers.get("x-xss-protection").unwrap(), "0");
    assert_eq!(headers.get("pragma").unwrap(), "no-cache");
    assert!(
        !headers.contains_key("strict-transport-security"),
        "HSTS must not be emitted over plain HTTP"
    );

    // X-Forwarded-Proto: https signals the request reached an HTTPS edge.
    let https_response = checked_response(
        app.clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/health")
                    .header("x-forwarded-proto", "https")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap(),
        StatusCode::OK,
        "GET /health with x-forwarded-proto=https",
    )
    .await;
    assert_eq!(
        https_response
            .headers()
            .get("strict-transport-security")
            .unwrap(),
        "max-age=63072000; includeSubDomains"
    );

    // SPA fallback path must also get the headers.
    let spa_response = checked_response(
        app.oneshot(
            Request::builder()
                .method("GET")
                .uri("/runs/abc123")
                .header("accept", "text/html")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap(),
        StatusCode::OK,
        "GET /runs/abc123",
    )
    .await;
    assert_eq!(
        spa_response.headers().get("x-frame-options").unwrap(),
        "DENY"
    );
    // Static files set their own cache-control (no-cache for index.html);
    // the middleware default must not stomp on it.
    assert_eq!(
        spa_response.headers().get("cache-control").unwrap(),
        "no-cache"
    );

    // CSP must cover the sources the embedded SPA actually loads: same-origin
    // scripts, Google Fonts, WASM instantiation (viz-js), data: and blob:
    // images, blob: workers, and terminal WebSockets.
    // Inline hashes are optional because the current SPA ships only
    // external module scripts.
    let csp = spa_response
        .headers()
        .get("content-security-policy-report-only")
        .expect("CSP report-only header should be emitted")
        .to_str()
        .expect("CSP should be ASCII");
    assert!(
        !spa_response
            .headers()
            .contains_key("content-security-policy"),
        "CSP should be report-only while tuning, not enforced"
    );
    assert!(csp.contains("default-src 'self'"), "got: {csp}");
    assert!(csp.contains("script-src 'self'"), "got: {csp}");
    assert!(csp.contains("'wasm-unsafe-eval'"), "got: {csp}");
    assert!(
        csp.contains("style-src 'self' https://fonts.googleapis.com 'unsafe-inline'"),
        "got: {csp}"
    );
    assert!(
        csp.contains("font-src 'self' https://fonts.gstatic.com"),
        "got: {csp}"
    );
    assert!(
        csp.contains("img-src 'self' data: blob: https://avatars.githubusercontent.com"),
        "got: {csp}"
    );
    assert!(csp.contains("connect-src 'self' ws: wss:"), "got: {csp}");
    assert!(csp.contains("worker-src 'self' blob:"), "got: {csp}");
    assert!(csp.contains("frame-ancestors 'none'"), "got: {csp}");
    assert!(csp.contains("object-src 'none'"), "got: {csp}");
}

#[tokio::test]
async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() {
    let settings = settings_from_toml(
        r"
_version = 1

[server.web]
enabled = false
",
    );
    let app = fabro_server::test_support::build_test_router_with_options(
        test_app_state_with_runtime_settings_and_options(
            settings.server_settings,
            settings.manifest_run_defaults,
            5,
        ),
        RouterOptions {
            web_enabled: false,
            ..RouterOptions::default()
        },
    );

    for (method, path, body) in [
        ("GET", "/", Body::empty()),
        ("GET", "/setup", Body::empty()),
        ("GET", "/runs/abc", Body::empty()),
        ("GET", "/auth/login/github", Body::empty()),
        ("GET", "/api/v1/auth/me", Body::empty()),
        ("GET", "/api/v1/setup/status", Body::empty()),
    ] {
        let request = Request::builder()
            .method(method)
            .uri(path)
            .header("content-type", "application/json")
            .body(body)
            .unwrap();

        let response = app.clone().oneshot(request).await.unwrap();
        response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await;
    }

    let settings_request = Request::builder()
        .method("GET")
        .uri("/api/v1/settings")
        .body(Body::empty())
        .unwrap();
    let settings_response = app.clone().oneshot(settings_request).await.unwrap();
    response_status(settings_response, StatusCode::OK, "GET /api/v1/settings").await;

    let health_request = Request::builder()
        .method("GET")
        .uri("/health")
        .body(Body::empty())
        .unwrap();
    let health_response = app.oneshot(health_request).await.unwrap();
    response_status(health_response, StatusCode::OK, "GET /health").await;
}

#[tokio::test]
async fn web_disabled_ignores_demo_header_dispatch() {
    let settings = settings_from_toml(
        r"
_version = 1

[server.web]
enabled = false
",
    );
    let app = fabro_server::test_support::build_test_router_with_options(
        test_app_state_with_runtime_settings_and_options(
            settings.server_settings,
            settings.manifest_run_defaults,
            5,
        ),
        RouterOptions {
            web_enabled: false,
            ..RouterOptions::default()
        },
    );
    let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";

    let request = Request::builder()
        .method("GET")
        .uri(format!("/api/v1/runs/{run_id}"))
        .header("X-Fabro-Demo", "1")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/runs/{id}").await;
}
