import Foundation
import Network
import SwiftUI
import NucleicProtocol

/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
typealias WireSessionSummary = NucleicProtocol.SessionSummary

/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
/// session's transcript/approvals. No canonical state is invented on-device.
@MainActor
final class RemoteStore: ObservableObject {
    enum Connectivity: Equatable {
        case unpaired
        case connecting
        case reconnecting
        case connected          // LAN
        case hostOffline
        case failed(String)

        var label: String {
            switch self {
            case .unpaired: "Not paired"
            case .connecting: "Connecting…"
            case .reconnecting: "Reconnecting…"
            case .connected: "Connected · LAN"
            case .hostOffline: "Mac offline"
            case .failed(let m): m
            }
        }
        var isLive: Bool { self == .connected }
    }

    @Published private(set) var connectivity: Connectivity = .unpaired
    @Published private(set) var hostName: String = ""
    @Published private(set) var sessions: [WireSessionSummary] = []
    @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
    @Published private(set) var grantedScope: DeviceScope = .approve
    /// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
    /// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
    @Published private(set) var modelCatalog: WireModelCatalog = .empty

    /// Home / Projects / To-Dos state — the dashboard projection.
    @Published private(set) var dashboard = DashboardSnapshot.empty

    // Open session projection.
    @Published private(set) var openSessionID: SessionID?
    @Published private(set) var openEvents: [AgentEvent] = []
    @Published private(set) var openApprovals: [ApprovalRequest] = []

    /// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
    var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }

    /// Sessions needing a human (drives the app-icon badge + NEEDS YOU section). Uses turn
    /// disposition so a finished-the-work session doesn't count, and excludes archived.
    var needsYouCount: Int {
        liveSessions.filter { $0.status.needsYou($0.disposition) }.count
    }

    var canControl: Bool { grantedScope >= .control }

    private let identity = IdentityStore.loadOrCreateIdentity()
    let discovery = LANDiscovery()
    private var client: SyncClient?
    private var eventTask: Task<Void, Never>?
    private var seenSeq: Set<UInt64> = []
    private var reconnectAttempts = 0

    /// Offline UI-preview mode (set `NUCLEIC_DEMO=1`): seeds mock state so the surfaces render
    /// without a paired Mac. No effect on real use.
    private let demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"

    var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
    var deviceFingerprint: String { identity.fingerprint }

    // MARK: - Lifecycle

    func onAppear() {
        if demoMode { seedDemo(); return }
        discovery.start()
        if isPaired { reconnect() }
    }

    private func seedDemo() {
        connectivity = .connected
        hostName = "Andrew's Mac"
        grantedScope = .control
        capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
        modelCatalog = WireModelCatalog(
            groups: [
                [WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
                                        contextBadge: "1M", contextWindow: 1_000_000,
                                        efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
                 WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
                                        contextBadge: nil, contextWindow: 200_000,
                                        efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
                [WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
                                        contextBadge: nil, contextWindow: 350_000,
                                        efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
                [WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
                                        contextBadge: nil, contextWindow: 256_000,
                                        efforts: ["auto"], effortNoun: "Reasoning")],
            ],
            effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
            orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
            fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
        let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
        func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
                 _ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
                 fav: Bool = false, arch: Bool = false, add: Int = 0, rem: Int = 0) -> WireSessionSummary {
            WireSessionSummary(
                sessionID: SessionID(rawValue: id), projectID: project.rawValue, projectName: name,
                backend: .claudeCode, status: status, disposition: disp, title: title,
                branch: "nucleic/\(id)", lastSeq: 10,
                diffStat: add + rem > 0 ? DiffStat(filesChanged: 2, added: add, removed: rem) : nil,
                pendingApprovalCount: approvals, favorite: fav, archived: arch,
                updatedAt: Date())
        }
        sessions = [
            sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
            sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
            sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
            sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
            sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
        ]
        let cal = Calendar.current
        let today = cal.startOfDay(for: Date())
        let activity = (0..<40).map { i -> ActivityDay in
            let count = (i * 7) % 6
            // Sample tokens roughly track messages so the preview grid shades by usage.
            return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
                               count: count, tokens: count * 8_500 + (i * 137) % 4_000)
        }
        dashboard = DashboardSnapshot(
            counts: DashboardCounts(
                projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
            activity: activity,
            projects: [
                WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
                WireProject(id: p2, name: "website", defaultBranch: "main", sessionCount: 2, activeCount: 1),
            ],
            todos: [
                WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
                         projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil, updatedAt: Date()),
                WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
                         projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil, updatedAt: Date()),
                WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
                         projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil, updatedAt: Date()),
            ])
    }

    /// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
    /// and on success pin the host key for future IK reconnects.
    func pair(with payload: PairingPayload) {
        teardown()
        connectivity = .connecting
        hostName = payload.hostName
        let deviceID = IdentityStore.deviceID()
        guard let endpoint = resolveEndpoint(
            fingerprint: payload.hostStaticKey.fingerprintHex,
            lanHost: payload.lanHost, lanPort: payload.lanPort)
        else { connectivity = .failed("No Mac found on this network"); return }

        let channel = makeChannel(endpoint)
        let client = SyncClient(
            channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
            mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
            deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex)
        self.client = client
        consume(client, pairingPayload: payload)
    }

    /// Reconnect to the already-paired host using IK against the pinned static key.
    func reconnect() {
        guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
        teardown()
        connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
        hostName = host.hostName
        guard let endpoint = resolveEndpoint(
            fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
        else { connectivity = .hostOffline; scheduleRetry(); return }

        let channel = makeChannel(endpoint)
        let client = SyncClient(
            channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
            mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name,
            pushToken: PushRegistrar.shared.tokenHex)
        self.client = client
        consume(client, pairingPayload: nil)
    }

    func unpair() {
        teardown()
        IdentityStore.clearPairedHost()
        connectivity = .unpaired
        sessions = []
    }

    // MARK: - Intents (UX_IOS §9)

    func open(_ sessionID: SessionID) {
        openSessionID = sessionID
        openEvents = []
        openApprovals = []
        seenSeq.removeAll()
        if demoMode { seedDemoTranscript(sessionID); return }
        send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
    }

    /// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
    /// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
    private func seedDemoTranscript(_ sessionID: SessionID) {
        func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
            AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
                       nativeType: nil, kind: kind)
        }
        openEvents = [
            event(1, .sessionStarted(SessionStarted(
                backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
            event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
            event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
            event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
            event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
            event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
            event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
            event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
            event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
            event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
            event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
            event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
            event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
            event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
            event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
        ]
    }

    func closeOpen() {
        if let id = openSessionID { send(.unsubscribe(id)) }
        openSessionID = nil
        openEvents = []
        openApprovals = []
    }

    func respond(_ approval: ApprovalRequest, _ decision: Decision) {
        send(.approvalRespond(approval.id, decision))
        openApprovals.removeAll { $0.id == approval.id }  // optimistic dismiss; host confirms
    }

    func sendInput(_ text: String, to sessionID: SessionID) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        send(.sendInput(sessionID, AgentInput(text: trimmed)))
    }

    func refreshSessions() { send(.listSessions); send(.listDashboard) }

    // MARK: - Control intents (control scope; the same actions the Mac can take)

    func startChat(in projectID: ProjectID, message: String, model: String? = nil,
                   effort: String? = nil, baseBranch: String? = nil,
                   useWorktree: Bool = true, auto: Bool? = nil) {
        let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else { return }
        send(.startChat(StartChatRequest(
            projectID: projectID, message: text, model: model, effort: effort,
            baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
    }

    func captureTodo(_ text: String, projectID: ProjectID?) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        send(.captureTodo(CaptureTodoRequest(text: trimmed, projectID: projectID)))
    }

    func dispatchTodo(_ id: TodoID, in projectID: ProjectID) { send(.dispatchTodo(id, projectID)) }
    func completeTodo(_ id: TodoID) { send(.setTodoStatus(id, .done)) }
    func deleteTodo(_ id: TodoID) { send(.deleteTodo(id)) }

    func renameSession(_ id: SessionID, to title: String) {
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        send(.renameSession(id, trimmed))
    }
    func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
    func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
    func deleteSession(_ id: SessionID) {
        send(.deleteSession(id))
        if id == openSessionID { closeOpen() }
    }
    func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
    func interrupt(_ id: SessionID) { send(.interrupt(id)) }

    // Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
    // `nil` model/effort resets the session to the host/app default.
    func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
    func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
    func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
    func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
    func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }

    // MARK: - Plumbing

    private func send(_ msg: ClientMsg) {
        guard let client else { return }
        Task { await client.send(msg) }
    }

    private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
        NWFrameChannel(endpoint: endpoint)
    }

    private func resolveEndpoint(fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
        discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort)
    }

    private func consume(_ client: SyncClient, pairingPayload: PairingPayload?) {
        eventTask = Task { [weak self] in
            let stream = await client.start()
            for await event in stream {
                await self?.handle(event, pairingPayload: pairingPayload)
            }
        }
    }

    private func handle(_ event: SyncClient.Event, pairingPayload: PairingPayload?) async {
        switch event {
        case .connecting:
            break
        case .ready(let welcome):
            reconnectAttempts = 0
            connectivity = .connected
            hostName = welcome.host.hostName
            capabilities = welcome.capabilities
            grantedScope = welcome.grantedScope
            modelCatalog = welcome.modelCatalog
            if let payload = pairingPayload, let hostKey = await client?.hostKey() {
                IdentityStore.savePairedHost(PairedHost(
                    deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
                    hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
                    lanHost: payload.lanHost, lanPort: payload.lanPort))
            }
            send(.listSessions)
            send(.listDashboard)
            if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
        case .sessionList(let list):
            sessions = list
        case .sessionUpdated(let summary):
            if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
            else { sessions.append(summary) }
        case .dashboard(let snapshot):
            dashboard = snapshot
        case .snapshot(let snapshot):
            guard snapshot.summary.sessionID == openSessionID else { break }
            seenSeq = Set(snapshot.recentEvents.map(\.seq))
            openEvents = snapshot.recentEvents
            openApprovals = snapshot.pendingApprovals
        case .events(let batch):
            guard batch.sessionID == openSessionID else { break }
            for e in batch.events where !seenSeq.contains(e.seq) {
                seenSeq.insert(e.seq)
                openEvents.append(e)
            }
        case .approvalRequested(let req):
            if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
                openApprovals.append(req)
            }
        case .approvalResolved(let resolved):
            openApprovals.removeAll { $0.id == resolved.id }
        case .wireError:
            break  // surfaced contextually by callers; not fatal
        case .failed(let message):
            connectivity = .failed(message)
            scheduleRetry()
        case .closed:
            if connectivity == .connected { connectivity = .reconnecting }
            scheduleRetry()
        }
    }

    private func scheduleRetry() {
        guard isPaired else { return }
        reconnectAttempts += 1
        let delay = min(Double(reconnectAttempts) * 1.5, 10)
        Task { [weak self] in
            try? await Task.sleep(for: .seconds(delay))
            guard let self, self.connectivity != .connected else { return }
            self.reconnect()
        }
    }

    private func teardown() {
        eventTask?.cancel()
        eventTask = nil
        if let client { Task { await client.disconnect() } }
        client = nil
    }
}

extension Data {
    /// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
    var fingerprintHex: String {
        DeviceIdentity.fingerprint(ofStaticKey: self)
    }
}
