mod common;

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
use doctate_server::transcribe::recovery::scan_and_enqueue;
use doctate_server::transcribe::whisper::{WhisperError, transcribe};
use serde_json::json;
use wiremock::matchers::{body_partial_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

use common::{TestConfig, test_user};

fn fixture(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name)
}

#[tokio::test]
async fn ffmpeg_remux_produces_valid_output() {
    let input = fixture("sample.m4a");
    assert!(input.exists(), "fixture missing: {}", input.display());

    let output = remux_faststart(&input).await.expect("remux failed");

    let meta = std::fs::metadata(output.path()).expect("output missing");
    assert!(meta.len() > 0, "output file is empty");

    // Sanity check: ffprobe should still recognize it as an m4a/mp4 container.
    let probe = std::process::Command::new("ffprobe")
        .arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-show_entries")
        .arg("format=format_name")
        .arg("-of")
        .arg("default=nw=1:nk=1")
        .arg(output.path())
        .output()
        .expect("ffprobe spawn failed");
    assert!(probe.status.success(), "ffprobe failed");
    let fmt = String::from_utf8_lossy(&probe.stdout);
    assert!(
        fmt.contains("mp4") || fmt.contains("m4a"),
        "unexpected format: {fmt}"
    );
}

#[tokio::test]
async fn ffmpeg_remux_fails_on_missing_input() {
    let input = PathBuf::from("/nonexistent/does-not-exist.m4a");
    let result = remux_faststart(&input).await;
    assert!(result.is_err(), "expected error for missing input");
}

#[tokio::test]
async fn whisper_client_posts_multipart_and_returns_text() {
    let server = MockServer::start().await;
    let expected = "Hallo Welt, das ist ein Test.";

    Mock::given(method("POST"))
        .and(path("/asr"))
        .and(query_param("output", "txt"))
        .and(query_param("language", "de"))
        .respond_with(ResponseTemplate::new(200).set_body_string(expected))
        .expect(1)
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let text = transcribe(
        &client,
        &server.uri(),
        &fixture("sample.m4a"),
        Duration::from_secs(10),
        None,
    )
    .await
    .expect("transcribe failed");

    assert_eq!(text, expected);
}

#[tokio::test]
async fn whisper_client_returns_status_error_on_500() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/asr"))
        .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let err = transcribe(
        &client,
        &server.uri(),
        &fixture("sample.m4a"),
        Duration::from_secs(10),
        None,
    )
    .await
    .expect_err("expected error");

    match err {
        WhisperError::Status { status, body } => {
            assert_eq!(status, 500);
            assert_eq!(body, "boom");
        }
        other => panic!("unexpected error variant: {other:?}"),
    }
}

#[tokio::test]
async fn whisper_client_times_out() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/asr"))
        .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let err = transcribe(
        &client,
        &server.uri(),
        &fixture("sample.m4a"),
        Duration::from_millis(100),
        None,
    )
    .await
    .expect_err("expected timeout");

    assert!(
        matches!(err, WhisperError::Http(_)),
        "expected Http error, got {err:?}"
    );
}

#[tokio::test]
async fn whisper_client_forwards_explicit_language() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/asr"))
        .and(query_param("language", "en"))
        .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
        .expect(1)
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    transcribe(
        &client,
        &server.uri(),
        &fixture("sample.m4a"),
        Duration::from_secs(10),
        Some("en"),
    )
    .await
    .expect("transcribe failed");
}

#[tokio::test]
async fn recovery_enqueues_only_pending_recordings() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // User 1, case with two .m4a — one pending, one already transcribed.
    let case_a = root.join("dr_a/aaaa");
    std::fs::create_dir_all(&case_a).unwrap();
    std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap();
    std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap();
    common::seed_recording_meta(
        &case_a,
        "2026-04-11T10-00-00Z",
        common::Transcript::Content {
            text: "done".into(),
        },
        None,
    );

    // User 2, case with one pending .m4a.
    let case_b = root.join("dr_b/bbbb");
    std::fs::create_dir_all(&case_b).unwrap();
    std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();

    let (tx, mut rx) = transcribe::channel();
    scan_and_enqueue(root, &tx).await;
    drop(tx); // close channel so the loop below terminates

    let mut received = Vec::new();
    while let Some(job) = rx.recv().await {
        received.push(job.audio_path);
    }

    assert_eq!(received.len(), 2, "got: {received:?}");
    // Oldest first:
    assert!(received[0].ends_with("2026-04-10T10-00-00Z.m4a"));
    assert!(received[1].ends_with("2026-04-12T10-00-00Z.m4a"));
}

// -------------------- Worker: failure marker --------------------

fn config_with_whisper(
    whisper_url: String,
) -> (
    std::sync::Arc<doctate_server::config::Config>,
    std::sync::Arc<doctate_server::settings::Settings>,
) {
    // Ollama URL is irrelevant — worker never reaches it in the failure path.
    // Port 1 is effectively unreachable, matching the historical intent.
    TestConfig::new()
        .with_label("transcribe")
        .with_user(test_user("dr_test"))
        .with_whisper(whisper_url, 5)
        .with_ollama("http://127.0.0.1:1")
        .build_pair()
}

/// Permanent Whisper errors (4xx other than 408/429) must rename `.m4a` to
/// `.m4a.failed` so recovery treats the recording as terminal. HTTP 400 is
/// the archetypal "the payload itself is wrong" response — retrying the
/// same bytes will hit the same wall, so the file is marked and only a
/// manual admin reset un-fails it.
#[tokio::test]
async fn worker_renames_audio_to_failed_on_permanent_whisper_error() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/asr"))
        .respond_with(ResponseTemplate::new(400).set_body_string("bad payload"))
        .mount(&server)
        .await;

    // Real case dir with a real m4a fixture so ffmpeg remux succeeds.
    let tmp = tempfile::tempdir().unwrap();
    let case_dir = tmp.path().join("dr_test/aaaa");
    std::fs::create_dir_all(&case_dir).unwrap();
    let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
    std::fs::copy(fixture("sample.m4a"), &audio).unwrap();

    let (config, settings) = config_with_whisper(server.uri());
    let (tx, rx) = transcribe::channel();
    tx.send(transcribe::TranscribeJob {
        audio_path: audio.clone(),
        user_slug: "dr_test".into(),
    })
    .await
    .unwrap();
    drop(tx); // close channel so the worker loop exits after processing the job

    let client = reqwest::Client::new();
    let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
    let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
    transcribe::worker::run(
        rx,
        config,
        settings,
        client,
        busy,
        vocab,
        doctate_server::events::channel(),
        doctate_server::oneliner_locks::OnelinerLocks::new(),
    )
    .await;

    // Audio must have been renamed so recovery skips it next time.
    assert!(!audio.exists(), "original .m4a still present");
    let failed = case_dir.join("2026-04-13T10-30-00Z.m4a.failed");
    assert!(failed.exists(), "expected {} to exist", failed.display());
}

/// Transient Whisper errors (5xx / 408 / 429 / network) must NOT rename
/// the audio. The file stays as plain `.m4a` so the page-load heal
/// (`enqueue_pending_for_user`) picks it up automatically on the next
/// refresh — which is what happens when Minerva recovers.
#[tokio::test]
async fn worker_leaves_m4a_intact_on_transient_whisper_error() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/asr"))
        .respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let case_dir = tmp.path().join("dr_test/bbbb");
    std::fs::create_dir_all(&case_dir).unwrap();
    let audio = case_dir.join("2026-04-13T10-31-00Z.m4a");
    std::fs::copy(fixture("sample.m4a"), &audio).unwrap();

    let (config, settings) = config_with_whisper(server.uri());
    let (tx, rx) = transcribe::channel();
    tx.send(transcribe::TranscribeJob {
        audio_path: audio.clone(),
        user_slug: "dr_test".into(),
    })
    .await
    .unwrap();
    drop(tx);

    let client = reqwest::Client::new();
    let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
    let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
    transcribe::worker::run(
        rx,
        config,
        settings,
        client,
        busy,
        vocab,
        doctate_server::events::channel(),
        doctate_server::oneliner_locks::OnelinerLocks::new(),
    )
    .await;

    // Original .m4a must still be there; .failed must NOT exist.
    assert!(audio.exists(), "transient error must not consume the .m4a");
    let failed = case_dir.join("2026-04-13T10-31-00Z.m4a.failed");
    assert!(
        !failed.exists(),
        "transient error must not mark {} as failed",
        failed.display()
    );
    // And no metadata sidecar yet either (the 503 produced no text).
    let meta = case_dir.join("2026-04-13T10-31-00Z.json");
    assert!(!meta.exists(), "no recording meta expected on 503");
}

/// The worker must route the Whisper response through the Gazetteer
/// before persisting. Mock returns the drift form "Zerebrum"; the
/// persisted `<stem>.json` must carry the canonical "Cerebrum".
#[tokio::test]
async fn transcribe_worker_normalizes_whisper_output() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/asr"))
        .respond_with(
            ResponseTemplate::new(200).set_body_string("Patient mit Blutung im Zerebrum."),
        )
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let case_dir = tmp.path().join("dr_test/ffff");
    std::fs::create_dir_all(&case_dir).unwrap();
    let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
    std::fs::copy(fixture("sample.m4a"), &audio).unwrap();

    let vocab_dir = tempfile::tempdir().unwrap();
    std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").unwrap();
    let vocab = Arc::new(
        doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path())
            .await
            .unwrap(),
    );
    assert_eq!(vocab.len(), 1);

    let (config, settings) = config_with_whisper(server.uri());
    let (tx, rx) = transcribe::channel();
    tx.send(transcribe::TranscribeJob {
        audio_path: audio.clone(),
        user_slug: "dr_test".into(),
    })
    .await
    .unwrap();
    drop(tx);

    let client = reqwest::Client::new();
    let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
    transcribe::worker::run(
        rx,
        config,
        settings,
        client,
        busy,
        vocab,
        doctate_server::events::channel(),
        doctate_server::oneliner_locks::OnelinerLocks::new(),
    )
    .await;

    let meta_path = case_dir.join("2026-04-13T10-30-00Z.json");
    assert!(meta_path.exists(), "recording meta missing");
    let bytes = std::fs::read(&meta_path).unwrap();
    let meta: doctate_common::RecordingMeta = serde_json::from_slice(&bytes).unwrap();
    match meta.transcript {
        doctate_common::Transcript::Content { text } => {
            assert_eq!(text, "Patient mit Blutung im Cerebrum.");
        }
        other => panic!("expected Transcript::Content, got {other:?}"),
    }
}

// -------------------- Ollama client --------------------

const MODEL: &str = "gemma3:4b";

fn ok_chat_body(content: &str) -> serde_json::Value {
    json!({
        "model": MODEL,
        "message": {"role": "assistant", "content": content},
        "done": true
    })
}

#[tokio::test]
async fn ollama_oneliner_parses_chat_response() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")),
        )
        .expect(1)
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let out = generate_oneliner(
        &client,
        &server.uri(),
        MODEL,
        0,
        "irrelevant",
        Duration::from_secs(5),
    )
    .await
    .expect("call failed");

    assert_eq!(out, "HOCM mit Septum-Hypertrophie");
}

#[tokio::test]
async fn ollama_oneliner_normalizes_response() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body(
            "\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges",
        )))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let out = generate_oneliner(
        &client,
        &server.uri(),
        MODEL,
        0,
        "x",
        Duration::from_secs(5),
    )
    .await
    .unwrap();
    assert_eq!(out, "Kniegelenk re., V.a. Meniskus");
}

#[tokio::test]
async fn ollama_oneliner_returns_status_on_500() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let err = generate_oneliner(
        &client,
        &server.uri(),
        MODEL,
        0,
        "x",
        Duration::from_secs(5),
    )
    .await
    .expect_err("expected error");
    match err {
        OllamaError::Status { status, body } => {
            assert_eq!(status, 500);
            assert_eq!(body, "boom");
        }
        other => panic!("unexpected: {other:?}"),
    }
}

#[tokio::test]
async fn ollama_oneliner_sends_keep_alive_and_model() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .and(body_partial_json(json!({
            "model": MODEL,
            "stream": false,
            "keep_alive": 0
        })))
        .respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("OK")))
        .expect(1)
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let _ = generate_oneliner(
        &client,
        &server.uri(),
        MODEL,
        0,
        "irrelevant",
        Duration::from_secs(5),
    )
    .await
    .expect("call failed");
    // Mock::expect(1) + server drop verifies the matcher hit exactly once.
}

#[tokio::test]
async fn ollama_oneliner_empty_response_when_model_obeys_silence_rule() {
    // Model followed the silence rule for a keyword-less transcript and
    // returned empty content. That's a valid outcome, not a parse error.
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("   \n  ")))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let err = generate_oneliner(
        &client,
        &server.uri(),
        MODEL,
        0,
        "äh, Moment",
        Duration::from_secs(5),
    )
    .await
    .expect_err("expected EmptyResponse");
    assert!(matches!(err, OllamaError::EmptyResponse), "got {err:?}");
}

#[tokio::test]
async fn ollama_oneliner_parse_error_on_missing_content() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/chat"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"done": true})))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let err = generate_oneliner(
        &client,
        &server.uri(),
        MODEL,
        0,
        "x",
        Duration::from_secs(5),
    )
    .await
    .expect_err("expected parse error");
    assert!(matches!(err, OllamaError::Parse(_)), "got {err:?}");
}
