//! Cache-key type and builder.
//!
//! [`CacheKey`] is the 32-byte content-addressed identity of a task
//! action under a particular set of inputs, predecessor streams,
//! and resolved environment (`CACHE-001`). [`CacheKeyBuilder`]
//! drives the canonical serialisation of `CACHE-004..009` through a
//! single [`Hasher`] and produces the final [`CacheKey`].

pub mod components;
pub mod prefix;

use haz_domain::action::TaskAction;
use haz_domain::settings::cache::HashAlgo;

use crate::hasher::Hasher;
use crate::hex::{self, HexError};
use crate::key::components::{
    contribute_action, contribute_env, contribute_input_files, contribute_predecessors,
};

pub use crate::key::components::{EnvContribution, InputFile, PredecessorStreams};
pub use crate::key::prefix::{CHAPTER_REVISION, hash_function_id, schema_version_prefix};

/// The cache-key identity of a task under a given set of inputs
/// (`CACHE-001`).
///
/// Width is 32 bytes: both specification-recognised hash functions
/// (`CACHE-002`) emit 32-byte digests. A future hash function with
/// a different width would require a chapter revision (`CACHE-003`)
/// and is out of scope here.
///
/// [`CacheKey`] is `Copy` because its only field is a 32-byte
/// array; passing it by value avoids accidental borrow lifetimes
/// in caller code that threads keys through scheduling layers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CacheKey([u8; 32]);

impl CacheKey {
    /// The 32 bytes of this key.
    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Wrap `bytes` directly as a [`CacheKey`]. Intended for callers
    /// that already obtained 32 bytes from a trusted source (e.g.
    /// hex-decoded manifest, network protocol). The cache library's
    /// invariants on a key are byte-level only; no further checking
    /// is performed.
    #[must_use]
    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// Hexadecimal encoding (lowercase, 64 ASCII characters) used
    /// for on-disk paths per `CACHE-010` and the manifest's `key`
    /// field per `CACHE-011`.
    #[must_use]
    pub fn to_hex(&self) -> String {
        hex::encode_32(&self.0)
    }

    /// Decode a 64-character hexadecimal string into a
    /// [`CacheKey`]. Mirror of [`CacheKey::to_hex`].
    ///
    /// # Errors
    ///
    /// Returns [`HexError`] when the input is not 64 hex
    /// characters.
    pub fn from_hex(s: &str) -> Result<Self, HexError> {
        Ok(Self(hex::decode_32(s)?))
    }
}

/// The full set of inputs to a cache-key derivation
/// (`CACHE-004..008`).
///
/// Carries borrowed references throughout; the builder consumes
/// nothing and the caller retains ownership of every component.
pub struct CacheKeyInputs<'a> {
    /// The task's declared action (`CACHE-005`).
    pub action: &'a TaskAction,
    /// The files matched by the task's `inputs` patterns, each
    /// paired with its content hash under the active hash function
    /// (`CACHE-006`).
    pub input_files: &'a [InputFile<'a>],
    /// The hard-edge predecessors with their captured stream
    /// hashes (`CACHE-007`).
    pub hard_predecessors: &'a [PredecessorStreams<'a>],
    /// The resolved environment contribution (`CACHE-008`).
    pub env: &'a EnvContribution<'a>,
}

/// Driver for cache-key derivation.
///
/// Construct with [`CacheKeyBuilder::new`] (which carries the
/// active [`HashAlgo`] and writes the schema-version prefix per
/// `CACHE-003`), then call [`CacheKeyBuilder::finish`] to consume
/// the canonical [`CacheKeyInputs`] and emit a [`CacheKey`].
///
/// One builder produces one key; the type is not reusable. To
/// derive several keys, construct a fresh builder per key. The
/// canonical-byte sequence depends on the prefix being the very
/// first contribution; the new-builder/finish pairing makes this
/// invariant structural.
pub struct CacheKeyBuilder {
    hasher: Hasher,
}

impl CacheKeyBuilder {
    /// Construct a fresh builder under `algo`.
    ///
    /// The schema-version prefix (`CACHE-003`) is written into the
    /// hasher immediately: `[CHAPTER_REVISION, hash_function_id]`.
    /// Any later `finish` call therefore derives a key under the
    /// composite of `(chapter_revision, hash_function_id)` for that
    /// algo, even when no other component is supplied.
    #[must_use]
    pub fn new(algo: HashAlgo) -> Self {
        let mut hasher = Hasher::new(algo);
        hasher.update(&schema_version_prefix(algo));
        Self { hasher }
    }

    /// Consume the builder, contribute every `CACHE-004` component
    /// in canonical order (`CACHE-009`), and return the finalised
    /// [`CacheKey`].
    #[must_use]
    pub fn finish(mut self, inputs: &CacheKeyInputs<'_>) -> CacheKey {
        contribute_action(&mut self.hasher, inputs.action);
        contribute_input_files(&mut self.hasher, inputs.input_files);
        contribute_predecessors(&mut self.hasher, inputs.hard_predecessors);
        contribute_env(&mut self.hasher, inputs.env);
        CacheKey(self.hasher.finalize())
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use haz_domain::action::{ShellType, TaskAction};
    use haz_domain::env::EnvVarName;
    use haz_domain::name::{ProjectName, TaskName};
    use haz_domain::settings::cache::HashAlgo;
    use nonempty::NonEmpty;

    use crate::key::components::{EnvContribution, InputFile, PredecessorStreams};
    use crate::key::{CacheKey, CacheKeyBuilder, CacheKeyInputs};

    fn key_of(action: &TaskAction, algo: HashAlgo) -> CacheKey {
        let host: BTreeMap<EnvVarName, Option<String>> = BTreeMap::new();
        let overrides: BTreeMap<EnvVarName, String> = BTreeMap::new();
        let env = EnvContribution {
            from_host: &host,
            overrides: &overrides,
        };
        let inputs = CacheKeyInputs {
            action,
            input_files: &[],
            hard_predecessors: &[],
            env: &env,
        };
        CacheKeyBuilder::new(algo).finish(&inputs)
    }

    fn cmd(args: &[&str]) -> TaskAction {
        TaskAction::Command(
            NonEmpty::from_vec(args.iter().map(|s| (*s).to_owned()).collect())
                .expect("non-empty argv"),
        )
    }

    fn shell(script: &str, shell_type: ShellType) -> TaskAction {
        TaskAction::Shell {
            script: script.to_owned(),
            shell: shell_type,
        }
    }

    // ----- CacheKey type -----

    #[test]
    fn cache_009_to_hex_is_64_lowercase_chars() {
        let key = key_of(&cmd(&["true"]), HashAlgo::Blake3);
        let h = key.to_hex();
        assert_eq!(h.len(), 64);
        assert!(
            h.chars()
                .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
        );
    }

    #[test]
    fn cache_key_is_copy() {
        // Sanity: the type's Copy bound is asserted at the type
        // level, but a use-site check guards against accidental
        // future changes.
        let key = key_of(&cmd(&["true"]), HashAlgo::Blake3);
        let copied = key;
        assert_eq!(key.as_bytes(), copied.as_bytes());
    }

    // ----- Schema-version prefix dependency -----

    #[test]
    fn cache_002_blake3_and_sha256_keys_diverge_on_same_inputs() {
        let action = cmd(&["true"]);
        let blake = key_of(&action, HashAlgo::Blake3);
        let sha = key_of(&action, HashAlgo::Sha256);
        assert_ne!(
            blake.as_bytes(),
            sha.as_bytes(),
            "hash_function_id byte must be in the prefix per CACHE-003"
        );
    }

    // ----- CACHE-005 task action -----

    #[test]
    fn cache_005_command_and_shell_with_same_text_diverge() {
        // Tag byte 0x01 (command) vs 0x02 (shell) keeps them
        // distinct even when the textual content is identical.
        let cmd_key = key_of(&cmd(&["foo"]), HashAlgo::Blake3);
        let shell_key = key_of(&shell("foo", ShellType::Sh), HashAlgo::Blake3);
        assert_ne!(cmd_key.as_bytes(), shell_key.as_bytes());
    }

    #[test]
    fn cache_005_shell_type_change_changes_key() {
        let sh_key = key_of(&shell("echo hi", ShellType::Sh), HashAlgo::Blake3);
        let bash_key = key_of(&shell("echo hi", ShellType::Bash), HashAlgo::Blake3);
        assert_ne!(sh_key.as_bytes(), bash_key.as_bytes());
    }

    #[test]
    fn cache_005_argv_order_changes_key() {
        // A change in argv order is a change to the command and
        // MUST produce a different key.
        let a = key_of(&cmd(&["echo", "a", "b"]), HashAlgo::Blake3);
        let b = key_of(&cmd(&["echo", "b", "a"]), HashAlgo::Blake3);
        assert_ne!(a.as_bytes(), b.as_bytes());
    }

    #[test]
    fn cache_005_empty_string_argument_is_distinct_from_no_argument() {
        // CACHE-005 length prefixes make a single empty argument
        // distinct from the absence of that argument.
        let with_empty = key_of(&cmd(&["echo", ""]), HashAlgo::Blake3);
        let without_arg = key_of(&cmd(&["echo"]), HashAlgo::Blake3);
        assert_ne!(with_empty.as_bytes(), without_arg.as_bytes());
    }

    // ----- CACHE-006 input files -----

    fn key_with_inputs(files: &[InputFile<'_>]) -> CacheKey {
        let host: BTreeMap<EnvVarName, Option<String>> = BTreeMap::new();
        let overrides: BTreeMap<EnvVarName, String> = BTreeMap::new();
        let env = EnvContribution {
            from_host: &host,
            overrides: &overrides,
        };
        let action = cmd(&["true"]);
        let inputs = CacheKeyInputs {
            action: &action,
            input_files: files,
            hard_predecessors: &[],
            env: &env,
        };
        CacheKeyBuilder::new(HashAlgo::Blake3).finish(&inputs)
    }

    #[test]
    fn cache_006_input_file_order_does_not_matter() {
        // CACHE-009 requires byte-wise ascending order on
        // workspace-absolute paths; the builder sorts internally
        // so caller order is irrelevant.
        let a = InputFile {
            workspace_absolute_path: "/p/a",
            content_hash: [0xAA; 32],
        };
        let b = InputFile {
            workspace_absolute_path: "/p/b",
            content_hash: [0xBB; 32],
        };
        let ab = key_with_inputs(&[a, b]);
        let ba = key_with_inputs(&[
            InputFile {
                workspace_absolute_path: "/p/b",
                content_hash: [0xBB; 32],
            },
            InputFile {
                workspace_absolute_path: "/p/a",
                content_hash: [0xAA; 32],
            },
        ]);
        assert_eq!(ab.as_bytes(), ba.as_bytes());
    }

    #[test]
    fn cache_006_input_file_count_changes_key() {
        let a = InputFile {
            workspace_absolute_path: "/p/a",
            content_hash: [0xAA; 32],
        };
        let with_one = key_with_inputs(&[a]);
        let empty = key_with_inputs(&[]);
        assert_ne!(with_one.as_bytes(), empty.as_bytes());
    }

    #[test]
    fn cache_006_input_file_path_change_changes_key() {
        let original = key_with_inputs(&[InputFile {
            workspace_absolute_path: "/p/a",
            content_hash: [0xAA; 32],
        }]);
        let renamed = key_with_inputs(&[InputFile {
            workspace_absolute_path: "/p/b",
            content_hash: [0xAA; 32],
        }]);
        assert_ne!(original.as_bytes(), renamed.as_bytes());
    }

    #[test]
    fn cache_006_input_file_content_change_changes_key() {
        let original = key_with_inputs(&[InputFile {
            workspace_absolute_path: "/p/a",
            content_hash: [0xAA; 32],
        }]);
        let edited = key_with_inputs(&[InputFile {
            workspace_absolute_path: "/p/a",
            content_hash: [0xBB; 32],
        }]);
        assert_ne!(original.as_bytes(), edited.as_bytes());
    }

    // ----- CACHE-007 hard-edge predecessors -----

    fn key_with_predecessors(preds: &[PredecessorStreams<'_>]) -> CacheKey {
        let host: BTreeMap<EnvVarName, Option<String>> = BTreeMap::new();
        let overrides: BTreeMap<EnvVarName, String> = BTreeMap::new();
        let env = EnvContribution {
            from_host: &host,
            overrides: &overrides,
        };
        let action = cmd(&["true"]);
        let inputs = CacheKeyInputs {
            action: &action,
            input_files: &[],
            hard_predecessors: preds,
            env: &env,
        };
        CacheKeyBuilder::new(HashAlgo::Blake3).finish(&inputs)
    }

    #[test]
    fn cache_007_predecessor_order_does_not_matter() {
        let p_a = ProjectName::try_new("alpha").unwrap();
        let p_b = ProjectName::try_new("beta").unwrap();
        let t_x = TaskName::try_new("x").unwrap();
        let t_y = TaskName::try_new("y").unwrap();
        let pred_a = PredecessorStreams {
            project: &p_a,
            task: &t_x,
            stdout_hash: [0x01; 32],
            stderr_hash: [0x02; 32],
        };
        let pred_b = PredecessorStreams {
            project: &p_b,
            task: &t_y,
            stdout_hash: [0x03; 32],
            stderr_hash: [0x04; 32],
        };
        let ab = key_with_predecessors(&[pred_a, pred_b]);
        let ba = key_with_predecessors(&[
            PredecessorStreams {
                project: &p_b,
                task: &t_y,
                stdout_hash: [0x03; 32],
                stderr_hash: [0x04; 32],
            },
            PredecessorStreams {
                project: &p_a,
                task: &t_x,
                stdout_hash: [0x01; 32],
                stderr_hash: [0x02; 32],
            },
        ]);
        assert_eq!(ab.as_bytes(), ba.as_bytes());
    }

    #[test]
    fn cache_007_predecessor_stdout_stderr_swap_changes_key() {
        // CACHE-007 keeps the two streams distinct: swapping
        // stdout and stderr hashes for the same predecessor MUST
        // yield a different key.
        let p = ProjectName::try_new("alpha").unwrap();
        let t = TaskName::try_new("x").unwrap();
        let original = key_with_predecessors(&[PredecessorStreams {
            project: &p,
            task: &t,
            stdout_hash: [0x01; 32],
            stderr_hash: [0x02; 32],
        }]);
        let swapped = key_with_predecessors(&[PredecessorStreams {
            project: &p,
            task: &t,
            stdout_hash: [0x02; 32],
            stderr_hash: [0x01; 32],
        }]);
        assert_ne!(original.as_bytes(), swapped.as_bytes());
    }

    // ----- CACHE-008 environment -----

    fn name(s: &str) -> EnvVarName {
        EnvVarName::try_new(s).unwrap()
    }

    fn key_with_env(env: &EnvContribution<'_>) -> CacheKey {
        let action = cmd(&["true"]);
        let inputs = CacheKeyInputs {
            action: &action,
            input_files: &[],
            hard_predecessors: &[],
            env,
        };
        CacheKeyBuilder::new(HashAlgo::Blake3).finish(&inputs)
    }

    #[test]
    fn from_host_value_change_changes_key() {
        let mut a_host = BTreeMap::new();
        a_host.insert(name("PATH"), Some("/usr/bin".to_owned()));
        let mut b_host = BTreeMap::new();
        b_host.insert(name("PATH"), Some("/usr/local/bin".to_owned()));
        let overrides = BTreeMap::new();
        let a = key_with_env(&EnvContribution {
            from_host: &a_host,
            overrides: &overrides,
        });
        let b = key_with_env(&EnvContribution {
            from_host: &b_host,
            overrides: &overrides,
        });
        assert_ne!(a.as_bytes(), b.as_bytes());
    }

    #[test]
    fn from_host_absent_differs_from_empty_string() {
        // CACHE-008's 0x00 absent marker keeps "host did not set
        // the variable" distinct from "host set it to the empty
        // string".
        let mut absent_host = BTreeMap::new();
        absent_host.insert(name("X"), None);
        let mut empty_host = BTreeMap::new();
        empty_host.insert(name("X"), Some(String::new()));
        let overrides = BTreeMap::new();
        let absent = key_with_env(&EnvContribution {
            from_host: &absent_host,
            overrides: &overrides,
        });
        let empty = key_with_env(&EnvContribution {
            from_host: &empty_host,
            overrides: &overrides,
        });
        assert_ne!(absent.as_bytes(), empty.as_bytes());
    }

    #[test]
    fn override_wins_over_from_host_on_name_collision() {
        // Same override is present in both runs; from_host's value
        // for the collided name differs. The key MUST be the same:
        // overrides win and the from_host value for the collided
        // name does not contribute (CACHE-008).
        let mut host_a = BTreeMap::new();
        host_a.insert(name("X"), Some("host-a".to_owned()));
        let mut host_b = BTreeMap::new();
        host_b.insert(name("X"), Some("host-b".to_owned()));
        let mut overrides = BTreeMap::new();
        overrides.insert(name("X"), "fixed".to_owned());

        let a = key_with_env(&EnvContribution {
            from_host: &host_a,
            overrides: &overrides,
        });
        let b = key_with_env(&EnvContribution {
            from_host: &host_b,
            overrides: &overrides,
        });
        assert_eq!(a.as_bytes(), b.as_bytes());
    }

    #[test]
    fn from_host_and_override_with_same_bytes_still_distinct() {
        // CACHE-008 keeps the two contributions distinct even when
        // a name appears under one and produces a byte-identical
        // value under the other. A name in from_host with value
        // "v" vs the same name in overrides with value "v" must
        // yield different keys.
        let mut host = BTreeMap::new();
        host.insert(name("X"), Some("v".to_owned()));
        let empty_overrides = BTreeMap::new();
        let only_host = key_with_env(&EnvContribution {
            from_host: &host,
            overrides: &empty_overrides,
        });

        let empty_host = BTreeMap::new();
        let mut overrides = BTreeMap::new();
        overrides.insert(name("X"), "v".to_owned());
        let only_overrides = key_with_env(&EnvContribution {
            from_host: &empty_host,
            overrides: &overrides,
        });

        assert_ne!(only_host.as_bytes(), only_overrides.as_bytes());
    }

    #[test]
    fn empty_env_is_distinct_from_any_named_env() {
        let empty_host = BTreeMap::new();
        let empty_overrides = BTreeMap::new();
        let empty = key_with_env(&EnvContribution {
            from_host: &empty_host,
            overrides: &empty_overrides,
        });

        let mut single_entry = BTreeMap::new();
        single_entry.insert(name("X"), None);
        let one_absent = key_with_env(&EnvContribution {
            from_host: &single_entry,
            overrides: &empty_overrides,
        });

        assert_ne!(empty.as_bytes(), one_absent.as_bytes());
    }

    // ----- Smoke / soundness -----

    #[test]
    fn cache_001_identical_inputs_yield_identical_keys() {
        // Cache-key determinism (CACHE-001): two builders fed the
        // same inputs in the same order produce the same key.
        let a = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
        let b = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
        assert_eq!(a.as_bytes(), b.as_bytes());
    }

    #[test]
    fn cache_001_task_identity_does_not_contribute() {
        // The builder takes no project/task identity. Two keys
        // derived for what would be different (project, task)
        // pairs in production code, but with identical components,
        // collide by design (CACHE-001 content addressing). This
        // test is a structural reminder: the API surface lacks any
        // means to inject identity.
        let a = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
        let b = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
        assert_eq!(a.as_bytes(), b.as_bytes());
    }
}
