import Foundation
import Testing

@testable import NucleicProtocol

/// `LiveActivitySnapshot` is the host's mirror of the phone's `NucleicSessionAttributes.ContentState`
/// — the APNs `content-state` this encodes to must decode into that iOS type. These tests pin the
/// exact JSON shape (field names + the `backend`/`kind` raw values) so a change on the host side
/// can't silently drift from the phone renderer.
@Suite struct LiveActivitySnapshotTests {
    private func summary(
        _ id: String, status: SessionStatus, disposition: TurnDisposition? = nil,
        backend: BackendID = .claudeCode, title: String = "T", project: String = "ProjA",
        approvals: Int = 0, questions: Int? = nil,
        firstApprovalID: String? = nil, firstApprovalIsHighRisk: Bool? = nil,
        diff: DiffStat? = nil, updated: TimeInterval = 1_700_000_000
    ) -> SessionSummary {
        SessionSummary(
            sessionID: SessionID(rawValue: id), projectID: "p1", projectName: project,
            backend: backend, status: status, disposition: disposition, title: title,
            branch: "b", lastSeq: 1, diffStat: diff, pendingApprovalCount: approvals,
            pendingQuestionCount: questions,
            firstApprovalID: firstApprovalID.map(ApprovalID.init(rawValue:)),
            firstApprovalIsHighRisk: firstApprovalIsHighRisk,
            updatedAt: Date(timeIntervalSince1970: updated))
    }

    @Test func jsonShapeMatchesContentState() throws {
        let snap = LiveActivitySnapshot(
            runningCount: 2, needsYouCount: 1, approvalCount: 3,
            filesChanged: 4, linesAdded: 40, linesRemoved: 7,
            lines: [.init(id: "s1", title: "Auth", project: "ProjA",
                          backend: "claude", kind: "approval", detail: "3 to approve")])
        let enc = JSONEncoder()
        enc.outputFormatting = [.sortedKeys]
        let obj = try JSONSerialization.jsonObject(
            with: enc.encode(snap)) as! [String: Any]
        // Exactly the keys the phone's ContentState decodes.
        #expect(Set(obj.keys) == [
            "runningCount", "needsYouCount", "approvalCount",
            "filesChanged", "linesAdded", "linesRemoved", "lines",
        ])
        let line = (obj["lines"] as! [[String: Any]])[0]
        // A row with no inline approval omits the optional keys (byte-compatible with older phones).
        #expect(Set(line.keys) == ["id", "title", "project", "backend", "kind", "detail"])
    }

    @Test func awaitingApprovalRowCarriesTheApprovalIDAndRisk() throws {
        // The oldest pending approval's id + risk ride the row so the glance's inline Allow/Deny know
        // which one to resolve; a high-risk one is flagged so the surface hides inline Allow.
        let snap = try #require(LiveActivitySnapshot.build(from: [
            summary("appr", status: .awaitingApproval, approvals: 1,
                    firstApprovalID: "ap-9", firstApprovalIsHighRisk: true),
        ]))
        let line = try #require(snap.lines.first)
        #expect(line.approvalID == "ap-9")
        #expect(line.approvalIsHighRisk == true)
        // The keys must match the phone's `SessionLine` so a pushed content-state decodes there.
        let obj = try JSONSerialization.jsonObject(with: JSONEncoder().encode(snap)) as! [String: Any]
        let json = (obj["lines"] as! [[String: Any]])[0]
        #expect(json["approvalID"] as? String == "ap-9")
        #expect(json["approvalIsHighRisk"] as? Bool == true)

        // A running session carries no inline approval even if one is somehow attached — only the
        // awaiting-approval row renders buttons, so other rows stay plain (id omitted).
        let running = try #require(LiveActivitySnapshot.build(from: [
            summary("run", status: .running, firstApprovalID: "ap-1"),
        ]))
        #expect(running.lines.first?.approvalID == nil)

        // An AskUserQuestion block (pendingQuestionCount set) is excluded from inline resolution —
        // it needs an answer selection the glance can't collect, so the row deep-links to the app.
        let question = try #require(LiveActivitySnapshot.build(from: [
            summary("q", status: .awaitingApproval, approvals: 1, questions: 2, firstApprovalID: "ap-q"),
        ]))
        #expect(question.lines.first?.approvalID == nil)
    }

    @Test func buildOrdersAttentionFirstAndAggregates() throws {
        // An approval outranks a running session; counts + churn sum across the active set.
        let snap = LiveActivitySnapshot.build(from: [
            summary("run", status: .running, diff: DiffStat(filesChanged: 2, added: 10, removed: 1),
                    updated: 2_000),
            summary("appr", status: .awaitingApproval, approvals: 2,
                    diff: DiffStat(filesChanged: 1, added: 5, removed: 0), updated: 1_000),
            summary("done", status: .awaitingInput, disposition: .completed, updated: 3_000),
        ])
        let s = try #require(snap)
        #expect(s.runningCount == 1)          // "run"
        #expect(s.needsYouCount == 1)         // "appr"
        #expect(s.approvalCount == 2)
        #expect(s.filesChanged == 3)          // run(2) + appr(1); "done" excluded — not active
        #expect(s.lines.first?.id == "appr")  // attention-first
        #expect(s.lines.first?.kind == "approval")
        #expect(s.lines.first?.detail == "2 to approve")
    }

    @Test func buildReturnsNilWhenNothingActive() {
        // Only a completed/idle session → no glance (host pushes an "end").
        #expect(LiveActivitySnapshot.build(from: [
            summary("done", status: .awaitingInput, disposition: .completed),
            summary("idle", status: .idle),
        ]) == nil)
    }

    @Test func backendAndKindTagsAreStable() {
        let claude = LiveActivitySnapshot.build(from: [summary("a", status: .running, backend: .claudeCode)])
        #expect(claude?.lines.first?.backend == "claude")
        let codex = LiveActivitySnapshot.build(from: [summary("b", status: .running, backend: .codex)])
        #expect(codex?.lines.first?.backend == "codex")
        let grok = LiveActivitySnapshot.build(from: [summary("c", status: .provisioning, backend: .grok)])
        #expect(grok?.lines.first?.backend == "grok")
        #expect(grok?.lines.first?.kind == "provisioning")
    }

    @Test func attentionSignatureIgnoresChurnButCatchesStatus() throws {
        // Same session running, only the diff churn ticks up (a mid-turn transcript edit) — the
        // signature must be unchanged so no Live Activity push fires.
        let a = try #require(LiveActivitySnapshot.build(from: [
            summary("run", status: .running, diff: DiffStat(filesChanged: 1, added: 3, removed: 0)),
        ]))
        let b = try #require(LiveActivitySnapshot.build(from: [
            summary("run", status: .running, diff: DiffStat(filesChanged: 9, added: 99, removed: 42)),
        ]))
        #expect(a != b)                                        // the churn *did* change the snapshot…
        #expect(a.attentionSignature == b.attentionSignature)  // …but not the push-worthy signature

        // A real status change (running → needs approval) must flip the signature.
        let c = try #require(LiveActivitySnapshot.build(from: [
            summary("run", status: .awaitingApproval, approvals: 1),
        ]))
        #expect(a.attentionSignature != c.attentionSignature)
    }

    @Test func registerCapabilityRoundTrips() throws {
        // The new ClientMsg cases + capability bit survive a CBOR round-trip.
        let reg = ClientMsg.registerLiveActivity("deadbeef", "activity-1")
        let back = try CBORDecoder().decode(ClientMsg.self, from: CBOREncoder().encode(reg))
        #expect(back == reg)
        let end = ClientMsg.endLiveActivity("activity-1")
        #expect(try CBORDecoder().decode(ClientMsg.self, from: CBOREncoder().encode(end)) == end)
        let start = ClientMsg.registerPushToStartToken("cafef00d")
        #expect(try CBORDecoder().decode(ClientMsg.self, from: CBOREncoder().encode(start)) == start)
        let caps = WireCapabilities(
            canModifyToolInput: true, allowAlwaysScopes: [],
            canPushLiveActivity: true, canPushToStartLiveActivity: true)
        let capsBack = try CBORDecoder().decode(WireCapabilities.self, from: CBOREncoder().encode(caps))
        #expect(capsBack.canPushLiveActivity)
        #expect(capsBack.canPushToStartLiveActivity)
        // A host that predates the bit decodes it as false (gate skipped), not a decode error.
        let old = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [], canPushLiveActivity: true)
        #expect(try CBORDecoder().decode(
            WireCapabilities.self, from: CBOREncoder().encode(old)).canPushToStartLiveActivity == false)
    }
}
