//! On-disk cache of the last successful `/api/oneliners` response.
//!
//! Survives app restarts so the UI has data before the first poll
//! completes. A single JSON file under a caller-chosen path. Atomic
//! writes via temp-file + rename. Read errors on malformed content are
//! treated the same as "no cache" — the next poll will rebuild it.

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

use doctate_common::oneliners::OnelinersResponse;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSnapshot {
    /// Last ETag we handed the server in `If-None-Match` — raw header
    /// value, including the `W/"..."` wrapper.
    pub etag: String,
    /// RFC3339 UTC; when the client received the response. Feeds the
    /// staleness indicator on cold start.
    pub fetched_at: String,
    pub response: OnelinersResponse,
}

#[derive(Clone)]
pub struct SnapshotCache {
    path: PathBuf,
}

impl SnapshotCache {
    pub fn new(path: PathBuf) -> Self {
        Self { path }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Read and deserialize. `None` on missing file, unreadable file, or
    /// parse error — the caller treats all three as "cold start".
    pub async fn load(&self) -> Option<CachedSnapshot> {
        let bytes = match tokio::fs::read(&self.path).await {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
            Err(e) => {
                warn!(path = %self.path.display(), error = %e, "snapshot cache read failed");
                return None;
            }
        };
        match serde_json::from_slice::<CachedSnapshot>(&bytes) {
            Ok(c) => Some(c),
            Err(e) => {
                warn!(path = %self.path.display(), error = %e, "snapshot cache malformed — ignoring");
                None
            }
        }
    }

    /// Persist atomically: write to `{path}.tmp`, then rename.
    pub async fn store(&self, cached: &CachedSnapshot) -> std::io::Result<()> {
        if let Some(parent) = self.path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        let tmp = self.path.with_extension(tmp_extension(&self.path));
        let bytes = serde_json::to_vec(cached)
            .map_err(|e| std::io::Error::other(format!("serialize cached snapshot: {e}")))?;
        tokio::fs::write(&tmp, bytes).await?;
        tokio::fs::rename(&tmp, &self.path).await?;
        debug!(path = %self.path.display(), "snapshot cache written");
        Ok(())
    }

    /// Delete the cache file. Idempotent: missing file is success.
    pub async fn clear(&self) -> std::io::Result<()> {
        match tokio::fs::remove_file(&self.path).await {
            Ok(_) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(e),
        }
    }
}

/// Build a `{original_extension}.tmp`-style extension that the rename
/// target never accidentally matches. For `foo.json` → `json.tmp`.
fn tmp_extension(path: &Path) -> String {
    match path.extension().and_then(|s| s.to_str()) {
        Some(ext) => format!("{ext}.tmp"),
        None => "tmp".to_owned(),
    }
}

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

    fn sample() -> CachedSnapshot {
        CachedSnapshot {
            etag: "W/\"1729512345-72\"".into(),
            fetched_at: "2026-04-18T10:45:00Z".into(),
            response: OnelinersResponse {
                as_of: "2026-04-18T10:45:00Z".into(),
                window_hours: 72,
                oneliners: vec![OnelinerEntry {
                    case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
                    oneliner: Some(OnelinerState::Ready {
                        text: "Kniegelenk".into(),
                        generated_at: "2026-04-18T10:30:00Z".into(),
                    }),
                    created_at: "2026-04-18T10:00:00Z".into(),
                    last_recording_at: Some("2026-04-18T10:30:00Z".into()),
                    updated_at: Some("2026-04-18T10:30:00Z".into()),
                }],
            },
        }
    }

    #[tokio::test]
    async fn store_and_load_roundtrip() {
        let tmp = TempDir::new().unwrap();
        let cache = SnapshotCache::new(tmp.path().join("cache.json"));
        let s = sample();
        cache.store(&s).await.unwrap();

        let loaded = cache.load().await.expect("cache present");
        assert_eq!(loaded.etag, s.etag);
        assert_eq!(loaded.response.oneliners.len(), 1);
        let text = match &loaded.response.oneliners[0].oneliner {
            Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
            _ => None,
        };
        assert_eq!(text, Some("Kniegelenk"));
    }

    #[tokio::test]
    async fn load_returns_none_when_missing() {
        let tmp = TempDir::new().unwrap();
        let cache = SnapshotCache::new(tmp.path().join("missing.json"));
        assert!(cache.load().await.is_none());
    }

    #[tokio::test]
    async fn load_returns_none_on_garbage() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("garbage.json");
        tokio::fs::write(&path, b"this is not json").await.unwrap();
        let cache = SnapshotCache::new(path);
        assert!(cache.load().await.is_none());
    }

    #[tokio::test]
    async fn clear_is_idempotent() {
        let tmp = TempDir::new().unwrap();
        let cache = SnapshotCache::new(tmp.path().join("cache.json"));
        cache.clear().await.unwrap();

        cache.store(&sample()).await.unwrap();
        assert!(cache.load().await.is_some());

        cache.clear().await.unwrap();
        assert!(cache.load().await.is_none());

        // Second clear on already-empty is still Ok.
        cache.clear().await.unwrap();
    }

    #[tokio::test]
    async fn store_creates_parent_dirs() {
        let tmp = TempDir::new().unwrap();
        let deep = tmp.path().join("a/b/c/cache.json");
        let cache = SnapshotCache::new(deep.clone());
        cache.store(&sample()).await.unwrap();
        assert!(deep.exists());
    }
}
