mod common;

use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, Instant};

use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use serde_json::json;
use tower::util::ServiceExt;

use doctate_common::API_KEY_HEADER;
use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink};
use doctate_server::{
    AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
};

use common::{TestConfig, body_json, test_user};

const API_KEY: &str = "key-dr_a";

fn create_request(body: &str, api_key: Option<&str>) -> Request<Body> {
    let mut b = Request::builder()
        .method("POST")
        .uri("/api/auth/magic-link")
        .header(header::CONTENT_TYPE, "application/json");
    if let Some(k) = api_key {
        b = b.header(API_KEY_HEADER, k);
    }
    b.body(Body::from(body.to_owned())).unwrap()
}

fn consume_request(token: &str) -> Request<Body> {
    Request::builder()
        .method("GET")
        .uri(common::paths::magic(token))
        .body(Body::empty())
        .unwrap()
}

/// Most tests only need a router. Only the expiry test keeps a handle
/// to the `MagicLinkStore` so it can insert a pre-expired token.
fn test_app() -> axum::Router {
    let cfg = TestConfig::new()
        .with_label("magic")
        .with_user(test_user("dr_a"))
        .build();
    doctate_server::create_router(cfg)
}

/// For the expiry test: build the full `AppState` so we retain a
/// reference to `magic_link_store` after the router is constructed.
fn build_state_with_stores() -> AppState {
    let cfg = TestConfig::new()
        .with_label("magic")
        .with_user(test_user("dr_a"))
        .build();
    let (transcribe_tx, _transcribe_rx) = transcribe::channel();
    let (analyze_tx, _analyze_rx) = analyze::channel();
    AppState {
        config: cfg,
        settings: Arc::new(doctate_server::settings::Settings::default()),
        transcribe_tx,
        analyze_tx,
        session_store: web_session::new_store(),
        magic_link_store: doctate_server::magic_link::new_store(),
        analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
        transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
        oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
        oneliner_locks: doctate_server::oneliner_locks::OnelinerLocks::new(),
        events_tx: doctate_server::events::channel(),
        http_client: reqwest::Client::new(),
        vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
    }
}

#[tokio::test]
async fn create_without_api_key_returns_401() {
    let app = test_app();
    let resp = app.oneshot(create_request("{}", None)).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn create_with_valid_key_returns_token() {
    let app = test_app();
    let resp = app
        .oneshot(create_request(
            r#"{"return_to":"/web/cases/abc"}"#,
            Some(API_KEY),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let body = body_json(resp).await;
    let token = body["token"].as_str().expect("token field");
    assert!(token.len() >= 30, "token looks short: {token}");
}

#[tokio::test]
async fn consume_valid_token_sets_cookie_and_redirects() {
    let app = test_app();

    // Step 1: issue token via API.
    let create_resp = app
        .clone()
        .oneshot(create_request(
            r#"{"return_to":"/web/cases/abc"}"#,
            Some(API_KEY),
        ))
        .await
        .unwrap();
    let token = body_json(create_resp).await["token"]
        .as_str()
        .unwrap()
        .to_owned();

    // Step 2: consume.
    let resp = app.oneshot(consume_request(&token)).await.unwrap();
    assert_eq!(resp.status(), StatusCode::SEE_OTHER);
    assert_eq!(
        resp.headers()
            .get(header::LOCATION)
            .unwrap()
            .to_str()
            .unwrap(),
        "/web/cases/abc"
    );
    assert_eq!(
        resp.headers()
            .get(header::REFERRER_POLICY)
            .unwrap()
            .to_str()
            .unwrap(),
        "no-referrer"
    );

    // Cookie must be set with HttpOnly and SameSite=Strict.
    let cookie = resp
        .headers()
        .get(header::SET_COOKIE)
        .expect("session cookie missing")
        .to_str()
        .unwrap();
    assert!(cookie.starts_with("session="));
    assert!(cookie.contains("HttpOnly"));
    assert!(cookie.contains("SameSite=Strict"));
}

#[tokio::test]
async fn consume_token_is_one_time_use() {
    let app = test_app();

    let token = body_json(
        app.clone()
            .oneshot(create_request("{}", Some(API_KEY)))
            .await
            .unwrap(),
    )
    .await["token"]
        .as_str()
        .unwrap()
        .to_owned();

    let first = app.clone().oneshot(consume_request(&token)).await.unwrap();
    assert_eq!(first.status(), StatusCode::SEE_OTHER);
    assert_eq!(
        first.headers().get(header::LOCATION).unwrap(),
        "/web/cases" // default
    );

    let second = app.oneshot(consume_request(&token)).await.unwrap();
    // Second use: token gone, redirect to login.
    assert_eq!(second.status(), StatusCode::SEE_OTHER);
    assert_eq!(
        second.headers().get(header::LOCATION).unwrap(),
        "/web/login"
    );
}

#[tokio::test]
async fn create_rejects_open_redirect_return_to() {
    let app = test_app();

    for evil in [
        "https://evil.com/",
        "//evil.com/",
        "/api/upload",
        "javascript:alert(1)",
    ] {
        let body = json!({ "return_to": evil }).to_string();
        let resp = app
            .clone()
            .oneshot(create_request(&body, Some(API_KEY)))
            .await
            .unwrap();
        assert_eq!(
            resp.status(),
            StatusCode::BAD_REQUEST,
            "should reject return_to={evil}"
        );
    }
}

#[tokio::test]
async fn consume_expired_token_redirects_to_login() {
    let state = build_state_with_stores();
    let store: MagicLinkStore = state.magic_link_store.clone();

    // Insert a token that expired 1s ago — bypassing the API to control time.
    let token = "test-token-expired".to_owned();
    {
        let mut w = store.write().await;
        w.insert(
            token.clone(),
            PendingMagicLink {
                slug: "dr_a".into(),
                role: "doctor".into(),
                return_to: "/web/cases".into(),
                expires_at: Instant::now() - Duration::from_secs(1),
            },
        );
    }

    let app = doctate_server::create_router_with_state(state);
    let resp = app.oneshot(consume_request(&token)).await.unwrap();
    assert_eq!(resp.status(), StatusCode::SEE_OTHER);
    assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/login");

    // And the token must be removed from the store, even though it expired.
    assert!(store.read().await.get(&token).is_none());
}
