use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;

use askama::Template;
use axum::extract::{Query, State};
use axum::response::Html;
use doctate_common::TranscriptState;
use doctate_common::oneliners::OnelinerState;
use serde::Deserialize;
use tracing::{info, warn};

use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};

use crate::analyze::{
    ANALYSIS_INPUT_FILE, DOCUMENT_FILE, auto_trigger, recovery as analyze_recovery,
};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::{CaseId, CaseIdPath};
use crate::config::Config;
use crate::error::AppError;
use crate::events::EventSender;
use crate::gazetteer::Gazetteer;
use crate::paths;
use crate::routes::case_actions::read_document;
use crate::routes::web::{RecordingView, scan_recordings};
use crate::settings::Settings;
use crate::transcribe::recovery as transcribe_recovery;
use crate::{BusyGuard, PipelineState};

/// UI-layer oneliner status for the case list. Combines the persisted
/// [`OnelinerState`] with derived transit states that only make sense
/// in the list context (transcription still pending, or no state file
/// despite completed transcription).
///
/// Kept local to the server — not part of the public API DTO, which
/// exposes the raw [`OnelinerState`] and lets each client decide how
/// to render transit states.
enum OnelinerDisplay {
    /// LLM produced a usable title.
    Ready(String),
    /// Stable "no title to expect" — either the LLM returned nothing
    /// (no medical content in transcript) or every recording failed so
    /// nothing can ever generate a title. UI treats both identically;
    /// the reason is uninteresting to the doctor in the list view.
    Empty,
    /// LLM call failed with transcripts available.
    Error,
    /// Transcription still running — a title cannot exist yet.
    Pending,
    /// Transcripts are done (or about to land) but the LLM hasn't
    /// written a state yet, OR a previous state is obsolete because new
    /// recordings arrived. The worker is generating right now or will
    /// generate as soon as the last transcript is in. Covers the
    /// reset-just-clicked case and the post-restart recovery window.
    Generating,
}

struct UserCaseView {
    case_id: String,
    most_recent: String,
    /// HH:MM of the most recent recording in UTC — no-JS fallback.
    /// Browser script replaces the displayed text with the browser-local
    /// equivalent via the parallel `recorded_at_iso` field.
    time_hms_utc: String,
    /// Full RFC3339 UTC timestamp of the most recent recording. Fed into
    /// the HTML `<time datetime="...">` attribute so browser JS can
    /// render it in the doctor's actual timezone (which may differ from
    /// the server's, e.g. server on Bahamas, doctor in Germany).
    recorded_at_iso: String,
    oneliner: OnelinerDisplay,
    /// True iff the persisted oneliner is `OnelinerState::Manual { .. }`
    /// AND the displayed text actually shows that manual content (i.e.
    /// not while we're rendering a Pending/Generating placeholder). The
    /// template uses this to render a 👤 marker next to the title so the
    /// doctor can tell at a glance which titles they themselves wrote.
    oneliner_is_manual: bool,
    recordings_count: usize,
    analyzing: bool,
    has_document: bool,
    /// True iff at least one recording is `.m4a.failed` — a *permanent*
    /// transcribe failure (ffmpeg remux or Whisper 4xx). Transient Whisper
    /// errors never set this flag because they leave the file as plain
    /// `.m4a` for the page-load heal to retry. Drives the verdrängende
    /// `fehler`-Badge over `offen`/`ausgewertet`.
    has_failed_recording: bool,
    /// True iff the inline "Analysieren"-button should be enabled.
    /// Requires: not currently analyzing, all non-failed recordings
    /// transcribed, and an LLM is configured.
    can_analyze: bool,
    /// True iff the case carries a `.closed` marker. Drives the muted
    /// styling, the Close/Reopen button swap, and the purge countdown.
    is_closed: bool,
    /// Remaining days before auto-purge. `Some(n)` only when the case
    /// is closed AND `auto_delete_days > 0`; `None` otherwise (so the
    /// template can render "wird in N Tagen entfernt" vs. plain
    /// "geschlossen"). Clamped at 0 — cases eligible for purge this
    /// sweep render as "0" briefly.
    days_until_purge: Option<u32>,
    /// Full `document.md` rendered through the same `md_to_html`
    /// pipeline as the case-detail page. `None` when the case has no
    /// document yet. The browser clamps this to `preview_lines` in the
    /// collapsed state and shows it whole when expanded — single
    /// rendering, two CSS layouts, no server-side truncation.
    analysis_html: Option<String>,
}

/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
/// and return `(Date, Rfc3339String)`. None on malformed input.
fn utc_date_and_iso_of(filename: &str) -> Option<(Date, String)> {
    let s = filename.get(..20)?;
    if s.as_bytes().get(19) != Some(&b'Z') {
        return None;
    }
    // Filename uses `-` between H/M/S; Rfc3339 wants `:`. Patch two bytes.
    let mut buf = [0u8; 20];
    buf.copy_from_slice(s.as_bytes());
    buf[13] = b':';
    buf[16] = b':';
    let fixed = std::str::from_utf8(&buf).ok()?;
    let dt = OffsetDateTime::parse(fixed, &Rfc3339).ok()?;
    Some((dt.date(), fixed.to_owned()))
}

/// Group cases (already sorted newest-first) into UTC-date buckets. The
/// label is the raw ISO date string (e.g. `2026-04-18`); browser JS
/// re-labels the current/previous UTC date to "Heute"/"Gestern". Note:
/// grouping is by UTC day, which may drift from the doctor's local-day
/// perception around midnight — acceptable tradeoff; full Browser-TZ
/// grouping would require a larger client-side restructure.
///
/// `closed_extra` carries the count of *closed* cases per day-label that
/// are NOT present in `cases` (i.e. the default `show_closed=false`
/// view). Each group's `total_count` is then `cases_in_group +
/// closed_extra[label]`. Under `show_closed=true` the caller passes an
/// empty map — closed cases are already in `cases` with `is_closed=true`
/// and get counted directly.
fn group_by_utc_date(
    cases: Vec<UserCaseView>,
    closed_extra: &HashMap<String, usize>,
) -> Vec<DateGroup> {
    let today = OffsetDateTime::now_utc().date();
    let mut groups: Vec<DateGroup> = Vec::new();
    for case in cases {
        let d = utc_date_and_iso_of(&case.most_recent)
            .map(|(d, _)| d)
            .unwrap_or(today);
        let label = d.to_string();
        match groups.last_mut() {
            Some(g) if g.label == label => g.cases.push(case),
            _ => groups.push(DateGroup {
                label,
                cases: vec![case],
                open_count: 0,
                total_count: 0,
            }),
        }
    }
    // Second pass: fill in the per-group counts. Doing this after all
    // cases have landed in their groups keeps the push-logic above
    // simple and avoids re-counting on every append.
    for g in &mut groups {
        g.open_count = g.cases.iter().filter(|c| !c.is_closed).count();
        g.total_count = g.cases.len() + closed_extra.get(&g.label).copied().unwrap_or(0);
    }
    groups
}

/// Count closed cases per UTC-date label for the `show_closed=false`
/// listing — the default view where closed cases are hidden from the
/// case rows but still contribute to the `Z` in `Y/Z Fälle`.
///
/// Lightweight on purpose: per closed case we do exactly one `read_dir`
/// and pick the lexicographically largest `.m4a[.failed]` filename as
/// the most-recent recording, then extract the UTC date. No oneliner,
/// document, or flag computation — those aren't rendered for hidden
/// cases anyway.
async fn count_closed_by_date(user_root: &Path) -> HashMap<String, usize> {
    let mut out: HashMap<String, usize> = HashMap::new();
    let Ok(mut entries) = tokio::fs::read_dir(user_root).await else {
        return out;
    };
    while let Ok(Some(entry)) = entries.next_entry().await {
        let Ok(case_id) = entry.file_name().into_string() else {
            continue;
        };
        if uuid::Uuid::parse_str(&case_id).is_err() {
            continue;
        }
        let case_path = entry.path();
        if !case_path.is_dir() {
            continue;
        }
        if !crate::paths::is_closed(&case_path).await {
            continue;
        }
        if let Some(label) = most_recent_date_label(&case_path).await {
            *out.entry(label).or_insert(0) += 1;
        }
    }
    out
}

/// Return the UTC-date label (`YYYY-MM-DD`) of the most recent recording
/// in `case_path`, or `None` if the directory has no parseable recording
/// filenames. Matches the `group_by_utc_date` bucketing rule exactly so
/// a closed case counts toward the same day its visible siblings do.
async fn most_recent_date_label(case_path: &Path) -> Option<String> {
    let mut entries = tokio::fs::read_dir(case_path).await.ok()?;
    let mut best: Option<String> = None;
    while let Ok(Some(entry)) = entries.next_entry().await {
        // Non-UTF8 filenames skipped, not fatal — an unparseable entry
        // must not shadow a valid recording sitting next to it.
        let Ok(name) = entry.file_name().into_string() else {
            continue;
        };
        // Accept both `.m4a` and `.m4a.failed` — both mark a recording
        // slot, and failed files still carry a valid timestamp prefix.
        if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) {
            continue;
        }
        if best.as_deref().map(|b| name.as_str() > b).unwrap_or(true) {
            best = Some(name);
        }
    }
    let name = best?;
    utc_date_and_iso_of(&name).map(|(d, _)| d.to_string())
}

/// Extract `HH:MM` from a recording filename of the form
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
/// Used only as a no-JS fallback — the canonical value rendered in the
/// browser comes from the `<time datetime>`-based script.
fn extract_hhmm_utc(filename: &str) -> String {
    let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else {
        return String::new();
    };
    if h.bytes().all(|b| b.is_ascii_digit()) && m.bytes().all(|b| b.is_ascii_digit()) {
        format!("{h}:{m}")
    } else {
        String::new()
    }
}

/// Build an RFC3339 UTC timestamp string from a recording filename.
/// Returns empty string on malformed input.
fn recorded_at_iso_of(filename: &str) -> String {
    utc_date_and_iso_of(filename)
        .map(|(_, iso)| iso)
        .unwrap_or_default()
}

struct DateGroup {
    /// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
    label: String,
    cases: Vec<UserCaseView>,
    /// Count of *open* (non-closed) cases in this group. Matches the
    /// number of rows actually rendered in the default view (where
    /// closed cases are hidden). Rendered as the `Y` in `Y/Z Fälle`.
    open_count: usize,
    /// Count of *all* cases whose most-recent recording falls on this
    /// date — open plus closed, regardless of the show_closed toggle.
    /// Rendered as the `Z` in `Y/Z Fälle` so doctors can see how many
    /// cases were actually worked that day even when the list hides
    /// the closed ones.
    total_count: usize,
}

#[derive(Template)]
#[template(path = "my_cases.html")]
struct MyCasesTemplate {
    slug: String,
    groups: Vec<DateGroup>,
    total: usize,
    /// True iff the session user is an admin — toggles full case_id display.
    is_admin: bool,
    /// Reflects the `?show_closed=1` query param. Controls the toggle
    /// link state and makes the bulk-purge button + per-case reopen
    /// buttons visible.
    show_closed: bool,
    /// True iff at least one closed case is currently visible. Needed
    /// to gate the bulk-purge bar so it does not appear on an
    /// all-open list rendered with `show_closed=1`.
    any_closed: bool,
    /// Per-user cap on visible analysis-preview lines. Rendered as a
    /// CSS `--preview-lines` custom property on `<html>`; the browser
    /// applies it via `line-clamp`. Coming from `users.toml`.
    preview_lines: u32,
    /// Session CSRF token — rendered as a hidden field into the bulk,
    /// purge-closed, and logout forms.
    csrf_token: String,
}

/// Query params for GET /web/cases and /web/cases/{case_id}.
#[derive(Debug, Default, Deserialize)]
pub struct CaseListQuery {
    /// `?show_closed=1` expands the list to include closed cases and
    /// swaps per-case close buttons with reopen buttons. Any other
    /// value (missing, "0", empty) keeps the default "open only" view.
    #[serde(default)]
    show_closed: Option<String>,
}

impl CaseListQuery {
    fn include_closed(&self) -> bool {
        matches!(self.show_closed.as_deref(), Some("1"))
    }
}

#[derive(Template)]
#[template(path = "case_page.html")]
struct CasePageTemplate {
    case_id: String,
    case_id_short: String,
    oneliner: OnelinerDisplay,
    /// Mirror of `UserCaseView::oneliner_is_manual`. See that field for
    /// the suppression-during-progress rationale.
    oneliner_is_manual: bool,
    /// RFC3339 UTC timestamp of the most recent recording. `None` iff the
    /// case has no recordings at all. Drives the datetime header under the
    /// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34".
    recorded_at_iso: Option<String>,
    /// UTC `HH:MM` as a no-JS fallback inside the `<time>` element.
    time_hms_utc: String,
    /// Rendered document HTML (already passed through `md_to_html`). `Some`
    /// iff `document.md` exists at render time — template prioritises this
    /// over status/analyzing placeholders.
    document_html: Option<String>,
    recordings_count: usize,
    transcribed_count: usize,
    can_analyze: bool,
    llm_missing: bool,
    analyzing: bool,
    has_document: bool,
    /// `Some` iff a previous LLM analysis failed AND no document exists.
    /// Renders a dead-end-recovery banner with reason + retry button so
    /// the user is never stranded when a transient LLM error left only a
    /// `.analysis_failed.json` marker behind. See `compute_flags`.
    analysis_failed: Option<FailureBanner>,
    /// True iff at least one `.m4a.failed` recording exists. Drives the
    /// verdrängende `fehler`-Badge in the case header; see the same
    /// field on `UserCaseView` for semantics.
    has_failed_recording: bool,
    is_admin: bool,
    /// True when the case carries a `.closed` marker. Toggles the
    /// header action button between close and reopen, and appends
    /// a "?show_closed=1" to the form action so a freshly reopened
    /// case still lands on the detail page.
    is_closed: bool,
    /// Session CSRF token — rendered as a hidden field into every
    /// state-changing form on the page (close/reopen/analyze/reset/logout).
    csrf_token: String,
    /// Admin-only debug copy bundle, pre-serialised as JSON for inline
    /// embedding into a `<script type="application/json">` block. Shape:
    /// `{"transcripts":["..."],"document":"..."|null}`. Empty string when
    /// `is_admin` is false (template skips the block in that case anyway).
    /// Pre-sanitised against `</script>` breakout via `<\/` substitution.
    debug_copy_json: String,
    /// Available LLM backends (filtered to those whose auth is satisfied).
    /// One submit button per entry is rendered next to the analyze form.
    /// The first entry is the default backend.
    backends: Vec<&'static crate::analyze::backend::LlmBackend>,
}

#[derive(Template)]
#[template(path = "case_recordings.html")]
struct CaseRecordingsTemplate {
    slug: String,
    case_id: String,
    case_id_short: String,
    oneliner: Option<String>,
    recordings: Vec<RecordingView>,
    transcribe_busy: bool,
    is_admin: bool,
    /// Session CSRF token — rendered as a hidden field into the
    /// per-recording delete forms and the logout form.
    csrf_token: String,
}

/// Information about a previous failed analysis run, surfaced in the
/// case page when no document exists yet so the user always has an
/// affordance to retry. Populated from `.analysis_failed.json` only
/// when `!has_document && !analyzing` — otherwise the marker is either
/// already obsolete (a successful run will overwrite it) or the worker
/// is currently producing a fresh result anyway.
struct FailureBanner {
    /// Raw `reason` string from the marker. Shown verbatim inside a
    /// `<details>` block — useful for admins, harmless for users.
    reason: String,
    /// RFC3339 UTC timestamp of the failed run. Browser JS reformats it
    /// into the doctor's local timezone (same `time_format.js` that
    /// formats `recorded_at_iso`).
    failed_at: String,
    /// UI label of the backend that produced the failure (resolved from
    /// the marker's `backend_id`). Empty if the marker predates the
    /// per-backend tracking or the id is no longer in the catalog.
    backend_label: String,
}

/// Flags derived from filesystem state + config.
/// `can_analyze` covers both first analysis and re-analysis — same precondition
/// (all non-failed recordings transcribed, LLM configured, no analysis in
/// flight). `llm_missing` is shown to the user as an explanation when an
/// analysis would otherwise be possible but no LLM is configured.
struct CaseFlags {
    has_document: bool,
    analyzing: bool,
    can_analyze: bool,
    llm_missing: bool,
    /// `Some` iff a previous LLM run failed AND no document exists yet.
    /// Drives the dead-end-recovery banner on the case page.
    analysis_failed: Option<FailureBanner>,
}

async fn compute_flags(
    case_dir: &Path,
    recordings: &[RecordingView],
    llm_configured: bool,
    worker_busy: bool,
) -> CaseFlags {
    let has_document = any_document_exists(case_dir).await;
    // Honest status: an input file alone does not mean a job is in flight.
    // The worker may be idle and the file an orphan from a crash. Page-level
    // self-heal (see `heal_orphans_if_idle`) re-enqueues those.
    let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_dir).await;

    let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
    let all_transcribed =
        !non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.has_file());

    let analyzable = !analyzing && all_transcribed;

    // Only surface the failure marker when neither a document nor an in-flight
    // analysis would otherwise occupy the same UI slot. Skips the FS read on
    // the success hot-path (document already on disk).
    let analysis_failed = if !has_document && !analyzing {
        auto_trigger::read_failure_marker(case_dir)
            .await
            .map(|m| FailureBanner {
                reason: m.reason,
                failed_at: m.failed_at,
                backend_label: m
                    .backend_id
                    .as_deref()
                    .and_then(crate::analyze::backend::find_backend)
                    .map(|b| b.label.clone())
                    .unwrap_or_default(),
            })
    } else {
        None
    };

    CaseFlags {
        has_document,
        analyzing,
        can_analyze: analyzable && llm_configured,
        llm_missing: analyzable && !llm_configured,
        analysis_failed,
    }
}

/// True iff `analysis_input.json` exists. The worker removes it after a
/// successful document write, so existence implies a pending or in-flight job.
pub(crate) async fn any_analysis_input_exists(case_dir: &Path) -> bool {
    tokio::fs::try_exists(case_dir.join(ANALYSIS_INPUT_FILE))
        .await
        .unwrap_or(false)
}

/// True iff `document.md` exists.
pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
    tokio::fs::try_exists(case_dir.join(DOCUMENT_FILE))
        .await
        .unwrap_or(false)
}

/// Self-heal: if a worker is idle but orphans exist on disk for this user,
/// re-enqueue them. Page-load is the trigger; no cron, no periodic task.
/// Runs for both pipelines so a page-load on either view cleans both.
///
/// Also opportunistically regenerates missing oneliners for the user — a
/// recording-delete leaves `oneliner.json` gone, and without this heal
/// call the stale state would only recover on server restart.
impl PipelineState {
    #[allow(clippy::too_many_arguments)]
    pub async fn heal_orphans_if_idle(
        &self,
        user_root: &Path,
        slug: &str,
        http_client: &reqwest::Client,
        settings: &Arc<Settings>,
        vocab: &Arc<Gazetteer>,
        events_tx: &EventSender,
    ) {
        if !self.analyze_busy.0.load(Ordering::Acquire) {
            analyze_recovery::enqueue_pending_for_user(user_root, &self.analyze_tx).await;
        }
        if !self.transcribe_busy.0.load(Ordering::Acquire) {
            transcribe_recovery::enqueue_pending_for_user(user_root, slug, &self.transcribe_tx)
                .await;
            // Oneliner self-heal: if the transcribe worker is busy, it
            // will write a fresh oneliner at batch-end anyway — skip to
            // avoid a redundant LLM call. Otherwise, fire the regen in a
            // detached task so the request handler returns immediately;
            // SSE `OnelinerUpdated` events deliver results to the browser.
            // The CAS on `oneliner_heal_busy` guards against concurrent
            // page-loads spawning their own parallel regen loops against
            // the same cases.
            if self
                .oneliner_heal_busy
                .0
                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
                .is_ok()
            {
                let user_root = user_root.to_path_buf();
                let slug_owned = slug.to_owned();
                let http_client = http_client.clone();
                let settings = Arc::clone(settings);
                let vocab = Arc::clone(vocab);
                let events_tx = events_tx.clone();
                let busy = Arc::clone(&self.oneliner_heal_busy.0);
                let locks = self.oneliner_locks.clone();
                info!(user = %slug, "oneliner heal spawned");
                tokio::spawn(async move {
                    // `BusyGuard::Drop` resets the flag to `false` — even
                    // on panic inside the regen loop. The redundant
                    // `store(true)` inside `BusyGuard::new` is harmless;
                    // the CAS above already owns the flag transition.
                    let _guard = BusyGuard::new(busy);
                    transcribe_recovery::regenerate_missing_oneliners_for_user(
                        &user_root,
                        &slug_owned,
                        &http_client,
                        &settings,
                        &vocab,
                        &events_tx,
                        &locks,
                    )
                    .await;
                });
            }
        }
    }
}

#[allow(clippy::too_many_arguments)]
pub async fn handle_my_cases(
    user: AuthenticatedWebUser,
    State(config): State<Arc<Config>>,
    State(settings): State<Arc<Settings>>,
    State(pipeline): State<PipelineState>,
    State(events_tx): State<EventSender>,
    State(http_client): State<reqwest::Client>,
    State(vocab): State<Arc<Gazetteer>>,
    Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> {
    let user_root = config.data_path.join(&user.slug);
    pipeline
        .heal_orphans_if_idle(
            &user_root,
            &user.slug,
            &http_client,
            &settings,
            &vocab,
            &events_tx,
        )
        .await;
    // Auto-analysis: hand every eligible case to the worker before we
    // render. The common case is "nothing to do" and costs a handful of
    // stat-calls per case; actual enqueues happen only when pre-conditions
    // flip (new transcripts in, stale document, ...).
    auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &events_tx).await;

    // Lazy retention sweep: at most one disk scan per /web/cases visit,
    // reusing the user's retention policy from users.toml. Runs before
    // the listing scan so any auto-close/auto-purge actions are
    // reflected in the same response.
    let retention = config
        .users
        .iter()
        .find(|u| u.slug == user.slug)
        .map(|u| u.retention.clone())
        .unwrap_or_default();
    crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;

    let show_closed = query.include_closed();
    let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
    let cases = scan_user_cases(
        &config,
        &user.slug,
        busy,
        show_closed,
        retention.auto_delete_days,
        crate::analyze::backend::any_backend_available(),
    )
    .await;
    let total = cases.len();
    let any_closed = cases.iter().any(|c| c.is_closed);
    // Under `show_closed=false` the case list skips closed entries
    // entirely — but the per-day `Y/Z Fälle` badge still needs to count
    // them for `Z`. Do a lightweight second scan (most-recent date per
    // closed case, no compute_case_view) and feed it into the grouper.
    // Under `show_closed=true` closed cases are already in `cases`
    // with their `is_closed` flag, so the map stays empty.
    let closed_extra = if show_closed {
        HashMap::new()
    } else {
        count_closed_by_date(&user_root).await
    };
    let groups = group_by_utc_date(cases, &closed_extra);
    let is_admin = user.is_admin();
    let preview_lines = config
        .users
        .iter()
        .find(|u| u.slug == user.slug)
        .map(|u| u.preview_lines)
        .unwrap_or(2);
    MyCasesTemplate {
        csrf_token: user.csrf_token,
        slug: user.slug,
        groups,
        total,
        is_admin,
        show_closed,
        any_closed,
        preview_lines,
    }
    .render()
    .map(Html)
    .map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}

/// GET /web/cases/{case_id}
///
/// Canonical case page. Renders the document inline if present; otherwise
/// shows status (analyzing / placeholder for empty / recordings-summary).
/// Action buttons (Analyze/Reanalyze/Reset/Delete) live here, not on the
/// recordings sub-page.
#[allow(clippy::too_many_arguments)]
pub async fn handle_case_page(
    user: AuthenticatedWebUser,
    State(config): State<Arc<Config>>,
    State(settings): State<Arc<Settings>>,
    State(pipeline): State<PipelineState>,
    State(events_tx): State<EventSender>,
    State(http_client): State<reqwest::Client>,
    State(vocab): State<Arc<Gazetteer>>,
    CaseIdPath(case_id): CaseIdPath,
    Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> {
    let user_root = config.data_path.join(&user.slug);
    pipeline
        .heal_orphans_if_idle(
            &user_root,
            &user.slug,
            &http_client,
            &settings,
            &vocab,
            &events_tx,
        )
        .await;

    let show_closed = query.include_closed();
    let case_dir = if show_closed {
        locate_closed_case_or_404(
            &user_root,
            &case_id,
            &user.slug,
            "case page (show_closed=1)",
        )
        .await?
    } else {
        locate_case_or_404(
            &user_root,
            &case_id,
            &user.slug,
            "case page (possible IDOR probe)",
        )
        .await?
    };
    // Auto-analysis trigger for deep-link navigation: same evaluation as
    // the list view. Document render below still wins the race if the
    // worker happens to finish synchronously.
    auto_trigger::try_enqueue(&case_dir, &pipeline.analyze_tx, &events_tx).await;
    let case_id_str = case_id.to_string();
    info!(slug = %user.slug, case_id = %case_id, "case page viewed");

    // Read document first; if it's on disk we display it regardless of what
    // the `analyzing` flag would otherwise say. Resolves the narrow race
    // where the worker finishes between flag check and template render.
    // Keep the raw markdown around for the admin debug-copy bundle so we
    // don't pay a second filesystem read.
    let document_md = read_document(&case_dir).await;
    let document_html = document_md
        .as_deref()
        .map(crate::analyze::render::md_to_html);

    let recordings = scan_recordings(&case_dir).await;
    let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await;

    let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
    let flags = compute_flags(
        &case_dir,
        &recordings,
        crate::analyze::backend::any_backend_available(),
        a_busy,
    )
    .await;

    let recordings_count = recordings.len();
    let transcribed_count = recordings
        .iter()
        .filter(|r| r.transcript.has_file())
        .count();
    let has_failed_recording = recordings.iter().any(|r| r.failed);
    let case_id_short = case_id_str.chars().take(8).collect();
    let is_admin = user.is_admin();

    let most_recent = recordings.last().map(|r| r.filename.as_str());
    let recorded_at_iso = most_recent
        .map(recorded_at_iso_of)
        .filter(|s| !s.is_empty());
    let time_hms_utc = most_recent.map(extract_hhmm_utc).unwrap_or_default();

    let is_closed = crate::paths::is_closed(&case_dir).await;

    // Admin debug-copy payload — only built when the viewer is an admin
    // so non-admin renders skip the JSON serialisation entirely.
    let debug_copy_json = if is_admin {
        let transcripts: Vec<&str> = recordings
            .iter()
            .filter_map(|r| r.transcript.as_content())
            .collect();
        let payload = serde_json::json!({
            "transcripts": transcripts,
            "document": document_md,
        });
        // `</` breaks out of a `<script>` element; the inline JSON block
        // would otherwise be vulnerable if a transcript or the document
        // ever contained a literal `</script>`.
        serde_json::to_string(&payload)
            .unwrap_or_else(|_| "{}".to_owned())
            .replace("</", "<\\/")
    } else {
        String::new()
    };

    CasePageTemplate {
        case_id: case_id_str,
        case_id_short,
        oneliner,
        oneliner_is_manual,
        recorded_at_iso,
        time_hms_utc,
        document_html,
        recordings_count,
        transcribed_count,
        can_analyze: flags.can_analyze,
        llm_missing: flags.llm_missing,
        analyzing: flags.analyzing,
        has_document: flags.has_document,
        analysis_failed: flags.analysis_failed,
        has_failed_recording,
        is_admin,
        is_closed,
        csrf_token: user.csrf_token,
        debug_copy_json,
        backends: crate::analyze::backend::available_backends(),
    }
    .render()
    .map(Html)
    .map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}

/// GET /web/cases/{case_id}/recordings
///
/// Read-only sub-page showing all `.m4a` + transcript pairs for the case.
/// Useful for power-users/admins; not part of the day-to-day workflow.
#[allow(clippy::too_many_arguments)]
pub async fn handle_case_recordings(
    user: AuthenticatedWebUser,
    State(config): State<Arc<Config>>,
    State(settings): State<Arc<Settings>>,
    State(pipeline): State<PipelineState>,
    State(events_tx): State<EventSender>,
    State(http_client): State<reqwest::Client>,
    State(vocab): State<Arc<Gazetteer>>,
    CaseIdPath(case_id): CaseIdPath,
) -> Result<Html<String>, AppError> {
    let user_root = config.data_path.join(&user.slug);
    pipeline
        .heal_orphans_if_idle(
            &user_root,
            &user.slug,
            &http_client,
            &settings,
            &vocab,
            &events_tx,
        )
        .await;

    let case_dir = locate_case_or_404(
        &user_root,
        &case_id,
        &user.slug,
        "case recordings (possible IDOR probe)",
    )
    .await?;
    let case_id_str = case_id.to_string();
    info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");

    let recordings = scan_recordings(&case_dir).await;
    let oneliner = ready_text(&case_dir).await;

    let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
    let case_id_short = case_id_str.chars().take(8).collect();
    let is_admin = user.is_admin();

    CaseRecordingsTemplate {
        csrf_token: user.csrf_token,
        slug: user.slug,
        case_id: case_id_str,
        case_id_short,
        oneliner,
        recordings,
        transcribe_busy: t_busy,
        is_admin,
    }
    .render()
    .map(Html)
    .map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}

/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
/// if the directory does not exist OR is closed (`.closed` marker
/// present). Both are treated as 404 / IDOR probe by callers.
pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::path::PathBuf> {
    let p = user_root.join(case_id);
    if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
        return None;
    }
    if crate::paths::is_closed(&p).await {
        return None;
    }
    Some(p)
}

/// Resolve a case directory for a handler, mapping any miss to
/// `AppError::NotFound("Case not found")` — matches the legacy 404 body
/// byte-for-byte. The `context` tag distinguishes log lines from the
/// different handlers (analyze, delete, reset, ...). Callers that need
/// a different not-found policy (silent skip in bulk ops) should keep
/// using `locate_case` directly.
pub(crate) async fn locate_case_or_404(
    user_root: &Path,
    case_id: &CaseId,
    user_slug: &str,
    context: &str,
) -> Result<std::path::PathBuf, AppError> {
    match locate_case(user_root, &case_id.to_string()).await {
        Some(p) => Ok(p),
        None => {
            warn!(
                slug = %user_slug,
                case_id = %case_id,
                context = %context,
                "case not found",
            );
            Err(AppError::NotFound("Case not found".into()))
        }
    }
}

/// Like `locate_case_or_404` but accepts closed cases. Only the detail
/// view uses this, and only when the user navigated there with
/// `?show_closed=1`. A non-existent directory still 404s as expected.
pub(crate) async fn locate_closed_case_or_404(
    user_root: &Path,
    case_id: &CaseId,
    user_slug: &str,
    context: &str,
) -> Result<std::path::PathBuf, AppError> {
    let p = user_root.join(case_id.to_string());
    if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
        warn!(
            slug = %user_slug,
            case_id = %case_id,
            context = %context,
            "case not found (show_closed)",
        );
        return Err(AppError::NotFound("Case not found".into()));
    }
    Ok(p)
}

/// Scan all of the given user's cases. Returned vec is sorted by most
/// recent recording filename (lexicographic ≈ chronological since
/// timestamps are ISO-8601), newest first.
///
/// `include_closed` flips the default "closed cases are hidden"
/// behaviour for the show-closed view. `auto_delete_days` is used to
/// compute the per-case `days_until_purge` countdown; pass 0 to
/// disable the countdown (maps to `None` on the view).
async fn scan_user_cases(
    config: &Config,
    slug: &str,
    worker_busy: bool,
    include_closed: bool,
    auto_delete_days: u32,
    llm_configured: bool,
) -> Vec<UserCaseView> {
    let user_root = config.data_path.join(slug);
    let mut cases = Vec::new();
    let now = OffsetDateTime::now_utc();

    let mut entries = match tokio::fs::read_dir(&user_root).await {
        Ok(r) => r,
        Err(_) => return cases,
    };

    while let Ok(Some(entry)) = entries.next_entry().await {
        let Some((case_id, case_path)) = filter_valid_case_dir(entry, include_closed).await else {
            continue;
        };
        if let Some(view) = compute_case_view(
            case_id,
            &case_path,
            llm_configured,
            worker_busy,
            auto_delete_days,
            now,
        )
        .await
        {
            cases.push(view);
        }
    }

    cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
    cases
}

/// Accept an entry only if it is a directory whose name is a valid UUID.
/// Closed cases are rejected unless `include_closed` is set; then the
/// caller (the show-closed listing) will downstream-render them with
/// muted styling and a badge.
async fn filter_valid_case_dir(
    entry: tokio::fs::DirEntry,
    include_closed: bool,
) -> Option<(String, std::path::PathBuf)> {
    let case_id = entry.file_name().into_string().ok()?;
    if uuid::Uuid::parse_str(&case_id).is_err() {
        warn!(case_id = %case_id, "Skipping non-UUID directory");
        return None;
    }
    let case_path = entry.path();
    if !case_path.is_dir() {
        return None;
    }
    if !include_closed && crate::paths::is_closed(&case_path).await {
        return None;
    }
    Some((case_id, case_path))
}

/// Build a `UserCaseView` for a single case directory. Returns `None`
/// for empty cases (no recordings yet) so the caller can skip them.
async fn compute_case_view(
    case_id: String,
    case_path: &Path,
    llm_configured: bool,
    worker_busy: bool,
    auto_delete_days: u32,
    now: OffsetDateTime,
) -> Option<UserCaseView> {
    let recordings = scan_recordings(case_path).await;
    if recordings.is_empty() {
        return None;
    }

    let most_recent = recordings
        .last()
        .map(|r| r.filename.clone())
        .unwrap_or_default();
    let recordings_count = recordings.len();
    let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
    let has_failed_recording = recordings.iter().any(|r| r.failed);
    let all_transcribed = non_failed_count > 0
        && recordings
            .iter()
            .filter(|r| !r.failed)
            .all(|r| r.transcript.has_file());

    let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await;

    let has_document = any_document_exists(case_path).await;
    let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
    let can_analyze = !analyzing && all_transcribed && llm_configured;

    let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;

    // One extra read per case with a document. Documents are in the KB
    // range — negligible next to the existing scan_recordings I/O — and
    // we skip the read entirely when the stat above said no document.
    // Rendered once, regardless of expand state: the browser handles
    // clamp-vs-expand purely through CSS toggling.
    let analysis_html = if has_document {
        read_document(case_path)
            .await
            .map(|md| crate::analyze::render::md_to_html(&md))
    } else {
        None
    };

    let time_hms_utc = extract_hhmm_utc(&most_recent);
    let recorded_at_iso = recorded_at_iso_of(&most_recent);
    Some(UserCaseView {
        case_id,
        most_recent,
        time_hms_utc,
        recorded_at_iso,
        oneliner,
        oneliner_is_manual,
        recordings_count,
        analyzing,
        has_document,
        has_failed_recording,
        can_analyze,
        is_closed,
        days_until_purge,
        analysis_html,
    })
}

/// Read the close marker (if any) and compute the purge countdown.
/// Returns `(is_closed, days_until_purge)`. `days_until_purge` is
/// `None` whenever the purge sweep is disabled (`auto_delete_days = 0`)
/// or the case is still open — the template then suppresses the
/// countdown and falls back to the bare "geschlossen" badge.
async fn closed_state(
    case_path: &Path,
    auto_delete_days: u32,
    now: OffsetDateTime,
) -> (bool, Option<u32>) {
    let Some(marker) = crate::paths::read_close_marker(case_path).await else {
        // No marker (open case) OR malformed marker. Malformed should
        // have been caught by `is_closed` upstream; we treat it as open
        // here to avoid a bogus countdown on a half-written file.
        let open_but_marker_present = crate::paths::is_closed(case_path).await;
        return (open_but_marker_present, None);
    };
    if auto_delete_days == 0 {
        return (true, None);
    }
    let age_days = OffsetDateTime::parse(&marker.closed_at, &Rfc3339)
        .map(|t| (now - t).whole_days().max(0))
        .unwrap_or(0);
    let remaining = (auto_delete_days as i64).saturating_sub(age_days).max(0);
    (true, Some(remaining as u32))
}

/// Extract only the text from a `Ready` state; other states (Empty,
/// Error, missing file) collapse to `None`. Used by the recordings
/// sub-page, which only needs a plain title string as heading.
async fn ready_text(case_dir: &Path) -> Option<String> {
    match paths::read_oneliner_state(case_dir).await.0 {
        Some(OnelinerState::Ready { text, .. }) => Some(text),
        _ => None,
    }
}

/// Compute the 5-state UI display for the oneliner plus a flag
/// indicating manual authorship. Identical logic to what
/// `compute_case_view` uses for the case list — extracted so the case
/// page can render the same states instead of a plain "Fall" fallback.
///
/// Returns `(display, is_manual)`. `is_manual` is `true` iff the
/// persisted state is `OnelinerState::Manual { .. }` AND the case is
/// in a stable state where that text is actually being shown
/// (i.e. not `Pending`/`Generating` — those override the persisted
/// content with progress placeholders, so the manual flag would be
/// misleading there).
async fn compute_oneliner_display(
    case_dir: &Path,
    recordings: &[RecordingView],
) -> (OnelinerDisplay, bool) {
    let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
    let all_transcribed = non_failed_count > 0
        && recordings
            .iter()
            .filter(|r| !r.failed)
            .all(|r| r.transcript.has_file());
    let (state, _) = paths::read_oneliner_state(case_dir).await;
    let is_manual = matches!(state, Some(OnelinerState::Manual { .. }));

    let display = if non_failed_count == 0 {
        // All recordings failed — stable end state, no LLM will ever run.
        // Missing state collapses to Empty ("unbenannt"): no title is
        // expected, regardless of why.
        match state {
            Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => {
                OnelinerDisplay::Ready(text)
            }
            Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
            Some(OnelinerState::Empty { .. }) | None => OnelinerDisplay::Empty,
        }
    } else if all_transcribed {
        // Transcripts done — stable state, except for the brief window
        // between transcript-write and state-write where the worker is
        // still producing its result.
        match state {
            Some(OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }) => {
                OnelinerDisplay::Ready(text)
            }
            Some(OnelinerState::Empty { .. }) => OnelinerDisplay::Empty,
            Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
            None => OnelinerDisplay::Generating,
        }
    } else {
        // Transcription still pending. Any persisted state is obsolete:
        // the worker will regenerate once the last transcript lands.
        match state {
            None => OnelinerDisplay::Pending,
            Some(_) => OnelinerDisplay::Generating,
        }
    };

    // Suppress the manual marker while we're showing a progress
    // placeholder ("transkribiere…", "generiere Titel…"): the displayed
    // string isn't the manual text anyway, so a 👤 next to it would lie.
    let show_manual = is_manual
        && !matches!(
            display,
            OnelinerDisplay::Pending | OnelinerDisplay::Generating
        );
    (display, show_manual)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mk_view(most_recent: &str, is_closed: bool) -> UserCaseView {
        UserCaseView {
            case_id: "00000000-0000-0000-0000-000000000000".into(),
            most_recent: most_recent.into(),
            time_hms_utc: String::new(),
            recorded_at_iso: String::new(),
            oneliner: OnelinerDisplay::Empty,
            oneliner_is_manual: false,
            recordings_count: 1,
            analyzing: false,
            has_document: false,
            has_failed_recording: false,
            can_analyze: false,
            is_closed,
            days_until_purge: None,
            analysis_html: None,
        }
    }

    #[test]
    fn group_by_utc_date_counts_open_only_cases_as_equal() {
        // Default view: no closed cases at all. open == total.
        let cases = vec![
            mk_view("2026-04-18T10-00-00Z.m4a", false),
            mk_view("2026-04-18T09-00-00Z.m4a", false),
        ];
        let extra = HashMap::new();
        let groups = group_by_utc_date(cases, &extra);
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].open_count, 2);
        assert_eq!(groups[0].total_count, 2);
    }

    #[test]
    fn group_by_utc_date_adds_closed_extra_to_total_only() {
        // show_closed=false path: visible cases are all open, closed ones
        // come in via the extra-map and inflate `total` without touching
        // `open`.
        let cases = vec![mk_view("2026-04-18T10-00-00Z.m4a", false)];
        let mut extra = HashMap::new();
        extra.insert("2026-04-18".to_string(), 2);
        let groups = group_by_utc_date(cases, &extra);
        assert_eq!(groups[0].open_count, 1);
        assert_eq!(groups[0].total_count, 3);
    }

    #[test]
    fn group_by_utc_date_under_show_closed_distinguishes_open_from_total() {
        // show_closed=true path: closed cases sit *inside* `cases` with
        // is_closed=true; the extra-map is empty. `open` must still be
        // the count of non-closed entries.
        let cases = vec![
            mk_view("2026-04-18T10-00-00Z.m4a", false),
            mk_view("2026-04-18T09-00-00Z.m4a", true),
            mk_view("2026-04-18T08-00-00Z.m4a", true),
        ];
        let extra = HashMap::new();
        let groups = group_by_utc_date(cases, &extra);
        assert_eq!(groups[0].open_count, 1);
        assert_eq!(groups[0].total_count, 3);
    }

    #[test]
    fn group_by_utc_date_separate_buckets_independent_counts() {
        // Two calendar days, one gets a closed-extra, the other doesn't.
        // Cross-talk between buckets would be a bug.
        let cases = vec![
            mk_view("2026-04-18T10-00-00Z.m4a", false),
            mk_view("2026-04-17T10-00-00Z.m4a", false),
            mk_view("2026-04-17T09-00-00Z.m4a", false),
        ];
        let mut extra = HashMap::new();
        extra.insert("2026-04-18".to_string(), 1);
        let groups = group_by_utc_date(cases, &extra);
        assert_eq!(groups.len(), 2);
        let g18 = groups.iter().find(|g| g.label == "2026-04-18").unwrap();
        let g17 = groups.iter().find(|g| g.label == "2026-04-17").unwrap();
        assert_eq!((g18.open_count, g18.total_count), (1, 2));
        assert_eq!((g17.open_count, g17.total_count), (2, 2));
    }

    #[tokio::test]
    async fn most_recent_date_label_picks_largest_filename() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path();
        std::fs::write(p.join("2026-04-17T10-00-00Z.m4a"), b"").unwrap();
        std::fs::write(p.join("2026-04-18T09-00-00Z.m4a"), b"").unwrap();
        std::fs::write(p.join("2026-04-18T08-00-00Z.m4a.failed"), b"").unwrap();
        // Noise files that must be ignored entirely.
        std::fs::write(p.join("document.md"), b"").unwrap();
        std::fs::write(p.join("oneliner.json"), b"").unwrap();
        let label = most_recent_date_label(p).await;
        assert_eq!(label.as_deref(), Some("2026-04-18"));
    }

    #[tokio::test]
    async fn most_recent_date_label_returns_none_without_recordings() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("document.md"), b"").unwrap();
        assert_eq!(most_recent_date_label(dir.path()).await, None);
    }
}
