//! GET /api/oneliners — list oneliners for the authenticated user.
//!
//! The time window is **server-authoritative**: it is read from the
//! user's `users.toml` entry (`window_hours`) and surfaced via the
//! [`AuthenticatedUser`] extractor. Clients no longer control it —
//! any `?hours=N` on the request URL is ignored.
//!
//! Each entry carries the case-id, the oneliner state (or null if no
//! state file has been written yet), and UTC timestamps for when the
//! case started and when its oneliner was last written.
//!
//! Conditional-GET: the response carries a weak ETag
//! `W/"<fingerprint_hex>-<hours>"`. The fingerprint is a deterministic
//! FNV-1a hash over the sorted tuples
//! `(case_id, last_recording_at_ns, oneliner_mtime_ns)` of every
//! *visible* case. Any listen-relevant FS change — new recording,
//! oneliner regeneration, soft-delete, undo, upload-auto-reopen —
//! shifts at least one tuple and the hash moves. Server restart is a
//! no-op: same FS, same hash.
//!
//! The `hours` component of the ETag stays so that an admin editing
//! `window_hours` in `users.toml` (and restarting the server)
//! invalidates existing client caches automatically.

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

use axum::Json;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use tracing::warn;

use crate::auth::AuthenticatedUser;
use crate::error::AppError;
use crate::paths;

pub async fn handle_oneliners(
    user: AuthenticatedUser,
    headers: HeaderMap,
) -> Result<Response, AppError> {
    let hours = user.window_hours;
    let now = OffsetDateTime::now_utc();
    let since = now - Duration::hours(hours as i64);

    // One scan pass yields both the case list and the fingerprint —
    // the fingerprint is the authoritative signal for "has the view
    // changed?". Body serialization is skipped if the client already
    // has this version (304).
    let scan = scan_cases(&user.data_dir, since).await;
    let etag = build_etag(scan.fingerprint, hours);

    if client_has_matching_etag(&headers, &etag) {
        return Ok(not_modified(&etag));
    }

    let body = OnelinersResponse {
        as_of: now.format(&Rfc3339).unwrap_or_default(),
        window_hours: hours,
        oneliners: scan.entries,
    };

    let mut response = Json(body).into_response();
    if let Ok(value) = etag.parse() {
        response.headers_mut().insert(header::ETAG, value);
    }
    Ok(response)
}

fn build_etag(fingerprint: u64, hours: u32) -> String {
    format!("W/\"{fingerprint:016x}-{hours}\"")
}

/// Case-sensitive match against the raw `If-None-Match` header value.
/// No wildcard or comma-split logic — the client is our own code sending
/// back the exact ETag we handed it.
fn client_has_matching_etag(headers: &HeaderMap, etag: &str) -> bool {
    headers
        .get(header::IF_NONE_MATCH)
        .and_then(|v| v.to_str().ok())
        .map(|sent| sent == etag)
        .unwrap_or(false)
}

fn not_modified(etag: &str) -> Response {
    let mut response = StatusCode::NOT_MODIFIED.into_response();
    if let Ok(value) = etag.parse() {
        response.headers_mut().insert(header::ETAG, value);
    }
    response
}

/// Output of [`scan_cases`]: the sorted entry list that becomes the
/// response body, and a fingerprint derived from the same scan.
struct ScanResult {
    entries: Vec<OnelinerEntry>,
    fingerprint: u64,
}

/// Scan `<user_data_dir>/<case_id>/` for cases whose latest recording
/// sits inside the `[since, now]` window. Returns entries sorted by
/// `last_recording_at` descending (most recently active first); cases
/// without recordings fall back to `created_at`. Soft-deleted cases and
/// non-UUID directories are filtered out.
///
/// Alongside the entries we compute a deterministic fingerprint
/// (`fnv1a_64`) over the sorted set of
/// `(case_id, last_recording_at_ns, oneliner_mtime_ns)` tuples — cheap
/// to compute during the scan, and it changes exactly when the visible
/// list changes. No RAM-local state, so server restart is a no-op.
async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult {
    let mut entries: Vec<OnelinerEntry> = Vec::new();
    let Ok(mut cases) = tokio::fs::read_dir(user_data_dir).await else {
        return ScanResult {
            entries,
            fingerprint: fnv1a_64(b""),
        };
    };
    // Parallel array keyed 1:1 with `entries` carrying the raw nanosecond
    // timestamps needed for the fingerprint. Kept separate so the
    // response DTO stays flat.
    let mut fp_tuples: Vec<(String, i128, i128)> = Vec::new();
    while let Ok(Some(case_entry)) = cases.next_entry().await {
        let case_dir = case_entry.path();
        if !case_dir.is_dir() {
            continue;
        }
        let Some(case_id) = case_dir.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        if uuid::Uuid::parse_str(case_id).is_err() {
            continue;
        }
        if crate::paths::is_closed(&case_dir).await {
            continue;
        }

        let Some((earliest, latest)) = m4a_mtime_range(&case_dir).await else {
            continue;
        };
        let created_at = OffsetDateTime::from(earliest);
        let last_recording_at = OffsetDateTime::from(latest);
        // Window check on the *latest* recording: a case that got a
        // follow-up dictation today must show up even if it was first
        // created years ago.
        if last_recording_at < since {
            continue;
        }

        let (oneliner_state, updated_at) = paths::read_oneliner_state(&case_dir).await;
        let Ok(created_at_str) = created_at.format(&Rfc3339) else {
            warn!(case = %case_dir.display(), "format created_at failed");
            continue;
        };
        let last_recording_at_str = last_recording_at.format(&Rfc3339).ok();
        let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok());

        let last_rec_ns = last_recording_at.unix_timestamp_nanos();
        let oneliner_ns = updated_at.map(|t| t.unix_timestamp_nanos()).unwrap_or(0);
        fp_tuples.push((case_id.to_owned(), last_rec_ns, oneliner_ns));

        entries.push(OnelinerEntry {
            case_id: case_id.to_owned(),
            oneliner: oneliner_state,
            created_at: created_at_str,
            last_recording_at: last_recording_at_str,
            updated_at: updated_at_str,
        });
    }
    // Primary sort: last_recording_at desc (most recent dictation on top).
    // Fallback for entries without recordings (shouldn't happen today):
    // use created_at. Strings sort in the same order as RFC3339 UTC
    // timestamps because they are lexicographically monotonic.
    entries.sort_by(|a, b| {
        let a_key = a.last_recording_at.as_deref().unwrap_or(&a.created_at);
        let b_key = b.last_recording_at.as_deref().unwrap_or(&b.created_at);
        b_key.cmp(a_key)
    });

    // Fingerprint: independent of entries' display order, so sort the
    // tuple list by case_id and hash.
    fp_tuples.sort_by(|a, b| a.0.cmp(&b.0));
    let fingerprint = fingerprint_tuples(&fp_tuples);

    ScanResult {
        entries,
        fingerprint,
    }
}

/// FNV-1a 64-bit over the concatenation of the tuples. Stable across
/// server restarts — no `RandomState`, no HashMap hashing. Ample
/// resistance against accidental collisions for ETag purposes.
fn fingerprint_tuples(tuples: &[(String, i128, i128)]) -> u64 {
    let mut h: u64 = FNV_OFFSET;
    for (case_id, last_rec_ns, oneliner_ns) in tuples {
        for b in case_id.as_bytes() {
            h ^= *b as u64;
            h = h.wrapping_mul(FNV_PRIME);
        }
        // Separator so `"ab" || "12"` ≠ `"a" || "b12"`.
        h ^= b'|' as u64;
        h = h.wrapping_mul(FNV_PRIME);
        for b in last_rec_ns.to_le_bytes() {
            h ^= b as u64;
            h = h.wrapping_mul(FNV_PRIME);
        }
        h ^= b'|' as u64;
        h = h.wrapping_mul(FNV_PRIME);
        for b in oneliner_ns.to_le_bytes() {
            h ^= b as u64;
            h = h.wrapping_mul(FNV_PRIME);
        }
        h ^= b'\n' as u64;
        h = h.wrapping_mul(FNV_PRIME);
    }
    h
}

fn fnv1a_64(bytes: &[u8]) -> u64 {
    let mut h: u64 = FNV_OFFSET;
    for b in bytes {
        h ^= *b as u64;
        h = h.wrapping_mul(FNV_PRIME);
    }
    h
}

const FNV_OFFSET: u64 = 0xcbf29ce4_84222325;
const FNV_PRIME: u64 = 0x00000100_000001b3;

/// Return `(earliest, latest)` `.m4a` mtime pair in `case_dir`.
/// `.m4a.failed` entries count — a failed recording still marks activity.
/// `None` on empty directory or read error. A single recording yields
/// `(mtime, mtime)`.
async fn m4a_mtime_range(case_dir: &Path) -> Option<(SystemTime, SystemTime)> {
    let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
    let mut earliest: Option<SystemTime> = None;
    let mut latest: Option<SystemTime> = None;
    while let Ok(Some(entry)) = files.next_entry().await {
        let path: PathBuf = entry.path();
        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) {
            continue;
        }
        let Ok(meta) = entry.metadata().await else {
            continue;
        };
        let Ok(mtime) = meta.modified() else {
            continue;
        };
        earliest = match earliest {
            None => Some(mtime),
            Some(e) if mtime < e => Some(mtime),
            other => other,
        };
        latest = match latest {
            None => Some(mtime),
            Some(l) if mtime > l => Some(mtime),
            other => other,
        };
    }
    match (earliest, latest) {
        (Some(e), Some(l)) => Some((e, l)),
        _ => None,
    }
}
