//! `cqs hook` — git-hook integration for watch-mode reconciliation.
//! (#1182 — Layer 1)
//!
//! Closes the **bulk-git missed-event** class: `git checkout`, `merge`,
//! `reset`, `rebase` shift many files at once but inotify (and especially
//! WSL's 9P bridge) routinely drops events under that load. The watch
//! loop never learns the working tree changed — index goes silently
//! stale, eval R@K shifts by 5-25 pp from line-number drift alone, and
//! the user can't tell whether a regression is real or operational.
//!
//! ## Mechanism
//!
//! `cqs hook install` writes three small shell wrappers into
//! `.git/hooks/`:
//!
//! - `post-checkout` — fires after `git checkout` (branch / file)
//! - `post-merge` — fires after `git merge` (or `git pull` fast-forward)
//! - `post-rewrite` — fires after `git rebase` / `git commit --amend`
//!
//! (Git has no `post-reset` hook; that case is left to Layer 2's
//! periodic walk.)
//!
//! Each wrapper runs `cqs hook fire <name> "$@" &` in the background,
//! exits 0 immediately. `cqs hook fire` connects to the running watch
//! daemon's socket and posts a `reconcile` message. If the daemon is
//! down, it touches `.cqs/.dirty` instead — the daemon promotes that
//! marker to a one-shot reconcile on next start.
//!
//! ## Idempotence
//!
//! Hook scripts carry a fixed marker line (`# cqs:hook v1`). The
//! installer skips files that already contain a cqs marker — re-running
//! `cqs hook install` is safe. Files that exist but lack the marker are
//! preserved and the user is warned (don't clobber third-party hooks).

use anyhow::{Context, Result};
use serde::Serialize;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use crate::cli::find_project_root;

/// Marker line embedded in every cqs-managed hook script. Bump the
/// version suffix when the wrapper template changes — the installer
/// uses the prefix to detect old-version cqs hooks and replace them.
pub(crate) const HOOK_MARKER_PREFIX: &str = "# cqs:hook";
pub(crate) const HOOK_MARKER_CURRENT: &str = "# cqs:hook v1";

/// Hooks the installer manages. Mapped to the `cqs hook fire` first
/// argument when the wrapper runs.
pub(crate) const MANAGED_HOOKS: &[&str] = &["post-checkout", "post-merge", "post-rewrite"];

/// `cqs hook` subcommand surface.
#[derive(clap::Subcommand, Debug, Clone)]
/// API-V1.38-1 (#1463): every variant flattens
/// [`crate::cli::definitions::TextJsonArgs`] (`output: TextJsonArgs`) instead
/// of an inline `json: bool`. Matches the codebase-wide pattern.
pub(crate) enum HookCommand {
    /// Install cqs hooks into `.git/hooks/`. Idempotent — re-running is
    /// safe; already-installed cqs hooks are upgraded in place.
    Install {
        /// Skip writing hooks that don't already exist. Useful in CI
        /// where you want a deterministic set without overwriting any
        /// existing third-party hooks.
        #[arg(long)]
        no_overwrite: bool,
        #[command(flatten)]
        output: crate::cli::definitions::TextJsonArgs,
    },
    /// Remove cqs-managed hooks from `.git/hooks/`. Hooks that don't
    /// carry the cqs marker are left alone (they may be third-party).
    Uninstall {
        #[command(flatten)]
        output: crate::cli::definitions::TextJsonArgs,
    },
    /// Internal: hook scripts call this. Posts a `reconcile` socket
    /// message; falls back to touching `.cqs/.dirty` if the daemon is
    /// down. Never blocks git — exits 0 within a few hundred ms.
    Fire {
        /// Hook name (`post-checkout`, `post-merge`, `post-rewrite`).
        name: String,
        /// Hook arguments forwarded from git (commit SHAs, etc.).
        /// Captured verbatim for tracing; not used for the reconcile
        /// algorithm itself.
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
        /// Output as JSON. Default is silent on success, brief stderr on
        /// failure — hooks shouldn't pollute git's output channel.
        #[command(flatten)]
        output: crate::cli::definitions::TextJsonArgs,
    },
    /// Show installed hooks + daemon connectivity.
    Status {
        #[command(flatten)]
        output: crate::cli::definitions::TextJsonArgs,
    },
}

// PB-V1.30.1-8: `git_dir` and `dirty_marker` carry forward-slash-normalized
// strings, not raw `PathBuf`s, so JSON output matches the rest of the
// surface (per `src/store/types.rs:220`). Backslashes on Windows would
// otherwise leak into reports and break tooling that grep-matches on
// canonical relative paths.
#[derive(Debug, Serialize)]
struct InstallReport {
    git_dir: String,
    installed: Vec<String>,
    upgraded: Vec<String>,
    skipped_existing: Vec<String>,
    skipped_no_overwrite: Vec<String>,
}

#[derive(Debug, Serialize)]
struct UninstallReport {
    git_dir: String,
    removed: Vec<String>,
    skipped_foreign: Vec<String>,
    not_present: Vec<String>,
}

#[derive(Debug, Serialize)]
struct StatusReport {
    git_dir: String,
    installed: Vec<String>,
    foreign: Vec<String>,
    missing: Vec<String>,
    daemon_up: bool,
    /// PB-V1.33-7 / #1354: on Windows, the MSYS shell that runs hooks does
    /// not see `%PATH%` reliably. This field carries the result of a
    /// `bash -c 'command -v cqs'` probe so operators see whether the hook
    /// will actually fire. `None` on Linux/macOS (PATH is shared, probe is
    /// moot) and on Windows when `bash` itself isn't available.
    #[serde(skip_serializing_if = "Option::is_none")]
    cqs_visible_in_hook_shell: Option<bool>,
}

#[derive(Debug, Serialize)]
struct FireReport {
    hook: String,
    args: Vec<String>,
    sent_to_daemon: bool,
    /// Set when the daemon was unreachable. The path of the touched
    /// fallback marker. Normalized to forward-slashes for JSON output.
    dirty_marker: Option<String>,
    /// Daemon error text (if any). Surfaces a connect failure without
    /// failing the hook — the dirty marker is the recovery path.
    daemon_error: Option<String>,
}

/// Top-level dispatch. Each variant handles its own JSON-vs-text output.
pub(crate) fn cmd_hook(subcmd: HookCommand) -> Result<()> {
    match subcmd {
        HookCommand::Install {
            no_overwrite,
            output,
        } => cmd_install(no_overwrite, output.json),
        HookCommand::Uninstall { output } => cmd_uninstall(output.json),
        HookCommand::Fire { name, args, output } => cmd_fire(name, args, output.json),
        HookCommand::Status { output } => cmd_status(output.json),
    }
}

/// RB-V1.36-3: bounded read of a managed hook file. Hooks are normally
/// <10 KB; cap at 1 MiB so a corrupt / hostile multi-GB hook file doesn't
/// OOM `cqs ci ...`. Files above the cap are reported as `read_to_string`
/// I/O errors with `kind() == InvalidData` so callers treat them as foreign
/// (refuse to overwrite) — same effect as the existing fall-through.
const HOOK_MAX_BYTES: u64 = 1024 * 1024;

fn read_hook_capped(path: &Path) -> std::io::Result<String> {
    let meta = std::fs::metadata(path)?;
    if meta.len() > HOOK_MAX_BYTES {
        tracing::warn!(
            path = %path.display(),
            size = meta.len(),
            cap = HOOK_MAX_BYTES,
            "Hook file exceeds size cap — treating as foreign hook"
        );
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "hook file exceeds size cap",
        ));
    }
    std::fs::read_to_string(path)
}

// ─── install ──────────────────────────────────────────────────────────────

fn cmd_install(no_overwrite: bool, json: bool) -> Result<()> {
    let _span = tracing::info_span!("cmd_hook_install", no_overwrite, json).entered();
    let root = find_project_root();
    let git_dir = locate_git_hooks_dir(&root)?;
    std::fs::create_dir_all(&git_dir).with_context(|| format!("create {}", git_dir.display()))?;

    let mut report = InstallReport {
        git_dir: cqs::normalize_path(&git_dir),
        installed: Vec::new(),
        upgraded: Vec::new(),
        skipped_existing: Vec::new(),
        skipped_no_overwrite: Vec::new(),
    };

    for &hook in MANAGED_HOOKS {
        let path = git_dir.join(hook);
        // EH-V1.33-3: distinguish `NotFound` (no hook present, safe to
        // install) from any other read failure (PermissionDenied, corrupt
        // UTF-8, IO hiccup). Collapsing all errors to `None` via `.ok()`
        // would let the `None` arm clobber a foreign hook that was simply
        // unreadable. Refuse to write in any non-NotFound failure case.
        // RB-V1.36-3: read_hook_capped bounds the read at 1 MiB.
        let existing = match read_hook_capped(&path) {
            Ok(s) => Some(s),
            Err(e) if e.kind() == ErrorKind::NotFound => None,
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    path = %path.display(),
                    "Refusing to install over unreadable hook (may be a foreign hook with restricted perms)"
                );
                report.skipped_existing.push(hook.to_string());
                continue;
            }
        };
        match existing {
            None => {
                if no_overwrite {
                    report.skipped_no_overwrite.push(hook.to_string());
                    continue;
                }
                write_hook_script(&path, hook)?;
                report.installed.push(hook.to_string());
            }
            Some(content) => {
                if content.contains(HOOK_MARKER_PREFIX) {
                    // Already a cqs-managed hook (any version) — overwrite
                    // unconditionally so the user gets the current
                    // template on every install.
                    write_hook_script(&path, hook)?;
                    report.upgraded.push(hook.to_string());
                } else {
                    // Foreign hook — never clobber. User's third-party
                    // tooling stays in place; they can chain `cqs hook
                    // fire` from their own script if they want.
                    report.skipped_existing.push(hook.to_string());
                }
            }
        }
    }

    emit(&report, json)?;
    if !report.skipped_existing.is_empty() && !json {
        eprintln!(
            "note: {} pre-existing third-party hook(s) left alone — chain `cqs hook fire {{name}}` from inside if you want both.",
            report.skipped_existing.len()
        );
    }
    Ok(())
}

fn write_hook_script(path: &Path, hook_name: &str) -> Result<()> {
    let fallback = absolute_cqs_for_hook();
    let body = render_hook_script(hook_name, fallback.as_deref());
    std::fs::write(path, body).with_context(|| format!("write {}", path.display()))?;
    set_executable(path)?;
    Ok(())
}

/// PB-V1.33-7 / #1354: Windows-only — the git-bash MSYS shell that runs
/// `.git/hooks/*` does not inherit `%PATH%`; only entries that translate
/// to valid POSIX-style paths survive (`C:\Users\foo\.cargo\bin` →
/// `/c/Users/foo/.cargo/bin`). Operators who installed `cqs` via
/// `cargo install` and verified `cqs --version` works in `cmd.exe` see
/// `command -v cqs` fail silently inside the hook. To recover, embed the
/// POSIX-translated absolute path of the running `cqs.exe` into the
/// wrapper as a fallback. Linux/macOS hits the `command -v cqs` branch
/// and never reads the fallback.
#[cfg(windows)]
fn absolute_cqs_for_hook() -> Option<String> {
    let exe = std::env::current_exe().ok()?;
    windows_to_msys_path(&exe)
}

#[cfg(not(windows))]
fn absolute_cqs_for_hook() -> Option<String> {
    None
}

/// Translate a Windows native path (`C:\Users\foo\.cargo\bin\cqs.exe`)
/// to the MSYS / git-bash POSIX form (`/c/Users/foo/.cargo/bin/cqs.exe`)
/// that the hook shell understands. Drive letter is lowercased; the `:`
/// is dropped; backslashes become forward slashes.
#[cfg(windows)]
fn windows_to_msys_path(p: &Path) -> Option<String> {
    let s = p.to_str()?;
    let bytes = s.as_bytes();
    if bytes.len() < 3 || bytes[1] != b':' {
        return None;
    }
    let drive = (bytes[0] as char).to_ascii_lowercase();
    let tail = &s[2..].replace('\\', "/");
    Some(format!("/{drive}{tail}"))
}

#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = std::fs::metadata(path)
        .with_context(|| format!("stat {}", path.display()))?
        .permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
    // Windows git-bash respects the file extension / shebang, no chmod
    // needed.
    Ok(())
}

/// Build the hook wrapper text. Single template — only the hook name
/// embedded in the `cqs hook fire` invocation differs between hooks.
///
/// The wrapper:
///   - Skips silently if `.cqs/` is missing (cqs not used in this
///     working tree).
///   - Skips silently if `cqs` isn't on `$PATH`.
///   - Forks `cqs hook fire <name> "$@" &` so git never waits.
///   - Always exits 0 — a flaky daemon must never break `git checkout`.
fn render_hook_script(hook_name: &str, absolute_fallback: Option<&str>) -> String {
    // Background fire-and-forget. `cqs hook fire` is bounded — it has its
    // own socket timeout and exits within a few hundred ms even on a wedged
    // daemon. The redirect keeps git's output channel clean.
    //
    // PB-V1.33-7 / #1354: on Windows-native installs, the MSYS shell that
    // runs hooks does not see `%PATH%` entries reliably. When `cqs hook
    // install` runs on Windows, the resolved absolute `cqs.exe` path is
    // embedded as a second branch; on Linux/macOS the fallback is absent
    // and the script is identical to the pre-#1354 form.
    let fallback_branch = match absolute_fallback {
        Some(p) => format!(
            "elif [ -x \"{p}\" ]; then\n    \"{p}\" hook fire {hook_name} \"$@\" >/dev/null 2>&1 &\n",
        ),
        None => String::new(),
    };
    format!(
        r#"#!/bin/sh
{marker}
# Forwards a {hook_name} event to the running cqs watch daemon. Falls
# back to .cqs/.dirty when the daemon is offline. Never blocks git.

# Bail silently when cqs isn't initialised or installed in this tree.
git_root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
[ -d "$git_root/.cqs" ] || exit 0

if command -v cqs >/dev/null 2>&1; then
    cqs hook fire {hook_name} "$@" >/dev/null 2>&1 &
{fallback_branch}fi

exit 0
"#,
        marker = HOOK_MARKER_CURRENT,
        hook_name = hook_name,
        fallback_branch = fallback_branch,
    )
}

// ─── uninstall ────────────────────────────────────────────────────────────

fn cmd_uninstall(json: bool) -> Result<()> {
    let _span = tracing::info_span!("cmd_hook_uninstall", json).entered();
    let root = find_project_root();
    let git_dir = locate_git_hooks_dir(&root)?;
    let report = do_uninstall(&git_dir)?;
    emit(&report, json)?;
    Ok(())
}

/// TC-HAP-1.30.1-2: path-aware uninstall helper. Body of `cmd_uninstall`
/// extracted so unit tests can drive a tempdir without the global
/// `find_project_root` walk.
fn do_uninstall(git_dir: &Path) -> Result<UninstallReport> {
    let mut report = UninstallReport {
        git_dir: cqs::normalize_path(git_dir),
        removed: Vec::new(),
        skipped_foreign: Vec::new(),
        not_present: Vec::new(),
    };

    for &hook in MANAGED_HOOKS {
        let path = git_dir.join(hook);
        // RB-V1.36-3: capped read; oversize files are treated as not-present
        // to fall through to the "leave foreign hook alone" branch.
        //
        // EH-V1.38-6 (#1463): distinguish NotFound (expected, fine) from
        // permission-denied / oversize / other I/O errors so an operator
        // doesn't see "hook not present" while a perm-locked or oversize
        // file silently survives. The report bucket stays the same to avoid
        // a schema change; the warn is the operator-facing signal.
        match read_hook_capped(&path) {
            Err(e) => {
                if e.kind() != std::io::ErrorKind::NotFound {
                    tracing::warn!(
                        hook = hook,
                        path = %path.display(),
                        error = %e,
                        kind = ?e.kind(),
                        "Hook present but unreadable; treating as not-present (uninstall will not touch it)"
                    );
                }
                report.not_present.push(hook.to_string());
            }
            Ok(content) => {
                if content.contains(HOOK_MARKER_PREFIX) {
                    std::fs::remove_file(&path)
                        .with_context(|| format!("remove {}", path.display()))?;
                    report.removed.push(hook.to_string());
                } else {
                    report.skipped_foreign.push(hook.to_string());
                }
            }
        }
    }

    Ok(report)
}

// ─── fire ─────────────────────────────────────────────────────────────────

fn cmd_fire(name: String, args: Vec<String>, json: bool) -> Result<()> {
    let _span = tracing::info_span!("cmd_hook_fire", hook = %name).entered();
    let root = find_project_root();
    let cqs_dir = cqs::resolve_index_dir(&root);
    let report = do_fire(&cqs_dir, &name, args, /*try_daemon=*/ true)?;
    emit(&report, json)?;
    Ok(())
}

/// TC-HAP-1.30.1-2: path-aware fire helper. Tries the daemon socket first
/// (when `try_daemon` is true and the platform supports unix sockets),
/// falls back to writing `.cqs/.dirty` atomically (DS-V1.30.1-D5).
fn do_fire(cqs_dir: &Path, name: &str, args: Vec<String>, try_daemon: bool) -> Result<FireReport> {
    let mut report = FireReport {
        hook: name.to_string(),
        args: args.clone(),
        sent_to_daemon: false,
        dirty_marker: None,
        daemon_error: None,
    };

    #[cfg(unix)]
    {
        if try_daemon {
            match cqs::daemon_translate::daemon_reconcile(cqs_dir, Some(name), &args) {
                Ok(_resp) => {
                    report.sent_to_daemon = true;
                    return Ok(report);
                }
                Err(e) => {
                    tracing::warn!(error = %e, "daemon_reconcile failed — touching .cqs/.dirty");
                    report.daemon_error = Some(e.as_message());
                }
            }
        } else {
            report.daemon_error = Some("daemon path skipped (test override)".to_string());
        }
    }
    #[cfg(not(unix))]
    {
        let _ = try_daemon; // unused on non-unix
        report.daemon_error = Some("hook fire requires unix sockets".to_string());
    }

    // Fallback: leave a marker the daemon will pick up on next start.
    let dirty = cqs_dir.join(".dirty");
    std::fs::create_dir_all(cqs_dir).with_context(|| format!("create {}", cqs_dir.display()))?;
    // DS-V1.30.1-D5: stage to .dirty.tmp then atomic_replace so the
    // marker survives a power-cut between write and the next directory
    // sync. atomic_replace fsyncs the tmp before rename and best-effort
    // fsyncs the parent afterwards. The marker is the *only* signal the
    // daemon will see post-reboot, so durability matters more than the
    // empty-file write cost.
    let tmp = cqs_dir.join(".dirty.tmp");
    std::fs::write(&tmp, b"").with_context(|| format!("stage {}", tmp.display()))?;
    cqs::fs::atomic_replace(&tmp, &dirty)
        .with_context(|| format!("promote {} -> {}", tmp.display(), dirty.display()))?;
    report.dirty_marker = Some(cqs::normalize_path(&dirty));
    Ok(report)
}

// ─── status ───────────────────────────────────────────────────────────────

fn cmd_status(json: bool) -> Result<()> {
    let _span = tracing::info_span!("cmd_hook_status", json).entered();
    let root = find_project_root();
    let git_dir = locate_git_hooks_dir(&root)?;
    let cqs_dir = cqs::resolve_index_dir(&root);

    let mut report = do_hook_status(&git_dir)?;
    #[cfg(unix)]
    {
        report.daemon_up = cqs::daemon_translate::daemon_status(&cqs_dir).is_ok();
    }
    #[cfg(not(unix))]
    {
        let _ = cqs_dir; // unused on non-unix
    }

    // PB-V1.33-7 / #1354: on Windows-native, surface whether the hook's
    // shell environment can actually find `cqs`. Linux/macOS share PATH
    // with the hook shell, so the probe is moot and the field stays None.
    #[cfg(windows)]
    {
        report.cqs_visible_in_hook_shell = probe_cqs_visible_in_bash();
    }

    emit(&report, json)?;
    Ok(())
}

/// Run `bash -c 'command -v cqs'` and report whether bash found `cqs`.
/// `None` when bash itself isn't available on PATH — the probe can't
/// distinguish "no bash" from "no cqs," so signal that with a missing
/// field rather than asserting one or the other.
#[cfg(windows)]
fn probe_cqs_visible_in_bash() -> Option<bool> {
    let out = std::process::Command::new("bash")
        .args(["-c", "command -v cqs >/dev/null 2>&1"])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .ok()?;
    Some(out.success())
}

/// TC-HAP-1.30.1-2: path-aware hook-status helper. Pure file-system
/// classification with no daemon probe — `cmd_status` overlays
/// `daemon_up` separately.
fn do_hook_status(git_dir: &Path) -> Result<StatusReport> {
    let mut report = StatusReport {
        git_dir: cqs::normalize_path(git_dir),
        installed: Vec::new(),
        foreign: Vec::new(),
        missing: Vec::new(),
        daemon_up: false,
        cqs_visible_in_hook_shell: None,
    };

    for &hook in MANAGED_HOOKS {
        let path = git_dir.join(hook);
        // RB-V1.36-3: capped read; oversize files are reported as foreign
        // (since we can't tell whether they contain HOOK_MARKER_PREFIX).
        //
        // EH-V1.38-6 (#1463): same NotFound-vs-other split as `do_uninstall`
        // — an operator running `cqs hook status` should see a warn for
        // permission-denied / oversize files instead of the discrepancy
        // hiding under a "missing" label.
        match read_hook_capped(&path) {
            Err(e) => {
                if e.kind() != std::io::ErrorKind::NotFound {
                    tracing::warn!(
                        hook = hook,
                        path = %path.display(),
                        error = %e,
                        kind = ?e.kind(),
                        "Hook present but unreadable; reporting as missing"
                    );
                }
                report.missing.push(hook.to_string());
            }
            Ok(content) => {
                if content.contains(HOOK_MARKER_PREFIX) {
                    report.installed.push(hook.to_string());
                } else {
                    report.foreign.push(hook.to_string());
                }
            }
        }
    }

    Ok(report)
}

// ─── helpers ──────────────────────────────────────────────────────────────

/// Find `.git/hooks/`. Honors `core.hooksPath` if set (some teams pin a
/// shared hooks dir under their dotfiles repo).
fn locate_git_hooks_dir(root: &Path) -> Result<PathBuf> {
    // Prefer git's own resolution since `core.hooksPath` may rewrite the
    // default. Falls back to `<root>/.git/hooks` if `git` isn't on PATH
    // — the common case in CI containers.
    let cmd = std::process::Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["rev-parse", "--git-path", "hooks"])
        .output();
    if let Ok(out) = cmd {
        if out.status.success() {
            let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !raw.is_empty() {
                let p = PathBuf::from(&raw);
                let resolved = if p.is_absolute() { p } else { root.join(p) };
                return Ok(resolved);
            }
        }
    }
    Ok(root.join(".git").join("hooks"))
}

fn emit<T: Serialize>(report: &T, json: bool) -> Result<()> {
    if json {
        crate::cli::json_envelope::emit_json(report)?;
    } else {
        // Text output: dump a compact one-line-per-field view. Hook
        // commands are rare interactive runs; verbosity is fine.
        let pretty = serde_json::to_string_pretty(report)?;
        println!("{pretty}");
    }
    Ok(())
}

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

    #[test]
    fn render_hook_script_contains_marker_and_name() {
        let body = render_hook_script("post-checkout", None);
        assert!(body.contains(HOOK_MARKER_CURRENT), "marker missing: {body}");
        assert!(body.contains("post-checkout"), "hook name missing: {body}");
        assert!(body.contains("cqs hook fire post-checkout"));
        assert!(body.starts_with("#!/bin/sh"));
    }

    #[test]
    fn render_hook_script_starts_with_shebang_then_marker() {
        // The shebang has to be *exactly* the first line for the kernel
        // to recognise it. The marker follows on line 2 so detection
        // doesn't depend on script body content.
        let body = render_hook_script("post-merge", None);
        let first_line = body.lines().next().unwrap_or("");
        let second_line = body.lines().nth(1).unwrap_or("");
        assert_eq!(first_line, "#!/bin/sh");
        assert_eq!(second_line, HOOK_MARKER_CURRENT);
    }

    /// PB-V1.33-7 / #1354: a Windows install passes the POSIX-translated
    /// absolute `cqs.exe` path as the fallback. The rendered script
    /// keeps the `command -v cqs` branch (so cargo-bin-on-MSYS-PATH users
    /// still hit the bare `cqs` invocation) and adds an `elif [ -x "<abs>" ]`
    /// branch that fires the hook through the absolute path.
    #[test]
    fn render_hook_script_with_absolute_fallback_emits_elif_branch() {
        let body = render_hook_script("post-checkout", Some("/c/Users/foo/.cargo/bin/cqs.exe"));
        assert!(
            body.contains("if command -v cqs >/dev/null 2>&1; then"),
            "PATH-first branch missing: {body}"
        );
        assert!(
            body.contains("cqs hook fire post-checkout"),
            "PATH branch invocation missing: {body}"
        );
        assert!(
            body.contains("elif [ -x \"/c/Users/foo/.cargo/bin/cqs.exe\" ]; then"),
            "absolute-path elif missing: {body}"
        );
        assert!(
            body.contains("\"/c/Users/foo/.cargo/bin/cqs.exe\" hook fire post-checkout"),
            "absolute-path invocation missing: {body}"
        );
    }

    /// Without a fallback (Linux / macOS), the script must not emit any
    /// `elif` branch — keeps the on-disk text minimal and identical to
    /// the pre-#1354 one-branch shape on platforms that don't need it.
    #[test]
    fn render_hook_script_without_fallback_has_no_elif() {
        let body = render_hook_script("post-merge", None);
        assert!(
            !body.contains("elif"),
            "no fallback should mean no elif: {body}"
        );
        assert!(
            body.contains("cqs hook fire post-merge"),
            "PATH-branch invocation missing: {body}"
        );
    }

    #[cfg(windows)]
    #[test]
    fn windows_to_msys_path_translates_drive_letter() {
        use std::path::PathBuf;
        // Lowercased drive, dropped colon, forward slashes.
        let p = PathBuf::from(r"C:\Users\foo\.cargo\bin\cqs.exe");
        assert_eq!(
            super::windows_to_msys_path(&p).as_deref(),
            Some("/c/Users/foo/.cargo/bin/cqs.exe")
        );
    }

    #[cfg(windows)]
    #[test]
    fn windows_to_msys_path_rejects_non_drive_path() {
        use std::path::PathBuf;
        let p = PathBuf::from(r"\\server\share\cqs.exe"); // UNC, no drive letter
        assert!(super::windows_to_msys_path(&p).is_none());
    }

    #[test]
    fn install_writes_three_hooks_into_fresh_repo() {
        let dir = TempDir::new().unwrap();
        // Simulate a real git repo with .git/hooks/.
        let hooks = dir.path().join(".git").join("hooks");
        std::fs::create_dir_all(&hooks).unwrap();

        // Drive the lower-level write directly so the test doesn't rely
        // on the global `find_project_root` binding to the temp dir.
        for &hook in MANAGED_HOOKS {
            let path = hooks.join(hook);
            write_hook_script(&path, hook).unwrap();
            assert!(path.exists(), "{hook} not written");
            let content = std::fs::read_to_string(&path).unwrap();
            assert!(content.contains(HOOK_MARKER_CURRENT));
            assert!(content.contains(&format!("cqs hook fire {hook}")));
        }
    }

    #[test]
    fn install_skips_foreign_hook_marker_check() {
        // Pre-existing hook without a cqs marker → install_install logic
        // would push to skipped_existing. Reproduce the marker check
        // inline so the assertion is independent of the (file-system-
        // bound) `cmd_install` function.
        let foreign_hook = "#!/bin/sh\necho 'i belong to husky'\n";
        assert!(
            !foreign_hook.contains(HOOK_MARKER_PREFIX),
            "foreign hook must not match cqs marker"
        );

        let cqs_hook = render_hook_script("post-checkout", None);
        assert!(cqs_hook.contains(HOOK_MARKER_PREFIX));
    }

    /// TC-HAP-1.30.1-1: a pre-existing v0-marker hook gets upgraded to the
    /// current marker on a re-install. Drives the lower-level
    /// `write_hook_script` directly because `cmd_install` would resolve the
    /// project root from the workspace, not the temp dir.
    #[test]
    fn install_upgrade_replaces_v0_marker_with_current() {
        let tmp = TempDir::new().unwrap();
        let hooks = tmp.path().join(".git").join("hooks");
        std::fs::create_dir_all(&hooks).unwrap();
        let path = hooks.join("post-checkout");
        // Seed a v0-marker hook (legacy install). The version part of
        // HOOK_MARKER_CURRENT differs but HOOK_MARKER_PREFIX matches both.
        std::fs::write(&path, "#!/bin/sh\n# cqs:hook v0\n").unwrap();
        let pre = std::fs::read_to_string(&path).unwrap();
        assert!(
            pre.contains(HOOK_MARKER_PREFIX),
            "v0 marker must match the prefix"
        );
        assert!(
            !pre.contains(HOOK_MARKER_CURRENT),
            "seed must not already be on the current marker"
        );

        // Simulate the upgrade path inside `cmd_install`'s `Some(content)`
        // arm: when an existing file contains the marker prefix, rewrite
        // it with the current template.
        write_hook_script(&path, "post-checkout").unwrap();

        let post = std::fs::read_to_string(&path).unwrap();
        assert!(
            post.contains(HOOK_MARKER_CURRENT),
            "upgrade must land the current marker, got: {post}"
        );
        // Old marker must be gone — the entire body is rewritten.
        assert!(
            !post.contains("# cqs:hook v0\n"),
            "v0-marker line must be replaced, got: {post}"
        );
    }

    /// TC-HAP-1.30.1-1: idempotency — a second `write_hook_script` call on
    /// an already-current hook leaves the file with exactly one marker.
    #[test]
    fn install_idempotent_second_run_keeps_marker() {
        let tmp = TempDir::new().unwrap();
        let hooks = tmp.path().join(".git").join("hooks");
        std::fs::create_dir_all(&hooks).unwrap();
        let path = hooks.join("post-merge");

        write_hook_script(&path, "post-merge").unwrap();
        let first = std::fs::read_to_string(&path).unwrap();
        write_hook_script(&path, "post-merge").unwrap();
        let second = std::fs::read_to_string(&path).unwrap();

        // The second call replaces the file body, not appends — exactly
        // one marker survives.
        let count = second.matches(HOOK_MARKER_CURRENT).count();
        assert_eq!(
            count, 1,
            "idempotent install must keep exactly one marker, got {count}: {second}"
        );
        // Bodies must be byte-identical (template is deterministic).
        assert_eq!(first, second, "hook body must be deterministic");
    }

    #[test]
    fn locate_git_hooks_dir_falls_back_to_dot_git() {
        // No `git` binary in PATH for this test (or the cmd fails for
        // any reason) — the fallback joins `<root>/.git/hooks`. Use a
        // path that doesn't exist so we know we hit the fallback.
        let bogus = PathBuf::from("/nonexistent/cqs-hook-test-tree");
        let resolved = locate_git_hooks_dir(&bogus).unwrap();
        // `git rev-parse` on a non-git tree exits non-zero, so we hit
        // the join branch. Either path shape is acceptable as long as
        // the function doesn't blow up.
        assert!(resolved.to_string_lossy().ends_with("hooks"));
    }

    // ───── TC-HAP-1.30.1-2: cmd_uninstall / cmd_fire / cmd_status ─────────
    //
    // These tests drive the path-aware helpers (`do_uninstall`, `do_fire`,
    // `do_hook_status`) so the body of each cmd_* dispatch is exercised
    // without the global `find_project_root` walk. Marker-classification
    // logic uses `HOOK_MARKER_PREFIX` (per line 45) — `HOOK_MARKER_CURRENT`
    // contains the prefix, so seeding tests with `HOOK_MARKER_CURRENT`
    // exercises the same code path the installer hits.

    #[test]
    fn cmd_uninstall_removes_only_marked_hooks() {
        let tmp = TempDir::new().unwrap();
        let hooks = tmp.path().join(".git").join("hooks");
        std::fs::create_dir_all(&hooks).unwrap();

        // Two cqs-marked hooks + one foreign hook + one missing hook.
        // HOOK_MARKER_CURRENT contains HOOK_MARKER_PREFIX so the
        // matcher at line ~279 fires for both.
        std::fs::write(hooks.join("post-checkout"), HOOK_MARKER_CURRENT).unwrap();
        std::fs::write(hooks.join("post-merge"), HOOK_MARKER_CURRENT).unwrap();
        std::fs::write(hooks.join("post-rewrite"), "#!/bin/sh\necho user").unwrap();

        let report = do_uninstall(&hooks).unwrap();
        assert_eq!(
            report.removed,
            vec!["post-checkout".to_string(), "post-merge".to_string()],
            "only cqs-marked hooks should be removed",
        );
        assert_eq!(
            report.skipped_foreign,
            vec!["post-rewrite".to_string()],
            "foreign hook (no cqs marker) must be left alone",
        );
        assert!(report.not_present.is_empty());

        // Disk reflects the in-memory report.
        assert!(!hooks.join("post-checkout").exists());
        assert!(!hooks.join("post-merge").exists());
        assert!(
            hooks.join("post-rewrite").exists(),
            "foreign hook must survive uninstall",
        );
    }

    #[test]
    fn cmd_uninstall_handles_missing_hooks() {
        // Empty .git/hooks/ — every managed hook should land in not_present
        // with no errors.
        let tmp = TempDir::new().unwrap();
        let hooks = tmp.path().join(".git").join("hooks");
        std::fs::create_dir_all(&hooks).unwrap();

        let report = do_uninstall(&hooks).unwrap();
        assert!(report.removed.is_empty());
        assert!(report.skipped_foreign.is_empty());
        assert_eq!(report.not_present.len(), MANAGED_HOOKS.len());
    }

    #[cfg(unix)]
    #[test]
    fn cmd_fire_writes_dirty_marker_when_daemon_absent() {
        // No socket — daemon unreachable. With try_daemon=false the
        // helper still goes through the fallback path that writes
        // .cqs/.dirty atomically (DS-V1.30.1-D5).
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");

        let report = do_fire(
            &cqs_dir,
            "post-checkout",
            vec![],
            /*try_daemon=*/ false,
        )
        .unwrap();
        assert!(!report.sent_to_daemon);
        // PB-V1.30.1-8: `dirty_marker` is a normalized String now, not PathBuf.
        assert_eq!(
            report.dirty_marker.as_ref().unwrap(),
            &cqs::normalize_path(&cqs_dir.join(".dirty")),
        );
        assert!(cqs_dir.join(".dirty").exists());

        // DS-V1.30.1-D5: the empty-bytes marker must be exactly 0 bytes.
        let meta = std::fs::metadata(cqs_dir.join(".dirty")).unwrap();
        assert_eq!(meta.len(), 0, "marker should be zero-length");

        // atomic_replace consumed the staging tmp file.
        assert!(
            !cqs_dir.join(".dirty.tmp").exists(),
            "staging .dirty.tmp must be cleaned up by the rename",
        );
    }

    #[cfg(not(unix))]
    #[test]
    fn cmd_fire_writes_dirty_marker_on_non_unix() {
        // On non-unix builds the daemon path is compiled out entirely;
        // the dirty marker is the only side effect.
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");

        let report = do_fire(&cqs_dir, "post-checkout", vec![], true).unwrap();
        assert!(!report.sent_to_daemon);
        assert!(cqs_dir.join(".dirty").exists());
        assert_eq!(std::fs::metadata(cqs_dir.join(".dirty")).unwrap().len(), 0);
        assert!(!cqs_dir.join(".dirty.tmp").exists());
    }

    #[test]
    fn cmd_status_classifies_three_hook_states() {
        let tmp = TempDir::new().unwrap();
        let hooks = tmp.path().join(".git").join("hooks");
        std::fs::create_dir_all(&hooks).unwrap();
        // Installed: marker-bearing (HOOK_MARKER_CURRENT contains PREFIX).
        std::fs::write(hooks.join("post-checkout"), HOOK_MARKER_CURRENT).unwrap();
        // Foreign: file exists but no cqs marker.
        std::fs::write(hooks.join("post-merge"), "#!/bin/sh\nuser stuff").unwrap();
        // Missing: post-rewrite simply isn't on disk.

        let report = do_hook_status(&hooks).unwrap();
        assert_eq!(report.installed, vec!["post-checkout".to_string()]);
        assert_eq!(report.foreign, vec!["post-merge".to_string()]);
        assert_eq!(report.missing, vec!["post-rewrite".to_string()]);
        // do_hook_status doesn't touch daemon_up — that's cmd_status's
        // overlay. Pin the default.
        assert!(!report.daemon_up);
    }
}
