// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0
//
// v0.7 Track G — Task G1: hook configuration schema + SIGHUP hot reload.
//
// # Canonical `hooks.toml` schema
//
// ```toml
// [[hook]]
// event = "post_store"
// command = "/usr/local/bin/auto-link-detector"
// priority = 100
// timeout_ms = 5000
// mode = "daemon"
// enabled = true
// namespace = "team/*"
// ```
//
// Multiple `[[hook]]` blocks may target the same event; insertion
// order is preserved so G5's chain-ordering pass can apply
// priority-descending sort deterministically.
//
// # Default config path
//
// `dirs::config_dir().join("ai-memory/hooks.toml")`. On Linux that
// resolves to `~/.config/ai-memory/hooks.toml`; on macOS,
// `~/Library/Application Support/ai-memory/hooks.toml`.
//
// # Hot reload
//
// `spawn_reload_task` listens for `SIGHUP` and atomically swaps
// the config snapshot held behind an `Arc<RwLock<…>>`. In-flight
// hook executions (landing in G3) read the snapshot once at
// dispatch time, so a reload mid-fire never tears.
//
// # Validation rules (G1)
//
// * `priority` — any `i32` (descending sort lives in G5).
// * `timeout_ms` — `u32`, capped at 30_000ms. Larger values are
//   rejected with a named [`HooksConfigError::Validation`].
// * `command` — must be non-empty. Path existence is *not*
//   checked here; the executor (G3) is the right layer for that
//   so a missing binary surfaces as an executor error with full
//   context, not a config-parse error before the daemon boots.
// * `namespace` — non-empty string. A real glob/pattern matcher
//   does not yet exist in this crate; G2/G3 will swap in the
//   real one when it ships. See the TODO below.
// * Parse errors include the failing TOML span (line:col) via
//   `toml::de::Error::span()` when the underlying error carries
//   one.

use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;

// ---------------------------------------------------------------------------
// HookEvent
// ---------------------------------------------------------------------------
//
// G1 shipped a 20-variant stub of `HookEvent` here so the
// configuration loader had a tag type to deserialize against.
// G2 lifts the canonical definition into `crate::hooks::events`
// and attaches a payload struct to every variant. The re-export
// below preserves `use crate::hooks::config::HookEvent` for any
// caller that landed against the G1 path.

pub use super::events::HookEvent;

// ---------------------------------------------------------------------------
// HookMode
// ---------------------------------------------------------------------------

/// Execution mode for a hook entry.
///
/// * [`HookMode::Exec`] — subprocess per fire; JSON over stdio.
/// * [`HookMode::Daemon`] — long-lived child; JSON-RPC framed.
///
/// G3 implements both. Hot-path events (`post_recall`,
/// `post_search`) default to `daemon` to preserve the v0.6.3
/// 50ms recall budget.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookMode {
    Exec,
    Daemon,
}

// ---------------------------------------------------------------------------
// FailMode
// ---------------------------------------------------------------------------

/// Hook crash-handling posture, consumed by G5's chain runner.
///
/// * [`FailMode::Open`] — when the executor returns `Err` (spawn
///   failure, decode failure, timeout, daemon unavailable, …) the
///   chain logs a warning and treats the failed fire as `Allow`. This
///   is the v0.7 default because the bias on the request path is
///   "fail open, log loudly" — a buggy hook must not brick recall.
/// * [`FailMode::Closed`] — the chain converts the executor error
///   into `ChainResult::Deny` and short-circuits the chain. Reserved
///   for hooks that gate compliance-critical paths (PII redaction,
///   regulated-tenant access control) where a silent fail-open is
///   worse than a hard refusal.
///
/// The field is optional in `hooks.toml`; missing entries default to
/// [`FailMode::Open`] so G3-era configs keep their behaviour after
/// G5 lands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailMode {
    Open,
    Closed,
}

impl Default for FailMode {
    fn default() -> Self {
        FailMode::Open
    }
}

/// Serde default helper — `serde(default)` only reaches for `Default::default`
/// on the *field type*; this named function lets the
/// `#[serde(default = "...")]` form work without a wrapper newtype.
fn default_fail_mode() -> FailMode {
    FailMode::Open
}

// ---------------------------------------------------------------------------
// HookConfig
// ---------------------------------------------------------------------------

/// Maximum allowed `timeout_ms`. A hook taking longer than 30s
/// is almost certainly a bug; the chain-orchestrator (G5/G6)
/// would otherwise stall the memory operation that fired it.
pub const MAX_TIMEOUT_MS: u32 = 30_000;

/// v0.7.0 R3-S3 — default execution mode for a given event.
///
/// CLAUDE.md and ROADMAP §4.7 both call out that hot-path events
/// must default to `mode = "daemon"` so a configured-but-unspecified
/// hook does not pay subprocess spawn cost on every recall / search.
/// Pre-R3 this was a documentation-only assertion: `HookConfig.mode`
/// was a required field, so omitting it produced a parse error rather
/// than the documented daemon default. R3-S3 closes the gap by
/// making `mode` optional in TOML and selecting daemon-mode for hot-
/// path events (`post_recall`, `post_search`, `pre_recall_expand`)
/// when the operator did not supply one.
///
/// Non-hot-path events default to `Exec` — the subprocess-per-fire
/// posture is the historical and lower-risk choice for cold-path
/// hooks that may not be written defensively against a long-lived
/// JSON-RPC framed lifecycle.
#[must_use]
pub fn default_mode_for_event(event: HookEvent) -> HookMode {
    match event {
        HookEvent::PostRecall | HookEvent::PostSearch | HookEvent::PreRecallExpand => {
            HookMode::Daemon
        }
        _ => HookMode::Exec,
    }
}

/// One `[[hook]]` block from `hooks.toml`.
///
/// v0.7.0 R3-S3 — `mode` is now optional in TOML; missing values are
/// resolved via [`default_mode_for_event`] (daemon for hot-path
/// events, exec otherwise). The struct field stays required so the
/// in-memory representation is unambiguous; only the wire shape is
/// relaxed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HookConfig {
    pub event: HookEvent,
    pub command: PathBuf,
    pub priority: i32,
    pub timeout_ms: u32,
    pub mode: HookMode,
    pub enabled: bool,
    pub namespace: String,
    /// G5 — chain crash-handling posture. Defaults to
    /// [`FailMode::Open`] so existing G3-era configs keep firing
    /// fail-open even after G5 wires the chain runner in. Hooks
    /// that gate compliance-critical paths set
    /// `fail_mode = "closed"` to convert executor errors into a
    /// chain-level `Deny`.
    #[serde(default = "default_fail_mode")]
    pub fail_mode: FailMode,
}

/// Wire-shape mirror of [`HookConfig`] used only for TOML
/// deserialization. `mode` is `Option<HookMode>` so missing values
/// can be filled in from [`default_mode_for_event`] at parse time
/// (serde defaults can't see sibling fields). The compiled
/// representation in [`HookConfig`] is unambiguous — the operator-
/// facing relaxation lives in this struct only.
#[derive(Debug, Deserialize)]
struct HookConfigRaw {
    event: HookEvent,
    command: PathBuf,
    priority: i32,
    timeout_ms: u32,
    /// v0.7.0 R3-S3 — optional; falls back to
    /// [`default_mode_for_event`] when missing.
    #[serde(default)]
    mode: Option<HookMode>,
    enabled: bool,
    namespace: String,
    #[serde(default = "default_fail_mode")]
    fail_mode: FailMode,
}

impl From<HookConfigRaw> for HookConfig {
    fn from(raw: HookConfigRaw) -> Self {
        let mode = raw
            .mode
            .unwrap_or_else(|| default_mode_for_event(raw.event));
        HookConfig {
            event: raw.event,
            command: raw.command,
            priority: raw.priority,
            timeout_ms: raw.timeout_ms,
            mode,
            enabled: raw.enabled,
            namespace: raw.namespace,
            fail_mode: raw.fail_mode,
        }
    }
}

/// Adapter shape implementing `Deserialize` via [`HookConfigRaw`].
/// Kept separate from [`HookConfig`] so the public type stays
/// derive-`Deserialize`-able (callers that build a `HookConfig`
/// in-memory and then `serde_json::from_value` still work).
impl<'de> serde::Deserialize<'de> for HookConfig {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        HookConfigRaw::deserialize(deserializer).map(Into::into)
    }
}

/// Top-level TOML shape: `[[hook]]` blocks collect into
/// `hooks: Vec<HookConfig>`.
#[derive(Debug, Deserialize)]
struct HooksFile {
    #[serde(default, rename = "hook")]
    hooks: Vec<HookConfig>,
}

impl HookConfig {
    /// Load and validate the hook config file at `path`.
    ///
    /// Returns the hook entries in their original on-disk order;
    /// G5's chain ordering pass is responsible for the
    /// priority-descending sort.
    pub fn load_from_file(path: &Path) -> Result<Vec<HookConfig>, HooksConfigError> {
        let contents = std::fs::read_to_string(path).map_err(HooksConfigError::Io)?;
        Self::load_from_str(&contents)
    }

    /// Parse + validate from a TOML string. Split out from
    /// [`Self::load_from_file`] so unit tests can exercise the
    /// parser without touching disk.
    pub fn load_from_str(contents: &str) -> Result<Vec<HookConfig>, HooksConfigError> {
        let parsed: HooksFile = toml::from_str(contents).map_err(|e| {
            // toml 0.8's `de::Error::span()` returns a byte range
            // into the input; convert to (line, col) for the
            // operator-facing error message.
            let (line, col) = e
                .span()
                .map(|s| byte_offset_to_line_col(contents, s.start))
                .unwrap_or((0, 0));
            HooksConfigError::Toml {
                line,
                column: col,
                message: e.to_string(),
            }
        })?;

        for (idx, h) in parsed.hooks.iter().enumerate() {
            validate_hook(idx, h)?;
        }

        Ok(parsed.hooks)
    }

    /// `dirs::config_dir().join("ai-memory/hooks.toml")` — the
    /// platform-correct default location.
    pub fn default_path() -> Option<PathBuf> {
        dirs::config_dir().map(|p| p.join("ai-memory/hooks.toml"))
    }
}

fn validate_hook(idx: usize, h: &HookConfig) -> Result<(), HooksConfigError> {
    if h.timeout_ms > MAX_TIMEOUT_MS {
        return Err(HooksConfigError::Validation {
            field: format!("hook[{idx}].timeout_ms"),
            reason: format!("{} exceeds maximum {MAX_TIMEOUT_MS}ms", h.timeout_ms),
        });
    }
    if h.command.as_os_str().is_empty() {
        return Err(HooksConfigError::Validation {
            field: format!("hook[{idx}].command"),
            reason: "must be a non-empty path".into(),
        });
    }
    // TODO(G2/G3): validate namespace against the real
    // pattern matcher once it ships. Today no glob matcher
    // exists in src/ — `db::matches_subtree` is prefix-only
    // and not callable from this layer. For now we accept any
    // non-empty string.
    if h.namespace.trim().is_empty() {
        return Err(HooksConfigError::Validation {
            field: format!("hook[{idx}].namespace"),
            reason: "must be a non-empty pattern (use \"*\" to match all)".into(),
        });
    }
    Ok(())
}

/// Convert a byte offset into a 1-indexed (line, column) pair
/// suitable for human-facing error messages.
fn byte_offset_to_line_col(s: &str, offset: usize) -> (usize, usize) {
    let mut line = 1usize;
    let mut col = 1usize;
    for (i, ch) in s.char_indices() {
        if i >= offset {
            break;
        }
        if ch == '\n' {
            line += 1;
            col = 1;
        } else {
            col += 1;
        }
    }
    (line, col)
}

// ---------------------------------------------------------------------------
// HooksConfigError
// ---------------------------------------------------------------------------

/// Errors surfaced by the hook config loader.
#[derive(Debug)]
pub enum HooksConfigError {
    /// Could not read the config file.
    Io(std::io::Error),
    /// TOML parse failure. `line` / `column` are 1-indexed when
    /// the underlying error carried a span; otherwise both are
    /// `0` to signal "location unknown".
    Toml {
        line: usize,
        column: usize,
        message: String,
    },
    /// Schema-level validation failure (e.g. `timeout_ms` over
    /// the 30s ceiling). `field` names the offending entry using
    /// `hook[<idx>].<field>` so operators can locate it in the
    /// source TOML.
    Validation { field: String, reason: String },
}

impl fmt::Display for HooksConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HooksConfigError::Io(e) => write!(f, "hooks.toml read error: {e}"),
            HooksConfigError::Toml {
                line,
                column,
                message,
            } => {
                if *line == 0 {
                    write!(f, "hooks.toml parse error: {message}")
                } else {
                    write!(
                        f,
                        "hooks.toml parse error at line {line}, column {column}: {message}"
                    )
                }
            }
            HooksConfigError::Validation { field, reason } => {
                write!(f, "hooks.toml validation error in {field}: {reason}")
            }
        }
    }
}

impl std::error::Error for HooksConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            HooksConfigError::Io(e) => Some(e),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// Hot reload (SIGHUP)
// ---------------------------------------------------------------------------

/// Shared, hot-swappable snapshot of the loaded hook config. The
/// executor (G3) holds an `Arc<HookConfigSnapshot>` and reads it
/// once per dispatch so an in-flight execution always lands on a
/// consistent view of the config — even if SIGHUP arrives mid-fire.
pub type HookConfigSnapshot = RwLock<Vec<HookConfig>>;

/// Spawn a tokio task that listens for `SIGHUP` and reloads
/// `path` into `snapshot` on every signal.
///
/// G1 ships the signal-handler plumbing; the hooks loaded into
/// the snapshot become live as soon as G3's executor starts
/// reading from it. Until then this is a no-op observable only
/// via a `tracing::info!` log line per reload — exactly what the
/// G1 epic doc calls for ("for now just load + emit a tracing
/// info on reload").
///
/// Returns the [`tokio::task::JoinHandle`] so the daemon main
/// loop can shut the task down on graceful exit.
#[cfg(unix)]
pub fn spawn_reload_task(
    path: PathBuf,
    snapshot: Arc<HookConfigSnapshot>,
) -> tokio::task::JoinHandle<()> {
    use tokio::signal::unix::{SignalKind, signal};

    tokio::spawn(async move {
        let mut sighup = match signal(SignalKind::hangup()) {
            Ok(s) => s,
            Err(e) => {
                tracing::error!(error = %e, "hooks: failed to install SIGHUP handler");
                return;
            }
        };

        while sighup.recv().await.is_some() {
            match HookConfig::load_from_file(&path) {
                Ok(new_cfg) => {
                    let count = new_cfg.len();
                    let mut guard = snapshot.write().await;
                    *guard = new_cfg;
                    tracing::info!(
                        path = %path.display(),
                        hooks = count,
                        "hooks: reloaded config on SIGHUP"
                    );
                }
                Err(e) => {
                    // Reload failure leaves the previous
                    // snapshot in place — operators get a loud
                    // error log but the running daemon keeps
                    // serving with the last-known-good config.
                    tracing::error!(
                        path = %path.display(),
                        error = %e,
                        "hooks: SIGHUP reload failed; keeping previous config"
                    );
                }
            }
        }
    })
}

// On non-unix platforms SIGHUP doesn't exist. The daemon is
// unix-only in practice (the Linux/macOS systemd + launchd
// units are the only supported deployments), so this is a stub
// to keep the windows build green for tooling like `cargo
// check --target x86_64-pc-windows-msvc`.
#[cfg(not(unix))]
pub fn spawn_reload_task(
    _path: PathBuf,
    _snapshot: Arc<HookConfigSnapshot>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        tracing::warn!("hooks: SIGHUP reload not supported on this platform");
    })
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    const VALID_CANONICAL: &str = r#"
[[hook]]
event = "post_store"
command = "/usr/local/bin/auto-link-detector"
priority = 100
timeout_ms = 5000
mode = "daemon"
enabled = true
namespace = "team/*"
"#;

    #[test]
    fn parses_canonical_example() {
        let hooks = HookConfig::load_from_str(VALID_CANONICAL).expect("parses");
        assert_eq!(hooks.len(), 1);
        let h = &hooks[0];
        assert_eq!(h.event, HookEvent::PostStore);
        assert_eq!(
            h.command,
            PathBuf::from("/usr/local/bin/auto-link-detector")
        );
        assert_eq!(h.priority, 100);
        assert_eq!(h.timeout_ms, 5_000);
        assert_eq!(h.mode, HookMode::Daemon);
        assert!(h.enabled);
        assert_eq!(h.namespace, "team/*");
    }

    #[test]
    fn rejects_timeout_over_cap() {
        let toml_src = r#"
[[hook]]
event = "post_recall"
command = "/bin/true"
priority = 0
timeout_ms = 60000
mode = "exec"
enabled = true
namespace = "*"
"#;
        let err = HookConfig::load_from_str(toml_src).unwrap_err();
        match err {
            HooksConfigError::Validation { field, reason } => {
                assert!(field.ends_with("timeout_ms"), "field was {field}");
                assert!(reason.contains("30000"), "reason was {reason}");
            }
            other => panic!("expected Validation, got {other:?}"),
        }
    }

    #[test]
    fn invalid_toml_reports_line_number() {
        // `mode = ` with no value — the parser will fail on the
        // line carrying the broken assignment. We assert the
        // error names a non-zero line so operators can grep for it.
        let toml_src = "\n\n[[hook]]\nevent = \"post_store\"\nmode = \n";
        let err = HookConfig::load_from_str(toml_src).unwrap_err();
        match err {
            HooksConfigError::Toml {
                line, ref message, ..
            } => {
                assert!(line > 0, "expected non-zero line, got {line}");
                let displayed = err.to_string();
                assert!(
                    displayed.contains(&format!("line {line}")),
                    "Display did not surface line: {displayed} (raw msg: {message})"
                );
            }
            other => panic!("expected Toml, got {other:?}"),
        }
    }

    #[test]
    fn multiple_hooks_same_event_preserve_order() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = "/bin/first"
priority = 10
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"

[[hook]]
event = "post_store"
command = "/bin/second"
priority = 5
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"

[[hook]]
event = "post_store"
command = "/bin/third"
priority = 50
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses");
        assert_eq!(hooks.len(), 3);
        assert_eq!(hooks[0].command, PathBuf::from("/bin/first"));
        assert_eq!(hooks[1].command, PathBuf::from("/bin/second"));
        assert_eq!(hooks[2].command, PathBuf::from("/bin/third"));
        // All three target the same event.
        assert!(hooks.iter().all(|h| h.event == HookEvent::PostStore));
    }

    #[test]
    fn rejects_empty_namespace() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = "/bin/true"
priority = 0
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = ""
"#;
        let err = HookConfig::load_from_str(toml_src).unwrap_err();
        assert!(matches!(err, HooksConfigError::Validation { .. }));
    }

    #[test]
    fn rejects_empty_command() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = ""
priority = 0
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"
"#;
        let err = HookConfig::load_from_str(toml_src).unwrap_err();
        match err {
            HooksConfigError::Validation { field, .. } => {
                assert!(field.ends_with("command"), "field was {field}");
            }
            other => panic!("expected Validation, got {other:?}"),
        }
    }

    #[test]
    fn empty_file_yields_zero_hooks() {
        let hooks = HookConfig::load_from_str("").expect("parses");
        assert!(hooks.is_empty());
    }

    // -----------------------------------------------------------------
    // v0.7.0 R3-S3 — hot-path daemon-mode default
    // -----------------------------------------------------------------

    /// `test_post_recall_default_mode_is_daemon` — when a `post_recall`
    /// hook block omits `mode`, the loader fills it in with `Daemon`
    /// per CLAUDE.md + ROADMAP §4.7. Pre-R3 this was a doc-only
    /// claim: `mode` was a required field, so an unspecified `mode`
    /// produced a parse error rather than the documented daemon
    /// default. R3-S3 closes the gap.
    #[test]
    fn test_post_recall_default_mode_is_daemon() {
        let toml_src = r#"
[[hook]]
event = "post_recall"
command = "/bin/true"
priority = 0
timeout_ms = 1000
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
        assert_eq!(hooks.len(), 1);
        assert_eq!(hooks[0].event, HookEvent::PostRecall);
        assert_eq!(
            hooks[0].mode,
            HookMode::Daemon,
            "post_recall must default to daemon mode (R3-S3)"
        );
    }

    /// `test_post_search_default_mode_is_daemon` — sibling of the
    /// post_recall test; post_search is the second documented
    /// hot-path event and must share the daemon default.
    #[test]
    fn test_post_search_default_mode_is_daemon() {
        let toml_src = r#"
[[hook]]
event = "post_search"
command = "/bin/true"
priority = 0
timeout_ms = 1000
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
        assert_eq!(hooks.len(), 1);
        assert_eq!(
            hooks[0].mode,
            HookMode::Daemon,
            "post_search must default to daemon mode (R3-S3)"
        );
    }

    /// `pre_recall_expand` shares the hot-path budget (G10) so it
    /// must also default to daemon mode.
    #[test]
    fn test_pre_recall_expand_default_mode_is_daemon() {
        let toml_src = r#"
[[hook]]
event = "pre_recall_expand"
command = "/bin/true"
priority = 0
timeout_ms = 1000
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
        assert_eq!(hooks[0].mode, HookMode::Daemon);
    }

    /// Non-hot-path events keep the historical `Exec` default. R3-S3
    /// only narrows the daemon-default to events that pay subprocess
    /// spawn cost on the recall p95 budget; cold-path hooks remain
    /// `Exec` for compatibility with hook scripts written against the
    /// per-fire subprocess lifecycle.
    #[test]
    fn test_post_store_default_mode_is_exec() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = "/bin/true"
priority = 0
timeout_ms = 1000
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
        assert_eq!(
            hooks[0].mode,
            HookMode::Exec,
            "cold-path events still default to exec mode (no R3-S3 change)"
        );
    }

    /// Explicit `mode` in TOML still wins — the R3-S3 default kicks
    /// in only when the field is *absent*. This preserves any
    /// existing operator configuration that opted into a specific
    /// mode against the new default.
    #[test]
    fn test_explicit_mode_overrides_default() {
        let toml_src = r#"
[[hook]]
event = "post_recall"
command = "/bin/true"
priority = 0
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses");
        assert_eq!(
            hooks[0].mode,
            HookMode::Exec,
            "explicit mode = \"exec\" must not be silently flipped to daemon"
        );
    }

    #[test]
    fn load_from_file_round_trip() {
        let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
        tmp.write_all(VALID_CANONICAL.as_bytes()).expect("write");
        let hooks = HookConfig::load_from_file(tmp.path()).expect("loads");
        assert_eq!(hooks.len(), 1);
        assert_eq!(hooks[0].event, HookEvent::PostStore);
    }

    /// Hot-reload smoke test: load config A, replace the file
    /// on disk, call `load_from_file` again (the same code path
    /// the SIGHUP task drives), assert the snapshot now reflects
    /// config B.
    ///
    /// We exercise the loader directly rather than spawning the
    /// signal task because portable + deterministic test signal
    /// delivery on macOS+Linux is fiddly enough that the value
    /// add lives in the loader, not in tokio's signal plumbing.
    /// G3 will gain an end-to-end SIGHUP integration test once
    /// the executor is wired in.
    #[tokio::test]
    async fn sighup_reload_swaps_snapshot() {
        let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
        tmp.write_all(VALID_CANONICAL.as_bytes()).expect("write A");

        let snapshot: Arc<HookConfigSnapshot> = Arc::new(RwLock::new(
            HookConfig::load_from_file(tmp.path()).expect("load A"),
        ));

        {
            let guard = snapshot.read().await;
            assert_eq!(guard.len(), 1);
            assert_eq!(
                guard[0].command,
                PathBuf::from("/usr/local/bin/auto-link-detector")
            );
        }

        // Replace on-disk content with config B (different
        // command, two entries) — this mirrors what an operator
        // does before sending SIGHUP.
        let config_b = r#"
[[hook]]
event = "pre_store"
command = "/opt/hooks/redact-pii"
priority = 200
timeout_ms = 2500
mode = "exec"
enabled = true
namespace = "*"

[[hook]]
event = "post_recall"
command = "/opt/hooks/expand-context"
priority = 50
timeout_ms = 100
mode = "daemon"
enabled = false
namespace = "team/*"
"#;
        std::fs::write(tmp.path(), config_b).expect("rewrite to B");

        // Drive the same code path the SIGHUP task uses.
        let new_cfg = HookConfig::load_from_file(tmp.path()).expect("load B");
        {
            let mut guard = snapshot.write().await;
            *guard = new_cfg;
        }

        let guard = snapshot.read().await;
        assert_eq!(guard.len(), 2);
        assert_eq!(guard[0].event, HookEvent::PreStore);
        assert_eq!(guard[0].command, PathBuf::from("/opt/hooks/redact-pii"));
        assert_eq!(guard[1].event, HookEvent::PostRecall);
        assert!(!guard[1].enabled);
    }

    #[test]
    fn default_path_is_under_config_dir() {
        // We can't assert the full path on every platform but we
        // can verify it ends with `ai-memory/hooks.toml` when
        // `dirs::config_dir()` resolves at all.
        if let Some(p) = HookConfig::default_path() {
            let s = p.to_string_lossy();
            assert!(
                s.ends_with("ai-memory/hooks.toml") || s.ends_with("ai-memory\\hooks.toml"),
                "unexpected default path: {s}"
            );
        }
    }

    #[test]
    fn hook_event_serde_uses_snake_case() {
        // Sanity-check the rename — config files use
        // `pre_governance_decision` not `PreGovernanceDecision`.
        let json = serde_json::to_string(&HookEvent::PreGovernanceDecision).unwrap();
        assert_eq!(json, "\"pre_governance_decision\"");
        let back: HookEvent = serde_json::from_str("\"on_index_eviction\"").unwrap();
        assert_eq!(back, HookEvent::OnIndexEviction);
    }

    #[test]
    fn hook_mode_serde_uses_snake_case() {
        let exec_json = serde_json::to_string(&HookMode::Exec).unwrap();
        let daemon_json = serde_json::to_string(&HookMode::Daemon).unwrap();
        assert_eq!(exec_json, "\"exec\"");
        assert_eq!(daemon_json, "\"daemon\"");
    }

    #[test]
    fn fail_mode_default_is_open() {
        assert_eq!(FailMode::default(), FailMode::Open);
        assert_eq!(default_fail_mode(), FailMode::Open);
    }

    #[test]
    fn fail_mode_serde_round_trip() {
        let open = serde_json::to_string(&FailMode::Open).unwrap();
        let closed = serde_json::to_string(&FailMode::Closed).unwrap();
        assert_eq!(open, "\"open\"");
        assert_eq!(closed, "\"closed\"");
        let back: FailMode = serde_json::from_str("\"closed\"").unwrap();
        assert_eq!(back, FailMode::Closed);
    }

    #[test]
    fn default_mode_for_event_matrix() {
        // Hot-path events default to Daemon.
        assert_eq!(
            default_mode_for_event(HookEvent::PostRecall),
            HookMode::Daemon
        );
        assert_eq!(
            default_mode_for_event(HookEvent::PostSearch),
            HookMode::Daemon
        );
        assert_eq!(
            default_mode_for_event(HookEvent::PreRecallExpand),
            HookMode::Daemon
        );
        // Cold-path events default to Exec.
        assert_eq!(default_mode_for_event(HookEvent::PostStore), HookMode::Exec);
        assert_eq!(default_mode_for_event(HookEvent::PreStore), HookMode::Exec);
        assert_eq!(default_mode_for_event(HookEvent::PreDelete), HookMode::Exec);
    }

    #[test]
    fn fail_mode_closed_is_parsed() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = "/bin/true"
priority = 0
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"
fail_mode = "closed"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses");
        assert_eq!(hooks[0].fail_mode, FailMode::Closed);
    }

    #[test]
    fn fail_mode_omitted_defaults_to_open() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = "/bin/true"
priority = 0
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "*"
"#;
        let hooks = HookConfig::load_from_str(toml_src).expect("parses");
        assert_eq!(hooks[0].fail_mode, FailMode::Open);
    }

    #[test]
    fn validation_error_display_surfaces_field_and_reason() {
        let err = HooksConfigError::Validation {
            field: "hook[0].timeout_ms".into(),
            reason: "exceeds maximum".into(),
        };
        let s = err.to_string();
        assert!(s.contains("hook[0].timeout_ms"));
        assert!(s.contains("exceeds maximum"));
    }

    #[test]
    fn io_error_display_and_source() {
        let io_err = std::io::Error::other("simulated read failure");
        let err = HooksConfigError::Io(io_err);
        let s = err.to_string();
        assert!(s.contains("hooks.toml read error"));
        assert!(s.contains("simulated read failure"));
        // source() returns Some for Io variant
        use std::error::Error;
        assert!(err.source().is_some());
    }

    #[test]
    fn toml_error_no_span_displays_without_line_marker() {
        // Manually construct (we can't easily force toml to produce a
        // no-span error from a public API, but this covers the `line == 0`
        // branch of Display).
        let err = HooksConfigError::Toml {
            line: 0,
            column: 0,
            message: "no span here".into(),
        };
        let s = err.to_string();
        assert!(s.contains("no span here"));
        assert!(!s.contains("line 0"));
    }

    #[test]
    fn toml_error_with_span_displays_line_and_column() {
        let err = HooksConfigError::Toml {
            line: 7,
            column: 3,
            message: "broken".into(),
        };
        let s = err.to_string();
        assert!(s.contains("line 7"));
        assert!(s.contains("column 3"));
    }

    #[test]
    fn hooks_config_error_source_for_non_io_variants_is_none() {
        use std::error::Error;
        let v = HooksConfigError::Validation {
            field: "x".into(),
            reason: "y".into(),
        };
        assert!(v.source().is_none());
        let t = HooksConfigError::Toml {
            line: 0,
            column: 0,
            message: "z".into(),
        };
        assert!(t.source().is_none());
    }

    #[test]
    fn load_from_file_returns_io_error_for_missing_path() {
        let p = std::path::Path::new("/this/path/does/not/exist/hooks-test.toml");
        let err = HookConfig::load_from_file(p).unwrap_err();
        assert!(matches!(err, HooksConfigError::Io(_)));
    }

    #[test]
    fn rejects_whitespace_only_namespace() {
        let toml_src = r#"
[[hook]]
event = "post_store"
command = "/bin/true"
priority = 0
timeout_ms = 1000
mode = "exec"
enabled = true
namespace = "   "
"#;
        let err = HookConfig::load_from_str(toml_src).unwrap_err();
        match err {
            HooksConfigError::Validation { field, .. } => {
                assert!(field.ends_with("namespace"));
            }
            other => panic!("expected Validation, got {other:?}"),
        }
    }

    #[test]
    fn byte_offset_to_line_col_handles_multiline_input() {
        let s = "first\nsecond\nthird";
        // offset 0 = line 1, col 1
        assert_eq!(byte_offset_to_line_col(s, 0), (1, 1));
        // offset 5 (newline after "first") still on line 1
        assert_eq!(byte_offset_to_line_col(s, 5), (1, 6));
        // offset 6 (start of "second") = line 2
        assert_eq!(byte_offset_to_line_col(s, 6), (2, 1));
        // offset way past end = still walks to end
        let (line, _) = byte_offset_to_line_col(s, 9_999);
        assert!(line >= 3);
    }
}
