//! Full-pipeline runner: audio → ffmpeg → whisper → gazetteer → LLM.
//!
//! Reproduces the production pipeline 1:1 by reusing the `doctate-server`
//! crate's modules. Outputs go strictly under `<case-dir>/runs/<run-id>/`.
//!
//! Usage:
//!   run_full_case \
//!     --case-dir case_c414cf52 \
//!     --llm-prompt prompts/llm/baseline.txt \
//!     --runs 3
//!
//! Path conventions:
//!   - `--case-dir` is relative to the experiments/ working directory
//!   - `--llm-prompt` is a path relative to `--case-dir`
//!   - `--settings` defaults to `../server/settings.toml`
//!   - `--vocab-dir` defaults to `../server/vocab`

use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use clap::Parser;
use doctate_experiments::{
    GazetteerDictMode, RecordingMeta, RunMeta, RunTimings, atomic_write, load_prompt, run_id,
};
use doctate_server::analyze::llm::{self as srv_llm, LlmSettings as LlmCallSettings};
use doctate_server::analyze::prompt::render_prompt;
use doctate_server::analyze::{AnalysisInput, RecordingInput};
use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::settings::Settings;
use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::whisper as srv_whisper;
use tracing::{info, warn};

#[derive(Parser, Debug)]
#[command(name = "run_full_case")]
struct Args {
    /// Directory containing `audios/`, `prompts/`, `runs/` for one case.
    #[arg(long)]
    case_dir: PathBuf,

    /// Path to the LLM system prompt file. Typically under `prompts/llm/`.
    #[arg(long)]
    llm_prompt: PathBuf,

    /// Number of runs to perform with the same variants. 2–3 recommended
    /// to surface model drift.
    #[arg(long, default_value_t = 1)]
    runs: u32,

    /// Settings file (URL, model, API key for LLM; URL, timeout for Whisper).
    #[arg(long, default_value = "../server/settings.toml")]
    settings: PathBuf,

    /// Vocab directory for the gazetteer.
    #[arg(long, default_value = "../server/vocab")]
    vocab_dir: PathBuf,

    /// Hunspell stem (e.g. `/usr/share/hunspell/de_DE`). Optional; if the
    /// files don't exist, gazetteer runs without dict-veto.
    #[arg(long, default_value = "/usr/share/hunspell/de_DE")]
    hunspell_stem: PathBuf,

    /// Skip the LLM stage. Useful for measuring pure Whisper-prompt
    /// effects without burning Ionos tokens.
    #[arg(long, default_value_t = false)]
    no_llm: bool,
}

#[tokio::main]
async fn main() -> Result<()> {
    init_tracing();
    let args = Args::parse();

    let case_dir = args
        .case_dir
        .canonicalize()
        .with_context(|| format!("case-dir not found: {}", args.case_dir.display()))?;

    let settings_path = args.settings.to_string_lossy();
    let settings = Settings::load_or_default(&settings_path);
    if !settings.llm_configured() && !args.no_llm {
        anyhow::bail!(
            "LLM not configured in {settings_path}. Set [llm].url and [llm].model, or pass --no-llm."
        );
    }

    let llm_system_prompt = load_prompt(&args.llm_prompt)?;

    let (gazetteer, dict_mode) = load_gazetteer(&args.vocab_dir, &args.hunspell_stem).await?;
    info!(
        vocab_dir = %args.vocab_dir.display(),
        loaded = gazetteer.len(),
        "gazetteer ready"
    );

    let audios = collect_audios(&case_dir.join("audios"))?;
    if audios.is_empty() {
        anyhow::bail!("no .m4a files found under {}/audios", case_dir.display());
    }
    info!(count = audios.len(), "audios discovered");

    let http = reqwest::Client::builder()
        .build()
        .context("building reqwest client")?;

    let llm_variant_id = stem(&args.llm_prompt);

    let runs_root = data_runs_root(&case_dir)?;

    for run_index in 1..=args.runs {
        let id = run_id(&llm_variant_id, run_index);
        let run_dir = runs_root.join(&id);
        std::fs::create_dir_all(run_dir.join("transcripts"))?;

        info!(run = %id, "begin run");
        let wallclock = Instant::now();
        let mut timings = RunTimings::default();
        let mut recordings_meta = Vec::new();
        let mut analysis_recordings = Vec::new();

        for audio in &audios {
            let recorded_at = stem(audio);
            let t_whisper = Instant::now();
            let raw = transcribe_one(&http, &settings, Some("de"), audio)
                .await
                .with_context(|| format!("transcribing {}", audio.display()))?;
            timings.whisper_total_ms += t_whisper.elapsed().as_millis() as u64;

            let t_gaz = Instant::now();
            let cleaned = gazetteer.replace(&raw);
            timings.gazetteer_total_ms += t_gaz.elapsed().as_millis() as u64;

            let transcript_path = run_dir
                .join("transcripts")
                .join(format!("{recorded_at}.txt"));
            atomic_write(&transcript_path, &cleaned)?;

            recordings_meta.push(RecordingMeta {
                recorded_at: recorded_at.clone(),
                audio_path: audio.display().to_string(),
                whisper_chars_raw: raw.chars().count(),
                whisper_chars_after_gazetteer: cleaned.chars().count(),
            });
            analysis_recordings.push(RecordingInput {
                recorded_at,
                text: cleaned,
            });
        }

        let last_recording_mtime = audios
            .last()
            .map(|p| stem(p))
            .unwrap_or_else(|| "unknown".to_string());
        let analysis_input = AnalysisInput {
            last_recording_mtime,
            recordings: analysis_recordings,
        };
        let user_content = render_prompt(&analysis_input);
        atomic_write(&run_dir.join("llm_input.txt"), &user_content)?;

        if !args.no_llm {
            let t_llm = Instant::now();
            let document_raw =
                call_llm(&http, &settings, &llm_system_prompt, &user_content).await?;
            timings.llm_ms = t_llm.elapsed().as_millis() as u64;
            // Post-LLM gazetteer pass — mirrors `server/src/analyze/worker.rs:150`.
            // Catches LLM-drift back to non-canonical forms (e.g. anglicization)
            // that the pre-LLM pass cannot reach because the drift happens
            // inside the LLM itself.
            let document = gazetteer.replace(&document_raw);
            atomic_write(&run_dir.join("document.md"), &document)?;
        }

        timings.wallclock_ms = wallclock.elapsed().as_millis() as u64;

        let meta = RunMeta {
            run_id: id.clone(),
            created_at_utc: time::OffsetDateTime::now_utc()
                .format(&time::format_description::well_known::Iso8601::DEFAULT)
                .unwrap_or_default(),
            case: case_dir
                .file_name()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or_default(),
            llm_variant: llm_variant_id.clone(),
            whisper_url: settings.whisper.url.clone(),
            llm_url: settings.llm.url.clone(),
            llm_model: settings.llm.model.clone(),
            llm_temperature: settings.llm.temperature,
            vocab_dir: args.vocab_dir.display().to_string(),
            vocab_loaded: gazetteer.len(),
            gazetteer_dict: dict_mode_clone(&dict_mode),
            timings_ms: timings,
            recordings: recordings_meta,
        };
        let meta_json = serde_json::to_string_pretty(&meta)?;
        atomic_write(&run_dir.join("meta.json"), &meta_json)?;

        info!(run = %id, wallclock_ms = meta.timings_ms.wallclock_ms, "run done");
    }

    Ok(())
}

fn init_tracing() {
    use tracing_subscriber::EnvFilter;
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
    tracing_subscriber::fmt().with_env_filter(filter).init();
}

async fn load_gazetteer(
    vocab_dir: &Path,
    hunspell_stem: &Path,
) -> Result<(Gazetteer, GazetteerDictMode)> {
    let g = Gazetteer::load_dir(vocab_dir)
        .await
        .with_context(|| format!("loading vocab dir {}", vocab_dir.display()))?;
    let aff = hunspell_stem.with_extension("aff");
    let dic = hunspell_stem.with_extension("dic");
    if aff.exists() && dic.exists() {
        match SpellbookDict::load(hunspell_stem) {
            Ok(d) => {
                let g = g.with_dict(Box::new(d));
                Ok((
                    g,
                    GazetteerDictMode::SpellbookHunspell {
                        stem: hunspell_stem.display().to_string(),
                    },
                ))
            }
            Err(e) => {
                warn!(
                    err = %e,
                    "spellbook dict load failed; gazetteer running without dict-veto"
                );
                Ok((g, GazetteerDictMode::NoDict))
            }
        }
    } else {
        warn!(
            stem = %hunspell_stem.display(),
            "hunspell .aff/.dic not found; gazetteer running without dict-veto"
        );
        Ok((g, GazetteerDictMode::NoDict))
    }
}

fn dict_mode_clone(m: &GazetteerDictMode) -> GazetteerDictMode {
    match m {
        GazetteerDictMode::NoDict => GazetteerDictMode::NoDict,
        GazetteerDictMode::SpellbookHunspell { stem } => {
            GazetteerDictMode::SpellbookHunspell { stem: stem.clone() }
        }
    }
}

/// Resolve the per-case output root under `experiments/_data/<case_name>/runs/`.
/// Inputs (audios/, prompts/, reference/) live under `case_<id>/` and are
/// versioned; outputs go to `_data/` and are gitignored.
fn data_runs_root(case_dir: &Path) -> Result<PathBuf> {
    let case_name = case_dir
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| anyhow::anyhow!("case-dir has no file name"))?;
    let experiments_root = case_dir
        .parent()
        .ok_or_else(|| anyhow::anyhow!("case-dir has no parent"))?;
    let runs = experiments_root.join("_data").join(case_name).join("runs");
    std::fs::create_dir_all(&runs)?;
    Ok(runs)
}

fn collect_audios(dir: &Path) -> Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) == Some("m4a") {
            out.push(path);
        }
    }
    out.sort();
    Ok(out)
}

fn stem<P: AsRef<Path>>(p: P) -> String {
    p.as_ref()
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "unknown".to_string())
}

async fn transcribe_one(
    http: &reqwest::Client,
    settings: &Settings,
    language: Option<&str>,
    audio: &Path,
) -> Result<String> {
    let remuxed = remux_faststart(audio)
        .await
        .with_context(|| format!("ffmpeg remux failed for {}", audio.display()))?;
    let timeout = Duration::from_secs(settings.whisper.timeout_seconds);
    let text = srv_whisper::transcribe(
        http,
        &settings.whisper.url,
        remuxed.path(),
        timeout,
        language,
    )
    .await
    .map_err(|e| anyhow::anyhow!("whisper transcribe failed: {e}"))?;
    Ok(text)
}

async fn call_llm(
    http: &reqwest::Client,
    settings: &Settings,
    system_prompt: &str,
    user_content: &str,
) -> Result<String> {
    let call = LlmCallSettings {
        base_url: &settings.llm.url,
        api_key: &settings.llm.api_key,
        model: &settings.llm.model,
        temperature: settings.llm.temperature,
    };
    let timeout = Duration::from_secs(settings.llm.timeout_seconds);
    let out = srv_llm::chat_once(http, &call, system_prompt, user_content, timeout)
        .await
        .map_err(|e| anyhow::anyhow!("llm chat failed: {e}"))?;
    Ok(out)
}
