//! Publish-state guard, ported from the AHE evolved harness.
//!
//! After a final/evaluator-style check passes (exit 0 + "final"/"acceptance"
//! wording in the description), the touched artifacts become the *publish
//! state*. Destructive or rewriting commands against those targets are blocked
//! until the model provides the override token with new failing evidence.

use regex::Regex;
use std::collections::BTreeSet;
use std::sync::OnceLock;

pub const OVERRIDE_TOKEN: &str = "ALLOW_POST_SUCCESS_RESET";
pub const GUARD_EXIT_CODE: i32 = 90;

macro_rules! re {
    ($name:ident, $pat:expr) => {
        fn $name() -> &'static Regex {
            static RE: OnceLock<Regex> = OnceLock::new();
            RE.get_or_init(|| Regex::new($pat).expect("valid guard regex"))
        }
    };
}

re!(final_check_re, r"(?i)\b(final|evaluator(-style)?|acceptance|end-to-end|final sweep|final layout|deliverable)\b");
re!(test_file_re, r"\btest\s+-f\s+([^\s;&|]+)");
re!(cmp_file_re, r"\bcmp(?:\s+-s)?\s+([^\s;&|]+)\s+([^\s;&|]+)");
re!(script_entry_re, r"(?i)\b(?:python\d*(?:\s+-u)?|bash|sh|node|perl|ruby)\s+((?:/|\./|\.\./)[^\s;&|]+|[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)");
re!(destructive_rm_re, r"(?i)\brm\s+-[A-Za-z-]*[fr]");
re!(destructive_find_delete_re, r"(?i)\bfind\s+[^\s;&|]+\s+[^\n;|&]*-delete\b");
re!(destructive_git_re, r"(?i)\bgit\b[^\n;|&]*(\breset\s+--hard\b|\bclean\b[^\n;|&]*\bf\b|\bcheckout\s+-f\b|\bbranch\s+-D\b|\binit\b)");
re!(git_meta_re, r"(?i)\bgit\b[^\n;|&]*(\bcommit\b|\bfilter-branch\b|\brebase\b|\bcherry-pick\b|\bmerge\b|\bamend\b|\bgc\b|\breflog\b|\brepack\b|\bprune\b|\bupdate-ref\b)");

fn clean_token(token: &str) -> String {
    token
        .trim()
        .trim_matches(|c| "\"'`[](){}".contains(c))
        .trim_end_matches([';', ',', ':'])
        .to_string()
}

#[derive(Debug, Default, Clone)]
pub struct PublishGuard {
    pub files: BTreeSet<String>,
    pub roots: BTreeSet<String>,
    pub active: bool,
}

impl PublishGuard {
    /// Call after every successful command; activates the guard when a
    /// final-check-style command exits 0. Returns a note for the model.
    pub fn observe_success(&mut self, command: &str, description: &str, exit_code: i32, cwd: &str) -> Option<String> {
        if exit_code != 0 || !final_check_re().is_match(description) {
            return None;
        }
        for cap in test_file_re().captures_iter(command) {
            self.files.insert(clean_token(&cap[1]));
        }
        for cap in cmp_file_re().captures_iter(command) {
            self.files.insert(clean_token(&cap[1]));
            self.files.insert(clean_token(&cap[2]));
        }
        for cap in script_entry_re().captures_iter(command) {
            let f = clean_token(&cap[1]);
            if !f.starts_with("/tmp/") {
                self.files.insert(f);
            }
        }
        if !cwd.starts_with("/tmp/") {
            self.roots.insert(cwd.to_string());
        }
        self.active = true;

        let targets: Vec<&String> = self.files.iter().chain(self.roots.iter()).take(4).collect();
        let preview = if targets.is_empty() {
            String::new()
        } else {
            format!(
                " Protected targets include: {}.",
                targets.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
            )
        };
        Some(format!(
            "This final/evaluator-style check passed, so the current state is now your publish state. \
             Do not reset repos, rewrite checked files, or rerun stateful generator scripts.{preview} \
             Do further experiments in /tmp; if a bounded cleanup is truly needed, include {OVERRIDE_TOKEN} in the description with the reason, then rerun the acceptance check."
        ))
    }

    /// Call before executing a command. Returns a block message if it would
    /// destroy the publish state.
    pub fn check(&self, command: &str, description: &str) -> Option<String> {
        if !self.active {
            return None;
        }
        let override_requested =
            command.contains(OVERRIDE_TOKEN) || description.contains(OVERRIDE_TOKEN);

        let mut reasons: Vec<String> = Vec::new();
        let mut hard = false;

        let destructive =
            destructive_rm_re().is_match(command) || destructive_find_delete_re().is_match(command);
        for f in &self.files {
            if destructive && mentions(command, f) {
                reasons.push(format!("delete protected output {f}"));
                if !f.starts_with("/tmp/") {
                    hard = true;
                }
            }
            if writes_file(command, f) {
                reasons.push(format!("rewrite protected file {f}"));
                hard = true;
            }
        }
        for root in &self.roots {
            if destructive_git_re().is_match(command) && command.contains(root.as_str()) {
                reasons.push(format!("reset protected root {root}"));
                hard = true;
            }
        }
        if git_meta_re().is_match(command) {
            reasons.push("mutate git history/metadata after publish state".to_string());
            hard = true;
        }

        if reasons.is_empty() || (override_requested && !hard) {
            return None;
        }

        let detail = reasons.join(", ");
        Some(if override_requested {
            format!(
                "Blocked a post-validation command even with the override token because it would {detail}. \
                 Split cleanup from validation: do reruns in /tmp, keep git history operations off the live deliverable, \
                 and rerun the full acceptance sweep after any real change."
            )
        } else {
            format!(
                "Blocked a potentially destructive post-validation command because a prior final/evaluator-style check \
                 succeeded and this command would {detail}. Use /tmp for further experiments. If you have new failing \
                 evidence and need a bounded change, include {OVERRIDE_TOKEN} in the description with the reason, then \
                 rerun the full acceptance sweep."
            )
        })
    }
}

fn mentions(command: &str, target: &str) -> bool {
    if target.is_empty() {
        return false;
    }
    if command.contains(target) {
        return true;
    }
    let base = target.rsplit('/').next().unwrap_or(target);
    base != target && command.contains(base)
}

fn writes_file(command: &str, target: &str) -> bool {
    let base = target.rsplit('/').next().unwrap_or(target);
    for t in [target, base] {
        if t.is_empty() {
            continue;
        }
        let esc = regex::escape(t);
        for pat in [
            format!(r"(?:^|[^<])>{{1,2}}\s*{esc}(\s|$)"),
            format!(r"\btee\b[^\n;|&]*\s{esc}(\s|$)"),
            format!(r"\b(?:sed|perl)\b[^\n;|&]*\s-i\S*[^\n;|&]*\s{esc}(\s|$)"),
            format!(r"\b(?:cp|mv|install|ln)\b[^\n;|&]*\s{esc}\s*(;|&&|\|\||$)"),
        ] {
            if Regex::new(&pat).map(|re| re.is_match(command)).unwrap_or(false) {
                return true;
            }
        }
    }
    false
}

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

    fn guarded() -> PublishGuard {
        let mut g = PublishGuard::default();
        let note = g.observe_success(
            "test -f out/result.txt && python3 check.py",
            "final acceptance check",
            0,
            "/work/project",
        );
        assert!(note.is_some());
        assert!(g.active);
        g
    }

    #[test]
    fn activates_and_blocks_rm() {
        let g = guarded();
        let block = g.check("rm -rf out/result.txt", "cleanup");
        assert!(block.is_some());
    }

    #[test]
    fn blocks_rewrite_of_protected_file() {
        let g = guarded();
        assert!(g.check("echo new > out/result.txt", "tweak").is_some());
    }

    #[test]
    fn allows_unrelated_commands() {
        let g = guarded();
        assert!(g.check("ls -la /tmp", "look around").is_none());
        assert!(g.check("cat out/result.txt", "read result").is_none());
    }

    #[test]
    fn soft_override_allows_tmp_cleanup() {
        let mut g = PublishGuard::default();
        g.observe_success("test -f /tmp/x.txt", "final check", 0, "/tmp/scratch");
        let blocked = g.check("rm -rf /tmp/x.txt", "cleanup ALLOW_POST_SUCCESS_RESET justified");
        assert!(blocked.is_none());
    }

    #[test]
    fn inactive_guard_is_silent() {
        let g = PublishGuard::default();
        assert!(g.check("rm -rf anything", "x").is_none());
    }
}
