import Foundation
import Testing

@testable import NucleicProtocol

@Suite struct CastWireTests {
    private func roundTrip<T: Codable & Equatable>(_ value: T) throws -> T {
        try CBORDecoder().decode(T.self, from: CBOREncoder().encode(value))
    }

    private let cast = WireCast(
        originHostID: String(repeating: "ab", count: 32), channel: "activity", castSeq: 7,
        mode: .stream, key: nil, typeID: "nucleic.feed.event.v1",
        payload: Data([0x01, 0x02, 0x03]), deleted: false,
        sentAt: Date(timeIntervalSince1970: 1_700_000_000))

    @Test func castRoundTrips() throws {
        #expect(try roundTrip(cast) == cast)
        let stateCast = WireCast(
            originHostID: cast.originHostID, channel: "runner.presence", castSeq: 3,
            mode: .state, key: cast.originHostID, typeID: "nucleic.runner.presence.v1",
            payload: Data(), deleted: true, sentAt: cast.sentAt)
        #expect(try roundTrip(stateCast) == stateCast)
    }

    @Test func clientAndHostMessagesRoundTrip() throws {
        let sub = CastSubscribe(
            channels: ["activity"],
            cursors: [CastCursor(originHostID: cast.originHostID, channel: "activity", afterSeq: 4)])
        if case .castSubscribe(let decoded) = try roundTrip(ClientMsg.castSubscribe(sub)) {
            #expect(decoded == sub)
        } else {
            Issue.record("castSubscribe did not round-trip")
        }

        let batch = CastBatch(casts: [cast])
        #expect(try roundTrip(HostMsg.casts(batch)) == .casts(batch))

        let catchUp = CastCatchUp(
            originHostID: cast.originHostID, channel: "activity", mode: .stream,
            batchIndex: 0, casts: [cast], complete: true, tipSeq: 7, reset: true)
        #expect(try roundTrip(HostMsg.castCatchUp(catchUp)) == .castCatchUp(catchUp))
    }

    /// The locked forward-compat rules: fields this change added decode with defaults when a
    /// legacy peer omits them, and an unknown future `CastMode` raw value survives a round trip.
    @Test func legacyTolerance() throws {
        // `WireCapabilities` without `canCast` (a pre-cast host) decodes false.
        let legacyCaps = try JSONDecoder().decode(WireCapabilities.self, from: Data("""
            {"canModifyToolInput": true, "allowAlwaysScopes": []}
            """.utf8))
        #expect(legacyCaps.canCast == false)

        // `WireClientCapabilities` without `canCast` (a pre-cast client) decodes false.
        let legacyClient = try JSONDecoder().decode(WireClientCapabilities.self, from: Data("""
            {"mesh": 1}
            """.utf8))
        #expect(legacyClient.canCast == false)

        // A `WireCast` without the optional `deleted` decodes false; without `key` nil.
        let legacyCast = try JSONDecoder().decode(WireCast.self, from: Data("""
            {"originHostID": "o", "channel": "c", "castSeq": 1, "mode": "stream",
             "typeID": "t", "payload": "", "sentAt": 1000}
            """.utf8))
        #expect(legacyCast.deleted == false)
        #expect(legacyCast.key == nil)

        // A `CastCatchUp` without `reset` decodes false; a `CastSubscribe` without cursors
        // decodes empty (subscribe-from-zero).
        let legacyCatchUp = try JSONDecoder().decode(CastCatchUp.self, from: Data("""
            {"originHostID": "o", "channel": "c", "mode": "state", "batchIndex": 0,
             "casts": [], "complete": true, "tipSeq": 0}
            """.utf8))
        #expect(legacyCatchUp.reset == false)
        let bareSubscribe = try JSONDecoder().decode(CastSubscribe.self, from: Data("{}".utf8))
        #expect(bareSubscribe.cursors.isEmpty)
        #expect(bareSubscribe.channels == nil)

        // An unknown future mode round-trips by raw value instead of throwing.
        let future = CastMode(rawValue: "compactedStream")
        #expect(try roundTrip(future) == future)
    }

    @Test func feedEventAndMessagePayloadsRoundTrip() throws {
        let feed = FeedEvent(
            kind: .needsApproval, sessionID: SessionID(rawValue: "s1"),
            sessionTitle: "auth refactor", projectName: "ProjA", hostName: "Studio",
            detail: "Run `npm test`", approvalID: ApprovalID(rawValue: "a1"),
            at: Date(timeIntervalSince1970: 1_700_000_000))
        #expect(try roundTrip(feed) == feed)
        // An unknown future kind decodes to its raw value (renders generically, never drops).
        #expect(FeedEvent.Kind(rawValue: "somethingNew").rawValue == "somethingNew")

        let message = MeshMessage(
            fromSessionID: SessionID(rawValue: "s1"), fromSessionTitle: "builder",
            fromHostName: "Studio", topic: "builds", body: "artifacts ready",
            at: Date(timeIntervalSince1970: 1_700_000_000))
        #expect(try roundTrip(message) == message)
    }

    @Test func castLedgerDedupsCompactsAndCursors() {
        let origin = String(repeating: "aa", count: 32)
        func cast(_ seq: UInt64, channel: String = "activity", key: String? = nil, mode: CastMode = .stream) -> WireCast {
            WireCast(
                originHostID: origin, channel: channel, castSeq: seq, mode: mode, key: key,
                typeID: "t", payload: Data(), sentAt: Date(timeIntervalSince1970: Double(seq)))
        }
        var ledger = CastLedger()
        // Fresh casts apply once; a duplicate identity (same cast via a second Mac) is dropped.
        #expect(ledger.apply([cast(1), cast(2)]).count == 2)
        #expect(ledger.apply([cast(2)]).isEmpty)
        #expect(ledger.cursors == [CastCursor(originHostID: origin, channel: "activity", afterSeq: 2)])
        // State channels compact to latest-per-key.
        ledger.apply([
            cast(1, channel: "runner.presence", key: "h1", mode: .state),
            cast(2, channel: "runner.presence", key: "h1", mode: .state),
        ])
        #expect(ledger.casts(channel: "runner.presence").map(\.castSeq) == [2])
        // A reset catch-up replaces the (origin, channel) wholesale.
        ledger.applyCatchUp(CastCatchUp(
            originHostID: origin, channel: "activity", mode: .stream, batchIndex: 0,
            casts: [cast(9)], complete: true, tipSeq: 9, reset: true))
        #expect(ledger.casts(channel: "activity").map(\.castSeq) == [9])
    }

    @Test func activityFeedMergeDedupsSortsAndCaps() throws {
        let origin = String(repeating: "aa", count: 32)
        func feedCast(_ seq: UInt64, at: Double) throws -> WireCast {
            let event = FeedEvent(
                kind: .finished, sessionID: SessionID(rawValue: "s\(seq)"), sessionTitle: "t",
                projectName: "p", hostName: "h", at: Date(timeIntervalSince1970: at))
            return WireCast(
                originHostID: origin, channel: MeshCastChannel.activity, castSeq: seq,
                mode: .stream, typeID: FeedEvent.typeID,
                payload: try #require(event.encodedCastPayload()),
                sentAt: Date(timeIntervalSince1970: at))
        }
        let items = ActivityFeedItem.merged(
            [], adding: [try feedCast(2, at: 200), try feedCast(1, at: 100)])
        #expect(items.map(\.castSeq) == [1, 2])  // sorted by event time
        // Re-adding the same identities changes nothing; a non-activity cast never decodes in.
        let again = ActivityFeedItem.merged(items, adding: [try feedCast(2, at: 200)])
        #expect(again == items)
        let other = WireCast(
            originHostID: origin, channel: "messages", castSeq: 3, mode: .stream,
            typeID: "x", payload: Data(), sentAt: Date(timeIntervalSince1970: 1))
        #expect(ActivityFeedItem.merged(items, adding: [other]) == items)
        // The cap keeps the newest.
        let capped = ActivityFeedItem.merged(
            items, adding: [try feedCast(3, at: 300)], cap: 2)
        #expect(capped.map(\.castSeq) == [2, 3])
    }

    @Test func runnerPresenceRoundTripsAndSkipsUnknownBackends() throws {
        let presence = RunnerPresence(
            hostName: "runner-1", kind: .cloud, canRunAgents: true, acceptsMeshDispatch: true,
            activeSessionCount: 2, maxConcurrent: 8, backends: [.claudeCode, .codex],
            projects: [ProjectDescriptor(
                projectID: ProjectID(rawValue: "p1"), name: "ProjA",
                rootCommitSHA: "abc", normalizedRemote: "github.com/x/y", defaultBranch: "main")],
            canCreateProjects: true, updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
        #expect(try roundTrip(presence) == presence)

        // A future backend id is skipped, not fatal — the rest of the card stays usable.
        let decoded = try JSONDecoder().decode(RunnerPresence.self, from: Data("""
            {"hostName": "r", "kind": "cloud", "canRunAgents": true,
             "acceptsMeshDispatch": true, "activeSessionCount": 0,
             "backends": ["claudeCode", "futureAgent9000"], "projects": [],
             "updatedAt": 1000}
            """.utf8))
        #expect(decoded.backends == [.claudeCode])
    }
}
