import AppKit
import Testing
@testable import OpenClaw

@Suite(.serialized)
@MainActor
struct NodePairingApprovalPrompterTests {
    @Test func `silent pairing requires a trusted SSH host key`() {
        let options = NodePairingApprovalPrompter._testSilentPairingSSHOptions()

        #expect(options.contains("BatchMode=yes"))
        #expect(options.contains("ControlMaster=no"))
        #expect(options.contains("ControlPath=none"))
        #expect(options.contains("ControlPersist=no"))
        #expect(options.contains("ForkAfterAuthentication=no"))
        #expect(options.contains("StrictHostKeyChecking=yes"))
        #expect(!options.contains("StrictHostKeyChecking=accept-new"))
    }

    @Test func `own node is automatically approved only for a local gateway`() {
        #expect(NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
            connectionMode: .local,
            requestNodeId: "node-1",
            localNodeId: "node-1"))
        #expect(!NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
            connectionMode: .remote,
            requestNodeId: "node-1",
            localNodeId: "node-1"))
        #expect(!NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
            connectionMode: .local,
            requestNodeId: "node-2",
            localNodeId: "node-1"))
    }

    @Test func `node pairing approval prompter exercises`() async {
        await NodePairingApprovalPrompter.exerciseForTesting()
    }

    @Test func `a newer device pairing request supersedes queued requests for the same device`() {
        func request(_ requestId: String, deviceId: String, ts: Double) -> DevicePairingApprovalPrompter
            .PendingRequest
        {
            DevicePairingApprovalPrompter.PendingRequest(
                requestId: requestId,
                deviceId: deviceId,
                publicKey: "pub",
                displayName: nil,
                platform: "MacIntel",
                clientId: nil,
                clientMode: nil,
                role: "node",
                scopes: nil,
                remoteIp: nil,
                silent: nil,
                isRepair: true,
                ts: ts)
        }

        let stale1 = request("req-1", deviceId: "device-a", ts: 1)
        let stale2 = request("req-2", deviceId: "device-a", ts: 2)
        let other = request("req-3", deviceId: "device-b", ts: 3)
        let fresh = request("req-4", deviceId: "device-a", ts: 4)

        // Stale requests for the same device collapse; other devices are untouched.
        let coalesced = DevicePairingApprovalPrompter.coalescedQueue([stale1, stale2, other], adding: fresh)
        #expect(coalesced?.map(\.requestId) == ["req-3", "req-4"])

        // Re-delivery of an already queued requestId is a no-op.
        #expect(DevicePairingApprovalPrompter.coalescedQueue([stale1, other], adding: stale1) == nil)
    }
}

@MainActor
struct PairingCardPresentationTests {
    private func nodeCard(
        displayName: String? = "Peter's MacBook Pro",
        platform: String? = "macos 26.5",
        modelIdentifier: String? = "MacBookPro18,3",
        caps: [String] = [],
        commands: [String] = [],
        previouslyPaired: Bool? = false) -> PairingApprovalCenter.Card
    {
        PairingApprovalCenter.Card(
            kind: .node,
            requestId: "req-1",
            subjectId: "19cec1c3301a7469d4fd71f5f81339508390dadda91b34aee15faf2849dccdc7",
            displayName: displayName,
            platform: platform,
            deviceFamily: "Mac",
            modelIdentifier: modelIdentifier,
            version: "2026.6.11",
            coreVersion: "2026.6.10",
            remoteIp: "::ffff:192.168.1.42",
            role: nil,
            scopes: [],
            caps: caps,
            commands: commands,
            isRepair: false,
            previouslyPaired: previouslyPaired,
            requestedAt: Date(timeIntervalSince1970: 1_700_000_000))
    }

    private func deviceCard(scopes: [String], isRepair: Bool = false) -> PairingApprovalCenter.Card {
        PairingApprovalCenter.Card(
            kind: .device,
            requestId: "req-2",
            subjectId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd",
            displayName: nil,
            platform: "MacIntel",
            deviceFamily: nil,
            modelIdentifier: nil,
            version: nil,
            coreVersion: nil,
            remoteIp: "192.0.2.10",
            role: "operator",
            scopes: scopes,
            caps: [],
            commands: [],
            isRepair: isRepair,
            previouslyPaired: false,
            requestedAt: Date(timeIntervalSince1970: 1_700_000_000))
    }

    @Test func `card copy summarizes the requesting hardware`() {
        let card = self.nodeCard()
        #expect(PairingCardPresentation.title(for: card) == "Peter's MacBook Pro")
        #expect(PairingCardPresentation.subtitle(for: card) == "macOS 26.5 · MacBookPro18,3")
        #expect(PairingCardPresentation.deviceSymbol(for: card) == "macbook")
        #expect(PairingCardPresentation.identityLine(for: card) == "ID 19cec1c3...9dccdc7 · 192.168.1.42")
        #expect(PairingCardPresentation.versionTooltip(for: card) == "App 2026.6.11 · Core 2026.6.10")
        #expect(PairingCardPresentation.headerSummary(for: [card]) == "A node wants to connect to OpenClaw.")
    }

    @Test func `device symbols map hardware families`() {
        #expect(PairingCardPresentation.deviceSymbol(for: self.deviceCard(scopes: [])) == "laptopcomputer")
        let iphone = PairingApprovalCenter.Card(
            kind: .device, requestId: "r", subjectId: "d", displayName: nil,
            platform: "ios 26", deviceFamily: nil, modelIdentifier: nil, version: nil,
            coreVersion: nil, remoteIp: nil, role: nil, scopes: [], caps: [], commands: [],
            isRepair: false, previouslyPaired: false, requestedAt: Date(timeIntervalSince1970: 0))
        #expect(PairingCardPresentation.deviceSymbol(for: iphone) == "iphone")
    }

    @Test func `node access rows flag command execution as elevated`() {
        let card = self.nodeCard(caps: ["screen", "camera"], commands: ["system.run", "system.notify"])
        let rows = PairingCardPresentation.accessRows(for: card)
        #expect(rows.first?.isElevated == true)
        #expect(rows.first?.text == "Can run system commands")
        #expect(rows.map(\.text).contains("Screen capture"))
        #expect(rows.map(\.text).contains("Camera"))

        // system.which alone is admin-scope (NODE_SYSTEM_RUN_COMMANDS).
        let whichOnly = self.nodeCard(commands: ["system.which"])
        #expect(PairingCardPresentation.accessRows(for: whichOnly).first?.isElevated == true)

        // Non-system commands are still part of the approved surface and
        // must render rather than being granted invisibly.
        let notifyOnly = self.nodeCard(commands: ["system.notify", "contacts.search"])
        let notifyRows = PairingCardPresentation.accessRows(for: notifyOnly)
        #expect(notifyRows.allSatisfy { !$0.isElevated })
        #expect(notifyRows.map(\.text).contains("Commands: system.notify, contacts.search"))
    }

    @Test func `session catalog capabilities render as one provider summary`() {
        let card = self.nodeCard(caps: [
            "canvas",
            "codex-app-server-threads",
            "claude-sessions",
            "browser",
            "codex-cli-sessions",
            "opencode-sessions",
            "pi-sessions",
            "file",
        ])

        let rows = PairingCardPresentation.accessRows(for: card)
        #expect(rows.map(\.text) == [
            "Canvas display",
            "Sessions: Codex, Claude, OpenCode, Pi",
            "Browser",
            "File transfer",
        ])
        #expect(rows.filter { $0.id == "cap-group:sessions" }.count == 1)
    }

    @Test func `every requested access row renders and admin can not hide`() {
        // No cap: hiding a row could conceal what approval grants.
        let caps = (1...12).map { "cap-\($0)" }
        #expect(PairingCardPresentation.accessRows(for: self.nodeCard(caps: caps)).count == caps.count)

        // operator.admin buried behind ordinary scopes surfaces first, elevated.
        let sneaky = self.deviceCard(scopes: [
            "operator.read",
            "operator.write",
            "operator.approvals",
            "operator.pairing",
            "operator.talk.secrets",
            "operator.admin",
        ])
        let rows = PairingCardPresentation.accessRows(for: sneaky)
        #expect(rows.count == 6)
        #expect(rows.first?.text == "Admin access")
        #expect(rows.first?.isElevated == true)
    }

    @Test func `device scopes render friendly names`() {
        let card = self.deviceCard(scopes: [
            "operator.admin",
            "operator.read",
            "operator.write",
            "operator.approvals",
            "operator.pairing",
        ])
        #expect(PairingCardPresentation.accessRows(for: card).map(\.text) == [
            "Admin access",
            "Read OpenClaw data",
            "Send messages and make changes",
            "Manage approvals",
            "Pair and repair devices",
        ])
        #expect(PairingCardPresentation.title(for: card) == "OpenClaw Mac app")
        #expect(PairingCardPresentation.subtitle(for: card) == "Mac (Intel) · Operator")
    }

    @Test func `trust line distinguishes first contact repair and reused ids`() {
        let fresh = self.nodeCard()
        #expect(PairingCardPresentation.trustLine(for: fresh).tone == .neutral)

        // The requester-claimed id is unauthenticated, so an already-paired id
        // must render as a caution (token replacement), never positive trust.
        let reusedId = self.nodeCard(previouslyPaired: true)
        let reusedLine = PairingCardPresentation.trustLine(for: reusedId)
        #expect(reusedLine.tone == .caution)
        #expect(reusedLine.text.contains("already paired"))

        let repair = self.deviceCard(scopes: [], isRepair: true)
        let repairLine = PairingCardPresentation.trustLine(for: repair)
        #expect(repairLine.tone == .caution)
        #expect(repairLine.text.contains("Repair"))

        // Unverified history (stale snapshot) must stay neutral.
        let unknown = self.nodeCard(previouslyPaired: nil)
        let unknownLine = PairingCardPresentation.trustLine(for: unknown)
        #expect(unknownLine.tone == .neutral)
        #expect(!unknownLine.text.contains("already paired"))
    }

    @Test func `identifier shortening keeps head and tail`() {
        let long = "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd"
        #expect(PairingCardPresentation.shortIdentifier(long) == "4a865684...f4e82bd")
        #expect(PairingCardPresentation.shortIdentifier("short-id") == "short-id")
    }

    @Test func `panel auto presents only for unseen requests`() {
        #expect(!PairingApprovalCenter.shouldAutoPresent(cardIds: [], snoozedIds: []))
        #expect(PairingApprovalCenter.shouldAutoPresent(cardIds: ["a"], snoozedIds: []))
        // Everything on screen was snoozed: stay hidden.
        #expect(!PairingApprovalCenter.shouldAutoPresent(cardIds: ["a"], snoozedIds: ["a"]))
        // A request the user has not seen yet reopens the panel.
        #expect(PairingApprovalCenter.shouldAutoPresent(cardIds: ["a", "b"], snoozedIds: ["a"]))
    }
}

@MainActor
struct PairingApprovalCenterBulkDecisionTests {
    @MainActor
    private final class DecisionRecorder {
        var decided: [String] = []

        func record(_ kind: String, _ card: PairingApprovalCenter.Card, _ decision: PairingApprovalCenter.Decision) {
            self.decided.append("\(kind):\(card.requestId):\(decision == .approve ? "approve" : "reject")")
        }
    }

    private func card(
        kind: PairingApprovalCenter.Kind,
        requestId: String) -> PairingApprovalCenter.Card
    {
        PairingApprovalCenter.Card(
            kind: kind,
            requestId: requestId,
            subjectId: "subject-\(requestId)",
            displayName: nil,
            platform: nil,
            deviceFamily: nil,
            modelIdentifier: nil,
            version: nil,
            coreVersion: nil,
            remoteIp: nil,
            role: nil,
            scopes: [],
            caps: [],
            commands: [],
            isRepair: false,
            previouslyPaired: false,
            requestedAt: Date(timeIntervalSince1970: 1_700_000_000))
    }

    @Test func `decide all routes every card to its kind handler exactly once`() async {
        let center = PairingApprovalCenter()
        let recorder = DecisionRecorder()
        center.register(kind: .node) { recorder.record("node", $0, $1) }
        center.register(kind: .device) { recorder.record("device", $0, $1) }
        let rendered = [
            self.card(kind: .node, requestId: "req-1"),
            self.card(kind: .device, requestId: "req-2"),
        ]

        center.decideAll(rendered, .approve)
        #expect(center.decisionsInFlight == ["req-1", "req-2"])
        // A second bulk click while decisions are in flight must not re-fire.
        center.decideAll(rendered, .approve)

        var attempts = 0
        while recorder.decided.count < 2 || !center.decisionsInFlight.isEmpty, attempts < 10000 {
            attempts += 1
            await Task.yield()
        }
        #expect(recorder.decided.sorted() == ["device:req-2:approve", "node:req-1:approve"])
        #expect(center.decisionsInFlight.isEmpty)
    }

    @Test func `reject all delivers reject to every handler`() async {
        let center = PairingApprovalCenter()
        let recorder = DecisionRecorder()
        center.register(kind: .device) { recorder.record("device", $0, $1) }
        let rendered = [
            self.card(kind: .device, requestId: "req-1"),
            self.card(kind: .device, requestId: "req-2"),
        ]

        center.decideAll(rendered, .reject)

        var attempts = 0
        while recorder.decided.count < 2, attempts < 10000 {
            attempts += 1
            await Task.yield()
        }
        #expect(recorder.decided.sorted() == ["device:req-1:reject", "device:req-2:reject"])
    }

    @Test func `bulk decisions resolve only the rendered snapshot`() async {
        // Security invariant: a request that arrives after the footer was
        // rendered but before the click lands must never be resolved by the
        // stale bulk button — only what the user actually saw gets decided.
        let center = PairingApprovalCenter()
        let recorder = DecisionRecorder()
        center.register(kind: .node) { recorder.record("node", $0, $1) }
        center.register(kind: .device) { recorder.record("device", $0, $1) }
        let rendered = [self.card(kind: .node, requestId: "req-seen")]
        // "req-unseen" lands in the live queue after render, before the click.
        center._testSetCards(rendered + [self.card(kind: .device, requestId: "req-unseen")])

        center.decideAll(rendered, .approve)

        var attempts = 0
        while recorder.decided.isEmpty, attempts < 10000 {
            attempts += 1
            await Task.yield()
        }
        #expect(recorder.decided == ["node:req-seen:approve"])
        #expect(!center.decisionsInFlight.contains("req-unseen"))
    }
}
