//! Integration tests for the manual oneliner override:
//! `PUT /api/cases/{case_id}/oneliner`.
//!
//! Covers:
//! - Status codes for happy path / auth / validation errors.
//! - The two correctness invariants of the override:
//!     1. Early-latch: once a `Manual` state exists on disk, the
//!        worker's `update_oneliner` skips the LLM call entirely.
//!     2. Re-check-under-lock: a manual PUT that arrives while an
//!        auto-regen is in flight wins — the post-LLM result is
//!        dropped because the per-case mutex serialises the
//!        read-modify-write of `oneliner.json`.
//! - Mutex-serialisation: parallel PUTs on the same case produce a
//!   well-formed final state, never a torn write.

mod common;

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

use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use serde_json::json;
use tower::util::ServiceExt;
use uuid::Uuid;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use doctate_common::API_KEY_HEADER;
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
use doctate_server::oneliner_locks::OnelinerLocks;
use doctate_server::{
    AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
};

use common::{
    TestConfig, body_json, csrf_form_post, form_post, login, login_with_csrf, paths, seed_case,
    seed_oneliner_state, test_user, write_closed_marker,
};

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

fn put_request(case_id: &str, body: &str, api_key: Option<&str>) -> Request<Body> {
    let mut b = Request::builder()
        .method("PUT")
        .uri(format!("/api/cases/{case_id}/oneliner"))
        .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()
}

/// Build a full `AppState` with the given config; returns both the
/// state (for direct access to `oneliner_locks`, `events_tx`, etc.)
/// and a router that wraps it.
fn build_app(cfg: Arc<doctate_server::config::Config>) -> AppState {
    build_app_with_events_tx(cfg, doctate_server::events::channel())
}

/// Variant of [`build_app`] that lets the caller pre-create the events
/// channel — needed when a test wants to subscribe BEFORE the request
/// fires, since the broadcast channel only retains messages for live
/// subscribers.
fn build_app_with_events_tx(
    cfg: Arc<doctate_server::config::Config>,
    events_tx: doctate_server::events::EventSender,
) -> AppState {
    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: OnelinerLocks::new(),
        events_tx,
        http_client: reqwest::Client::new(),
        vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
    }
}

// =====================================================================
// 1. Happy path
// =====================================================================

#[tokio::test]
async fn put_writes_manual_state_to_disk() {
    let cfg = TestConfig::new()
        .with_label("oneliner-override-ok")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);

    let state = build_app(cfg);
    let app = doctate_server::create_router_with_state(state);

    let resp = app
        .oneshot(put_request(
            &case_id,
            r#"{"text":"55 J., Knieschmerz li."}"#,
            Some(API_KEY),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let body = body_json(resp).await;
    assert_eq!(body["kind"], "manual");
    assert_eq!(body["text"], "55 J., Knieschmerz li.");
    assert!(body["set_at"].as_str().is_some_and(|s| !s.is_empty()));

    // On-disk state must match.
    let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
        .await
        .expect("oneliner.json missing");
    let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
    match on_disk {
        OnelinerState::Manual { text, .. } => {
            assert_eq!(text, "55 J., Knieschmerz li.");
        }
        other => panic!("expected Manual, got {other:?}"),
    }
}

// =====================================================================
// 2-5. Error paths
// =====================================================================

#[tokio::test]
async fn put_unknown_case_returns_404() {
    let cfg = TestConfig::new()
        .with_label("oneliner-override-404")
        .with_user(test_user(SLUG))
        .build();
    let unknown_case = Uuid::new_v4().to_string();
    let app = doctate_server::create_router_with_state(build_app(cfg));

    let resp = app
        .oneshot(put_request(&unknown_case, r#"{"text":"x"}"#, Some(API_KEY)))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn put_without_api_key_returns_401() {
    let cfg = TestConfig::new()
        .with_label("oneliner-override-401")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);
    let app = doctate_server::create_router_with_state(build_app(cfg));

    let resp = app
        .oneshot(put_request(&case_id, r#"{"text":"x"}"#, None))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn put_empty_text_returns_400() {
    let cfg = TestConfig::new()
        .with_label("oneliner-override-empty")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);
    let app = doctate_server::create_router_with_state(build_app(cfg));

    // Whitespace-only also collapses to empty after trim.
    let resp = app
        .oneshot(put_request(
            &case_id,
            r#"{"text":"   \t\n"}"#,
            Some(API_KEY),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn put_too_long_text_returns_400() {
    let cfg = TestConfig::new()
        .with_label("oneliner-override-long")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);
    let app = doctate_server::create_router_with_state(build_app(cfg));

    let big = "a".repeat(501);
    let body = json!({ "text": big }).to_string();
    let resp = app
        .oneshot(put_request(&case_id, &body, Some(API_KEY)))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// =====================================================================
// 6. Early-latch: pre-existing Manual blocks LLM call
// =====================================================================

#[tokio::test]
async fn early_latch_skips_llm_call_when_manual_set() {
    // Mock-Ollama with hit counter. After the manual-latched run we
    // expect zero invocations.
    let mock = MockServer::start().await;
    let hits = Arc::new(AtomicUsize::new(0));
    let hits_for_mock = hits.clone();
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(move |_req: &wiremock::Request| {
            hits_for_mock.fetch_add(1, Ordering::Relaxed);
            ResponseTemplate::new(200).set_body_json(json!({
                "message": {"content": "auto-generated"}
            }))
        })
        .mount(&mock)
        .await;

    let (cfg, settings) = TestConfig::new()
        .with_label("oneliner-override-early-latch")
        .with_user(test_user(SLUG))
        .with_ollama(mock.uri())
        .build_pair();
    let case_id = Uuid::new_v4().to_string();
    let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);

    // Pre-seed a Manual state directly on disk, simulating an earlier PUT.
    seed_oneliner_state(
        &case_dir,
        &OnelinerState::Manual {
            text: "manual-text".into(),
            set_at: "2026-04-26T10:00:00Z".into(),
        },
    );
    // Also seed a transcript so update_oneliner has content to pass to LLM
    // (would call without latch).
    std::fs::write(case_dir.join("2026-04-26T11-00-00Z.m4a"), b"audio-bytes").unwrap();
    common::seed_recording_meta(
        &case_dir,
        "2026-04-26T11-00-00Z",
        common::Transcript::Content {
            text: "Patient hat Fieber.".into(),
        },
        None,
    );

    let locks = OnelinerLocks::new();
    let events_tx = doctate_server::events::channel();
    let vocab = doctate_server::gazetteer::Gazetteer::empty();

    transcribe::worker::update_oneliner(
        &case_dir,
        SLUG,
        &reqwest::Client::new(),
        &settings,
        &vocab,
        &events_tx,
        &locks,
    )
    .await;

    assert_eq!(
        hits.load(Ordering::Relaxed),
        0,
        "Manual override must skip LLM entirely"
    );

    // Disk still has the manual state — untouched.
    let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
        .await
        .unwrap();
    let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
    assert!(matches!(on_disk, OnelinerState::Manual { .. }));
}

// =====================================================================
// 7. Race: manual PUT during in-flight LLM regen wins
// =====================================================================

#[tokio::test]
async fn re_check_drops_llm_result_when_manual_appears_during_regen() {
    // Mock-Ollama with artificial delay so the test window is wide
    // enough to inject a PUT before the LLM result lands.
    let mock = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(json!({
                    "message": {"content": "auto-from-llm"}
                }))
                .set_delay(Duration::from_millis(500)),
        )
        .mount(&mock)
        .await;

    let (cfg, settings) = TestConfig::new()
        .with_label("oneliner-override-race")
        .with_user(test_user(SLUG))
        .with_ollama(mock.uri())
        .build_pair();
    let case_id = Uuid::new_v4().to_string();
    let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);

    // Seed transcript so update_oneliner enters the LLM branch.
    std::fs::write(case_dir.join("2026-04-26T11-00-00Z.m4a"), b"audio-bytes").unwrap();
    common::seed_recording_meta(
        &case_dir,
        "2026-04-26T11-00-00Z",
        common::Transcript::Content {
            text: "Patient klagt über Schwindel.".into(),
        },
        None,
    );

    let locks = OnelinerLocks::new();
    let events_tx = doctate_server::events::channel();

    // Spawn the regen — it will wait ~500ms inside Mock-Ollama before
    // reaching the re-check-under-lock branch.
    let regen_settings = settings.clone();
    let regen_locks = locks.clone();
    let regen_events = events_tx.clone();
    let regen_dir = case_dir.clone();
    let regen = tokio::spawn(async move {
        let vocab = doctate_server::gazetteer::Gazetteer::empty();
        transcribe::worker::update_oneliner(
            &regen_dir,
            SLUG,
            &reqwest::Client::new(),
            &regen_settings,
            &vocab,
            &regen_events,
            &regen_locks,
        )
        .await;
    });

    // Give the regen a moment to enter the Ollama call, then write
    // the manual override directly through the same lock — emulates
    // the PUT handler.
    tokio::time::sleep(Duration::from_millis(150)).await;
    {
        let _guard = locks.lock_for(&case_dir).await;
        let manual = OnelinerState::Manual {
            text: "doctor-wins".into(),
            set_at: "2026-04-26T11:01:00Z".into(),
        };
        doctate_server::paths::write_oneliner_state(&case_dir, &manual)
            .await
            .unwrap();
    }

    regen.await.unwrap();

    // Final state must be the manual one — the auto result was dropped.
    let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
        .await
        .unwrap();
    let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
    match on_disk {
        OnelinerState::Manual { text, .. } => assert_eq!(text, "doctor-wins"),
        other => panic!("manual must win race, got {other:?}"),
    }
}

// =====================================================================
// 8. Mutex serialises parallel PUTs
// =====================================================================

#[tokio::test]
async fn parallel_puts_serialize_via_mutex() {
    let cfg = TestConfig::new()
        .with_label("oneliner-override-parallel")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);

    let app = doctate_server::create_router_with_state(build_app(cfg));

    // Two concurrent PUTs on the same case.
    let (r1, r2) = tokio::join!(
        app.clone()
            .oneshot(put_request(&case_id, r#"{"text":"first"}"#, Some(API_KEY),)),
        app.oneshot(put_request(&case_id, r#"{"text":"second"}"#, Some(API_KEY),)),
    );

    assert_eq!(r1.unwrap().status(), StatusCode::OK);
    assert_eq!(r2.unwrap().status(), StatusCode::OK);

    // Final on-disk state must be valid JSON of the Manual variant
    // and contain exactly one of the two texts (no torn write, no
    // mixed content).
    let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
        .await
        .unwrap();
    let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
    match on_disk {
        OnelinerState::Manual { text, .. } => {
            assert!(
                text == "first" || text == "second",
                "expected one of the two texts, got {text:?}"
            );
        }
        other => panic!("expected Manual, got {other:?}"),
    }
}

// =====================================================================
// 9-15. Web-UI endpoint: POST /web/cases/{case_id}/oneliner
//
// Same disk semantics as the API PUT (both call `apply_manual_override`),
// but with browser auth: session cookie + CSRF form field. The closed-
// case test pins down the only intentional behavioural divergence —
// the web handler rejects closed cases via `locate_case_or_404`,
// whereas the API endpoint accepts them.
// =====================================================================

#[tokio::test]
async fn web_put_oneliner_writes_manual_state() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-ok")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);

    let state = build_app(cfg);
    let store = state.session_store.clone();
    let app = doctate_server::create_router_with_state(state);

    let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
    let resp = app
        .oneshot(csrf_form_post(
            paths::case_oneliner_web(&case_id),
            &cookie,
            &csrf,
            "text=42+J.%2C+R%C3%BCckenschmerz",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let body = body_json(resp).await;
    assert_eq!(body["kind"], "manual");
    assert_eq!(body["text"], "42 J., Rückenschmerz");

    let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
        .await
        .expect("oneliner.json missing");
    let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
    assert!(
        matches!(on_disk, OnelinerState::Manual { ref text, .. } if text == "42 J., Rückenschmerz")
    );
}

#[tokio::test]
async fn web_put_oneliner_without_csrf_returns_403() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-no-csrf")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);
    let app = doctate_server::create_router_with_state(build_app(cfg));

    let cookie = login(&app, SLUG, "s").await;
    let resp = app
        .oneshot(form_post(
            paths::case_oneliner_web(&case_id),
            &cookie,
            "text=x",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn web_put_oneliner_without_session_redirects_to_login() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-no-session")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);
    let app = doctate_server::create_router_with_state(build_app(cfg));

    // No Cookie header at all.
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(paths::case_oneliner_web(&case_id))
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
                .body(Body::from("text=x&csrf_token=any"))
                .unwrap(),
        )
        .await
        .unwrap();
    // The CsrfForm extractor maps a missing session to a 302 redirect
    // to /web/login (`AppError::Redirect`). Browsers follow it as a
    // GET, which is exactly what we want — the user lands on the login
    // page instead of staring at a 401.
    assert_eq!(resp.status(), StatusCode::FOUND);
    let loc = resp
        .headers()
        .get(header::LOCATION)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    assert_eq!(loc, "/web/login");
}

#[tokio::test]
async fn web_put_oneliner_empty_text_returns_400() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-empty")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);
    let state = build_app(cfg);
    let store = state.session_store.clone();
    let app = doctate_server::create_router_with_state(state);

    let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
    let resp = app
        .oneshot(csrf_form_post(
            paths::case_oneliner_web(&case_id),
            &cookie,
            &csrf,
            "text=%20%20%20",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn web_put_oneliner_unknown_case_returns_404() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-unknown")
        .with_user(test_user(SLUG))
        .build();
    let unknown_case = Uuid::new_v4().to_string();
    let state = build_app(cfg);
    let store = state.session_store.clone();
    let app = doctate_server::create_router_with_state(state);

    let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
    let resp = app
        .oneshot(csrf_form_post(
            paths::case_oneliner_web(&unknown_case),
            &cookie,
            &csrf,
            "text=x",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn web_put_oneliner_on_closed_case_returns_404() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-closed")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
    write_closed_marker(&case_dir, "2026-04-25T10:00:00Z");

    let state = build_app(cfg);
    let store = state.session_store.clone();
    let app = doctate_server::create_router_with_state(state);

    let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
    let resp = app
        .oneshot(csrf_form_post(
            paths::case_oneliner_web(&case_id),
            &cookie,
            &csrf,
            "text=x",
        ))
        .await
        .unwrap();
    // `locate_case_or_404` rejects closed cases — intentional asymmetry
    // with the X-API-Key endpoint, which would have accepted it.
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn web_put_oneliner_emits_event() {
    let cfg = TestConfig::new()
        .with_label("oneliner-web-event")
        .with_user(test_user(SLUG))
        .build();
    let case_id = Uuid::new_v4().to_string();
    seed_case(&cfg.data_path, SLUG, &case_id);

    // Pre-create the broadcast channel so we can subscribe BEFORE the
    // request fires — broadcast channels do not buffer for absent
    // subscribers, so a subscribe-after-emit would miss the event.
    let events_tx = doctate_server::events::channel();
    let mut events_rx = events_tx.subscribe();

    let state = build_app_with_events_tx(cfg, events_tx);
    let store = state.session_store.clone();
    let app = doctate_server::create_router_with_state(state);

    let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
    let resp = app
        .oneshot(csrf_form_post(
            paths::case_oneliner_web(&case_id),
            &cookie,
            &csrf,
            "text=hello",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let evt = tokio::time::timeout(Duration::from_secs(1), events_rx.recv())
        .await
        .expect("no event within 1s")
        .expect("event channel closed");
    assert_eq!(evt.user_slug, SLUG);
    assert_eq!(evt.case_id, case_id);
    assert!(matches!(
        evt.kind,
        doctate_server::events::CaseEventKind::OnelinerUpdated
    ));
}
