import Foundation
import Testing

@testable import NucleicProtocol

/// The JSON view of the wire message set (docs/WINDOWS_PORT.md §6, work item 10).
///
/// `ProtocolJSON` has no per-case mapping to test — it rides `ClientMsg`/`HostMsg`'s existing
/// `Codable` conformance, the same one the CBOR wire codec drives, so per-message correctness is
/// true by construction and already covered by `WireMessageTests`. What these tests pin is the
/// short list of things that *are* choices: the coder strategies the C# DTOs
/// (`windows/NucleicProtocol.Interop`) will be written against, and the `"t"` discriminator they
/// switch on. Changing any of them should break here, loudly, rather than in a renderer.
@Suite struct ProtocolJSONTests {
    private let sid = SessionID(rawValue: "s1")

    // MARK: - Round trips

    @Test func clientMessageRoundTripsThroughJSON() throws {
        let msg = ClientMsg.subscribe(Subscribe(sessionID: sid, sinceSeq: 42, verbosity: .full))
        let json = try ProtocolJSON.encode(msg)
        #expect(try ProtocolJSON.decode(ClientMsg.self, from: json) == msg)
    }

    @Test func hostMessageRoundTripsThroughJSON() throws {
        let msg = HostMsg.error(WireError(code: .unknownSession, message: "gone", sessionID: sid))
        let json = try ProtocolJSON.encode(msg)
        #expect(try ProtocolJSON.decode(HostMsg.self, from: json) == msg)
    }

    @Test func promptRoutingDecisionRoundTripsAndRemainsBackwardCompatible() throws {
        let decision = PromptRoutingDecision(
            purpose: "debugging", level: 2, model: "gpt-5.6-sol", effort: "high",
            reason: "Debugging · Deep — selected for diagnosis")
        let chunk = TextChunk(
            messageID: "user-1", text: "Why does this crash?", isPartial: false,
            routing: decision)
        let encoded = try JSONEncoder().encode(chunk)
        #expect(try JSONDecoder().decode(TextChunk.self, from: encoded) == chunk)

        // Transcripts written before routing provenance existed have no `routing` key.
        let legacy = Data(#"{"messageID":"user-1","text":"hello","isPartial":false}"#.utf8)
        let decodedLegacy = try JSONDecoder().decode(TextChunk.self, from: legacy)
        #expect(decodedLegacy.routing == nil)
        #expect(decodedLegacy.text == "hello")
    }

    /// The C ABI hands C# a UTF-8 `String`, not `Data` — the string form must be the same bytes.
    @Test func stringAndDataFormsAgree() throws {
        let msg = ClientMsg.interrupt(sid)
        #expect(try ProtocolJSON.encodeToString(msg) == String(
            decoding: try ProtocolJSON.encode(msg), as: UTF8.self))
        #expect(try ProtocolJSON.decode(
            ClientMsg.self, fromString: ProtocolJSON.encodeToString(msg)) == msg)
    }

    // MARK: - The tag surface

    /// `tag(of:)` is a hand-written exhaustive switch; this is what keeps it honest against the
    /// `"t"` the encoder actually emits. A typo in one of the ~115 labels shows up here.
    @Test func tagMatchesTheEncodedDiscriminator() throws {
        func check(_ msg: ClientMsg) throws {
            #expect(try discriminator(of: ProtocolJSON.encode(msg)) == ProtocolJSON.tag(of: msg))
        }
        func check(_ msg: HostMsg) throws {
            #expect(try discriminator(of: ProtocolJSON.encode(msg)) == ProtocolJSON.tag(of: msg))
        }
        try check(ClientMsg.ping)
        try check(ClientMsg.listSessions)
        try check(ClientMsg.unsubscribe(sid))
        try check(ClientMsg.setFavorite(sid, true))
        try check(ClientMsg.markSessionDone(sid))
        try check(ClientMsg.hello(Hello(deviceID: "d", deviceLabel: "PC")))
        try check(HostMsg.pong)
        try check(HostMsg.directGo)
        try check(HostMsg.transferReady("t1"))
        try check(HostMsg.error(WireError(code: .backpressure, message: "x")))
    }

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

    // MARK: - Pinned coder decisions

    /// Dates cross as a `Double` of seconds since the 2001 reference date — the SAME number the
    /// CBOR wire carries (`CBOREncoder` leaves `Date` on its `Codable` default). Not ISO8601, not
    /// Unix epoch: a fixture pairs the two encodings, so they have to agree.
    @Test func datesEncodeAsReferenceDateSeconds() throws {
        let when = Date(timeIntervalSinceReferenceDate: 1234.5)
        let msg = HostMsg.approvalResolved(
            ApprovalResolved(
                id: ApprovalID(rawValue: "a1"), decision: .deny(reason: nil),
                decidedBy: "phone", decidedAt: when))
        let object = try JSONSerialization.jsonObject(with: ProtocolJSON.encode(msg))
        #expect((object as? [String: Any])?["decidedAt"] as? Double == 1234.5)
    }

    /// `Data` crosses as base64 — the one place the JSON view deliberately diverges from CBOR
    /// (which, having no `Data` special case, emits an array of byte-valued integers). A blob as
    /// thousands of JSON numbers would be both huge and hostile to `byte[]`.
    @Test func dataEncodesAsBase64() throws {
        let blob = Data([0xDE, 0xAD, 0xBE, 0xEF])
        let msg = HostMsg.carbonShardData(
            CarbonBlobData(requestID: "r1", projectUUID: UUID(), id: "sha", blob: blob))
        // Matched against the whole document rather than a key path: the payload nests under the
        // envelope's own key (`carbonBlob`), and this assertion is about the *encoding* of the
        // bytes, not about where they sit. A byte-array encoding could not contain this literal.
        let text = try ProtocolJSON.encodeToString(msg)
        #expect(text.contains("\"\(blob.base64EncodedString())\""))
        #expect(!text.contains("[222,173,190,239]"))
        // …and it decodes back to the same bytes, which is all round-tripping needs.
        let decoded = try ProtocolJSON.decode(HostMsg.self, from: ProtocolJSON.encode(msg))
        guard case .carbonShardData(let back) = decoded else {
            Issue.record("expected carbonShardData, got \(ProtocolJSON.tag(of: decoded))")
            return
        }
        #expect(back.blob == blob)
    }

    /// Sorted keys, so `fixtures/protocol-abi/` can be compared byte-for-byte rather than
    /// structurally — a diff on a fixture should be a real change, not a dictionary reordering.
    @Test func encodingIsDeterministic() throws {
        let msg = ClientMsg.hello(
            Hello(deviceID: "dev", deviceLabel: "PC", channel: .canary, deviceKind: "windows"))
        let first = try ProtocolJSON.encode(msg)
        for _ in 0..<8 { #expect(try ProtocolJSON.encode(msg) == first) }
        let text = String(decoding: first, as: UTF8.self)
        let keyOrder = ["deviceID", "deviceKind", "deviceLabel"]
        let positions = keyOrder.compactMap { text.range(of: "\"\($0)\"")?.lowerBound }
        #expect(positions.count == keyOrder.count)
        #expect(positions == positions.sorted())
    }

    /// Slashes stay unescaped, so a path in a fixture reads as a path.
    @Test func slashesAreNotEscaped() throws {
        let msg = ClientMsg.setSessionShipBranch(sid, "nucleic/auth-refactor")
        let text = try ProtocolJSON.encodeToString(msg)
        #expect(text.contains("nucleic/auth-refactor"))
        #expect(!text.contains("nucleic\\/auth-refactor"))
    }

    /// CBOR and JSON are two views of one value: whatever survives one survives the other. This
    /// is the property the golden fixtures generalize across every message kind.
    @Test func jsonAndCBORAgreeOnTheValue() throws {
        let msg = HostMsg.sessionUpdated(
            SessionSummary(
                sessionID: sid, projectID: "p1", projectName: "ProjA",
                backend: .claudeCode, status: .awaitingApproval, disposition: .completed,
                title: "auth-refactor", branch: "nucleic/auth", lastSeq: 120,
                diffStat: DiffStat(filesChanged: 3, added: 312, removed: 40),
                pendingApprovalCount: 1, favorite: true, archived: false,
                updatedAt: Date(timeIntervalSince1970: 1_700_000_000)))
        let viaJSON = try ProtocolJSON.decode(HostMsg.self, from: ProtocolJSON.encode(msg))
        let viaCBOR = try CBORDecoder().decode(HostMsg.self, from: CBOREncoder().encode(msg))
        #expect(viaJSON == msg)
        #expect(viaCBOR == msg)
        #expect(viaJSON == viaCBOR)
    }
}
