//! Catalog **fetch + cache**: pulling a source's signed index over HTTPS and keeping the last good
//! copy on disk (design `plugin-store.md` §4.1).
//!
//! Two properties matter more than freshness here:
//!
//! - **Fail closed on signatures, fail soft on the network.** A source with a pinned key whose
//!   document doesn't verify is an *error* — we keep serving the last good copy and say so, and we
//!   never fall back to "well, unsigned then". But a box that simply can't reach the internet
//!   (LAN-only, common for a streaming host) keeps a working store: the cached shelf still browses,
//!   and installs off it still pin and integrity-check, because the pin travelled with the entry.
//! - **Bounded everything.** Size cap, timeout, redirect cap, https-only, no credentials ever
//!   attached. A source URL is operator config, not request-time input, so there is no lever here
//!   for a browser to aim the host at an arbitrary address.

use super::index::{Index, MAX_INDEX_BYTES};
use super::sources::Source;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// Wall-clock budget for one index (or signature) fetch.
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);

/// What a refresh attempt produced.
pub(crate) enum Fetched {
    /// A new, verified, parsed document.
    Fresh {
        index: Box<Index>,
        etag: Option<String>,
    },
    /// The source says our cached copy is still current (HTTP 304).
    NotModified,
    /// Nothing usable arrived. The caller keeps whatever it had and marks the source stale.
    Failed(String),
}

/// Fetch, verify and parse a source's index.
///
/// Blocking (`ureq`) — callers run this on a blocking thread, never on the async runtime.
pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
    if !source.url.starts_with("https://") {
        return Fetched::Failed("source url must be https".into());
    }
    let agent: ureq::Agent = ureq::Agent::config_builder()
        .timeout_global(Some(FETCH_TIMEOUT))
        // A signed document doesn't need many hops to reach us; a redirect chain is a good way to
        // waste a host's time.
        .max_redirects(3)
        .user_agent(format!("punktfunk-host/{}", super::index::host_version()))
        .build()
        .into();

    let mut req = agent.get(&source.url);
    if let Some(tag) = etag {
        req = req.header("If-None-Match", tag);
    }
    let mut resp = match req.call() {
        // `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
        // arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
        // (the intuitive reading) means every refresh after the first one verifies a signature
        // over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and
        // never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
        Ok(r) if r.status() == 304 => return Fetched::NotModified,
        Ok(r) => r,
        Err(ureq::Error::StatusCode(code)) => {
            return Fetched::Failed(format!("index fetch returned HTTP {code}"))
        }
        Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
    };
    let new_etag = resp
        .headers()
        .get("etag")
        .and_then(|v| v.to_str().ok())
        .map(str::to_string);
    let body = match read_capped(&mut resp) {
        Ok(b) => b,
        Err(e) => return Fetched::Failed(e),
    };

    // Signature FIRST — nothing below may look at a field before this passes (design §6.3).
    let keys = source.keys();
    if !keys.is_empty() {
        let sig = match agent.get(&source.sig_url()).call() {
            Ok(mut r) => match read_capped(&mut r) {
                Ok(b) => b,
                Err(e) => return Fetched::Failed(format!("signature: {e}")),
            },
            Err(e) => {
                return Fetched::Failed(format!(
                    "this source is signed but its signature could not be fetched: {e}"
                ))
            }
        };
        let sig_text = match String::from_utf8(sig) {
            Ok(s) => s,
            Err(_) => return Fetched::Failed("signature file is not text".into()),
        };
        if let Err(e) = super::index::verify_signature(&body, &sig_text, &keys) {
            return Fetched::Failed(format!("signature check failed: {e}"));
        }
    }

    match Index::parse(&body) {
        Ok(index) => Fetched::Fresh {
            index: Box::new(index),
            etag: new_etag,
        },
        Err(e) => Fetched::Failed(format!("{e:#}")),
    }
}

/// Read a response body, refusing anything past the cap without buffering it.
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, String> {
    // Limit is cap+1 so a body of exactly cap+1 comes back intact and is rejected by the length
    // check below with our own message; anything larger trips ureq's own limit error. Either way it
    // is an Err — unlike ureq 2's `take()`, which truncated silently and then failed the signature
    // check with a message that pointed at the wrong thing.
    let buf = resp
        .body_mut()
        .with_config()
        .limit((MAX_INDEX_BYTES + 1) as u64)
        .read_to_vec()
        .map_err(|e| format!("reading the response body failed: {e}"))?;
    if buf.len() > MAX_INDEX_BYTES {
        return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
    }
    Ok(buf)
}

// ---------------------------------------------------------------- disk cache

/// `<config_dir>/store-cache` — the last good copy of every source's index, so a host that boots
/// without a network still has a browsable store.
pub(crate) fn cache_dir() -> PathBuf {
    pf_paths::config_dir().join("store-cache")
}

fn body_path(dir: &Path, source: &str) -> PathBuf {
    dir.join(format!("{source}.json"))
}

fn meta_path(dir: &Path, source: &str) -> PathBuf {
    dir.join(format!("{source}.meta.json"))
}

#[derive(serde::Serialize, serde::Deserialize, Default)]
pub(crate) struct CacheMeta {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub etag: Option<String>,
    /// Unix seconds of the fetch that produced the cached body.
    #[serde(default)]
    pub fetched_at: u64,
}

/// The cached index for a source, if one is on disk and still parses. Re-validated on read: a
/// cache file is just as untrusted as the wire (it lives in a directory an admin can write).
///
/// NOTE the signature is **not** re-checked here — it was checked when the bytes were accepted,
/// and the cache directory is inside the host's own private config tree.
pub(crate) fn read_cache(dir: &Path, source: &str) -> Option<(Index, CacheMeta)> {
    let body = std::fs::read(body_path(dir, source)).ok()?;
    let index = Index::parse(&body).ok()?;
    let meta = std::fs::read(meta_path(dir, source))
        .ok()
        .and_then(|b| serde_json::from_slice::<CacheMeta>(&b).ok())
        .unwrap_or_default();
    Some((index, meta))
}

/// Persist a freshly verified index as the new last-good copy.
pub(crate) fn write_cache(dir: &Path, source: &str, index: &Index, meta: &CacheMeta) {
    if let Err(e) = pf_paths::create_private_dir(dir) {
        tracing::warn!("could not create the store cache dir: {e}");
        return;
    }
    let write = |path: PathBuf, bytes: Vec<u8>| {
        let tmp = path.with_extension("tmp");
        if std::fs::write(&tmp, bytes).is_ok() {
            let _ = std::fs::rename(&tmp, &path);
        }
    };
    match serde_json::to_vec_pretty(index) {
        Ok(b) => write(body_path(dir, source), b),
        Err(e) => tracing::warn!("could not serialize the catalog cache: {e}"),
    }
    if let Ok(b) = serde_json::to_vec_pretty(meta) {
        write(meta_path(dir, source), b);
    }
}

/// Drop a removed source's cache files so a later re-add can't be served a stale shelf.
pub(crate) fn drop_cache(dir: &Path, source: &str) {
    let _ = std::fs::remove_file(body_path(dir, source));
    let _ = std::fs::remove_file(meta_path(dir, source));
}

pub(crate) fn unix_now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    fn sample_index() -> Index {
        Index::parse(
            br#"{"schema":1,"name":"t","plugins":[{"id":"a","pkg":"@p/plugin-a",
                "registry":"https://r.example/","title":"A","version":"1.0.0",
                "integrity":"sha512-AAAA"}]}"#,
        )
        .unwrap()
    }

    #[test]
    fn cache_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let idx = sample_index();
        let meta = CacheMeta {
            etag: Some("\"abc\"".into()),
            fetched_at: 1_700_000_000,
        };
        write_cache(dir.path(), "unom", &idx, &meta);

        let (back, back_meta) = read_cache(dir.path(), "unom").expect("cache should be readable");
        assert_eq!(back.plugins.len(), 1);
        assert_eq!(back.plugins[0].id, "a");
        assert_eq!(back_meta.etag.as_deref(), Some("\"abc\""));
        assert_eq!(back_meta.fetched_at, 1_700_000_000);
    }

    #[test]
    fn missing_or_corrupt_cache_reads_as_none() {
        let dir = tempfile::tempdir().unwrap();
        assert!(read_cache(dir.path(), "nope").is_none());
        std::fs::write(dir.path().join("bad.json"), b"{ not json").unwrap();
        assert!(read_cache(dir.path(), "bad").is_none());
    }

    #[test]
    fn cache_is_revalidated_on_read() {
        // A tampered cache file (schema bumped by hand) must not be served.
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("x.json"), br#"{"schema":99,"plugins":[]}"#).unwrap();
        assert!(read_cache(dir.path(), "x").is_none());
    }

    #[test]
    fn drop_cache_removes_both_files() {
        let dir = tempfile::tempdir().unwrap();
        write_cache(dir.path(), "s", &sample_index(), &CacheMeta::default());
        assert!(read_cache(dir.path(), "s").is_some());
        drop_cache(dir.path(), "s");
        assert!(read_cache(dir.path(), "s").is_none());
        assert!(!meta_path(dir.path(), "s").exists());
    }

    /// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on.
    ///
    /// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a
    /// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes
    /// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and
    /// silently breaks every refresh after the first: the empty body fails the signature check and
    /// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than
    /// on someone's host.
    #[test]
    fn ureq_returns_304_as_ok() {
        use std::io::{Read as _, Write as _};
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let server = std::thread::spawn(move || {
            if let Ok((mut sock, _)) = listener.accept() {
                // Drain the request BEFORE answering. Closing a socket that still has unread
                // received data makes Windows send an RST rather than a FIN, which destroys the
                // response we just wrote — the client then sees a transport error (os error 10053)
                // instead of the 304 this test exists to pin. A GET has no body, so the header
                // terminator is the whole request.
                let mut buf = Vec::new();
                let mut chunk = [0u8; 1024];
                while let Ok(n) = sock.read(&mut chunk) {
                    if n == 0 {
                        break;
                    }
                    buf.extend_from_slice(&chunk[..n]);
                    if buf.windows(4).any(|w| w == b"\r\n\r\n") {
                        break;
                    }
                }
                let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
                let _ = sock.flush();
            }
        });

        let resp = ureq::get(&format!("http://{addr}/index.json"))
            .header("If-None-Match", "\"x\"")
            .call();
        let _ = server.join();

        match resp {
            Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"),
            Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"),
        }
    }

    #[test]
    fn non_https_source_never_reaches_the_network() {
        let s = Source {
            name: "x".into(),
            url: "http://example.org/i.json".into(),
            public_key: None,
        };
        assert!(matches!(fetch(&s, None), Fetched::Failed(_)));
    }
}
