import Foundation
import Testing

@testable import NucleicCore

/// The Codex edit gate (`CodexToolGate`, docs/LOCKING.md §4.6): the `PreToolUse` hook that makes a
/// Codex `apply_patch` acquire its file locks before it lands, the way Claude's tool-permission
/// bridge does. Payload shapes are from a live codex-cli 0.146.0 capture — a code-mode turn calling
/// `tools.apply_patch(...)`, which reaches the hook as `tool_name: "apply_patch"` with the patch
/// envelope in `tool_input.command`, and a shell call, which codex normalizes to `tool_name: "Bash"`.
@Suite struct CodexToolGateTests {
    // MARK: - Classification

    @Test func classifiesCodeModeApplyPatchByItsEnvelope() {
        // Verbatim shape from the live capture
        // (fixtures/codex-appserver/0.146.0/02-pre-tool-use-hook.jsonl).
        let input: JSONValue = [
            "command": .string(
                "*** Begin Patch\n*** Update File: Sources/App/Auth.swift\n@@\n-old\n+new\n"
                    + "*** End Patch\n")
        ]
        #expect(
            CodexToolGate.classify(toolName: "apply_patch", input: input)
                == .patch(["Sources/App/Auth.swift"]))
    }

    @Test func classifiesShellAsShell() {
        let input: JSONValue = ["command": .string("echo hi > notes.txt")]
        #expect(
            CodexToolGate.classify(toolName: "Bash", input: input) == .shell("echo hi > notes.txt"))
        // codex's own tool name, and an argv-shaped command, reach the same branch.
        #expect(
            CodexToolGate.classify(
                toolName: "exec_command", input: ["cmd": .string("make test")])
                == .shell("make test"))
        #expect(
            CodexToolGate.classify(
                toolName: "shell",
                input: ["command": .array([.string("bash"), .string("-lc"), .string("ls -la")])])
                == .shell("ls -la"))
    }

    @Test func ignoresEverythingThatIsNotAMutation() {
        // A tool the gate doesn't know must never invent a lock — the hook fires for every call.
        #expect(CodexToolGate.classify(toolName: "web_search", input: ["query": "x"]) == .ignore)
        #expect(CodexToolGate.classify(toolName: "update_plan", input: [:]) == .ignore)
        #expect(CodexToolGate.classify(toolName: "view_image", input: ["path": "a.png"]) == .ignore)
        #expect(
            CodexToolGate.classify(toolName: "mcp__nucleic__check_conflict", input: [:]) == .ignore)
        // Right tool, unreadable input → ignore rather than a spurious denial.
        #expect(CodexToolGate.classify(toolName: "apply_patch", input: [:]) == .ignore)
        #expect(CodexToolGate.classify(toolName: "Bash", input: [:]) == .ignore)
    }

    @Test func readsTheFreeformAndFunctionToolShapes() {
        let patch = "*** Begin Patch\n*** Add File: a.txt\n+hi\n*** End Patch\n"
        #expect(CodexToolGate.classify(toolName: "apply_patch", input: .string(patch)) == .patch(["a.txt"]))
        #expect(
            CodexToolGate.classify(toolName: "apply_patch", input: ["input": .string(patch)])
                == .patch(["a.txt"]))
        // `apply_patch` as an argv (the exec_command spelling of the same edit).
        #expect(
            CodexToolGate.classify(
                toolName: "apply_patch",
                input: ["command": .array([.string("apply_patch"), .string(patch)])])
                == .patch(["a.txt"]))
    }

    @Test func classifiesTheHeaderOnlyEnvelopeTheHookSends() {
        // The hook drops the diff body before POSTing (a megabyte refactor patch would otherwise
        // exceed `MCPApprovalServer.maxRequestBytes`, and an unanswerable gate denies), so the host
        // must read the trimmed shape exactly as it reads a whole envelope.
        let trimmed = "*** Begin Patch\n*** Update File: hello.txt\n*** End Patch\n"
        #expect(
            CodexToolGate.classify(toolName: "apply_patch", input: ["command": .string(trimmed)])
                == .patch(["hello.txt"]))
    }

    // MARK: - Patch parsing

    @Test func parsesEveryFileOperationInAnEnvelope() {
        let patch = """
            *** Begin Patch
            *** Add File: docs/new.md
            +hello
            *** Update File: Sources/App/Auth.swift
            @@ func login()
            -  old()
            +  new()
            *** Delete File: obsolete.txt
            *** End Patch
            """
        #expect(
            CodexToolGate.patchPaths(in: patch)
                == ["docs/new.md", "Sources/App/Auth.swift", "obsolete.txt"])
    }

    @Test func locksBothSidesOfARename() {
        // A `Move to:` deletes the source, so a sibling editing it loses work exactly as with a
        // `git mv` — both paths are locked (LOCKING §4.4, `--no-renames`).
        let patch = """
            *** Begin Patch
            *** Update File: Old.swift
            *** Move to: New.swift
            @@
            -a
            +b
            *** End Patch
            """
        #expect(CodexToolGate.patchPaths(in: patch) == ["Old.swift", "New.swift"])
    }

    @Test func patchWithNoFileHeaderLocksNothing() {
        #expect(CodexToolGate.patchPaths(in: "*** Begin Patch\n*** End Patch\n").isEmpty)
        #expect(CodexToolGate.patchPaths(in: "").isEmpty)
    }

    // MARK: - Path resolution

    @Test func resolvesRelativePatchPathsAgainstTheTurnCwd() {
        let paths = CodexToolGate.lockablePaths(
            ["Sources/App/Auth.swift"], cwd: "/work/tree", root: "/work/tree")
        #expect(paths == ["/work/tree/Sources/App/Auth.swift"])
    }

    @Test func dropsWritesOutsideTheWorktree() {
        // Not a worktree mutation ⇒ no lock: its gate is the sandbox + approval policy, exactly as
        // for a raw-shell write (NASH §5.6). `/work/tree2` must not pass as inside `/work/tree`.
        let paths = CodexToolGate.lockablePaths(
            ["/etc/passwd", "/work/tree2/x.swift", "../outside.txt", "in.swift"],
            cwd: "/work/tree", root: "/work/tree")
        #expect(paths == ["/work/tree/in.swift"])
    }

    @Test func absolutePatchPathsInsideTheWorktreeAreKeptOnce() {
        let paths = CodexToolGate.lockablePaths(
            ["/work/tree/a.swift", "a.swift"], cwd: "/work/tree", root: "/work/tree")
        #expect(paths == ["/work/tree/a.swift"])
    }

    // MARK: - Arbitration (no coordinator attached ⇒ fail open, per ConflictCoordinator)

    @Test func allowsWhenNothingIsWiredToArbitrate() async {
        let call = MCPApprovalServer.PreToolUseCall(
            toolName: "apply_patch",
            input: ["command": .string("*** Begin Patch\n*** Update File: a.swift\n*** End Patch\n")],
            toolUseID: "exec-1", cwd: "/work/tree")
        let decision = await CodexToolGate.arbitrate(
            call, sessionID: SessionID(rawValue: "s1"), worktreeRoot: "/work/tree",
            coordinator: nil)
        #expect(decision == .allow)
    }

    @Test func stillRefusesAnUnlockableShellWriteWithNoCoordinator() async {
        // A rule about the command, not about contention (WorktreeMutationGuard): a target that
        // can't be named before the command runs can't be locked, so it must not fail open.
        let call = MCPApprovalServer.PreToolUseCall(
            toolName: "Bash", input: ["command": .string("patch -p1 < fix.diff")],
            toolUseID: nil, cwd: "/work/tree")
        let decision = await CodexToolGate.arbitrate(
            call, sessionID: SessionID(rawValue: "s1"), worktreeRoot: "/work/tree",
            coordinator: nil)
        guard case .deny = decision else {
            Issue.record("expected a denial for an unlockable write, got \(decision)")
            return
        }
    }

    // MARK: - Wire shapes

    @Test func denyCarriesTheReasonTheModelReads() {
        let allow = MCPApprovalServer.PreToolUseDecision.allow.wireJSON()
        #expect(allow.contains("\"decision\":\"allow\""))
        let deny = MCPApprovalServer.PreToolUseDecision.deny(reason: "locked by session Q").wireJSON()
        #expect(deny.contains("\"decision\":\"deny\""))
        #expect(deny.contains("locked by session Q"))
    }

    // MARK: - Container install

    @Test func managedConfigDeclaresASyncPreToolUseHook() {
        let toml = CodexToolGate.managedConfigSource()
        #expect(toml.contains("[[hooks.PreToolUse]]"))
        #expect(toml.contains("matcher = \"*\""))
        #expect(toml.contains(CodexToolGate.hookScriptPath))
        // A timed-out hook fails OPEN in codex, so the hook's own deadline must land first.
        #expect(toml.contains("timeout = \(CodexToolGate.hookTimeoutSeconds)"))
        #expect(CodexToolGate.clientDeadlineMs < CodexToolGate.hookTimeoutSeconds * 1000)
    }

    @Test func installScriptWritesBothFilesRootOwned() {
        let script = CodexToolGate.installScript()
        #expect(script.contains(CodexToolGate.hookScriptPath))
        #expect(script.contains(CodexToolGate.managedConfigPath))
        // Root-owned and not agent-writable: a gate the gated party can rewrite is not a gate
        // (the same reasoning as nash's operator policy, NASH §4.3).
        #expect(script.contains("chown 0:0 \(CodexToolGate.hookScriptPath)"))
        #expect(script.contains("chown 0:0 \(CodexToolGate.managedConfigPath)"))
        #expect(script.contains("chmod 0555 \(CodexToolGate.hookScriptPath)"))
    }

    /// Replays the real codex-cli 0.146.0 `PreToolUse` hook payloads through the gate's own
    /// classification, pinning the byte-level contract this whole mechanism rests on: a code-mode
    /// `tools.apply_patch(…)` arrives as `apply_patch` with the envelope in `tool_input.command`,
    /// and codex normalizes its shell tool to `Bash`. Captured live, so this is golden, not
    /// synthetic (ADAPTERS §4).
    @Test func realHookCaptureClassifiesAsPatchAndShell() throws {
        let repoRoot = URL(fileURLWithPath: #filePath)
            .deletingLastPathComponent()  // NucleicCoreTests
            .deletingLastPathComponent()  // Tests
            .deletingLastPathComponent()  // repo root
        let fixture = repoRoot
            .appendingPathComponent("fixtures/codex-appserver/0.146.0/02-pre-tool-use-hook.jsonl")
        let content = try String(contentsOf: fixture, encoding: .utf8)

        var gates: [CodexToolGate.Gate] = []
        for line in content.split(separator: "\n") where !line.isEmpty {
            let root = try JSONValue(parsing: String(line))
            #expect(root["hook_event_name"]?.stringValue == "PreToolUse")
            gates.append(
                CodexToolGate.classify(
                    toolName: root["tool_name"]?.stringValue ?? "",
                    input: root["tool_input"] ?? .object([:])))
        }
        #expect(gates == [.patch(["hello.txt"]), .shell("echo notes >> notes.txt")])
    }

    @Test func hookEnvPointsCodexAtTheGate() {
        let env = CommandInterceptor.hookEnv(
            host: "127.0.0.1", port: 8080, token: "tok", sessionID: SessionID(rawValue: "s1"),
            commandTracing: false)
        #expect(env[CodexToolGate.urlEnvKey] == "http://127.0.0.1:8080/pre-tool-use")
        #expect(env["NUCLEIC_HOOK_TOKEN"] == "tok")
    }
}
