//! Per-case marker store for native clients.
//!
//! Each known case (either created locally by this client or seen in a
//! server snapshot) has one JSON file: `{dir}/{case_id}.json`. The store
//! keeps an in-memory copy and broadcasts a sorted snapshot on every
//! write via [`tokio::sync::watch`].
//!
//! Two mutation paths treat the server snapshot asymmetrically:
//!
//! - [`CaseStore::merge_server_snapshot`] **adds or updates** markers —
//!   never deletes. `/api/oneliners` is a sliding-window view, so
//!   "missing from snapshot" on its own is ambiguous (could be deleted
//!   server-side, could simply have fallen out of the window).
//! - [`CaseStore::reconcile_with_server_snapshot`] **removes** synced
//!   markers the server doesn't know about **anymore**, scoped to the
//!   window reported by `OnelinersResponse.as_of - window_hours`. Pending
//!   markers (`synced_to_server = false`) and markers older than the
//!   window are preserved — their absence from the snapshot carries no
//!   information about deletion.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use doctate_common::oneliners::{OnelinerState, OnelinersResponse};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use tokio::sync::{Mutex, watch};
use tracing::{debug, info, warn};
use uuid::Uuid;

/// On-disk representation of a single case known to this client.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CaseMarker {
    pub case_id: Uuid,
    /// RFC3339 UTC; set once at creation or on first server-snapshot merge.
    pub created_at: String,
    /// RFC3339 UTC; bumped on every new recording + on server-snapshot
    /// updates. Drives the UI sort order (newest activity first).
    pub last_activity_at: String,
    /// `true` once this case has been seen in any `/api/oneliners`
    /// response or after a successful upload ACK. Drives two clean-up
    /// paths: [`CaseStore::reconcile_with_server_snapshot`] only removes
    /// synced markers the server no longer lists, and
    /// [`CaseStore::cleanup_stale`] only ages out synced markers. Pending
    /// (`= false`) markers guard unsent uploads and are never touched by
    /// either — losing one would silently erase a patient recording.
    pub synced_to_server: bool,
    /// Persisted oneliner state mirrored from the server snapshot.
    /// `None` while no state file has been written server-side yet (fresh
    /// case, nothing transcribed). The variant distinguishes usable text
    /// from "no medical content" and "generation failed" — clients render
    /// each case as they see fit.
    pub oneliner: Option<OnelinerState>,
}

#[derive(Debug, Error)]
pub enum CaseStoreError {
    #[error("I/O error at {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("serialize marker: {0}")]
    Serialize(#[from] serde_json::Error),
    #[error("format timestamp: {0}")]
    Format(#[from] time::error::Format),
    #[error("parse timestamp: {0}")]
    Parse(#[from] time::error::Parse),
}

type Snapshot = Arc<Vec<CaseMarker>>;

/// Shared case store. Cheap to `clone()`; clones share the same state.
#[derive(Clone)]
pub struct CaseStore {
    dir: PathBuf,
    state: Arc<Mutex<HashMap<Uuid, CaseMarker>>>,
    tx: watch::Sender<Snapshot>,
}

impl CaseStore {
    /// Open or create the store at `dir`. Reads every `*.json` file in
    /// the directory as an initial marker. Returns the store plus a
    /// receiver that yields sorted snapshots (newest `last_activity_at`
    /// first) on every mutation.
    pub async fn open(dir: PathBuf) -> Result<(Self, watch::Receiver<Snapshot>), CaseStoreError> {
        tokio::fs::create_dir_all(&dir)
            .await
            .map_err(|e| CaseStoreError::Io {
                path: dir.clone(),
                source: e,
            })?;

        let initial = read_all_markers(&dir).await?;
        let map: HashMap<Uuid, CaseMarker> = initial.into_iter().map(|m| (m.case_id, m)).collect();
        let snapshot = sorted_snapshot(&map);
        let (tx, rx) = watch::channel(snapshot);

        let store = Self {
            dir,
            state: Arc::new(Mutex::new(map)),
            tx,
        };
        Ok((store, rx))
    }

    /// Append a new case the client itself just started (no server
    /// knowledge yet). Writes the marker atomically and broadcasts the
    /// new snapshot. If a marker with the same `case_id` already exists,
    /// it is overwritten — callers must check before if that matters.
    pub async fn create_local(
        &self,
        case_id: Uuid,
        at: OffsetDateTime,
    ) -> Result<CaseMarker, CaseStoreError> {
        let ts = format_utc(at)?;
        let marker = CaseMarker {
            case_id,
            created_at: ts.clone(),
            last_activity_at: ts,
            synced_to_server: false,
            oneliner: None,
        };
        let mut state = self.state.lock().await;
        write_marker_file(&self.dir, &marker).await?;
        state.insert(case_id, marker.clone());
        self.broadcast(&state);
        Ok(marker)
    }

    /// Record new activity on an existing case (e.g. a follow-up
    /// recording). Creates the marker if missing — callers may invoke
    /// this after an upload without caring whether `create_local` ran
    /// first. `synced_to_server` is preserved.
    pub async fn mark_activity(
        &self,
        case_id: Uuid,
        at: OffsetDateTime,
    ) -> Result<(), CaseStoreError> {
        let ts = format_utc(at)?;
        let mut state = self.state.lock().await;
        let marker = state.entry(case_id).or_insert_with(|| CaseMarker {
            case_id,
            created_at: ts.clone(),
            last_activity_at: ts.clone(),
            synced_to_server: false,
            oneliner: None,
        });
        marker.last_activity_at = ts;
        write_marker_file(&self.dir, marker).await?;
        self.broadcast(&state);
        Ok(())
    }

    /// Mark an existing case as confirmed-by-server. Called by the
    /// `ServerSync` worker immediately after a successful upload ACK — so
    /// the `synced_to_server` flag reflects reality without waiting for
    /// the next `/api/oneliners` poll to round-trip.
    ///
    /// No-op if the marker does not exist locally (e.g. `clear_all` was
    /// called between `submit` and `ACK`). `last_activity_at` is **not**
    /// touched: its semantics are "last user activity" (recording end),
    /// not "last server confirmation".
    pub async fn mark_synced(&self, case_id: Uuid) -> Result<(), CaseStoreError> {
        let mut state = self.state.lock().await;
        let Some(marker) = state.get_mut(&case_id) else {
            return Ok(());
        };
        if marker.synced_to_server {
            return Ok(()); // already true, skip the disk write
        }
        marker.synced_to_server = true;
        let marker_clone = marker.clone();
        write_marker_file(&self.dir, &marker_clone).await?;
        self.broadcast(&state);
        Ok(())
    }

    /// Apply a doctor's manual oneliner override locally — used by the
    /// PUT-handler call site (Desktop "Save" button) for an optimistic
    /// UI update so the new text shows up before the next `/api/oneliners`
    /// poll round-trips. Server-side persistence is the caller's job
    /// (via [`crate::put_oneliner`]); this method only mirrors the
    /// resulting state into the local cache and broadcasts.
    ///
    /// No-op if the marker does not exist locally (analogous to
    /// [`Self::mark_synced`]). The `last_activity_at` field is left
    /// untouched: a manual edit is metadata, not new patient activity.
    pub async fn set_oneliner_local(
        &self,
        case_id: Uuid,
        state: OnelinerState,
    ) -> Result<(), CaseStoreError> {
        let mut store_state = self.state.lock().await;
        let Some(marker) = store_state.get_mut(&case_id) else {
            return Ok(());
        };
        marker.oneliner = Some(state);
        let marker_clone = marker.clone();
        write_marker_file(&self.dir, &marker_clone).await?;
        self.broadcast(&store_state);
        Ok(())
    }

    /// Apply a server `/api/oneliners` response. For every entry in the
    /// snapshot: insert (with `synced_to_server = true`) or update the
    /// local marker (oneliner + last_activity_at from server timestamps,
    /// taking the newer of local vs. server). Never deletes.
    ///
    /// The `last_activity_at` seed from the server prefers
    /// `last_recording_at` (most recent m4a mtime — the authoritative
    /// "when was this case last worked on?") over `updated_at` (oneliner
    /// regeneration time, which collapses to the recovery-scan moment
    /// after a restart). Falls back to `created_at` if neither is set.
    pub async fn merge_server_snapshot(
        &self,
        snapshot: &OnelinersResponse,
    ) -> Result<(), CaseStoreError> {
        let mut state = self.state.lock().await;
        for entry in &snapshot.oneliners {
            let Ok(case_id) = Uuid::parse_str(&entry.case_id) else {
                warn!(case_id = %entry.case_id, "server snapshot entry with invalid case_id, skipped");
                continue;
            };
            let server_activity = entry
                .last_recording_at
                .clone()
                .or_else(|| entry.updated_at.clone())
                .unwrap_or_else(|| entry.created_at.clone());

            let updated_marker = match state.get(&case_id) {
                Some(existing) => {
                    let mut m = existing.clone();
                    m.synced_to_server = true;
                    m.oneliner = entry.oneliner.clone();
                    // Keep whichever `last_activity_at` is newer — the
                    // client may have recorded an addendum the server
                    // hasn't processed yet.
                    if server_activity > m.last_activity_at {
                        m.last_activity_at = server_activity;
                    }
                    m
                }
                None => CaseMarker {
                    case_id,
                    created_at: entry.created_at.clone(),
                    last_activity_at: server_activity,
                    synced_to_server: true,
                    oneliner: entry.oneliner.clone(),
                },
            };
            write_marker_file(&self.dir, &updated_marker).await?;
            state.insert(case_id, updated_marker);
        }
        self.broadcast(&state);
        Ok(())
    }

    /// Reconcile local markers against an authoritative server snapshot.
    ///
    /// The window itself is **read from the server response**
    /// (`OnelinersResponse.window_hours`). Clients no longer decide how
    /// wide it is — the policy lives in the server's `users.toml` and
    /// arrives unchanged in every response. This function's only job
    /// is to interpret the window: within it, the server's silence
    /// about a case means "deleted"; outside it, silence means nothing.
    ///
    /// Removes exactly those markers that are all of:
    /// 1. `synced_to_server == true` (server has seen this case before),
    /// 2. `last_activity_at >= as_of - window_hours` (inside the window
    ///    the response is authoritative for), and
    /// 3. `case_id ∉ response.oneliners` (server no longer reports it).
    ///
    /// In that combination the server's silence is unambiguous: the
    /// case was deleted (web UI soft-delete, or future hard-delete by a
    /// retention sweep). Pending markers (`synced_to_server = false`)
    /// guard unsent uploads and are left alone. Markers older than the
    /// window are also preserved — their absence from the snapshot is
    /// expected and says nothing about deletion.
    ///
    /// Uses the server-reported `as_of` (not the client clock) as the
    /// window anchor, so clock skew between client and server cannot
    /// cause spurious deletions.
    pub async fn reconcile_with_server_snapshot(
        &self,
        response: &OnelinersResponse,
    ) -> Result<usize, CaseStoreError> {
        let as_of = OffsetDateTime::parse(&response.as_of, &Rfc3339)?;
        let window_start = as_of - Duration::hours(response.window_hours as i64);
        let window_start_str = format_utc(window_start)?;

        let server_ids: HashSet<Uuid> = response
            .oneliners
            .iter()
            .filter_map(|e| Uuid::parse_str(&e.case_id).ok())
            .collect();

        let mut state = self.state.lock().await;
        let to_remove: Vec<Uuid> = state
            .iter()
            .filter(|(id, m)| {
                m.synced_to_server
                    && m.last_activity_at >= window_start_str
                    && !server_ids.contains(id)
            })
            .map(|(id, _)| *id)
            .collect();
        let count = to_remove.len();
        for id in &to_remove {
            let path = self.dir.join(format!("{id}.json"));
            match tokio::fs::remove_file(&path).await {
                Ok(_) => {
                    info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)")
                }
                Err(e) => warn!(case_id = %id, error = %e, "failed to remove reconciled marker"),
            }
            state.remove(id);
        }
        if count > 0 {
            self.broadcast(&state);
        }
        Ok(count)
    }

    /// Drop markers whose last activity is older than `cutoff` **and**
    /// that the server has already confirmed (`synced_to_server = true`).
    /// Pending/unsynced markers are preserved — they guard uploads that
    /// have not yet reached the server, and their loss would silently
    /// erase patient recordings from the client's awareness.
    ///
    /// Returns the number of markers removed. Intended as an
    /// app-startup pass so the data-minimization rule holds: metadata
    /// ages out after a few days even without an explicit UI action.
    pub async fn cleanup_stale(&self, cutoff: OffsetDateTime) -> Result<usize, CaseStoreError> {
        let cutoff_str = format_utc(cutoff)?;
        let mut state = self.state.lock().await;
        let to_remove: Vec<Uuid> = state
            .iter()
            .filter(|(_, m)| m.synced_to_server && m.last_activity_at < cutoff_str)
            .map(|(id, _)| *id)
            .collect();
        let count = to_remove.len();
        for id in &to_remove {
            let path = self.dir.join(format!("{id}.json"));
            match tokio::fs::remove_file(&path).await {
                Ok(_) => {
                    tracing::info!(case_id = %id, path = %path.display(), "stale marker removed")
                }
                Err(e) => warn!(case_id = %id, error = %e, "failed to remove stale marker"),
            }
            state.remove(id);
        }
        if count > 0 {
            self.broadcast(&state);
        }
        Ok(count)
    }

    /// Remove every marker from memory and disk. Used on config change
    /// (new API key = potentially different user).
    pub async fn clear_all(&self) -> Result<(), CaseStoreError> {
        let mut state = self.state.lock().await;
        let mut entries = tokio::fs::read_dir(&self.dir)
            .await
            .map_err(|e| CaseStoreError::Io {
                path: self.dir.clone(),
                source: e,
            })?;
        while let Ok(Some(entry)) = entries.next_entry().await {
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            if let Err(e) = tokio::fs::remove_file(&path).await {
                warn!(path = %path.display(), error = %e, "failed to remove marker during clear");
            }
        }
        state.clear();
        self.broadcast(&state);
        Ok(())
    }

    /// Current in-memory list, for tests and inspection.
    pub async fn list(&self) -> Vec<CaseMarker> {
        let state = self.state.lock().await;
        sorted_vec(&state)
    }

    fn broadcast(&self, state: &HashMap<Uuid, CaseMarker>) {
        let snapshot = sorted_snapshot(state);
        // receiver-count=0 is fine (nobody listening); ignore.
        let _ = self.tx.send(snapshot);
    }
}

fn format_utc(at: OffsetDateTime) -> Result<String, time::error::Format> {
    at.to_offset(time::UtcOffset::UTC).format(&Rfc3339)
}

fn sorted_snapshot(state: &HashMap<Uuid, CaseMarker>) -> Snapshot {
    Arc::new(sorted_vec(state))
}

fn sorted_vec(state: &HashMap<Uuid, CaseMarker>) -> Vec<CaseMarker> {
    let mut v: Vec<CaseMarker> = state.values().cloned().collect();
    v.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
    v
}

async fn read_all_markers(dir: &Path) -> Result<Vec<CaseMarker>, CaseStoreError> {
    let mut out = Vec::new();
    let mut entries = tokio::fs::read_dir(dir)
        .await
        .map_err(|e| CaseStoreError::Io {
            path: dir.to_owned(),
            source: e,
        })?;
    while let Ok(Some(entry)) = entries.next_entry().await {
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        match read_marker_file(&path).await {
            Ok(m) => out.push(m),
            Err(e) => {
                // A single unreadable file should not poison the store;
                // log and skip so recovery of the rest proceeds.
                warn!(path = %path.display(), error = %e, "skipping unreadable marker");
            }
        }
    }
    Ok(out)
}

async fn read_marker_file(path: &Path) -> Result<CaseMarker, CaseStoreError> {
    let bytes = tokio::fs::read(path)
        .await
        .map_err(|e| CaseStoreError::Io {
            path: path.to_owned(),
            source: e,
        })?;
    Ok(serde_json::from_slice(&bytes)?)
}

/// Write a marker atomically: temp-file + rename. The rename on the same
/// filesystem is atomic on POSIX and NTFS, so readers never see a
/// partially-written file.
async fn write_marker_file(dir: &Path, marker: &CaseMarker) -> Result<(), CaseStoreError> {
    let bytes = serde_json::to_vec_pretty(marker)?;
    let final_path = dir.join(format!("{}.json", marker.case_id));
    let tmp_path = dir.join(format!("{}.json.tmp", marker.case_id));
    tokio::fs::write(&tmp_path, &bytes)
        .await
        .map_err(|e| CaseStoreError::Io {
            path: tmp_path.clone(),
            source: e,
        })?;
    tokio::fs::rename(&tmp_path, &final_path)
        .await
        .map_err(|e| CaseStoreError::Io {
            path: final_path.clone(),
            source: e,
        })?;
    debug!(path = %final_path.display(), "marker written");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use doctate_common::oneliners::OnelinerEntry;
    use tempfile::TempDir;

    fn t(s: &str) -> OffsetDateTime {
        OffsetDateTime::parse(s, &Rfc3339).unwrap()
    }

    fn ready(text: &str) -> OnelinerState {
        OnelinerState::Ready {
            text: text.to_owned(),
            generated_at: "2026-04-18T10:00:00Z".into(),
        }
    }

    fn text_of(o: &Option<OnelinerState>) -> Option<&str> {
        match o {
            Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
            _ => None,
        }
    }

    fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry {
        OnelinerEntry {
            case_id: case_id.to_string(),
            oneliner: oneliner.map(ready),
            created_at: created.to_owned(),
            last_recording_at: Some(created.to_owned()),
            updated_at: Some(created.to_owned()),
        }
    }

    fn server_snapshot(entries: Vec<OnelinerEntry>) -> OnelinersResponse {
        OnelinersResponse {
            as_of: "2026-04-18T10:00:00Z".into(),
            window_hours: 72,
            oneliners: entries,
        }
    }

    #[tokio::test]
    async fn create_local_persists_and_reloads() {
        let tmp = TempDir::new().unwrap();
        let (store, mut rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        assert!(rx.borrow().is_empty());

        let id = Uuid::new_v4();
        let marker = store
            .create_local(id, t("2026-04-18T10:32:00Z"))
            .await
            .unwrap();
        assert_eq!(marker.case_id, id);
        assert!(!marker.synced_to_server);
        assert!(marker.oneliner.is_none());

        rx.changed().await.unwrap();
        let snap = rx.borrow().clone();
        assert_eq!(snap.len(), 1);
        assert_eq!(snap[0].case_id, id);

        // Reopen: state must survive.
        drop(store);
        let (store2, _rx2) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        assert_eq!(store2.list().await.len(), 1);
    }

    #[tokio::test]
    async fn mark_activity_updates_timestamp() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        store
            .mark_activity(id, t("2026-04-18T10:45:00Z"))
            .await
            .unwrap();

        let list = store.list().await;
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].last_activity_at, "2026-04-18T10:45:00Z");
        // `created_at` must not change.
        assert_eq!(list[0].created_at, "2026-04-18T10:00:00Z");
    }

    #[tokio::test]
    async fn mark_activity_creates_if_missing() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .mark_activity(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        assert_eq!(store.list().await.len(), 1);
    }

    #[tokio::test]
    async fn merge_adds_unknown_server_case() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        let snap = server_snapshot(vec![server_entry(
            id,
            Some("Kniegelenk re."),
            "2026-04-18T09:30:00Z",
        )]);
        store.merge_server_snapshot(&snap).await.unwrap();

        let list = store.list().await;
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].case_id, id);
        assert_eq!(text_of(&list[0].oneliner), Some("Kniegelenk re."));
        assert!(list[0].synced_to_server);
    }

    #[tokio::test]
    async fn merge_updates_existing_local_marker() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();

        let snap = server_snapshot(vec![server_entry(
            id,
            Some("Hypertonie"),
            "2026-04-18T10:00:00Z",
        )]);
        store.merge_server_snapshot(&snap).await.unwrap();

        let list = store.list().await;
        assert_eq!(list.len(), 1);
        assert!(list[0].synced_to_server);
        assert_eq!(text_of(&list[0].oneliner), Some("Hypertonie"));
    }

    #[tokio::test]
    async fn merge_keeps_local_when_missing_from_server() {
        // User recorded locally, upload not yet through. Server snapshot
        // is empty — marker must survive.
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();

        let snap = server_snapshot(vec![]);
        store.merge_server_snapshot(&snap).await.unwrap();

        assert_eq!(store.list().await.len(), 1);
    }

    #[tokio::test]
    async fn merge_keeps_synced_marker_when_missing_from_server() {
        // Case was synced earlier, now out of the server's time window.
        // Must not be deleted.
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        let synced_snap =
            server_snapshot(vec![server_entry(id, Some("old"), "2026-04-18T10:00:00Z")]);
        store.merge_server_snapshot(&synced_snap).await.unwrap();

        // Next poll: server window moved, case fell out.
        store
            .merge_server_snapshot(&server_snapshot(vec![]))
            .await
            .unwrap();

        let list = store.list().await;
        assert_eq!(list.len(), 1);
        assert!(list[0].synced_to_server);
        assert_eq!(text_of(&list[0].oneliner), Some("old"));
    }

    #[tokio::test]
    async fn merge_prefers_newer_last_activity() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T09:00:00Z"))
            .await
            .unwrap();
        store
            .mark_activity(id, t("2026-04-18T11:00:00Z"))
            .await
            .unwrap();

        // Server says last activity was 10:00 (stale) — local 11:00 must win.
        let snap = server_snapshot(vec![server_entry(id, Some("x"), "2026-04-18T10:00:00Z")]);
        store.merge_server_snapshot(&snap).await.unwrap();

        let list = store.list().await;
        assert_eq!(list[0].last_activity_at, "2026-04-18T11:00:00Z");
    }

    #[tokio::test]
    async fn clear_all_removes_everything() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        store
            .create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        store
            .create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z"))
            .await
            .unwrap();
        assert_eq!(store.list().await.len(), 2);

        store.clear_all().await.unwrap();
        assert!(store.list().await.is_empty());
        // Marker files on disk are gone.
        let mut count = 0;
        let mut entries = tokio::fs::read_dir(tmp.path()).await.unwrap();
        while let Ok(Some(e)) = entries.next_entry().await {
            if e.path().extension().and_then(|s| s.to_str()) == Some("json") {
                count += 1;
            }
        }
        assert_eq!(count, 0);
    }

    /// The client must seed `last_activity_at` from `last_recording_at`
    /// (the authoritative dictation-activity timestamp), not from
    /// `updated_at` (oneliner regeneration time, which collapses to a
    /// narrow window after a startup recovery scan and breaks ordering).
    #[tokio::test]
    async fn merge_prefers_last_recording_over_updated_at() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();

        // Server: case created yesterday, last dictation 30 min ago,
        // oneliner regenerated just now in a recovery scan.
        let entry = OnelinerEntry {
            case_id: id.to_string(),
            oneliner: Some(ready("x")),
            created_at: "2026-04-17T09:00:00Z".into(),
            last_recording_at: Some("2026-04-18T09:30:00Z".into()),
            updated_at: Some("2026-04-18T10:00:00Z".into()),
        };
        store
            .merge_server_snapshot(&server_snapshot(vec![entry]))
            .await
            .unwrap();

        let list = store.list().await;
        assert_eq!(list[0].last_activity_at, "2026-04-18T09:30:00Z");
    }

    /// Backward-compat: if the server response omits `last_recording_at`
    /// (older server or future schema shift), the client falls back to
    /// `updated_at` then `created_at`.
    #[tokio::test]
    async fn merge_falls_back_to_updated_at_when_last_recording_missing() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        let entry = OnelinerEntry {
            case_id: id.to_string(),
            oneliner: Some(ready("x")),
            created_at: "2026-04-17T09:00:00Z".into(),
            last_recording_at: None,
            updated_at: Some("2026-04-18T10:00:00Z".into()),
        };
        store
            .merge_server_snapshot(&server_snapshot(vec![entry]))
            .await
            .unwrap();

        let list = store.list().await;
        assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z");
    }

    #[tokio::test]
    async fn cleanup_stale_removes_old_synced_markers() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let old_synced = Uuid::new_v4();
        let young_synced = Uuid::new_v4();
        let old_unsynced = Uuid::new_v4();

        // old_synced: created 4 days ago, then merged in → synced=true, old
        store
            .create_local(old_synced, t("2026-04-14T10:00:00Z"))
            .await
            .unwrap();
        store
            .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
                case_id: old_synced.to_string(),
                oneliner: Some(ready("old")),
                created_at: "2026-04-14T10:00:00Z".into(),
                last_recording_at: Some("2026-04-14T10:00:00Z".into()),
                updated_at: Some("2026-04-14T10:00:00Z".into()),
            }]))
            .await
            .unwrap();

        // young_synced: created today, synced
        store
            .create_local(young_synced, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        store
            .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
                case_id: young_synced.to_string(),
                oneliner: Some(ready("young")),
                created_at: "2026-04-18T10:00:00Z".into(),
                last_recording_at: Some("2026-04-18T10:00:00Z".into()),
                updated_at: Some("2026-04-18T10:00:00Z".into()),
            }]))
            .await
            .unwrap();

        // old_unsynced: created 4 days ago, NEVER merged in → pending upload
        store
            .create_local(old_unsynced, t("2026-04-14T10:00:00Z"))
            .await
            .unwrap();

        // Cutoff: 72h before the "today" markers, i.e. 2026-04-15.
        let cutoff = t("2026-04-15T10:00:00Z");
        let removed = store.cleanup_stale(cutoff).await.unwrap();

        assert_eq!(removed, 1, "only old+synced must be removed");
        let list = store.list().await;
        let ids: Vec<Uuid> = list.iter().map(|m| m.case_id).collect();
        assert!(ids.contains(&young_synced), "young synced must stay");
        assert!(
            ids.contains(&old_unsynced),
            "old unsynced must stay (pending upload)"
        );
        assert!(!ids.contains(&old_synced), "old synced must be gone");

        // Disk: old_synced's file deleted.
        let old_path = tmp.path().join(format!("{old_synced}.json"));
        assert!(
            !old_path.exists(),
            "marker file for old_synced must be deleted"
        );
    }

    #[tokio::test]
    async fn cleanup_stale_is_noop_when_nothing_old() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        store
            .create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        let removed = store
            .cleanup_stale(t("2026-01-01T00:00:00Z"))
            .await
            .unwrap();
        assert_eq!(removed, 0);
        assert_eq!(store.list().await.len(), 1);
    }

    /// Helper: build a snapshot with an explicit `as_of` anchor.
    fn snapshot_as_of(as_of: &str, entries: Vec<OnelinerEntry>) -> OnelinersResponse {
        OnelinersResponse {
            as_of: as_of.into(),
            window_hours: 72,
            oneliners: entries,
        }
    }

    /// Core case: a synced marker whose `last_activity_at` sits inside the
    /// server's window, but whose `case_id` is missing from the snapshot →
    /// that can only mean server-side deletion, so the marker goes.
    #[tokio::test]
    async fn reconcile_removes_synced_marker_missing_from_snapshot() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();

        // Seed a synced marker with recent activity.
        store
            .create_local(id, t("2026-04-18T09:00:00Z"))
            .await
            .unwrap();
        store.mark_synced(id).await.unwrap();

        // Snapshot authoritative for [2026-04-15T10Z, 2026-04-18T10Z],
        // but the case is not listed — server deleted it.
        let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
        let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();

        assert_eq!(removed, 1);
        assert!(store.list().await.is_empty());
        let path = tmp.path().join(format!("{id}.json"));
        assert!(!path.exists(), "marker file must be deleted");
    }

    /// Pending markers guard unsent uploads — they must never be touched
    /// by reconciliation, even if the server knows nothing about them.
    #[tokio::test]
    async fn reconcile_keeps_pending_marker_missing_from_snapshot() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();

        // Pending: synced_to_server = false (default after create_local).
        store
            .create_local(id, t("2026-04-18T09:00:00Z"))
            .await
            .unwrap();

        let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
        let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();

        assert_eq!(removed, 0);
        assert_eq!(store.list().await.len(), 1);
    }

    /// A synced marker older than the window: server not listing it is
    /// expected (fell out of the sliding window), not a deletion signal.
    /// Must survive reconciliation.
    #[tokio::test]
    async fn reconcile_keeps_old_synced_marker_outside_window() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();

        // last_activity_at = 2026-04-10 (8 days ago); window = 72h anchor 2026-04-18T10Z
        // → window_start = 2026-04-15T10Z. Marker is outside.
        store
            .create_local(id, t("2026-04-10T10:00:00Z"))
            .await
            .unwrap();
        store.mark_synced(id).await.unwrap();

        let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
        let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();

        assert_eq!(removed, 0);
        assert_eq!(store.list().await.len(), 1);
    }

    /// When every local marker is present in the snapshot, reconciliation
    /// is a pure no-op. Important negative test — prevents accidental
    /// over-deletion from a buggy predicate.
    #[tokio::test]
    async fn reconcile_is_noop_when_all_markers_present() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id_a = Uuid::new_v4();
        let id_b = Uuid::new_v4();
        store
            .create_local(id_a, t("2026-04-18T09:00:00Z"))
            .await
            .unwrap();
        store
            .create_local(id_b, t("2026-04-18T09:30:00Z"))
            .await
            .unwrap();
        store.mark_synced(id_a).await.unwrap();
        store.mark_synced(id_b).await.unwrap();

        let snap = snapshot_as_of(
            "2026-04-18T10:00:00Z",
            vec![
                server_entry(id_a, Some("a"), "2026-04-18T09:00:00Z"),
                server_entry(id_b, Some("b"), "2026-04-18T09:30:00Z"),
            ],
        );
        let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();

        assert_eq!(removed, 0);
        assert_eq!(store.list().await.len(), 2);
    }

    /// The window anchor is the server's `as_of`, not the local clock.
    /// A marker that would look "old" against `now` but sits inside the
    /// server's authoritative window must be reconciled correctly based
    /// on the server timestamp.
    #[tokio::test]
    async fn reconcile_uses_server_as_of_not_client_clock() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();

        // Marker last_activity_at 2026-04-18T09:00Z, synced.
        store
            .create_local(id, t("2026-04-18T09:00:00Z"))
            .await
            .unwrap();
        store.mark_synced(id).await.unwrap();

        // Server reports as_of 2026-04-18T10:00Z (1h after marker,
        // clearly inside 72h window) and does NOT list the case.
        let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
        assert_eq!(
            store.reconcile_with_server_snapshot(&snap).await.unwrap(),
            1,
            "inside server window → must be removed"
        );

        // Seed a second case for the symmetric assertion.
        let id2 = Uuid::new_v4();
        store
            .create_local(id2, t("2026-04-18T09:00:00Z"))
            .await
            .unwrap();
        store.mark_synced(id2).await.unwrap();

        // Now the server reports a much later as_of. The marker's
        // last_activity_at is further than 72h before as_of, so it's
        // *outside* the authoritative window → keep.
        let snap2 = snapshot_as_of("2026-04-30T10:00:00Z", vec![]);
        assert_eq!(
            store.reconcile_with_server_snapshot(&snap2).await.unwrap(),
            0,
            "outside server window → must survive"
        );
    }

    #[tokio::test]
    async fn mark_synced_flips_flag_without_touching_activity() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        assert!(!store.list().await[0].synced_to_server);

        store.mark_synced(id).await.unwrap();
        let list = store.list().await;
        assert!(list[0].synced_to_server);
        assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z");
    }

    #[tokio::test]
    async fn mark_synced_is_noop_when_marker_missing() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        // No marker at all — must not fail, must not create one.
        store.mark_synced(Uuid::new_v4()).await.unwrap();
        assert!(store.list().await.is_empty());
    }

    #[tokio::test]
    async fn mark_synced_is_idempotent() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        store.mark_synced(id).await.unwrap();
        store.mark_synced(id).await.unwrap(); // second call: no disk churn
        assert!(store.list().await[0].synced_to_server);
    }

    #[tokio::test]
    async fn sorted_newest_first() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let older = Uuid::new_v4();
        let newer = Uuid::new_v4();
        store
            .create_local(older, t("2026-04-18T08:00:00Z"))
            .await
            .unwrap();
        store
            .create_local(newer, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();

        let list = store.list().await;
        assert_eq!(list[0].case_id, newer);
        assert_eq!(list[1].case_id, older);
    }

    #[tokio::test]
    async fn set_oneliner_local_updates_existing_marker() {
        let tmp = TempDir::new().unwrap();
        let (store, mut rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let id = Uuid::new_v4();
        store
            .create_local(id, t("2026-04-18T10:00:00Z"))
            .await
            .unwrap();
        // Drain the broadcast emitted by `create_local` so the next
        // `changed()` call observes the override-write specifically.
        let _ = rx.borrow_and_update();

        let manual = OnelinerState::Manual {
            text: "doctor-edit".into(),
            set_at: "2026-04-26T11:00:00Z".into(),
        };
        store.set_oneliner_local(id, manual.clone()).await.unwrap();

        // In-memory state reflects the override.
        let list = store.list().await;
        assert_eq!(list.len(), 1);
        match &list[0].oneliner {
            Some(OnelinerState::Manual { text, set_at }) => {
                assert_eq!(text, "doctor-edit");
                assert_eq!(set_at, "2026-04-26T11:00:00Z");
            }
            other => panic!("expected Manual, got {other:?}"),
        }

        // Watch channel fired (broadcast).
        assert!(rx.has_changed().unwrap_or(false));

        // Marker file on disk reflects the override too — survives restart.
        let path = tmp.path().join(format!("{id}.json"));
        let bytes = tokio::fs::read(&path).await.unwrap();
        let on_disk: CaseMarker = serde_json::from_slice(&bytes).unwrap();
        assert!(matches!(
            on_disk.oneliner,
            Some(OnelinerState::Manual { .. })
        ));
    }

    #[tokio::test]
    async fn set_oneliner_local_no_op_when_marker_missing() {
        let tmp = TempDir::new().unwrap();
        let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
        let unknown = Uuid::new_v4();

        let result = store
            .set_oneliner_local(
                unknown,
                OnelinerState::Manual {
                    text: "x".into(),
                    set_at: "2026-04-26T11:00:00Z".into(),
                },
            )
            .await;
        assert!(result.is_ok(), "no-op must not error");
        assert!(store.list().await.is_empty(), "no marker should be created");
    }
}
