//! Integration tests for `haz-cache` against `StdFilesystem`.
//!
//! The unit tests under `libs/haz-cache/src/` already cover every
//! lookup miss reason, every error-return shape, and the canonical
//! serialisation contracts against `MemFilesystem`. This file
//! exercises the same public surface against a real
//! `tempfile`-rooted workspace, targeting behaviours where
//! real-filesystem semantics may diverge from the in-memory test
//! double, plus end-to-end happy paths.

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

use haz_cache::{
    CHAPTER_REVISION, CacheKey, CacheWriter, HashFunctionLabel, Manifest, StoreInputs,
    StoredOutput, layout,
};
use haz_domain::settings::cache::HashAlgo;
use haz_vfs::StdFilesystem;
use tempfile::TempDir;

// ---------- helpers ----------

fn sample_key() -> CacheKey {
    let mut bytes = [0u8; 32];
    bytes[0] = 0xAB;
    bytes[1] = 0xCD;
    CacheKey::from_bytes(bytes)
}

fn key_with_first_byte(first: u8) -> CacheKey {
    let mut bytes = [0u8; 32];
    bytes[0] = first;
    CacheKey::from_bytes(bytes)
}

fn fresh_workspace() -> TempDir {
    tempfile::tempdir().expect("create workspace tempdir")
}

fn make_cache(workspace_root: &Path, algo: HashAlgo) -> CacheWriter<StdFilesystem> {
    CacheWriter::new(StdFilesystem::new(), workspace_root, algo)
}

fn write_workspace_file(workspace_root: &Path, rel: &str, bytes: &[u8]) -> PathBuf {
    let target = workspace_root.join(rel);
    if let Some(parent) = target.parent() {
        std::fs::create_dir_all(parent).expect("create parent dir");
    }
    std::fs::write(&target, bytes).expect("write workspace file");
    target
}

fn len_u64(s: &[u8]) -> u64 {
    u64::try_from(s.len()).expect("len fits u64")
}

#[cfg(unix)]
fn read_unix_mode(path: &Path) -> u32 {
    use std::os::unix::fs::PermissionsExt;
    std::fs::metadata(path)
        .expect("metadata")
        .permissions()
        .mode()
        & 0o777
}

// ---------- 1: golden path ----------

#[test]
fn store_then_lookup_then_restore_round_trip() {
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "proj/out", b"hello-world");

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/proj/out",
        on_disk_path: &on_disk,
        mode: 0o644,
    }];
    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"out-bytes",
                stderr: b"err-bytes",
                created_at_unix: 1_715_700_000,
            },
        )
        .expect("store succeeds");

    let manifest = cache.reader().lookup(&key).expect("lookup hits");
    assert_eq!(manifest.outputs.len(), 1);
    assert_eq!(manifest.stdout_len, len_u64(b"out-bytes"));
    assert_eq!(manifest.stderr_len, len_u64(b"err-bytes"));

    // Mutate the on-disk file so we can prove restore actually
    // re-publishes the cached bytes rather than no-oping over a
    // pre-existing identical target.
    std::fs::write(&on_disk, b"divergent").expect("mutate on_disk");

    let restored = cache.restore(&manifest).expect("restore succeeds");
    assert_eq!(restored.stdout, b"out-bytes");
    assert_eq!(restored.stderr, b"err-bytes");
    assert_eq!(
        std::fs::read(&on_disk).expect("read on_disk"),
        b"hello-world"
    );
}

// ---------- 2: mode bits across the store/restore round trip ----------

#[cfg(unix)]
#[test]
fn output_blob_preserves_unix_mode_bits() {
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "bin/runme", b"#!/bin/sh\necho hi\n");

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/bin/runme",
        on_disk_path: &on_disk,
        mode: 0o755,
    }];
    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"",
                stderr: b"",
                created_at_unix: 0,
            },
        )
        .expect("store");

    // Drop the source file so restore must re-materialise it.
    std::fs::remove_file(&on_disk).expect("remove on_disk");

    let manifest = cache.reader().lookup(&key).expect("lookup");
    cache.restore(&manifest).expect("restore");

    assert_eq!(
        std::fs::read(&on_disk).expect("read restored file"),
        b"#!/bin/sh\necho hi\n"
    );
    assert_eq!(read_unix_mode(&on_disk), 0o755);
}

// ---------- 3: store cleans up its tmp directory ----------

#[test]
fn tmp_entry_directory_atomically_publishes_as_entry_dir() {
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "proj/out", b"x");

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/proj/out",
        on_disk_path: &on_disk,
        mode: 0o644,
    }];
    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"",
                stderr: b"",
                created_at_unix: 0,
            },
        )
        .expect("store");

    let entry = layout::entry_dir(cache.cache_root(), &key);
    assert!(
        entry.is_dir(),
        "entry dir must be published at the sharded path"
    );

    let shard = layout::shard_dir(cache.cache_root(), &key);
    for read in std::fs::read_dir(&shard).expect("read shard dir") {
        let name = read.expect("dir entry").file_name();
        let name_str = name.to_string_lossy();
        assert!(
            !name_str.starts_with(".tmp-"),
            "no .tmp-* should remain after a successful store, found: {name_str}"
        );
    }
}

// ---------- 4: restore cleans up its staging directory ----------

#[test]
fn restore_leaves_no_staging_directory_after_success() {
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "proj/out", b"x");

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/proj/out",
        on_disk_path: &on_disk,
        mode: 0o644,
    }];
    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"",
                stderr: b"",
                created_at_unix: 0,
            },
        )
        .expect("store");

    let manifest = cache.reader().lookup(&key).expect("lookup");
    cache.restore(&manifest).expect("restore");

    for read in std::fs::read_dir(cache.cache_root()).expect("read cache_root") {
        let name = read.expect("dir entry").file_name();
        let name_str = name.to_string_lossy();
        assert!(
            !name_str.starts_with(".restore-"),
            "no .restore-* should remain after a successful restore, found: {name_str}"
        );
    }
}

// ---------- 5: second store of same key, real FS ----------

#[test]
fn second_store_of_same_key_overwrites_and_remains_a_hit() {
    // POSIX `rename(2)` returns `ENOTEMPTY` for a non-empty
    // directory destination, so `Cache::store` evicts any
    // existing entry at the key's final path before renaming the
    // tmp directory onto it. This test exercises that eviction
    // step against a real filesystem.
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "proj/out", b"v1");

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/proj/out",
        on_disk_path: &on_disk,
        mode: 0o644,
    }];

    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"first",
                stderr: b"first-err",
                created_at_unix: 1,
            },
        )
        .expect("first store succeeds");

    std::fs::write(&on_disk, b"v2-longer").expect("rewrite output for second run");

    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"second",
                stderr: b"second-err",
                created_at_unix: 2,
            },
        )
        .expect("second store with same key must also succeed");

    let manifest = cache
        .reader()
        .lookup(&key)
        .expect("entry hits after second store");
    assert_eq!(manifest.stdout_len, len_u64(b"second"));
    assert_eq!(manifest.created_at_unix, 2);
}

// ---------- 6: clear empties the cache ----------

#[test]
fn clear_empties_a_populated_cache_on_real_fs() {
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "proj/out", b"x");
    let sibling = root.join("unrelated.txt");
    std::fs::write(&sibling, b"keep me").expect("write sibling");

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/proj/out",
        on_disk_path: &on_disk,
        mode: 0o644,
    }];
    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"",
                stderr: b"",
                created_at_unix: 0,
            },
        )
        .expect("store");
    assert!(
        cache.reader().lookup(&key).is_some(),
        "precondition: entry present"
    );

    cache.clear().expect("clear");
    assert!(
        cache.reader().lookup(&key).is_none(),
        "lookup must miss after clear"
    );
    assert_eq!(
        std::fs::read(&sibling).expect("read sibling"),
        b"keep me",
        "files outside cache_root must be untouched"
    );
}

// ---------- 7: clean --soft prunes a schema-stale entry ----------

#[test]
fn clean_soft_prunes_chapter_revision_mismatch_entry() {
    let workspace = fresh_workspace();
    let root = workspace.path();

    let cache = make_cache(root, HashAlgo::Blake3);
    let key = key_with_first_byte(0xAB);

    // Hand-write a syntactically valid but schema-stale manifest
    // (bumped chapter_revision) into the entry directory, bypassing
    // `Cache::store` so we have full control over the chapter_revision
    // field.
    let entry_dir = layout::entry_dir(cache.cache_root(), &key);
    std::fs::create_dir_all(&entry_dir).expect("mkdir entry_dir");
    let manifest = Manifest {
        chapter_revision: CHAPTER_REVISION.saturating_add(1),
        hash_function: HashFunctionLabel::Blake3,
        key,
        outputs: vec![],
        stdout_len: 0,
        stderr_len: 0,
        stdout_hash: [0u8; 32],
        stderr_hash: [0u8; 32],
        exit_status: 0,
        created_at_unix: 0,
    };
    std::fs::write(
        layout::manifest_path(cache.cache_root(), &key),
        manifest.to_json_bytes(),
    )
    .expect("write manifest");

    let opts = haz_cache::CleanOptions {
        soft: true,
        ..Default::default()
    };
    let outcome = cache.clean(&opts).expect("clean --soft");
    assert!(outcome.failures.is_empty(), "no failures on a clean run");
    let report = outcome.report;
    assert_eq!(report.evicted_by_soft, 1);
    assert!(
        !entry_dir.exists(),
        "entry dir must be gone after clean --soft prunes it"
    );
}

// ---------- 8: SHA-256 variant ----------

#[test]
fn store_then_lookup_round_trip_under_sha256() {
    let workspace = fresh_workspace();
    let root = workspace.path();
    let on_disk = write_workspace_file(root, "proj/out", b"sha-bytes");

    let cache = make_cache(root, HashAlgo::Sha256);
    let key = sample_key();
    let outs = [StoredOutput {
        workspace_absolute_path: "/proj/out",
        on_disk_path: &on_disk,
        mode: 0o600,
    }];
    cache
        .store(
            &key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"sha-out",
                stderr: b"sha-err",
                created_at_unix: 0,
            },
        )
        .expect("store under sha256");

    let manifest = cache.reader().lookup(&key).expect("lookup");
    assert_eq!(manifest.hash_function, HashFunctionLabel::Sha256);
    assert_eq!(manifest.outputs.len(), 1);
    assert_eq!(manifest.stdout_len, len_u64(b"sha-out"));
}
