import Foundation
import Testing

@testable import NucleicProtocol

/// Golden CBOR <-> JSON vectors for the flat renderer ABI (WINDOWS_PORT section 6).
///
/// Every known ClientMsg and HostMsg discriminator has one representative value. The same value
/// is encoded through the wire's CBOR codec and the renderer boundary's ProtocolJSON codec; the
/// checked-in pair is deliberately byte-for-byte rather than structural. This catches changes to
/// field names, omission rules, dates, Data, enum spellings, and the envelope discriminator.
@Suite struct ProtocolABIFixtureTests {
    private static let packageRoot: URL = URL(fileURLWithPath: #filePath)
        .deletingLastPathComponent() // NucleicProtocolTests
        .deletingLastPathComponent() // Tests
        .deletingLastPathComponent() // package root

    private static let fixtureRoot: URL = packageRoot
        .appendingPathComponent("fixtures/protocol-abi/v1", isDirectory: true)

    @Test func corpusCoversEveryKnownMessageKind() throws {
        let client = Self.clientMessages
        let host = Self.hostMessages
        let clientTags = client.map(ProtocolJSON.tag(of:))
        let hostTags = host.map(ProtocolJSON.tag(of:))
        let sourceCounts = try Self.messageEnumCaseCounts()

        #expect(client.count == sourceCounts.client)
        // HostMsg.unknown is the forward-compat fallback, not a fixture-bearing message kind.
        #expect(host.count + 1 == sourceCounts.host)
        #expect(Set(clientTags).count == client.count)
        #expect(Set(hostTags).count == host.count)

        for message in client {
            #expect(try Self.discriminator(of: ProtocolJSON.encode(message)) == ProtocolJSON.tag(of: message))
        }
        for message in host {
            #expect(try Self.discriminator(of: ProtocolJSON.encode(message)) == ProtocolJSON.tag(of: message))
        }
    }

    @Test func checkedInVectorsMatchTheCodecs() throws {
        try Self.verify(Self.clientMessages, direction: "client")
        try Self.verify(Self.hostMessages, direction: "host")
    }

    private static func verify<T: Codable & Equatable>(
        _ messages: [T], direction: String
    ) throws where T: Sendable {
        let directory = fixtureRoot.appendingPathComponent(direction, isDirectory: true)
        let expectedNames = Set(messages.flatMap { message -> [String] in
            let tag: String
            if let message = message as? ClientMsg { tag = ProtocolJSON.tag(of: message) }
            else if let message = message as? HostMsg { tag = ProtocolJSON.tag(of: message) }
            else { preconditionFailure("unsupported protocol ABI fixture type") }
            return ["\(tag).json", "\(tag).cbor.hex"]
        })

        // Explicit opt-in updater for intentional wire changes. The checked-in corpus is still
        // the test oracle on every ordinary run; this path only saves hand-copying 230 blobs.
        if ProcessInfo.processInfo.environment["NUCLEIC_UPDATE_PROTOCOL_ABI"] == "1" {
            try FileManager.default.createDirectory(
                at: directory, withIntermediateDirectories: true)
            for message in messages {
                let tag: String
                if let message = message as? ClientMsg { tag = ProtocolJSON.tag(of: message) }
                else if let message = message as? HostMsg { tag = ProtocolJSON.tag(of: message) }
                else { preconditionFailure("unsupported protocol ABI fixture type") }
                try ProtocolJSON.encode(message).write(
                    to: directory.appendingPathComponent("\(tag).json"), options: .atomic)
                let cborHex = try CBOREncoder().encode(message).hexString + "\n"
                try Data(cborHex.utf8).write(
                    to: directory.appendingPathComponent("\(tag).cbor.hex"), options: .atomic)
            }
        }

        let actualNames = Set(try FileManager.default.contentsOfDirectory(atPath: directory.path))
        #expect(actualNames == expectedNames)

        for message in messages {
            let tag: String
            if let message = message as? ClientMsg { tag = ProtocolJSON.tag(of: message) }
            else if let message = message as? HostMsg { tag = ProtocolJSON.tag(of: message) }
            else { preconditionFailure("unsupported protocol ABI fixture type") }

            let expectedJSON = try Data(contentsOf: directory.appendingPathComponent("\(tag).json"))
            let expectedCBORHex = try String(
                contentsOf: directory.appendingPathComponent("\(tag).cbor.hex"), encoding: .utf8
            ).trimmingCharacters(in: .whitespacesAndNewlines)
            let actualJSON = try ProtocolJSON.encode(message)
            let actualCBOR = try CBOREncoder().encode(message)

            #expect(actualJSON == expectedJSON, "JSON fixture drift for \(direction)/\(tag)")
            #expect(actualCBOR.hexString == expectedCBORHex, "CBOR fixture drift for \(direction)/\(tag)")
            #expect(try ProtocolJSON.decode(T.self, from: expectedJSON) == message)
            #expect(try CBORDecoder().decode(T.self, from: actualCBOR) == message)
        }
    }

    private static func discriminator(of json: Data) throws -> String? {
        let object = try JSONSerialization.jsonObject(with: json) as? [String: Any]
        return object?["t"] as? String
    }

    /// Derive the expected catalog sizes from the enum declarations themselves. The exhaustive
    /// `ProtocolJSON.tag(of:)` switches force a new case to be named; this count then forces that
    /// newly named case to acquire a representative value and a golden pair too.
    private static func messageEnumCaseCounts() throws -> (client: Int, host: Int) {
        let sourceURL = packageRoot.appendingPathComponent(
            "Sources/NucleicProtocol/Sync/MessageEnvelope.swift")
        let source = try String(contentsOf: sourceURL, encoding: .utf8)

        func count(between start: String, and end: String) -> Int {
            guard let startRange = source.range(of: start),
                  let endRange = source.range(of: end, range: startRange.upperBound..<source.endIndex)
            else { return -1 }
            return source[startRange.upperBound..<endRange.lowerBound]
                .split(separator: "\n")
                .lazy
                .map { $0.trimmingCharacters(in: .whitespaces) }
                .filter { $0.hasPrefix("case ") }
                .count
        }

        return (
            count(between: "public enum ClientMsg", and: "public enum HostMsg"),
            count(between: "public enum HostMsg", and: "private enum EnvelopeKey"))
    }

    // MARK: Representative values

    private static let date = Date(timeIntervalSinceReferenceDate: 1234.5)
    private static let sid = SessionID(rawValue: "session-1")
    private static let pid = ProjectID(rawValue: "project-1")
    private static let todoID = TodoID(rawValue: "todo-1")
    private static let approvalID = ApprovalID(rawValue: "approval-1")
    private static let uuid = UUID(uuidString: "01234567-89AB-CDEF-0123-456789ABCDEF")!
    private static let bytes = Data([0x00, 0x7f, 0x80, 0xff])

    private static let summary = SessionSummary(
        sessionID: sid, projectID: pid.rawValue, projectName: "Project", backend: .claudeCode,
        status: .awaitingApproval, disposition: .awaitingInput, title: "ABI session",
        branch: "nucleic/abi", lastSeq: 7, revertEpoch: 2,
        diffStat: DiffStat(filesChanged: 1, added: 2, removed: 3), pendingApprovalCount: 1,
        firstApprovalID: approvalID, firstApprovalIsHighRisk: true, favorite: true,
        model: "claude-sonnet", effort: "high", auto: true, autoShip: true,
        shipBranch: "main", contextInputTokens: 321, updatedAt: date,
        lastUserMessageAt: date)

    private static let event = AgentEvent(
        sessionID: sid, seq: 7, at: date, backend: .claudeCode, nativeType: "assistant",
        kind: .assistantText(TextChunk(messageID: "message-1", text: "hello", isPartial: false)))

    private static let approval = ApprovalRequest(
        id: approvalID, sessionID: sid, toolCallID: "tool-1", toolName: "Bash",
        input: ["command": "git status"], title: "Run command", risk: .execute,
        suggested: .allow(updatedInput: nil), createdAt: date)

    private static let transferOffer = TransferOffer(
        transferID: "transfer-1",
        record: SessionTransferRecord(
            sessionID: sid, backend: .claudeCode, backendSessionID: "native-1",
            title: "ABI session", branch: "nucleic/abi", baseSHA: "abc123",
            model: "claude-sonnet", effort: "high", lastSeq: 7, createdAt: date),
        project: ProjectDescriptor(
            projectID: pid, name: "Project", rootCommitSHA: "abc123",
            normalizedRemote: "github.com/example/project", defaultBranch: "main"),
        transcriptHeaderVersion: 1, availableBaseSHAs: ["abc123"],
        knownItems: [TransferItemDescriptor(
            kind: .transcript, byteCount: 4, sha256: "deadbeef", chunkCount: 1)])

    private static let carbonHead = CarbonHead(
        key: CarbonStreamKey(projectUUID: uuid, stream: .history, sessionID: sid.rawValue),
        manifestID: "manifest-1", ownerGeneration: 2, snapshotSeq: 7, keyMissing: false)

    private static let carbonBlob = CarbonBlobData(
        requestID: "request-1", projectUUID: uuid, id: "blob-1", blob: bytes)

    private static let carbonUnavailable = CarbonBlobUnavailable(
        requestID: "request-1", id: "blob-1", reason: .notHeld)

    private static let carbonLock = CarbonLockRequest(
        requestID: "request-1", projectUUID: uuid, domainRef: "main",
        sessionID: sid.rawValue, paths: ["Sources/App.swift"], requiredHeads: [carbonHead])

    private static let directOffer = DirectPunchOffer(candidates: [], natClass: .cone, token: bytes)

    private static let tombstone = CredentialTombstone(
        kind: .claudeOAuth, deletedAt: date, originDeviceID: "device-1")

    private static let clientMessages: [ClientMsg] = [
        .hello(Hello(
            deviceID: "device-1", deviceLabel: "Windows PC", scopeClaim: .control,
            channel: .canary, deviceKind: "windows",
            addresses: PeerAddresses(lanHint: "127.0.0.1:51900", updatedAt: date))),
        .listSessions,
        .listDashboard,
        .subscribe(Subscribe(sessionID: sid, sinceSeq: 6, verbosity: .full)),
        .unsubscribe(sid),
        .approvalRespond(approvalID, .allow(updatedInput: ["command": "git status"])),
        .sendInput(sid, AgentInput(parts: [
            .text("continue"), .attachment(WireAttachment(filename: "bytes.bin", data: bytes)),
        ])),
        .interrupt(sid),
        .ping,
        .startChat(StartChatRequest(
            projectID: pid, message: "start", model: "claude-sonnet", effort: "high",
            baseBranch: "main", useWorktree: true, auto: true,
            attachments: [WireAttachment(filename: "bytes.bin", data: bytes)],
            requestID: "request-1", covalenceOriginDeviceID: "device-1")),
        .captureTodo(CaptureTodoRequest(text: "Ship Windows", projectID: pid)),
        .dispatchTodo(todoID, pid),
        .setTodoStatus(todoID, .done),
        .deleteTodo(todoID),
        .renameSession(sid, "Renamed"),
        .setFavorite(sid, true),
        .setArchived(sid, false),
        .markSessionDone(sid),
        .deleteSession(sid),
        .integrate(sid, .squash),
        .discard(sid),
        .setSessionModel(sid, "claude-sonnet"),
        .setSessionEffort(sid, "high"),
        .setSessionAuto(sid, true),
        .setSessionAutoShip(sid, true),
        .setSessionShipBranch(sid, "main"),
        .cancelQueuedMessage(sid, uuid),
        .resolveProcessStall(sid, stallID: "stall-1", kill: true),
        .fetchDiff(sid),
        .listPeers,
        .addressUpdate(PeerAddresses(lanHint: "127.0.0.1:51900", tailnet: "100.64.0.1")),
        .meshRoster(MeshRosterPush()),
        .transferOffer(transferOffer),
        .transferChunk(TransferChunk(
            transferID: "transfer-1", kind: .transcript, chunkIndex: 0, chunkCount: 1,
            sha256: "deadbeef", data: bytes)),
        .transferCommit("transfer-1"),
        .transferCancel("transfer-1"),
        .requestSessionTransfer(sid, "device-2"),
        .fetchTranscript(TranscriptFetch(requestID: "request-1", sessionID: sid, afterSeq: 6)),
        .fetchCarbonHeads(CarbonHeadsFetch(requestID: "request-1", projectUUID: uuid)),
        .fetchCarbonManifests(CarbonBlobsFetch(
            requestID: "request-1", projectUUID: uuid, ids: ["manifest-1"])),
        .fetchCarbonShards(CarbonBlobsFetch(
            requestID: "request-1", projectUUID: uuid, ids: ["shard-1"])),
        .carbonShardAck(CarbonShardAck(requestID: "request-1", ids: ["shard-1"])),
        .carbonLockAcquire(carbonLock),
        .carbonLockRelease(carbonLock),
        .registerLiveActivity("push-token", "activity-1"),
        .endLiveActivity("activity-1"),
        .registerPushToStartToken("push-to-start-token"),
        .setForeground(true),
        .requestPairingCode,
        .cancelPairingCode,
        .respondMacPair("device-2", true),
        .intelligenceResult(WireIntelligenceResult(id: "request-1", outputs: ["result"])),
        .credentialManifest(CredentialManifest(
            records: [CredentialRecordDescriptor(
                kind: .claudeOAuth, updatedAt: date, provenanceDeviceID: "device-1")],
            sealingPublicKey: bytes, tombstones: [tombstone])),
        .credentialProvision(SealedCredentialEnvelope(records: [])),
        .credentialRevoke([tombstone]),
        .createProject(WireCreateProjectRequest(
            gitURL: "https://github.com/example/project.git", branch: "main",
            name: "Project", requestID: "request-1")),
        .castSubscribe(CastSubscribe(
            channels: ["composer.typing"],
            cursors: [CastCursor(originHostID: "host-1", channel: "composer.typing", afterSeq: 2)])),
        .composerTyping(WireComposerTyping(sessionID: sid, text: "draft", active: true)),
        .updateSettings(SyncedSettings(resolveApprovalsViaCloud: true)),
        .directOffer(directOffer),
        .directSelect(DirectSelect(ip: "127.0.0.1", port: 51900)),
        .agentLoginBegin(WireAgentLoginBegin(
            requestID: "request-1", provider: .codex, boundLoopbackPort: 1455)),
        .agentLoginCallback(WireAgentLoginCallback(
            requestID: "request-1", code: "code", state: "state")),
        .agentLoginCancel("request-1"),
    ]

    private static let hostMessages: [HostMsg] = [
        .welcome(Welcome(
            grantedScope: .control, host: HostInfo(hostID: "host-1", hostName: "Windows PC"),
            capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session]),
            channel: .canary, addresses: PeerAddresses(lanHint: "127.0.0.1:51900"),
            hostKind: "windows", settings: SyncedSettings(resolveApprovalsViaCloud: true))),
        .sessionList([summary]),
        .snapshot(SessionSnapshot(
            summary: summary, recentEvents: [event], pendingApprovals: [approval], cursor: 7,
            toolSummaries: [ToolSummaryLine(toolCallID: "tool-1", line: "Ran git status")])),
        .events(EventBatch(sessionID: sid, events: [event])),
        .approvalRequested(approval),
        .approvalResolved(ApprovalResolved(
            id: approvalID, decision: .allow(updatedInput: nil),
            decidedBy: "Windows PC", decidedAt: date)),
        .sessionUpdated(summary),
        .dashboard(.empty),
        .sessionDiff(WireSessionDiff(
            sessionID: sid, stat: DiffStat(filesChanged: 1, added: 2, removed: 3),
            files: [WireFileDiff(
                path: "Sources/App.swift", oldPath: nil, status: "modified", added: 2, removed: 3)],
            patch: "diff --git a/Sources/App.swift b/Sources/App.swift\n", truncated: false)),
        .peerList([PeerSummary(
            deviceID: "device-2", label: "Phone", kind: .iphone, online: true, lastSeenAt: date)]),
        .meshRoster(MeshRosterPush()),
        .transferAccept(TransferAccept(
            transferID: "transfer-1", resolvedProjectID: pid, haveSHAs: ["abc123"],
            resumeFrom: [.transcript: 1], alreadyHaveItems: [.nativeTranscript])),
        .transferReject(TransferReject(
            transferID: "transfer-1", reason: .branchCollision, message: "branch exists")),
        .transferReady("transfer-1"),
        .transferCommitted("transfer-1"),
        .transferChunkAck(TransferChunkAck(
            transferID: "transfer-1", kind: .transcript, chunkIndex: 0)),
        .transcriptChunk(TranscriptChunk(
            requestID: "request-1", sessionID: sid, batchIndex: 0, events: [event])),
        .transcriptFetchComplete(TranscriptFetchComplete(
            requestID: "request-1", sessionID: sid, throughSeq: 7,
            headerJSON: bytes, revertEpoch: 2)),
        .transcriptUnavailable(TranscriptUnavailable(
            requestID: "request-1", sessionID: sid, reason: .notHeld)),
        .carbonHeads(CarbonHeadsReply(requestID: "request-1", heads: [carbonHead])),
        .carbonHeadAdvanced(carbonHead),
        .carbonManifestData(carbonBlob),
        .carbonManifestUnavailable(carbonUnavailable),
        .carbonShardData(carbonBlob),
        .carbonShardUnavailable(carbonUnavailable),
        .carbonLockDecision(CarbonLockDecisionMsg(
            requestID: "request-1", projectUUID: uuid, domainRef: "main",
            sessionID: sid.rawValue, outcome: .granted)),
        .carbonLockLedgerAdvanced(carbonHead),
        .toolSummaries(ToolSummaryBatch(
            sessionID: sid, summaries: [ToolSummaryLine(toolCallID: "tool-1", line: "Ran git status")])),
        .transcriptReverted(TranscriptReverted(sessionID: sid, throughSeq: 4, revertEpoch: 2)),
        .relayMembership(WireRelayMembership(
            roomID: "room-1", token: "token", url: "https://relay.example", exp: 1234.5)),
        .pairingCode("nucleic://pair?d=payload"),
        .macPairRequested(WireMacPairRequest(deviceID: "device-2", label: "Laptop")),
        .macPairResolved("device-2", true),
        .intelligenceRequest(WireIntelligenceRequest(
            id: "request-1", kind: .classifyTurn, inputs: ["done"], context: "context",
            deadlineSeconds: 5)),
        .credentialNeeded(WireCredentialNeed(kinds: [.claudeOAuth], sealingPublicKey: bytes)),
        .credentialUpdate(SealedCredentialEnvelope(records: [])),
        .credentialRevoked([tombstone]),
        .projectCreated(WireProjectCreated(requestID: "request-1", projectID: pid, name: "Project")),
        .runnerPoolCredential(WireRunnerPoolCredential(
            poolID: "pool-1", secret: "secret", url: "https://runner.example", updatedAt: date)),
        .chatStarted(WireChatStarted(requestID: "request-1", sessionID: sid)),
        .casts(CastBatch(casts: [])),
        .castCatchUp(CastCatchUp(
            originHostID: "host-1", channel: "composer.typing", mode: .state,
            batchIndex: 0, casts: [], complete: true, tipSeq: 2, reset: true)),
        .settings(SyncedSettings(resolveApprovalsViaCloud: true)),
        .directAnswer(directOffer),
        .directGo,
        .directDecline("symmetric NAT"),
        .agentLoginChallenge(WireAgentLoginChallenge(
            requestID: "request-1", authorizeURL: "https://auth.example",
            capture: .loopback(port: 1455, path: "/auth/callback"), expiresAt: date)),
        .agentLoginResult(WireAgentLoginResult(
            requestID: "request-1", accountLabel: "user@example.com")),
        .agentAuthStatus([WireProviderAuthStatus(
            provider: .codex, name: "Codex", installed: true, authenticated: true,
            method: "oauth", accountLabel: "user@example.com", canBrokerLogin: true)]),
        .error(WireError(code: .backpressure, message: "busy", sessionID: sid)),
        .pong,
    ]
}

private extension Data {
    var hexString: String { map { String(format: "%02x", $0) }.joined() }
}
