#![expect(
    clippy::disallowed_methods,
    reason = "These browser-debug integration tests synchronously probe for Chrome binaries before launching external tooling."
)]

mod common;

use std::process::Stdio;

use serde_json::json;
use tokio::net::TcpListener;
use tokio::process::Command as TokioCommand;
use tokio::time::{Duration, sleep};
use twin_openai::config::Config;

#[tokio::test]
async fn debug_html_page_serves_valid_html_on_empty_state() {
    let server = common::spawn_server().await.expect("server should start");

    let response = server
        .client
        .get(format!("{}/__debug", server.base_url))
        .send()
        .await
        .expect("debug page request should complete");

    assert_eq!(response.status(), 200);
    let content_type = response
        .headers()
        .get("content-type")
        .expect("content-type header should be present")
        .to_str()
        .expect("content-type should be valid string");
    assert!(
        content_type.contains("text/html"),
        "content-type should contain text/html, got: {content_type}"
    );

    let body = response.text().await.expect("body should read");
    assert!(
        body.contains("<!DOCTYPE html>"),
        "response should contain DOCTYPE"
    );
    assert!(
        body.contains("twin-openai"),
        "response should contain project name"
    );
    assert!(body.contains("debug"), "response should contain 'debug'");
    assert!(
        body.contains("no active namespaces"),
        "empty state should show 'no active namespaces'"
    );
}

#[tokio::test]
async fn debug_json_endpoint_returns_correct_state_snapshot() {
    let server = common::spawn_server().await.expect("server should start");

    // Load two scenarios: one success, one error
    server
        .enqueue_scenarios(json!({
            "scenarios": [
                {
                    "matcher": { "endpoint": "responses", "model": "gpt-test" },
                    "script": { "kind": "success" }
                },
                {
                    "matcher": { "endpoint": "responses", "model": "gpt-error" },
                    "script": {
                        "kind": "error",
                        "status": 500,
                        "message": "test error",
                        "error_type": "server_error",
                        "code": "server_error"
                    }
                }
            ]
        }))
        .await;

    // Make one request that consumes the first (success) scenario
    let response = server
        .post_responses(json!({
            "model": "gpt-test",
            "input": "hello debug",
            "stream": false
        }))
        .await;
    assert_eq!(response.status(), 200);

    // GET the debug JSON endpoint (unauthenticated)
    let response = server
        .client
        .get(format!("{}/__debug/state.json", server.base_url))
        .send()
        .await
        .expect("debug json request should complete");

    assert_eq!(response.status(), 200);
    let content_type = response
        .headers()
        .get("content-type")
        .expect("content-type header should be present")
        .to_str()
        .expect("content-type should be valid string");
    assert!(
        content_type.contains("application/json"),
        "content-type should contain application/json, got: {content_type}"
    );

    let body: serde_json::Value = response.json().await.expect("json should parse");

    // Should have a top-level namespaces array
    let namespaces = body["namespaces"]
        .as_array()
        .expect("namespaces should be an array");
    assert_eq!(namespaces.len(), 1, "should have exactly one namespace");

    let ns = &namespaces[0];
    assert!(
        ns["key"].as_str().unwrap().starts_with("Bearer:"),
        "namespace key should start with 'Bearer:', got: {}",
        ns["key"]
    );

    // Should have 1 remaining scenario (the error one; the success was consumed)
    let scenarios = ns["scenarios"]
        .as_array()
        .expect("scenarios should be an array");
    assert_eq!(scenarios.len(), 1, "should have 1 remaining scenario");
    assert_eq!(scenarios[0]["endpoint"], "responses");
    assert_eq!(scenarios[0]["model"], "gpt-error");
    assert_eq!(scenarios[0]["script_kind"], "error");

    // Should have 1 request log
    let request_logs = ns["request_logs"]
        .as_array()
        .expect("request_logs should be an array");
    assert_eq!(request_logs.len(), 1, "should have 1 request log");
    assert_eq!(request_logs[0]["endpoint"], "responses");
    assert_eq!(request_logs[0]["model"], "gpt-test");
    assert!(
        request_logs[0]["input_text"]
            .as_str()
            .unwrap()
            .contains("hello debug"),
        "request log should contain input text 'hello debug'"
    );
}

#[tokio::test]
async fn debug_html_page_reflects_loaded_scenarios_and_request_logs() {
    let server = common::spawn_server().await.expect("server should start");

    // Load one success scenario
    server
        .enqueue_scenarios(json!({
            "scenarios": [
                {
                    "matcher": { "endpoint": "responses", "model": "gpt-html-test" },
                    "script": { "kind": "success" }
                }
            ]
        }))
        .await;

    // Make one request with a different model (won't match, but still gets logged
    // via the default behavior)
    let response = server
        .post_responses(json!({
            "model": "gpt-other",
            "input": "check the page",
            "stream": false
        }))
        .await;
    // The request gets a deterministic response (no matching scenario consumed
    // since model doesn't match). Status should be 200 (default behavior).
    assert_eq!(response.status(), 200);

    // GET the debug HTML page
    let response = server
        .client
        .get(format!("{}/__debug", server.base_url))
        .send()
        .await
        .expect("debug page request should complete");

    assert_eq!(response.status(), 200);
    let body = response.text().await.expect("body should read");

    // Should contain the scenario's model name
    assert!(
        body.contains("gpt-html-test"),
        "HTML should contain scenario model 'gpt-html-test'"
    );
    // Should contain the script kind
    assert!(
        body.contains("success"),
        "HTML should contain script kind 'success'"
    );
    // Should contain the request log model
    assert!(
        body.contains("gpt-other"),
        "HTML should contain request log model 'gpt-other'"
    );
    // Should contain the request log input text
    assert!(
        body.contains("check the page"),
        "HTML should contain request log input text 'check the page'"
    );
    // Verify the server-rendered content section does not show empty state.
    // The JS source always includes the "no active namespaces" string as a
    // template, so we check that the server-rendered content div contains
    // namespace sections rather than the empty-state paragraph.
    assert!(
        body.contains("namespace-header"),
        "HTML should contain a namespace-header element (proving non-empty rendering)"
    );
}

#[tokio::test]
async fn debug_routes_not_accessible_when_admin_disabled() {
    let listener = TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind should succeed");
    let addr = listener.local_addr().expect("should have addr");
    let app = twin_openai::build_app_with_config(Config {
        bind_addr:    "127.0.0.1:0".parse().expect("valid addr"),
        require_auth: false,
        enable_admin: false,
    });

    tokio::spawn(async move {
        axum::serve(listener, app).await.expect("server should run");
    });

    let base_url = format!("http://{addr}");
    let client = common::test_http_client().expect("test client");

    let html_response = client
        .get(format!("{base_url}/__debug"))
        .send()
        .await
        .expect("request should complete");
    assert_eq!(
        html_response.status(),
        404,
        "debug HTML should be 404 when admin disabled"
    );

    let json_response = client
        .get(format!("{base_url}/__debug/state.json"))
        .send()
        .await
        .expect("request should complete");
    assert_eq!(
        json_response.status(),
        404,
        "debug JSON should be 404 when admin disabled"
    );
}

#[tokio::test]
async fn debug_page_renders_in_headless_chrome() {
    // Find Chrome binary
    let chrome_binary = ["chromium", "google-chrome", "chromium-browser"]
        .iter()
        .find(|name| {
            std::process::Command::new("which")
                .arg(name)
                .output()
                .is_ok_and(|o| o.status.success())
        });

    let Some(chrome_binary) = chrome_binary.copied() else {
        return;
    };

    let server = common::spawn_server().await.expect("server should start");

    // Load a scenario and make a request so the page has content
    server
        .enqueue_scenarios(json!({
            "scenarios": [
                {
                    "matcher": { "endpoint": "responses", "model": "gpt-screenshot" },
                    "script": { "kind": "success" }
                }
            ]
        }))
        .await;

    let response = server
        .post_responses(json!({
            "model": "gpt-screenshot",
            "input": "screenshot test",
            "stream": false
        }))
        .await;
    assert_eq!(response.status(), 200);

    let screenshot_path = format!(
        "/tmp/twin-openai-debug-screenshot-{}.png",
        std::process::id()
    );
    let mut command = TokioCommand::new(chrome_binary);
    command.args([
        "--headless",
        "--disable-gpu",
        &format!("--screenshot={screenshot_path}"),
        "--window-size=1280,900",
    ]);
    if cfg!(target_os = "linux") {
        // Ubuntu 24.04 GitHub runners block Chrome's default sandbox unless it
        // is launched with a compatible user namespace or disabled explicitly.
        command.arg("--no-sandbox");
    }
    let mut child = command
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        // Static mode keeps the page visually identical for the screenshot
        // while avoiding a live refresh loop that can stall headless Chrome
        // on Linux CI.
        .arg(format!("{}/__debug?refresh=0", server.base_url))
        .spawn()
        .expect("Chrome should start");
    let mut screenshot_data = None;
    for _ in 0..200 {
        if let Ok(data) = std::fs::read(&screenshot_path) {
            if data.len() >= 10_000 {
                screenshot_data = Some(data);
                break;
            }
        }

        if let Some(status) = child
            .try_wait()
            .expect("Chrome status check should succeed")
        {
            panic!("Chrome exited before writing screenshot, status: {status}");
        }

        sleep(Duration::from_millis(100)).await;
    }

    let screenshot_data = screenshot_data.expect("Chrome should write a screenshot within 20s");
    if child
        .try_wait()
        .expect("Chrome status check should succeed")
        .is_none()
    {
        child.start_kill().expect("Chrome should be killable");
        let _ = child.wait().await;
    }

    assert!(
        screenshot_data.len() >= 10_000,
        "screenshot should be at least 10KB, got {} bytes",
        screenshot_data.len()
    );
    // Check PNG magic bytes
    assert_eq!(
        &screenshot_data[..8],
        b"\x89PNG\r\n\x1a\n",
        "screenshot should be a valid PNG"
    );

    // Clean up
    let _ = std::fs::remove_file(&screenshot_path);
}

#[tokio::test]
async fn debug_html_escapes_user_controlled_values() {
    let server = common::spawn_server().await.expect("server should start");

    // Load a scenario with an XSS attempt in the model name
    server
        .enqueue_scenarios(json!({
            "scenarios": [
                {
                    "matcher": {
                        "endpoint": "responses",
                        "model": "<script>alert('xss')</script>"
                    },
                    "script": { "kind": "success" }
                }
            ]
        }))
        .await;

    let response = server
        .client
        .get(format!("{}/__debug", server.base_url))
        .send()
        .await
        .expect("debug page request should complete");

    assert_eq!(response.status(), 200);
    let body = response.text().await.expect("body should read");

    // Should contain the escaped form
    assert!(
        body.contains("&lt;script&gt;"),
        "HTML should contain escaped '<script>' as '&lt;script&gt;'"
    );
    // Should NOT contain the raw injection
    assert!(
        !body.contains("<script>alert"),
        "HTML should NOT contain raw unescaped '<script>alert'"
    );
}
