//! Content-addressed blob storage and archive extraction for ingest.
//!
//! Blobs are stored at `<base>/blobs/<sha[0:2]>/<sha>` and deduplicated by
//! digest; per-run working directories live under `<base>/work/`.

use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

use crate::error::AgentError;

/// Read buffer size for streaming hashes/copies (64 KiB).
const BUF_LEN: usize = 64 * 1024;

/// Stream-hash a file with SHA-256, returning the lowercase-hex digest and the
/// byte length. Streams so large firmware images never load fully into memory.
pub fn hash_file(path: &Path) -> Result<(String, u64), AgentError> {
    let mut file = File::open(path)?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; BUF_LEN];
    let mut total: u64 = 0;
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
        total += n as u64;
    }
    Ok((hex::encode(hasher.finalize()), total))
}

/// Store raw bytes in the content-addressed blob store under `base`, returning
/// the SHA-256 digest. Used to stash a small derived artifact (e.g. the extracted
/// PLC program source) so a Werkbank runner can fetch it by hash. Idempotent.
pub fn store_bytes(base: &Path, bytes: &[u8]) -> Result<String, AgentError> {
    let sha = hex::encode(Sha256::digest(bytes));
    let dir = base.join("blobs").join(&sha[0..2]);
    fs::create_dir_all(&dir)?;
    let dest = dir.join(&sha);
    if !dest.exists() {
        fs::write(&dest, bytes)?;
    }
    Ok(sha)
}

/// Read a blob's bytes by its SHA-256 digest. Rejects a non-hex/wrong-length hash
/// so a request can't traverse outside the blob store.
pub fn read_blob(base: &Path, sha: &str) -> Result<Vec<u8>, AgentError> {
    if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
        return Err(AgentError::Other(format!("invalid content hash '{sha}'")));
    }
    let path = base.join("blobs").join(&sha[0..2]).join(sha);
    Ok(fs::read(path)?)
}

/// Copy `src` into the content-addressed blob store under `base`, returning the
/// stored path. Idempotent: an already-present blob is not rewritten.
pub fn store_file(base: &Path, src: &Path, sha: &str) -> Result<PathBuf, AgentError> {
    if sha.len() < 2 {
        return Err(AgentError::Other(format!("invalid content hash '{sha}'")));
    }
    let dir = base.join("blobs").join(&sha[0..2]);
    fs::create_dir_all(&dir)?;
    let dest = dir.join(sha);
    if !dest.exists() {
        fs::copy(src, &dest)?;
    }
    Ok(dest)
}

/// Extract a zip archive into `dest` (created if needed). `enclosed_name`
/// sanitizes each entry path, so this is safe against zip-slip traversal.
pub fn extract_zip(archive: &Path, dest: &Path) -> Result<(), AgentError> {
    let file = File::open(archive)?;
    let mut zip =
        zip::ZipArchive::new(file).map_err(|e| AgentError::Other(format!("open zip: {e}")))?;
    fs::create_dir_all(dest)?;
    for i in 0..zip.len() {
        let mut entry = zip
            .by_index(i)
            .map_err(|e| AgentError::Other(format!("read zip entry: {e}")))?;
        // `enclosed_name` returns `None` for traversal-unsafe paths — skip them.
        let Some(rel) = entry.enclosed_name() else {
            continue;
        };
        let out = dest.join(rel);
        if entry.is_dir() {
            fs::create_dir_all(&out)?;
        } else {
            if let Some(parent) = out.parent() {
                fs::create_dir_all(parent)?;
            }
            let mut outfile = File::create(&out)?;
            io::copy(&mut entry, &mut outfile)?;
        }
    }
    Ok(())
}

/// The working directory for one artifact of a target: `<base>/work/<target>/<artifact>`.
pub fn work_dir(base: &Path, target_id: &str, artifact_id: &str) -> PathBuf {
    base.join("work").join(target_id).join(artifact_id)
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    /// A unique scratch directory, removed on drop.
    struct Scratch(PathBuf);
    impl Scratch {
        fn new() -> Self {
            let p = std::env::temp_dir().join(format!("cs-ingest-{}", uuid::Uuid::new_v4()));
            fs::create_dir_all(&p).expect("mkdir scratch");
            Self(p)
        }
        fn path(&self) -> &Path {
            &self.0
        }
    }
    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn hash_is_stable_and_reports_size() {
        let dir = Scratch::new();
        let f = dir.path().join("a.bin");
        fs::write(&f, b"hello world").expect("write");
        let (sha, size) = hash_file(&f).expect("hash");
        assert_eq!(size, 11);
        // Known SHA-256 of "hello world".
        assert_eq!(
            sha,
            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
        );
    }

    #[test]
    fn store_is_content_addressed_and_idempotent() {
        let base = Scratch::new();
        let src = base.path().join("src.bin");
        fs::write(&src, b"payload").expect("write");
        let (sha, _) = hash_file(&src).expect("hash");
        let p1 = store_file(base.path(), &src, &sha).expect("store");
        let p2 = store_file(base.path(), &src, &sha).expect("store again");
        assert_eq!(p1, p2);
        assert!(p1.ends_with(&sha));
        assert!(p1.starts_with(base.path().join("blobs").join(&sha[0..2])));
        assert_eq!(fs::read(&p1).expect("read"), b"payload");
    }

    #[test]
    fn extract_zip_writes_entries() {
        let base = Scratch::new();
        let archive = base.path().join("a.zip");
        {
            let file = File::create(&archive).expect("create");
            let mut w = zip::ZipWriter::new(file);
            let opts: zip::write::SimpleFileOptions = Default::default();
            w.start_file("dir/hello.txt", opts).expect("start");
            io::Write::write_all(&mut w, b"hi").expect("write");
            w.finish().expect("finish");
        }
        let dest = base.path().join("out");
        extract_zip(&archive, &dest).expect("extract");
        assert_eq!(
            fs::read_to_string(dest.join("dir/hello.txt")).expect("read"),
            "hi"
        );
    }
}
