//! Git hook management — sync, execution, and validation.
//!
//! Keeps `.githooks/` in sync with the `hooks` section of `sr.yaml` and
//! provides the runtime for executing configured hook entries.

use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use std::path::Path;

use crate::config::{DEFAULT_CONFIG_FILE, HookEntry, HooksConfig, ReleaseConfig};
use crate::error::ReleaseError;

/// Marker comment embedded in generated hook scripts to identify sr-managed hooks.
const GENERATED_MARKER: &str = "# Generated by sr";

/// File storing the hash of the hooks config for staleness detection.
const HASH_FILE: &str = ".sr-hooks-hash";

/// Sync `.githooks/` with the hooks config. Returns `Ok(true)` if changes were made.
///
/// - Writes shim scripts for configured hooks
/// - Removes sr-managed shims no longer in config
/// - Backs up conflicting non-sr-managed hooks
/// - Updates the hash file
/// - Sets `core.hooksPath`
pub fn sync_hooks(
    repo_root: &Path,
    config: &HooksConfig,
) -> Result<bool, crate::error::ReleaseError> {
    let hooks_dir = repo_root.join(".githooks");
    let hash_path = hooks_dir.join(HASH_FILE);
    let current_hash = config_hash(config);

    // Fast path: already in sync.
    if let Ok(stored) = std::fs::read_to_string(&hash_path)
        && stored.trim() == current_hash
    {
        return Ok(false);
    }

    let configured: BTreeSet<&str> = config
        .hooks
        .iter()
        .filter(|(_, entries)| !entries.is_empty())
        .map(|(name, _)| name.as_str())
        .collect();

    if configured.is_empty() {
        let removed = remove_stale_hooks(&hooks_dir, &configured)?;
        // Clean up hash file too.
        let _ = std::fs::remove_file(&hash_path);
        return Ok(removed);
    }

    std::fs::create_dir_all(&hooks_dir).map_err(|e| {
        crate::error::ReleaseError::Config(format!("failed to create .githooks: {e}"))
    })?;

    let mut changed = false;

    for &hook_name in &configured {
        let hook_path = hooks_dir.join(hook_name);
        let expected = shim_script(hook_name);

        match std::fs::read_to_string(&hook_path) {
            Ok(existing) if existing == expected => {
                // Already correct.
            }
            Ok(existing) if existing.contains(GENERATED_MARKER) => {
                // Sr-managed but outdated — overwrite.
                write_shim(&hook_path, &expected)?;
                changed = true;
            }
            Ok(_) => {
                // Non-sr-managed hook conflicts — back up and replace.
                let backup = hooks_dir.join(format!("{hook_name}.bak"));
                std::fs::rename(&hook_path, &backup).map_err(|e| {
                    crate::error::ReleaseError::Config(format!(
                        "failed to backup .githooks/{hook_name}: {e}"
                    ))
                })?;
                eprintln!("backed up .githooks/{hook_name} → .githooks/{hook_name}.bak");
                write_shim(&hook_path, &expected)?;
                changed = true;
            }
            Err(_) => {
                // Does not exist — create.
                write_shim(&hook_path, &expected)?;
                changed = true;
            }
        }
    }

    if remove_stale_hooks(&hooks_dir, &configured)? {
        changed = true;
    }

    // Write hash file.
    std::fs::write(&hash_path, &current_hash).map_err(|e| {
        crate::error::ReleaseError::Config(format!("failed to write hooks hash: {e}"))
    })?;

    if changed {
        set_hooks_path(repo_root);
    }

    Ok(changed)
}

/// Check whether hooks need syncing (cheap hash comparison).
pub fn needs_sync(repo_root: &Path, config: &HooksConfig) -> bool {
    let hash_path = repo_root.join(".githooks").join(HASH_FILE);
    match std::fs::read_to_string(&hash_path) {
        Ok(stored) => stored.trim() != config_hash(config),
        Err(_) => {
            // No hash file — need sync if there are hooks configured.
            !config.hooks.is_empty()
        }
    }
}

/// Compute a deterministic hash of the hooks config.
fn config_hash(config: &HooksConfig) -> String {
    let json = serde_json::to_string(&config.hooks).unwrap_or_default();
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    json.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

/// Generate the canonical shim script for a hook.
fn shim_script(hook_name: &str) -> String {
    format!(
        "#!/usr/bin/env sh\n\
         {GENERATED_MARKER} — edit the hooks section in {config} to modify.\n\
         exec sr hook run {hook_name} -- \"$@\"\n",
        config = DEFAULT_CONFIG_FILE,
    )
}

/// Write a shim script and set it executable.
fn write_shim(path: &Path, content: &str) -> Result<(), crate::error::ReleaseError> {
    std::fs::write(path, content)
        .map_err(|e| crate::error::ReleaseError::Config(format!("failed to write hook: {e}")))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
            crate::error::ReleaseError::Config(format!("failed to chmod hook: {e}"))
        })?;
    }

    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        eprintln!("synced .githooks/{name}");
    }

    Ok(())
}

/// Remove sr-managed hooks not in the configured set. Returns `true` if any were removed.
fn remove_stale_hooks(
    hooks_dir: &Path,
    configured: &BTreeSet<&str>,
) -> Result<bool, crate::error::ReleaseError> {
    if !hooks_dir.is_dir() {
        return Ok(false);
    }

    let mut removed = false;
    let entries = std::fs::read_dir(hooks_dir).map_err(|e| {
        crate::error::ReleaseError::Config(format!("failed to read .githooks: {e}"))
    })?;

    for entry in entries {
        let entry = entry.map_err(|e| crate::error::ReleaseError::Config(e.to_string()))?;
        let path = entry.path();

        if !path.is_file() {
            continue;
        }

        let name = match path.file_name().and_then(|n| n.to_str()) {
            Some(n) => n.to_string(),
            None => continue,
        };

        // Skip the hash file and backup files.
        if name == HASH_FILE || name.ends_with(".bak") {
            continue;
        }

        // Only remove sr-managed hooks.
        if !is_sr_managed(&path) {
            continue;
        }

        if !configured.contains(name.as_str()) {
            std::fs::remove_file(&path).map_err(|e| {
                crate::error::ReleaseError::Config(format!(
                    "failed to remove .githooks/{name}: {e}"
                ))
            })?;
            eprintln!("removed stale .githooks/{name}");
            removed = true;
        }
    }

    Ok(removed)
}

/// Check if a hook file was generated by sr.
fn is_sr_managed(path: &Path) -> bool {
    std::fs::read_to_string(path)
        .map(|content| content.contains(GENERATED_MARKER))
        .unwrap_or(false)
}

/// Set `core.hooksPath` to `.githooks/`.
fn set_hooks_path(repo_root: &Path) {
    let _ = std::process::Command::new("git")
        .args(["config", "core.hooksPath", ".githooks/"])
        .current_dir(repo_root)
        .status();
}

// ---------------------------------------------------------------------------
// Shell execution
// ---------------------------------------------------------------------------

/// Run a shell command (`sh -c`), optionally piping data to stdin and/or
/// injecting environment variables. Returns an error if the command exits
/// non-zero.
pub fn run_shell(
    cmd: &str,
    stdin_data: Option<&str>,
    env: &[(&str, &str)],
) -> Result<(), ReleaseError> {
    let mut child = {
        let mut builder = std::process::Command::new("sh");
        builder.args(["-c", cmd]);
        for &(k, v) in env {
            builder.env(k, v);
        }
        if stdin_data.is_some() {
            builder.stdin(std::process::Stdio::piped());
        } else {
            builder.stdin(std::process::Stdio::inherit());
        }
        builder
            .spawn()
            .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
    };

    if let Some(data) = stdin_data
        && let Some(ref mut stdin) = child.stdin
    {
        use std::io::Write;
        let _ = stdin.write_all(data.as_bytes());
    }

    let status = child
        .wait()
        .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;

    if !status.success() {
        let code = status.code().unwrap_or(1);
        return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Hook execution
// ---------------------------------------------------------------------------

/// Build a JSON context object for a git hook based on its name and positional args.
pub fn build_hook_json(hook_name: &str, args: &[String]) -> serde_json::Value {
    let mut obj = serde_json::Map::new();
    obj.insert("hook".into(), serde_json::Value::String(hook_name.into()));
    obj.insert(
        "args".into(),
        serde_json::Value::Array(
            args.iter()
                .map(|a| serde_json::Value::String(a.clone()))
                .collect(),
        ),
    );

    // Add named fields for well-known hooks
    match hook_name {
        "commit-msg" => {
            if let Some(f) = args.first() {
                obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
            }
        }
        "prepare-commit-msg" => {
            if let Some(f) = args.first() {
                obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
            }
            if let Some(s) = args.get(1) {
                obj.insert("source".into(), serde_json::Value::String(s.clone()));
            }
            if let Some(s) = args.get(2) {
                obj.insert("sha".into(), serde_json::Value::String(s.clone()));
            }
        }
        "pre-push" => {
            if let Some(r) = args.first() {
                obj.insert("remote_name".into(), serde_json::Value::String(r.clone()));
            }
            if let Some(u) = args.get(1) {
                obj.insert("remote_url".into(), serde_json::Value::String(u.clone()));
            }
        }
        "pre-rebase" => {
            if let Some(u) = args.first() {
                obj.insert("upstream".into(), serde_json::Value::String(u.clone()));
            }
            if let Some(b) = args.get(1) {
                obj.insert("branch".into(), serde_json::Value::String(b.clone()));
            }
        }
        "post-checkout" => {
            if let Some(r) = args.first() {
                obj.insert("prev_ref".into(), serde_json::Value::String(r.clone()));
            }
            if let Some(r) = args.get(1) {
                obj.insert("new_ref".into(), serde_json::Value::String(r.clone()));
            }
            if let Some(f) = args.get(2) {
                obj.insert(
                    "branch_checkout".into(),
                    serde_json::Value::String(f.clone()),
                );
            }
        }
        "post-merge" => {
            if let Some(s) = args.first() {
                obj.insert("squash".into(), serde_json::Value::String(s.clone()));
            }
        }
        _ => {}
    }

    serde_json::Value::Object(obj)
}

/// Get staged files from git (excluding deletes).
fn staged_files() -> Result<Vec<String>, ReleaseError> {
    let output = std::process::Command::new("git")
        .args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
        .output()
        .map_err(|e| ReleaseError::Hook(format!("git diff --cached: {e}")))?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    Ok(stdout
        .lines()
        .filter(|l| !l.is_empty())
        .map(|l| l.to_string())
        .collect())
}

/// Match files against glob patterns. Matches against both the full path and the basename.
fn match_files(files: &[String], patterns: &[String]) -> Vec<String> {
    let compiled: Vec<glob::Pattern> = patterns
        .iter()
        .filter_map(|p| glob::Pattern::new(p).ok())
        .collect();

    files
        .iter()
        .filter(|f| {
            let basename = Path::new(f)
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or(f);
            compiled
                .iter()
                .any(|pat| pat.matches(f) || pat.matches(basename))
        })
        .cloned()
        .collect()
}

/// Run all entries for a configured git hook.
///
/// Simple entries run as shell commands with JSON context piped to stdin.
/// Step entries match staged files against patterns and run rules only when matches exist.
/// Rules containing `{files}` receive the matched file list.
pub fn run_hook(
    config: &ReleaseConfig,
    hook_name: &str,
    args: &[String],
) -> Result<(), ReleaseError> {
    let entries = config
        .hooks
        .hooks
        .get(hook_name)
        .ok_or_else(|| ReleaseError::Hook(format!("no hook configured for '{hook_name}'")))?;

    if entries.is_empty() {
        return Ok(());
    }

    let json = build_hook_json(hook_name, args);
    let json_str = serde_json::to_string(&json)
        .map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;

    // Lazily fetch staged files only when a Step entry needs them.
    let mut cached_staged: Option<Vec<String>> = None;

    for entry in entries {
        match entry {
            HookEntry::Simple(cmd) => {
                run_shell(cmd, Some(&json_str), &[])?;
            }
            HookEntry::Step {
                step,
                patterns,
                rules,
            } => {
                let all_staged = match &mut cached_staged {
                    Some(files) => files,
                    None => {
                        cached_staged = Some(staged_files().unwrap_or_default());
                        cached_staged.as_mut().unwrap()
                    }
                };

                if all_staged.is_empty() {
                    eprintln!("{hook_name}: no staged files, skipping steps.");
                    break;
                }

                let matched = match_files(all_staged, patterns);
                if matched.is_empty() {
                    eprintln!("{hook_name} [{step}]: no files match {patterns:?}, skipping.");
                    continue;
                }

                let files_str = matched.join(" ");

                for rule in rules {
                    let cmd = if rule.contains("{files}") {
                        rule.replace("{files}", &files_str)
                    } else {
                        rule.clone()
                    };

                    eprintln!("{hook_name} [{step}]: {cmd}");
                    run_shell(&cmd, None, &[])?;
                }
            }
        }
    }

    Ok(())
}

/// Validate a commit message file against the configured conventional commit pattern and types.
/// Reads hook JSON from stdin to get the message_file path.
pub fn validate_commit_msg(config: &ReleaseConfig) -> Result<(), ReleaseError> {
    use std::io::Read;
    let mut input = String::new();
    std::io::stdin()
        .read_to_string(&mut input)
        .map_err(|e| ReleaseError::Hook(format!("failed to read stdin: {e}")))?;

    let json: serde_json::Value = serde_json::from_str(&input)
        .map_err(|e| ReleaseError::Hook(format!("invalid JSON on stdin: {e}")))?;

    let file = json["message_file"]
        .as_str()
        .ok_or_else(|| ReleaseError::Hook("missing 'message_file' in hook JSON".into()))?;

    let content = std::fs::read_to_string(file)
        .map_err(|e| ReleaseError::Hook(format!("cannot read commit message file: {e}")))?;

    let first_line = content.lines().next().unwrap_or("").trim();

    // Allow merge commits
    if first_line.starts_with("Merge ") {
        return Ok(());
    }

    // Allow fixup/squash/amend commits (from rebase -i)
    if first_line.starts_with("fixup! ")
        || first_line.starts_with("squash! ")
        || first_line.starts_with("amend! ")
    {
        return Ok(());
    }

    let re = regex::Regex::new(&config.commit_pattern)
        .map_err(|e| ReleaseError::Hook(format!("invalid commit_pattern: {e}")))?;

    if !re.is_match(first_line) {
        let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
        return Err(ReleaseError::Hook(format!(
            "commit message does not follow Conventional Commits.\n\n\
             \x20 Expected: <type>(<scope>): <description>\n\
             \x20 Got:      {first_line}\n\n\
             \x20 Valid types: {}\n\
             \x20 Breaking:    append '!' before the colon, e.g. feat!: ...\n\n\
             \x20 Examples:\n\
             \x20   feat: add release dry-run flag\n\
             \x20   fix(core): handle empty tag list\n\
             \x20   feat!: redesign config format",
            type_names.join(", "),
        )));
    }

    // Extract and validate the type
    if let Some(caps) = re.captures(first_line) {
        let msg_type = caps.name("type").map(|m| m.as_str()).unwrap_or_default();

        if !config.types.iter().any(|t| t.name == msg_type) {
            let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
            return Err(ReleaseError::Hook(format!(
                "commit type '{msg_type}' is not allowed.\n\n\
                 \x20 Valid types: {}",
                type_names.join(", "),
            )));
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::HookEntry;
    use std::collections::BTreeMap;

    fn make_config(hooks: &[(&str, Vec<HookEntry>)]) -> HooksConfig {
        let mut map = BTreeMap::new();
        for (name, entries) in hooks {
            map.insert(name.to_string(), entries.clone());
        }
        HooksConfig { hooks: map }
    }

    #[test]
    fn creates_hook_scripts() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);

        let changed = sync_hooks(dir.path(), &config).unwrap();
        assert!(changed);

        let hook = dir.path().join(".githooks/pre-commit");
        assert!(hook.exists());
        let content = std::fs::read_to_string(&hook).unwrap();
        assert!(content.contains("sr hook run pre-commit"));
        assert!(content.contains(GENERATED_MARKER));
    }

    #[test]
    fn idempotent_returns_false() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(&[(
            "commit-msg",
            vec![HookEntry::Simple("sr hook commit-msg".into())],
        )]);

        assert!(sync_hooks(dir.path(), &config).unwrap());
        // Second call — hash matches, no changes.
        assert!(!sync_hooks(dir.path(), &config).unwrap());
    }

    #[test]
    fn removes_stale_hooks() {
        let dir = tempfile::tempdir().unwrap();
        let hooks_dir = dir.path().join(".githooks");
        std::fs::create_dir_all(&hooks_dir).unwrap();

        // Write a sr-managed hook that won't be in config.
        std::fs::write(
            hooks_dir.join("pre-push"),
            format!("{GENERATED_MARKER}\nold script"),
        )
        .unwrap();

        // Write a non-sr-managed hook that should be left alone.
        std::fs::write(hooks_dir.join("post-checkout"), "#!/bin/sh\necho custom").unwrap();

        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);

        sync_hooks(dir.path(), &config).unwrap();

        assert!(
            !hooks_dir.join("pre-push").exists(),
            "stale sr-managed hook should be removed"
        );
        assert!(
            hooks_dir.join("post-checkout").exists(),
            "non-sr-managed hook should be preserved"
        );
        assert!(hooks_dir.join("pre-commit").exists());
    }

    #[test]
    fn backs_up_conflicting_hooks() {
        let dir = tempfile::tempdir().unwrap();
        let hooks_dir = dir.path().join(".githooks");
        std::fs::create_dir_all(&hooks_dir).unwrap();

        // Write a non-sr-managed hook with the same name as a configured hook.
        let custom_content = "#!/bin/sh\necho custom commit-msg hook";
        std::fs::write(hooks_dir.join("commit-msg"), custom_content).unwrap();

        let config = make_config(&[(
            "commit-msg",
            vec![HookEntry::Simple("sr hook commit-msg".into())],
        )]);

        sync_hooks(dir.path(), &config).unwrap();

        // Original should be backed up.
        let backup = hooks_dir.join("commit-msg.bak");
        assert!(backup.exists());
        assert_eq!(std::fs::read_to_string(&backup).unwrap(), custom_content);

        // New hook should be the shim.
        let content = std::fs::read_to_string(hooks_dir.join("commit-msg")).unwrap();
        assert!(content.contains("sr hook run commit-msg"));
    }

    #[test]
    fn empty_config_cleans_up() {
        let dir = tempfile::tempdir().unwrap();
        let hooks_dir = dir.path().join(".githooks");
        std::fs::create_dir_all(&hooks_dir).unwrap();

        std::fs::write(
            hooks_dir.join("pre-commit"),
            format!("{GENERATED_MARKER}\nscript"),
        )
        .unwrap();
        std::fs::write(hooks_dir.join(".sr-hooks-hash"), "oldhash").unwrap();

        let config = make_config(&[]);
        sync_hooks(dir.path(), &config).unwrap();

        assert!(!hooks_dir.join("pre-commit").exists());
        assert!(!hooks_dir.join(".sr-hooks-hash").exists());
    }

    #[test]
    fn needs_sync_detects_changes() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);

        assert!(needs_sync(dir.path(), &config));

        sync_hooks(dir.path(), &config).unwrap();
        assert!(!needs_sync(dir.path(), &config));

        // Change config.
        let config2 =
            make_config(&[("pre-commit", vec![HookEntry::Simple("echo changed".into())])]);
        assert!(needs_sync(dir.path(), &config2));
    }
}
