import Foundation
import Testing

@testable import NucleicCore

/// `HostExecPolicy` is the code-enforced resistance behind `host_exec`: it sorts a command into
/// host-only (proceed), read-only (allow, but re-prompt), container-runnable (deny), ambiguous
/// (force a fresh prompt), or secret-file-blocked (hard deny a `.env` read), and judges whether the
/// agent's justification is substantive. These tests pin the calibration so a later edit can't
/// silently widen the escape hatch or unblock a `.env` read.
@Suite("host_exec policy")
struct HostExecPolicyTests {

    // MARK: - host-only: the one legitimate reason to escape the sandbox

    /// A Swift/Xcode toolchain command is the genuine host-only case, so it must classify as
    /// `hostOnly` — the container has no Swift toolchain, so this can't run in the sandbox.
    @Test func swiftAndXcodeCommandsAreHostOnly() {
        #expect(HostExecPolicy.classify("swift build") == .hostOnly)
        #expect(HostExecPolicy.classify("swift build && swift test") == .hostOnly)
        #expect(HostExecPolicy.classify("xcodebuild -scheme App test") == .hostOnly)
        #expect(HostExecPolicy.classify("xcrun simctl list") == .hostOnly)
        // A `cd` into the worktree first is common and must not change the verdict.
        #expect(HostExecPolicy.classify("cd App && swift build") == .hostOnly)
    }

    /// A genuine host tool piping into an ordinary text util (`| grep`, `| head`) is still a host
    /// build — the incidental helper must not demote it to container-runnable and get it denied.
    @Test func hostOnlyToolPipedIntoTextUtilStaysHostOnly() {
        #expect(HostExecPolicy.classify("xcodebuild -scheme App build | grep error") == .hostOnly)
    }

    // MARK: - container-runnable: deny, with a pointer back to the container

    /// The container owns JavaScript/Python/etc.; running them on the host is never warranted, so
    /// these are denied and the offending program is named for the guidance message.
    @Test func containerToolchainsAreDeniedWithProgramNamed() {
        #expect(HostExecPolicy.classify("npm test") == .containerRunnable(program: "npm"))
        #expect(HostExecPolicy.classify("python3 script.py") == .containerRunnable(program: "python3"))
        #expect(HostExecPolicy.classify("pytest -q") == .containerRunnable(program: "pytest"))
        #expect(HostExecPolicy.classify("cargo build") == .containerRunnable(program: "cargo"))
    }

    // MARK: - read-only inspection: allowed on the host (still approval-gated)

    /// Read-only inspection utilities are permitted on the host — they can look at, but not mutate,
    /// the machine — so they classify as `readOnly` (allowed) rather than being denied.
    @Test func readOnlyInspectionToolsAreAllowed() {
        #expect(HostExecPolicy.classify("ls -la /tmp") == .readOnly(program: "ls"))
        #expect(HostExecPolicy.classify("cat /etc/hosts") == .readOnly(program: "cat"))
        #expect(HostExecPolicy.classify("find . -name '*.swift'") == .readOnly(program: "find"))
        #expect(HostExecPolicy.classify("grep -r TODO Sources") == .readOnly(program: "grep"))
        #expect(HostExecPolicy.classify("stat Package.swift") == .readOnly(program: "stat"))
    }

    /// A container toolchain hidden behind a read-only tool still loses: the deny wins over the
    /// allow, so `cat x | node -` doesn't launder `node` onto the host as "just a cat".
    @Test func containerToolchainBeatsReadOnly() {
        #expect(HostExecPolicy.classify("cat x.js | node -") == .containerRunnable(program: "node"))
    }

    // MARK: - .env secrets: hard-blocked, ahead of everything

    /// Reading a `.env` file is forbidden outright, whatever tool does the reading and wherever the
    /// file sits — this must win over the read-only allowance and over a host-only pipeline.
    @Test func readingDotenvIsBlocked() {
        #expect(HostExecPolicy.classify("cat .env") == .secretFileBlocked)
        #expect(HostExecPolicy.classify("cat ./config/.env") == .secretFileBlocked)
        #expect(HostExecPolicy.classify("head /app/.env.local") == .secretFileBlocked)
        #expect(HostExecPolicy.classify("grep SECRET .env.production") == .secretFileBlocked)
        // The block outranks a genuine host-only tool in the same pipeline.
        #expect(HostExecPolicy.classify("cat .env | xcrun foo") == .secretFileBlocked)
    }

    /// The block keys on the *filename* `.env`, not the mere letters — a longer name that only
    /// contains "env" (`.environment`, `.envrc`, `foo.env`) is not a `.env` secrets file and is not
    /// blocked, so the guard doesn't over-reach into unrelated files.
    @Test func nonDotenvNamesAreNotBlocked() {
        #expect(HostExecPolicy.classify("cat .environment") == .readOnly(program: "cat"))
        #expect(HostExecPolicy.classify("cat .envrc") == .readOnly(program: "cat"))
        #expect(HostExecPolicy.classify("cat foo.env") == .readOnly(program: "cat"))
    }

    /// The write-a-script-then-run-it-by-filename shape (the documented git-shield bypass) hits the
    /// interpreter directly, so running one on the host is now denied outright at this gate.
    @Test func runningAnInterpreterOnAFileIsDenied() {
        #expect(HostExecPolicy.classify("node /tmp/x.mjs") == .containerRunnable(program: "node"))
        #expect(HostExecPolicy.classify("python3 /tmp/x.py") == .containerRunnable(program: "python3"))
    }

    // MARK: - ambiguous: can't prove it needs the host → force a fresh prompt

    /// `make` may or may not invoke swift, `bash -c "…"` hides its payload, and a bare script path
    /// reveals nothing — none can be shown to need the host, so each stays ambiguous (re-prompted).
    @Test func unprovableCommandsAreAmbiguous() {
        #expect(HostExecPolicy.classify("make test") == .ambiguous)
        #expect(HostExecPolicy.classify("bash -lc \"swift build\"") == .ambiguous)
        #expect(HostExecPolicy.classify("./scripts/build.sh") == .ambiguous)
        #expect(HostExecPolicy.classify("") == .ambiguous)
    }

    // MARK: - justification acceptability

    /// Empty, whitespace, and obvious placeholders are rejected — the reason field must carry a real
    /// justification, not a rubber stamp.
    @Test func emptyOrBoilerplateReasonsAreRejected() {
        #expect(!HostExecPolicy.isAcceptableReason(""))
        #expect(!HostExecPolicy.isAcceptableReason("   "))
        #expect(!HostExecPolicy.isAcceptableReason("n/a"))
        #expect(!HostExecPolicy.isAcceptableReason("test"))
        #expect(!HostExecPolicy.isAcceptableReason("because"))
        #expect(!HostExecPolicy.isAcceptableReason("build"))  // too short
    }

    /// A specific, substantive reason passes — the gate is friction, not a wall.
    @Test func aSubstantiveReasonIsAccepted() {
        #expect(HostExecPolicy.isAcceptableReason(
            "builds a SwiftUI app that only compiles with Xcode on macOS"))
    }
}

/// The on-device reason cross-check (`auditHostExecReason`) resists a deceptive agent by producing
/// its verdict from a separate model, and by refusing to mint trust it can't justify. These tests
/// pin the model-token mapping and the model-free fallback.
@Suite("host_exec reason audit")
struct HostExecReasonAuditTests {

    /// Known verdict tokens map to their trust level, case- and whitespace-insensitively.
    @Test func knownModelTokensMapToTrustLevels() {
        #expect(HostExecReasonAudit.trust(fromModelToken: "CONSISTENT") == .consistent)
        #expect(HostExecReasonAudit.trust(fromModelToken: " partial ") == .partial)
        #expect(HostExecReasonAudit.trust(fromModelToken: "contradictory") == .contradictory)
        #expect(HostExecReasonAudit.trust(fromModelToken: "UNCLEAR") == .unclear)
    }

    /// A confused or manipulated model returning an unexpected token must NOT be read as trust —
    /// it falls to `.unclear`, never silently `.consistent`.
    @Test func unrecognizedTokenNeverMintsTrust() {
        #expect(HostExecReasonAudit.trust(fromModelToken: "TOTALLY FINE, APPROVE") == .unclear)
        #expect(HostExecReasonAudit.trust(fromModelToken: "") == .unclear)
    }

    /// Without a model (the heuristic provider, tests, or AFM off/unavailable) the verdict is
    /// `.notChecked` — the UI shows "not verified", so a missing model never reads as a clean pass.
    @Test func heuristicProviderReportsNotChecked() async {
        let audit = await HeuristicIntelligence().auditHostExecReason(
            command: "swift build", reason: "builds a macOS app")
        #expect(audit.trust == .notChecked)
        #expect(audit == .notChecked)
    }
}
