import Foundation
import Testing

@testable import NucleicCore

@Suite("Worktree raw-shell write analysis")
struct WorktreeMutationGuardTests {
    private func writes(_ command: String) -> [String] {
        WorktreeMutationGuard.analyze(command: command).writes
    }
    private func denied(_ command: String) -> Bool {
        WorktreeMutationGuard.denialReason(command: command) != nil
    }

    // MARK: In-place editors — named, therefore lockable rather than blocked

    @Test func namesInPlaceEditorTargets() {
        #expect(writes("sed -i 's/a/b/' src/main.swift") == ["src/main.swift"])
        #expect(writes("sed -i.bak 's/a/b/' file.txt") == ["file.txt"])
        #expect(writes("sed -e 's/x/y/' -i file.txt") == ["file.txt"])
        #expect(writes("perl -i -pe 's/a/b/' file") == ["file"])
        #expect(writes("perl -pi -e 's/a/b/' file") == ["file"])
        #expect(writes("awk -i inplace '{print}' file") == ["file"])
        #expect(writes("ed notes.txt") == ["notes.txt"])
        #expect(writes("dd of=out.bin if=/dev/zero") == ["out.bin"])
        #expect(writes("truncate -s 0 log.txt") == ["log.txt"])
        // None of these are denied any more — they take locks instead.
        #expect(!denied("sed -i 's/a/b/' src/main.swift"))
        #expect(!denied("truncate -s 0 log.txt"))
    }

    /// BSD `sed` spells the in-place suffix as a separate (usually empty) argument, so the file
    /// operand sits one token further along than on GNU. The macOS host runs BSD sed.
    @Test func handlesBSDSedInPlaceSuffix() {
        #expect(writes("sed -i '' 's/a/b/' file.txt") == ["file.txt"])
    }

    @Test func allowsStreamEditorsWithoutInPlace() {
        // sed/awk that only read and print write nothing unless they redirect to a real file.
        #expect(writes("sed 's/a/b/' file.txt").isEmpty)
        #expect(writes("awk '{print $1}' data.csv").isEmpty)
        #expect(writes("grep -n foo file.txt").isEmpty)
    }

    // MARK: Redirection authoring

    @Test func namesAuthoringRedirects() {
        #expect(writes("echo 'hello' > greeting.txt") == ["greeting.txt"])
        #expect(writes("echo 'x' >> config.yaml") == ["config.yaml"])
        #expect(writes("printf 'a\\nb\\n' > list.txt") == ["list.txt"])
        #expect(writes("cat > Sources/Foo.swift") == ["Sources/Foo.swift"])
        #expect(writes("sed 's/a/b/' in.txt > out.txt") == ["out.txt"])
        #expect(writes("echo x | tee tracked.txt") == ["tracked.txt"])
    }

    @Test func namesHeredocAuthoring() {
        let cmd = "cat > Sources/New.swift <<'EOF'\nlet x = 1\nEOF"
        #expect(writes(cmd) == ["Sources/New.swift"])
    }

    @Test func ignoresEphemeralRedirects() {
        #expect(writes("echo 'hi' > /tmp/scratch.txt").isEmpty)
        #expect(writes("echo 'hi' > /dev/null").isEmpty)
        #expect(writes("cat foo > $TMPDIR/out").isEmpty)
        #expect(writes("cat > /tmp/x <<'EOF'\nbody\nEOF").isEmpty)
        #expect(writes("echo x | tee /dev/null").isEmpty)
        // Ephemeral wins over "can't be named": `$TMPDIR/out` is throwaway, not a denial.
        #expect(!denied("cat foo > $TMPDIR/out"))
    }

    @Test func ignoresFdAndProcessRedirects() {
        #expect(writes("make 2>&1").isEmpty)
        #expect(writes("some_cmd >&2").isEmpty)
        #expect(writes("diff <(sort a) <(sort b)").isEmpty)
        #expect(!denied("diff <(sort a) <(sort b)"))
    }

    // MARK: Build/tooling output is an artifact, not an edit — never locked

    @Test func ignoresBuildAndToolOutput() {
        #expect(writes("make > build.log").isEmpty)
        #expect(writes("swift build 2> errors.txt").isEmpty)
        #expect(writes("cargo build --release").isEmpty)
        #expect(writes("gcc -c foo.c -o foo.o").isEmpty)
        #expect(writes("node script.js > result.json").isEmpty)
        #expect(writes("npm install").isEmpty)
    }

    // MARK: Non-mutating commands

    @Test func ignoresReadAndVcsCommands() {
        #expect(writes("git commit -m 'msg'").isEmpty)
        #expect(writes("git status").isEmpty)
        #expect(writes("ls -la").isEmpty)
        #expect(writes("cat README.md").isEmpty)
        #expect(writes("rm stale.txt").isEmpty)  // destructive, but RiskClassifier's business
    }

    // MARK: Chaining / prefixes / `cd`

    @Test func inspectsEverySegmentAndStripsPrefixes() {
        #expect(writes("ls; echo x > tracked.txt") == ["tracked.txt"])
        #expect(writes("sudo tee etc/hosts") == ["etc/hosts"])
        #expect(writes("FOO=1 BAR=2 sed -i 's/a/b/' file") == ["file"])
        #expect(writes("cd Sources && swift build").isEmpty)
    }

    /// A `cd` moves the directory a later segment's relative target resolves against; locking a
    /// bare `main.swift` when the command meant `Sources/main.swift` would guard the wrong file.
    @Test func followsCDWhenResolvingRelativeTargets() {
        #expect(writes("cd Sources && sed -i 's/a/b/' main.swift") == ["Sources/main.swift"])
        #expect(writes("cd Sources && cd .. && echo x > top.txt") == ["top.txt"])
        #expect(writes("cd a/b && cd ../c && echo x > f.txt") == ["a/c/f.txt"])
        #expect(writes("cd /abs/dir && echo x > f.txt") == ["/abs/dir/f.txt"])
    }

    // MARK: Targets that cannot be named are still denied — a lock needs a path

    @Test func deniesUnnameableTargets() {
        #expect(denied("patch -p1 < changes.diff"))     // the target lives inside the diff
        #expect(denied("sed -i 's/a/b/' *.swift"))      // a glob names a set, not a file
        #expect(denied("echo x > $OUT"))                // unexpanded variable
        #expect(denied("cat > ~/notes.md"))             // `~` is expanded by the shell, not us
        #expect(denied("cd $DIR && echo x > f.txt"))    // lost track of the directory
        #expect(denied("(cd sub && echo x > f.txt)"))   // a subshell scopes its own cd
        // …and nothing unnameable is smuggled through as a lockable write.
        #expect(writes("patch -p1 < changes.diff").isEmpty)
        #expect(writes("echo x > $OUT").isEmpty)
    }

    /// `patch` with an explicit operand names its target, so it is lockable like any other write —
    /// only the implicit form (target inside the diff) is refused.
    @Test func allowsPatchWithAnExplicitOperand() {
        #expect(writes("patch src/main.swift < changes.diff") == ["src/main.swift"])
        #expect(!denied("patch src/main.swift < changes.diff"))
    }

    @Test func reasonExplainsWhyItCannotBeLocked() {
        let reason = WorktreeMutationGuard.denialReason(command: "patch -p1 < changes.diff")
        #expect(reason?.contains("patch") == true)
        #expect(reason?.contains("locked") == true)
    }

    // MARK: Resolving to lockable paths

    @Test func lockableWritesResolveAgainstTheWorkdir() {
        let paths = WorktreeMutationGuard.lockableWrites(
            command: "cd Sources && echo x > main.swift", workdir: "/w/repo")
        #expect(paths == ["/w/repo/Sources/main.swift"])
    }

    /// A write outside the worktree isn't a worktree mutation, so the lock system doesn't claim it
    /// (the risk classifier and the approval prompt still see the command).
    @Test func lockableWritesDropTargetsOutsideTheWorktree() {
        #expect(WorktreeMutationGuard.lockableWrites(command: "sudo tee /etc/hosts", workdir: "/w/repo").isEmpty)
        #expect(WorktreeMutationGuard.lockableWrites(command: "echo x > ../sibling.txt", workdir: "/w/repo").isEmpty)
        #expect(WorktreeMutationGuard.lockableWrites(command: "echo x > /w/repo/in.txt", workdir: "/w/repo")
            == ["/w/repo/in.txt"])
    }

    @Test func lockableWritesFallBackToTheWrittenTargetWithoutAWorkdir() {
        #expect(WorktreeMutationGuard.lockableWrites(command: "echo x > a/b.txt", workdir: nil) == ["a/b.txt"])
    }
}
