import Foundation
import Testing

@testable import NucleicProtocol

/// Wire + ledger behavior for mesh composer streaming (the "composer.typing" state channel):
/// the `ComposerTypingState` payload, the `ClientMsg.composerTyping` relay verb, the
/// `canStreamComposer` capability gate, and the lock/unlock lifecycle through `CastLedger`.
@Suite struct ComposerTypingTests {
    private func roundTrip<T: Codable & Equatable>(_ value: T) throws -> T {
        try CBORDecoder().decode(T.self, from: CBOREncoder().encode(value))
    }

    private let origin = String(repeating: "ab", count: 32)
    private let sessionID = SessionID(rawValue: "s1")

    @Test func payloadRoundTripsAndTruncates() throws {
        let state = ComposerTypingState(
            sessionID: sessionID, deviceID: origin, deviceName: "Blake's iPhone",
            text: "let me also add a test for", at: Date(timeIntervalSince1970: 1_700_000_000))
        #expect(try roundTrip(state) == state)

        // The streamed draft is a preview, not a transfer: init truncates past the cap.
        let long = ComposerTypingState(
            sessionID: sessionID, deviceID: origin, deviceName: "Studio",
            text: String(repeating: "x", count: ComposerTypingState.maxTextChars + 500),
            at: Date(timeIntervalSince1970: 0))
        #expect(long.text.count == ComposerTypingState.maxTextChars)
    }

    @Test func payloadDecodesThroughCastEnvelope() throws {
        let state = ComposerTypingState(
            sessionID: sessionID, deviceID: origin, deviceName: "Studio",
            text: "draft", at: Date(timeIntervalSince1970: 1_700_000_000))
        let cast = WireCast(
            originHostID: origin, channel: MeshCastChannel.composerTyping, castSeq: 1,
            mode: .state, key: sessionID.rawValue, typeID: ComposerTypingState.typeID,
            payload: try #require(state.encodedCastPayload()), sentAt: state.at)
        #expect(ComposerTypingState.decode(cast) == state)
        // A different typeID is simply not this feature's cast.
        let foreign = WireCast(
            originHostID: origin, channel: MeshCastChannel.composerTyping, castSeq: 2,
            mode: .state, key: sessionID.rawValue, typeID: "nucleic.other.v1",
            payload: try #require(state.encodedCastPayload()), sentAt: state.at)
        #expect(ComposerTypingState.decode(foreign) == nil)
    }

    @Test func freshnessGuardsTheLock() {
        let published = Date(timeIntervalSince1970: 1_700_000_000)
        let state = ComposerTypingState(
            sessionID: sessionID, deviceID: origin, deviceName: "Studio",
            text: "draft", at: published)
        #expect(state.isFresh(at: published))
        #expect(state.isFresh(at: published.addingTimeInterval(ComposerTypingState.staleTimeout - 1)))
        // At (or past) the stale deadline the entry no longer locks — the crash guard for a
        // typer that vanished without its tombstone.
        #expect(!state.isFresh(at: published.addingTimeInterval(ComposerTypingState.staleTimeout)))
    }

    @Test func composerTypingClientMsgRoundTrips() throws {
        let typing = WireComposerTyping(sessionID: sessionID, text: "half a thou", active: true)
        if case .composerTyping(let decoded) = try roundTrip(ClientMsg.composerTyping(typing)) {
            #expect(decoded == typing)
        } else {
            Issue.record("composerTyping did not round-trip")
        }

        // The stop report (send / idle) round-trips too.
        let stop = WireComposerTyping(sessionID: sessionID, text: "", active: false)
        if case .composerTyping(let decoded) = try roundTrip(ClientMsg.composerTyping(stop)) {
            #expect(decoded == stop)
        } else {
            Issue.record("composerTyping stop did not round-trip")
        }
    }

    @Test func legacyTolerance() throws {
        // A host that predates composer streaming never advertises it.
        let legacyCaps = try JSONDecoder().decode(WireCapabilities.self, from: Data("""
            {"canModifyToolInput": true, "allowAlwaysScopes": []}
            """.utf8))
        #expect(legacyCaps.canStreamComposer == false)

        // A minimal report decodes with safe defaults (no text, not active).
        let bare = try JSONDecoder().decode(WireComposerTyping.self, from: Data("""
            {"sessionID": "s1"}
            """.utf8))
        #expect(bare.text.isEmpty)
        #expect(bare.active == false)
    }

    @Test func channelPolicyIsStateScopedPerSession() {
        let policy = CastChannelRegistry.policy(for: MeshCastChannel.composerTyping)
        #expect(policy.mode == .state)
        // The cap comfortably fits a max-length draft plus the envelope, but stays a signal.
        #expect(policy.maxPayloadBytes <= 64 * 1024)
    }

    /// The lock/unlock lifecycle through the client-side ledger: successive drafts compact to
    /// the latest per session, and the tombstone (send / idle) drops the key — unlocking.
    @Test func ledgerLocksThenUnlocksOnTombstone() throws {
        func typingCast(_ seq: UInt64, text: String, deleted: Bool = false) throws -> WireCast {
            let state = ComposerTypingState(
                sessionID: sessionID, deviceID: origin, deviceName: "Studio",
                text: text, at: Date(timeIntervalSince1970: Double(seq)))
            return WireCast(
                originHostID: origin, channel: MeshCastChannel.composerTyping, castSeq: seq,
                mode: .state, key: sessionID.rawValue, typeID: ComposerTypingState.typeID,
                payload: try #require(state.encodedCastPayload()), deleted: deleted,
                sentAt: state.at)
        }

        var ledger = CastLedger()
        ledger.apply([try typingCast(1, text: "hel"), try typingCast(2, text: "hello wor")])
        let locked = ledger.stateValues(channel: MeshCastChannel.composerTyping)
        #expect(locked.count == 1)
        let latest = try #require(locked[sessionID.rawValue])
        #expect(ComposerTypingState.decode(latest)?.text == "hello wor")

        // The tombstone compacts the key away and is itself filtered from state values.
        ledger.apply([try typingCast(3, text: "", deleted: true)])
        #expect(ledger.stateValues(channel: MeshCastChannel.composerTyping).isEmpty)
    }
}
