// FrameCodecTests.swift
// Unit tests for BinaryFrame, ClientToServer encoding, and ServerToClient decoding.
//
// All tests are pure (no network, no Starscream, no async).  FrameCodec.swift
// imports only Foundation, making this test target dependency-free.
//
// IC-1 spec reference: docs/PHASE-2-ios-mvp.md §Wire Protocol

import XCTest
@testable import piRemote

final class FrameCodecTests: XCTestCase {

    // =========================================================================
    // MARK: 1. BinaryFrame.decode
    // =========================================================================

    /// Well-formed binary frame: 8-byte big-endian seq = 1, payload = "hello".
    func testBinaryFrameDecode_knownBytes() throws {
        // seq = 1 in big-endian: 00 00 00 00 00 00 00 01
        let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]
        let payload   = Array("hello".utf8)
        let raw       = Data(seqBytes + payload)

        let frame = try XCTUnwrap(BinaryFrame.decode(raw))
        XCTAssertEqual(frame.seq,  1)
        XCTAssertEqual(frame.data, Data("hello".utf8))
    }

    /// UInt64.max must round-trip through the 8-byte big-endian header.
    func testBinaryFrameDecode_maxSeq() throws {
        let seqBytes: [UInt8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
        let raw = Data(seqBytes) // empty payload is valid

        let frame = try XCTUnwrap(BinaryFrame.decode(raw))
        XCTAssertEqual(frame.seq, UInt64.max)
        XCTAssertTrue(frame.data.isEmpty)
    }

    /// seq = 0x0000_0001_0000_0000 = 4_294_967_296 verifies big-endian byte order.
    func testBinaryFrameDecode_bigEndianOrdering() throws {
        let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
        let raw = Data(seqBytes + [0xAB, 0xCD])

        let frame = try XCTUnwrap(BinaryFrame.decode(raw))
        XCTAssertEqual(frame.seq,  4_294_967_296)
        XCTAssertEqual(frame.data, Data([0xAB, 0xCD]))
    }

    /// Frames shorter than 8 bytes must return nil (header incomplete).
    func testBinaryFrameDecode_tooShort_returnsNil() {
        XCTAssertNil(BinaryFrame.decode(Data([0x00, 0x01, 0x02])))
    }

    /// Empty data must also return nil (0 bytes < 8-byte minimum).
    func testBinaryFrameDecode_emptyData_returnsNil() {
        XCTAssertNil(BinaryFrame.decode(Data()))
    }

    /// Exactly 8 bytes is valid: empty payload, seq extracted from header.
    func testBinaryFrameDecode_exactlyEightBytes_emptyPayload() throws {
        // seq = 42 = 0x2A
        let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A]
        let frame = try XCTUnwrap(BinaryFrame.decode(Data(seqBytes)))
        XCTAssertEqual(frame.seq, 42)
        XCTAssertTrue(frame.data.isEmpty)
    }

    // =========================================================================
    // MARK: 2. ClientToServer JSON encoding — IC-1 field names
    // =========================================================================

    func testEncode_resume_nilLastSeq_producesNull() throws {
        // IC-1 requires {"type":"resume","lastSeq":null}
        let json = try FrameCodec.encode(.resume(lastSeq: nil))
        let obj  = try asDict(json)
        XCTAssertEqual(obj["type"] as? String, "resume", "type field must be 'resume'")
        XCTAssertTrue(obj["lastSeq"] is NSNull,
                      "lastSeq must be JSON null, got: \(String(describing: obj["lastSeq"]))")
    }

    func testEncode_resume_withLastSeq42() throws {
        // IC-1 requires {"type":"resume","lastSeq":42}
        let json = try FrameCodec.encode(.resume(lastSeq: 42))
        let obj  = try asDict(json)
        XCTAssertEqual(obj["type"]    as? String, "resume")
        XCTAssertEqual(obj["lastSeq"] as? Int,    42)
    }

    func testEncode_key_escape() throws {
        // IC-1 requires {"type":"key","name":"escape"}
        let json = try FrameCodec.encode(.key(name: "escape"))
        XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#,
                       "Exact IC-1 encoding expected")
    }

    func testEncode_keys_data() throws {
        // IC-1 requires {"type":"keys","data":"hello"}
        let json = try FrameCodec.encode(.keys(data: "hello"))
        let obj  = try asDict(json)
        XCTAssertEqual(obj["type"] as? String, "keys")
        XCTAssertEqual(obj["data"] as? String, "hello")
    }

    func testEncode_paste_data() throws {
        // IC-1 requires {"type":"paste","data":"text"}
        let json = try FrameCodec.encode(.paste(data: "text"))
        let obj  = try asDict(json)
        XCTAssertEqual(obj["type"] as? String, "paste")
        XCTAssertEqual(obj["data"] as? String, "text")
    }

    func testEncode_snapshotRequest() throws {
        // IC-1 requires {"type":"snapshot-request"}
        let json = try FrameCodec.encode(.snapshotRequest)
        XCTAssertEqual(json, #"{"type":"snapshot-request"}"#,
                       "Exact IC-1 encoding expected")
    }

    /// Multi-line paste must survive encoding without key truncation.
    func testEncode_paste_multilineData() throws {
        let json = try FrameCodec.encode(.paste(data: "line1\nline2"))
        let obj  = try asDict(json)
        XCTAssertEqual(obj["data"] as? String, "line1\nline2")
    }

    // =========================================================================
    // MARK: 3. ServerToClient JSON decoding
    // =========================================================================

    func testDecode_state_idle() throws {
        let payload = #"{"type":"state","value":"idle","ts":1716000000}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .state(let value, let tool, let ts) = frame else {
            return XCTFail("Expected .state, got \(frame)")
        }
        XCTAssertEqual(value, .idle)
        XCTAssertNil(tool)
        XCTAssertEqual(ts, 1_716_000_000)
    }

    func testDecode_state_thinking() throws {
        let payload = #"{"type":"state","value":"thinking","ts":1}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .state(let value, _, _) = frame else {
            return XCTFail("Expected .state, got \(frame)")
        }
        XCTAssertEqual(value, .thinking)
    }

    func testDecode_state_tool_withToolName() throws {
        let payload = #"{"type":"state","value":"tool","tool":"bash","ts":42}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .state(let value, let tool, let ts) = frame else {
            return XCTFail("Expected .state, got \(frame)")
        }
        XCTAssertEqual(value, .tool)
        XCTAssertEqual(tool,  "bash")
        XCTAssertEqual(ts,    42)
    }

    func testDecode_state_awaitingInput() throws {
        // IC-1 raw value is "awaiting-input" (hyphenated)
        let payload = #"{"type":"state","value":"awaiting-input","ts":0}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .state(let value, _, _) = frame else {
            return XCTFail("Expected .state, got \(frame)")
        }
        XCTAssertEqual(value, .awaitingInput,
                       "PiState must map 'awaiting-input' → .awaitingInput")
    }

    func testDecode_snapshot() throws {
        let payload = #"{"type":"snapshot","seq":1234,"data":"SGVsbG8="}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .snapshot(let seq, let data) = frame else {
            return XCTFail("Expected .snapshot, got \(frame)")
        }
        XCTAssertEqual(seq,  1234)
        XCTAssertEqual(data, "SGVsbG8=")
    }

    func testDecode_sessionMeta_withDescription() throws {
        let payload = #"{"type":"session-meta","name":"my-session","description":"A test session","createdAt":"2026-05-15T10:00:00Z"}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .sessionMeta(let name, let description, let createdAt) = frame else {
            return XCTFail("Expected .sessionMeta, got \(frame)")
        }
        XCTAssertEqual(name,        "my-session")
        XCTAssertEqual(description, "A test session")
        XCTAssertEqual(createdAt,   "2026-05-15T10:00:00Z")
    }

    func testDecode_sessionMeta_withoutDescription() throws {
        // description is optional per IC-1
        let payload = #"{"type":"session-meta","name":"bare","createdAt":"2026-01-01T00:00:00Z"}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .sessionMeta(let name, let description, _) = frame else {
            return XCTFail("Expected .sessionMeta, got \(frame)")
        }
        XCTAssertEqual(name, "bare")
        XCTAssertNil(description)
    }

    func testDecode_error() throws {
        let payload = #"{"type":"error","code":"auth_failed","message":"Invalid token"}"#
        let frame   = try FrameCodec.decode(payload)
        guard case .error(let code, let message) = frame else {
            return XCTFail("Expected .error, got \(frame)")
        }
        XCTAssertEqual(code,    "auth_failed")
        XCTAssertEqual(message, "Invalid token")
    }

    /// Unknown type keys must throw a `DecodingError`, not silently return garbage.
    func testDecode_unknownType_throws() {
        let payload = #"{"type":"tree","nodes":[]}"#
        XCTAssertThrowsError(try FrameCodec.decode(payload),
                             "Unknown type discriminator must throw")
    }

    /// Malformed JSON must throw.
    func testDecode_malformedJSON_throws() {
        XCTAssertThrowsError(try FrameCodec.decode("not json at all"))
    }

    // =========================================================================
    // MARK: 4. Round-trip: encode ClientToServer → decode shape check
    // =========================================================================

    /// Encode a .key frame and verify it can be re-parsed by JSONSerialization.
    func testRoundtrip_encode_isValidJSON() throws {
        let json = try FrameCodec.encode(.key(name: "up"))
        // Must not throw — i.e., the output is valid JSON.
        XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(json.utf8)))
    }

    // =========================================================================
    // MARK: Helpers
    // =========================================================================

    private func asDict(_ json: String) throws -> [String: Any] {
        let obj = try JSONSerialization.jsonObject(with: Data(json.utf8))
        return try XCTUnwrap(obj as? [String: Any],
                             "Expected top-level JSON object, got \(type(of: obj))")
    }
}
