import Foundation

/// Detects raw-shell file mutations that should have gone through a structured edit tool
/// (Edit/Write/MultiEdit, Codex `apply_patch`, Grok edit) instead of the Bash/shell tool.
///
/// Why this exists: structured edits are synthesized into `fileChange` events and landed as
/// per-edit trunk commits (with conflict arbitration on the touched paths). A file mutated by a
/// raw shell command — `sed -i`, `echo … > file`, `cat > file`, `tee`, `perl -i`, `patch` — emits
/// no `fileChange`, so it skips conflict locking and never gets its own trunk commit (it lingers as
/// uncommitted residue until a later structured edit or crash-recovery sweeps it in). To keep every
/// agent's edits tracked the way Claude's are, the approval layer denies these commands and steers
/// the agent to the structured edit tool; a separate residue-capture backstop catches anything that
/// still slips through.
///
/// The detector is deliberately conservative to avoid tripping legitimate build/tooling output
/// (`make > build.log`, compilers writing objects): a redirection is only flagged when its producer
/// is a plain content emitter (the classic "author a file's contents by hand" shape), and writes to
/// clearly-ephemeral destinations (`/tmp`, `/dev/null`, `$TMPDIR`, …) are always allowed. The
/// residue-capture backstop covers whatever this misses.
public enum WorktreeMutationGuard {

    /// If `command` performs a raw in-tree file mutation that should use a structured edit tool,
    /// returns a human-readable denial reason (naming the offending sub-command); otherwise nil.
    public static func denialReason(command: String) -> String? {
        let (flattened, _) = ShellLexer.stripHeredocs(command)
        for segment in ShellLexer.splitSegments(flattened) {
            if let culprit = mutatingCulprit(in: segment) {
                return denialMessage(culprit: culprit)
            }
        }
        return nil
    }

    // MARK: - Segment analysis

    /// The offending fragment (e.g. `sed -i`, `echo > src.swift`) if this single segment mutates a
    /// worktree file via raw shell, else nil.
    private static func mutatingCulprit(in segment: String) -> String? {
        let rawTokens = ShellLexer.tokenize(segment)
        guard !rawTokens.isEmpty else { return nil }
        let tokens = ShellLexer.stripLeadingPrefixes(rawTokens)
        guard let first = tokens.first else { return nil }
        let cmd = commandBasename(first)

        // 1. In-place editors / file-writing utilities — flagged by their own semantics regardless
        //    of any redirection, because the file operand IS the mutation target.
        switch cmd {
        case "sed":
            if hasInPlaceFlag(tokens, letter: "i") { return "sed -i" }
        case "perl":
            if hasInPlaceFlag(tokens, letter: "i") { return "perl -i" }
        case "awk", "gawk":
            // GNU awk in-place is `-i inplace` / `-iinplace`.
            if tokens.contains(where: { $0 == "inplace" || $0.hasSuffix("=inplace") || $0 == "-iinplace" }) {
                return "awk -i inplace"
            }
        case "ed":
            // `ed` edits a file in place from a script; a bare `ed file` is an interactive editor.
            return "ed"
        case "patch":
            return "patch"
        case "tee":
            // `positionals` on the arguments only (drop the command word) — its non-flag operands
            // are the files tee writes; `-a` (append) takes no value.
            let args = Array(tokens.dropFirst())
            if let target = firstNonEphemeral(ShellLexer.positionals(args, valueFlags: [])) {
                return "tee \(target)"
            }
        case "dd":
            if let of = tokens.first(where: { $0.hasPrefix("of=") }) {
                let target = String(of.dropFirst(3))
                if !isEphemeral(target) { return "dd \(of)" }
            }
        case "truncate":
            let args = Array(tokens.dropFirst())
            if let target = firstNonEphemeral(ShellLexer.positionals(args, valueFlags: ["-s", "--size"])) {
                return "truncate \(target)"
            }
        default:
            break
        }

        // 2. Output redirection (`>`, `>>`) whose producer is a plain content emitter — the
        //    "hand-write a file's contents" pattern (`echo … > f`, `cat > f`, `sed … > f`).
        if contentEmitters.contains(cmd), let target = redirectTarget(in: segment),
            !isEphemeral(target)
        {
            return "\(cmd) > \(target)"
        }
        return nil
    }

    /// Commands that emit text to stdout, so redirecting them into a file authors that file's
    /// contents by hand — as opposed to build/compiler tools whose file output is a legitimate
    /// artifact and must not be blocked.
    private static let contentEmitters: Set<String> = [
        "echo", "printf", "cat", "tee", "yes", "jq", "sed", "awk", "gawk",
    ]

    // MARK: - Redirection parsing

    /// The target path of an output redirection (`>`, `>>`, `>|`) in a segment, or nil when the
    /// segment has none. Ignores fd-duplication (`>&1`, `2>&1`), process substitution (`>(…)`), and
    /// stderr/stdin-only redirects. Handles both spaced (`> file`) and attached (`>file`, `1>file`)
    /// forms; quotes/heredocs are already stripped by the caller.
    static func redirectTarget(in segment: String) -> String? {
        let chars = Array(segment)
        var i = 0
        var quote: Character? = nil
        while i < chars.count {
            let c = chars[i]
            if let q = quote { if c == q { quote = nil }; i += 1; continue }
            if c == "'" || c == "\"" { quote = c; i += 1; continue }
            if c == ">" {
                // Skip a leading fd digit already consumed as part of the previous token; here we
                // only need to classify what follows the '>'.
                var j = i + 1
                if j < chars.count && chars[j] == ">" { j += 1 }          // ">>"
                if j < chars.count && chars[j] == "|" { j += 1 }          // ">|"
                // Fd duplication ">&2" / ">&-": not a file write.
                if j < chars.count && chars[j] == "&" { i = j + 1; continue }
                // Process substitution ">(…)": not a plain file.
                if j < chars.count && chars[j] == "(" { i = j + 1; continue }
                while j < chars.count && (chars[j] == " " || chars[j] == "\t") { j += 1 }
                var target = ""
                var tq: Character? = nil
                while j < chars.count {
                    let d = chars[j]
                    if let q = tq { if d == q { tq = nil } else { target.append(d) }; j += 1; continue }
                    if d == "'" || d == "\"" { tq = d; j += 1; continue }
                    if d == " " || d == "\t" || d == ">" || d == "<" || d == "|" { break }
                    target.append(d); j += 1
                }
                if !target.isEmpty { return target }
                i = j
                continue
            }
            i += 1
        }
        return nil
    }

    // MARK: - Helpers

    /// Whether a write target is a throwaway/out-of-tree destination that should never be blocked
    /// (`/dev/null`, `/tmp/…`, `$TMPDIR/…`, macOS per-user temp, fd targets like `/dev/stdout`).
    static func isEphemeral(_ rawTarget: String) -> Bool {
        let t = rawTarget.trimmingCharacters(in: CharacterSet(charactersIn: " \t'\""))
        if t.isEmpty || t == "-" { return true }
        if t.hasPrefix("&") { return true }              // fd dup (>&1)
        let ephemeralPrefixes = [
            "/dev/", "/tmp/", "/private/tmp/", "/var/tmp/", "/private/var/tmp/", "/var/folders/",
            "$TMPDIR", "${TMPDIR", "/dev/null",
        ]
        if ephemeralPrefixes.contains(where: { t.hasPrefix($0) }) { return true }
        // git plumbing / internal metadata is tooling, not source authoring.
        if t.contains("/.git/") { return true }
        return false
    }

    /// The first positional that is NOT an ephemeral destination, or nil if all are ephemeral.
    private static func firstNonEphemeral(_ paths: [String]) -> String? {
        paths.first(where: { !isEphemeral($0) })
    }

    /// Whether a tokenized command carries an in-place flag whose letter is `letter` — matches a
    /// bundled short flag (`-i`, `-pi`, `-i.bak`) as well as the long `--in-place` form.
    private static func hasInPlaceFlag(_ tokens: [String], letter: Character) -> Bool {
        for t in tokens {
            if t == "--in-place" || t.hasPrefix("--in-place=") { return true }
            guard t.hasPrefix("-"), !t.hasPrefix("--") else { continue }
            // A short-flag cluster like `-i`, `-i.bak`, `-pi`, `-ne` — the in-place letter counts
            // even when bundled with other single-letter flags before an optional `.suffix`.
            let body = t.dropFirst()
            let flagLetters = body.prefix(while: { $0 != "." })
            if flagLetters.contains(letter) { return true }
        }
        return false
    }

    /// The basename of a command word (`/usr/bin/sed` → `sed`), lowercased.
    private static func commandBasename(_ word: String) -> String {
        (word as NSString).lastPathComponent.lowercased()
    }

    private static func denialMessage(culprit: String) -> String {
        "Blocked: `\(culprit)` mutates a file in the workspace with raw shell. Use the structured "
            + "edit tool (Edit/Write, apply_patch, or the ACP edit) so the change is tracked, "
            + "conflict-checked against other agents, and committed to the trunk as its own edit. "
            + "For scratch or throwaway output, redirect to /tmp or /dev/null instead."
    }
}
