use std::path::{Path, PathBuf};

use doctate_common::RECORDING_META_SUFFIX;
use doctate_common::oneliners::OnelinerState;
use tracing::{info, warn};

use super::{TranscribeJob, TranscribeSender};
use crate::events::EventSender;
use crate::gazetteer::Gazetteer;
use crate::oneliner_locks::OnelinerLocks;
use crate::paths;
use crate::settings::Settings;

/// Walk `data_path/*/` and enqueue every recording pending transcription for
/// every user. Intended for startup; the same primitive backs the per-user
/// self-heal triggered by web handlers.
pub async fn scan_and_enqueue(data_path: &Path, tx: &TranscribeSender) {
    let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
        return;
    };
    // Collect + sort by slug so enqueue order is deterministic across
    // users (within a user, the primitive sorts by timestamp).
    let mut user_dirs: Vec<(String, std::path::PathBuf)> = Vec::new();
    while let Ok(Some(user_entry)) = users.next_entry().await {
        if let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) {
            user_dirs.push((slug, user_entry.path()));
        }
    }
    user_dirs.sort_by(|a, b| a.0.cmp(&b.0));

    let mut total = 0;
    for (slug, path) in user_dirs {
        total += enqueue_pending_for_user(&path, &slug, tx).await;
    }
    info!(count = total, "Transcribe recovery scan finished");
}

/// Scan a single `<user_root>/<case>/*.m4a` set and enqueue every recording
/// whose metadata sidecar (`<stem>.json`) is missing. Returns the number
/// sent. Channel send is awaited (bounded channel); only fails if the
/// worker is gone.
pub async fn enqueue_pending_for_user(
    user_root: &Path,
    slug: &str,
    tx: &TranscribeSender,
) -> usize {
    let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
        return 0;
    };
    let mut pending: Vec<std::path::PathBuf> = Vec::new();
    while let Ok(Some(case_entry)) = cases.next_entry().await {
        let case_dir = case_entry.path();
        if !case_dir.is_dir() || crate::paths::is_closed(&case_dir).await {
            continue;
        }
        let Ok(mut files) = tokio::fs::read_dir(&case_dir).await else {
            continue;
        };
        while let Ok(Some(file)) = files.next_entry().await {
            let path = file.path();
            if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
                continue;
            }
            // Silent recordings (empty transcript on disk) are terminal —
            // the transcriber already ran. Only true `Pending` recordings
            // (no sidecar at all) need enqueuing.
            if !paths::read_transcript_state(&path).await.is_pending() {
                continue;
            }
            pending.push(path);
        }
    }
    // Oldest first — filenames are ISO timestamps so path order = chronological.
    pending.sort();

    let mut sent = 0;
    for path in pending {
        if tx
            .send(TranscribeJob {
                audio_path: path.clone(),
                user_slug: slug.to_owned(),
            })
            .await
            .is_err()
        {
            warn!(file = %path.display(), "recovery send failed (worker gone)");
            return sent;
        }
        sent += 1;
    }
    sent
}

/// Walk `data_path/*/*` and return every case directory that has at
/// least one finished transcript (content or silent) and whose persisted
/// oneliner state warrants (re-)generation, paired with the user-slug
/// (parent directory name). Pure filesystem scan, no LLM calls —
/// separated from the regeneration wrapper so it can be unit-tested
/// without mocking Ollama.
///
/// Silent-only cases are intentionally included: `update_oneliner` then
/// settles them to [`OnelinerState::Empty`] instead of leaving the UI
/// stuck on "Generating" forever.
///
/// Retry policy by persisted state:
/// - missing file → retry (first generation, or crash before write)
/// - [`OnelinerState::Error`] → retry (transient LLM failure last time)
/// - [`OnelinerState::Ready`] or [`OnelinerState::Empty`] → do **not**
///   retry; `Empty` is a valid outcome and `Ready` is already correct.
///   A fresh transcript write will trigger regeneration through the
///   normal worker path.
pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> {
    let mut out: Vec<(PathBuf, String)> = Vec::new();
    let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
        return out;
    };
    while let Ok(Some(user_entry)) = users.next_entry().await {
        let user_path = user_entry.path();
        if !user_path.is_dir() {
            continue;
        }
        let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
            continue;
        };
        for case_dir in cases_needing_oneliner_in(&user_path).await {
            out.push((case_dir, slug.clone()));
        }
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    out
}

/// Per-user variant of [`cases_needing_oneliner`]: scans only `user_root`'s
/// case dirs. Used both as the building block for the all-users scan and
/// from the page-load self-heal path.
pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();
    let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
        return out;
    };
    while let Ok(Some(case_entry)) = cases.next_entry().await {
        let case_dir = case_entry.path();
        if !case_dir.is_dir() || paths::is_closed(&case_dir).await {
            continue;
        }
        let (state, _) = paths::read_oneliner_state(&case_dir).await;
        let needs_retry = match state {
            None | Some(OnelinerState::Error { .. }) => true,
            Some(
                OnelinerState::Ready { .. }
                | OnelinerState::Empty { .. }
                | OnelinerState::Manual { .. },
            ) => false,
        };
        if !needs_retry {
            continue;
        }
        if has_any_transcript(&case_dir).await {
            out.push(case_dir);
        }
    }
    out.sort();
    out
}

/// True iff the case has at least one recording in a *terminal* state —
/// meaning the transcribe pipeline will produce no further artefacts
/// and `update_oneliner` has enough information to settle the case.
///
/// Two terminal shapes count:
/// - `<stem>.json` next to `<stem>.m4a` → transcriber wrote its
///   metadata sidecar (carries either Silent or Content). The
///   `<stem>.m4a` pairing is required because the case directory also
///   holds case-level JSONs (`oneliner.json`, `analysis_input.json`)
///   that must not be misread as a recording sidecar.
/// - `<stem>.m4a.failed` → permanent transcription failure (ffmpeg
///   remux or Whisper 4xx). Transient Whisper errors leave the file
///   as plain `.m4a` without sidecar (Pending), so they do *not*
///   match here — the page-load heal re-enqueues them on the next
///   refresh.
///
/// Cases where every recording is still `Pending` (plain `.m4a`, no
/// sidecar, not marked failed) return false: the transcriber hasn't
/// finished yet and there is nothing for the oneliner worker to act
/// on.
///
/// Both Silent and permanent-failed count because `update_oneliner`
/// settles each to [`OnelinerState::Empty`] via the same no-content
/// branch in `worker::update_oneliner`.
async fn has_any_transcript(case_dir: &Path) -> bool {
    let Ok(mut files) = tokio::fs::read_dir(case_dir).await else {
        return false;
    };
    while let Ok(Some(f)) = files.next_entry().await {
        let Some(name) = f.file_name().to_str().map(str::to_owned) else {
            continue;
        };
        if name.ends_with(".m4a.failed") {
            return true;
        }
        // A per-recording meta sidecar's stem matches its audio's
        // stem. Pair the JSON with `<stem>.m4a` to filter out
        // case-level JSONs (oneliner.json, analysis_input.json).
        if let Some(stem) = name.strip_suffix(&format!(".{RECORDING_META_SUFFIX}"))
            && tokio::fs::try_exists(case_dir.join(format!("{stem}.m4a")))
                .await
                .unwrap_or(false)
        {
            return true;
        }
    }
    false
}

/// Regenerate `oneliner.json` for every case that has transcripts but no
/// state file (crash between transcript-write and oneliner-write) or a
/// previously-errored state. Called at startup after `scan_and_enqueue`.
/// Sequential on purpose — the oneliner model is light, but spamming it
/// in parallel is not worth it.
pub async fn regenerate_missing_oneliners(
    data_path: &Path,
    client: &reqwest::Client,
    settings: &Settings,
    vocab: &Gazetteer,
    events_tx: &EventSender,
    locks: &OnelinerLocks,
) {
    let cases = cases_needing_oneliner(data_path).await;
    if cases.is_empty() {
        return;
    }
    info!(count = cases.len(), "Regenerating missing oneliners");
    for (case_dir, slug) in cases {
        super::worker::update_oneliner(&case_dir, &slug, client, settings, vocab, events_tx, locks)
            .await;
    }
}

/// Per-user variant of [`regenerate_missing_oneliners`]: fixes only the
/// given user's cases. Intended for the page-load self-heal path, so a
/// single missing oneliner is restored as soon as its owner opens a page
/// — no server restart needed.
pub async fn regenerate_missing_oneliners_for_user(
    user_root: &Path,
    slug: &str,
    client: &reqwest::Client,
    settings: &Settings,
    vocab: &Gazetteer,
    events_tx: &EventSender,
    locks: &OnelinerLocks,
) {
    let cases = cases_needing_oneliner_in(user_root).await;
    if cases.is_empty() {
        return;
    }
    for case_dir in cases {
        super::worker::update_oneliner(&case_dir, slug, client, settings, vocab, events_tx, locks)
            .await;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use doctate_common::Transcript;
    use tempfile::tempdir;

    /// Seed `<stem>.m4a` plus its `<stem>.json` metadata sidecar — the
    /// shape `has_any_transcript` recognises as terminal. The pair
    /// (audio + sidecar) is required because recovery filters
    /// case-level JSONs from the per-recording set by checking for an
    /// audio sibling.
    async fn seed_recording(case: &Path, stem: &str, transcript: Transcript) {
        tokio::fs::write(case.join(format!("{stem}.m4a")), b"audio")
            .await
            .unwrap();
        crate::paths::write_recording_meta_sync(case, stem, transcript, None);
    }

    fn content(text: &str) -> Transcript {
        Transcript::Content {
            text: text.to_owned(),
        }
    }

    #[tokio::test]
    async fn cases_needing_oneliner_finds_crash_survivor() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;

        let got = cases_needing_oneliner(data.path()).await;
        assert_eq!(got, vec![(case, "user".to_owned())]);
    }

    async fn write_state(case: &Path, state: &OnelinerState) {
        let bytes = serde_json::to_vec(state).unwrap();
        tokio::fs::write(case.join("oneliner.json"), bytes)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn cases_needing_oneliner_skips_ready_state() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
        write_state(
            &case,
            &OnelinerState::Ready {
                text: "Existing".into(),
                generated_at: "2026-04-16T10:01:00Z".into(),
            },
        )
        .await;

        assert!(cases_needing_oneliner(data.path()).await.is_empty());
    }

    #[tokio::test]
    async fn cases_needing_oneliner_skips_empty_state() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
        write_state(
            &case,
            &OnelinerState::Empty {
                generated_at: "2026-04-16T10:01:00Z".into(),
            },
        )
        .await;

        assert!(cases_needing_oneliner(data.path()).await.is_empty());
    }

    #[tokio::test]
    async fn cases_needing_oneliner_retries_error_state() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
        write_state(
            &case,
            &OnelinerState::Error {
                generated_at: "2026-04-16T10:01:00Z".into(),
            },
        )
        .await;

        let got = cases_needing_oneliner(data.path()).await;
        assert_eq!(got, vec![(case, "user".to_owned())]);
    }

    /// Silent-only cases (sidecar carries `Transcript::Silent`) are
    /// picked up by recovery so `update_oneliner` can settle them to
    /// [`OnelinerState::Empty`]. Previously they were skipped, which
    /// left the UI stuck on "Generating" forever (the bug the
    /// three-state model fixes).
    #[tokio::test]
    async fn cases_needing_oneliner_picks_up_silent_only_cases_for_empty_settlement() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        seed_recording(&case, "2026-04-16T10-00-00Z", Transcript::Silent).await;

        let got = cases_needing_oneliner(data.path()).await;
        assert_eq!(got, vec![(case, "user".to_owned())]);
    }

    /// Fully-pending cases (no sidecar yet) are skipped —
    /// `update_oneliner` has nothing to act on until the transcriber
    /// writes one.
    #[tokio::test]
    async fn cases_needing_oneliner_skips_fully_pending_cases() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        tokio::fs::write(case.join("2026-04-16T10-00-00Z.m4a"), b"audio")
            .await
            .unwrap();
        // No <stem>.json — the recording is still Pending.

        assert!(cases_needing_oneliner(data.path()).await.is_empty());
    }

    #[tokio::test]
    async fn cases_needing_oneliner_skips_closed_cases() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
        // Empty JSON object is treated as a valid close marker
        // (see paths::is_closed — existence of the file is enough).
        tokio::fs::write(case.join(".closed"), "{}").await.unwrap();

        assert!(cases_needing_oneliner(data.path()).await.is_empty());
    }

    /// `.m4a.failed`-only cases are terminal — the pipeline will produce
    /// no further artefacts. Recovery must pick them up so
    /// `update_oneliner` can persist `OnelinerState::Empty`; otherwise
    /// the case would rely on the UI fallback forever and never have a
    /// stable on-disk state.
    #[tokio::test]
    async fn cases_needing_oneliner_picks_up_failed_only_cases() {
        let data = tempdir().unwrap();
        let case = data.path().join("user").join("case1");
        tokio::fs::create_dir_all(&case).await.unwrap();
        tokio::fs::write(case.join("2026-04-16T10-00-00Z.m4a.failed"), b"x")
            .await
            .unwrap();

        let got = cases_needing_oneliner(data.path()).await;
        assert_eq!(got, vec![(case, "user".to_owned())]);
    }

    #[tokio::test]
    async fn has_any_transcript_true_for_failed_only_case() {
        let case = tempdir().unwrap();
        tokio::fs::write(case.path().join("2026-04-16T10-00-00Z.m4a.failed"), b"x")
            .await
            .unwrap();

        assert!(has_any_transcript(case.path()).await);
    }

    #[tokio::test]
    async fn has_any_transcript_false_for_pending_only_case() {
        let case = tempdir().unwrap();
        tokio::fs::write(case.path().join("2026-04-16T10-00-00Z.m4a"), b"audio")
            .await
            .unwrap();

        assert!(!has_any_transcript(case.path()).await);
    }
}
