import Foundation
import Network
import VoiceCatCore

struct PendingIdentity: Identifiable {
    let id = UUID()
    let displayText: String
    let tofuStatus: VoiceCatTofuStatus
}

@Observable
@MainActor
final class AppState {
    var servers: [SavedServer] = ServerListStore.shared.load()
    var session: SessionState?

    // Connect-flow state
    var isConnecting = false
    var connectStatus = ""
    var showAddServer = false
    var editingServer: SavedServer?
    var showPasswordPrompt = false
    var pendingIdentity: PendingIdentity?

    private var connectingClient: VoiceCatClient?
    private(set) var connectingServer: SavedServer?
    private var identityHandled = false

    /// The server we are currently fully connected to. Set on auth success (when `session` is
    /// created) and cleared on teardown. Used to build the `lastSession` restore snapshot when a
    /// live-session disconnect fires through `SessionState.handleEvent` — `connectingServer` is
    /// already nil by then, and `handleConnectEvent`'s `server` parameter is out of scope because
    /// `SessionState` owns `client.onEvent` after auth success (see `SessionState.init`).
    private var connectedServer: SavedServer?

    // MARK: - Reconnect state
    //
    // The C core surfaces every unexpected connection drop as a `.disconnected` event; nothing
    // in the C core auto-reconnects (intentional — reconnect UX is the client's job). Two layers
    // drive iOS reconnect:
    //
    //   1. **Event-driven** (the C core's TCP read eventually fails after the keepalive/reaper
    //      timeout, ~30-60 s on a hard Wi-Fi drop): `SessionState.handleEvent` `.disconnected`
    //      plays the audible cue, then calls back into AppState via `onLiveSessionDisconnected`,
    //      which snapshots the live session, tears it down, and arms `scheduleReconnect`.
    //      (Live-session events never reach `AppState.handleConnectEvent` — `SessionState.init`
    //      overwrites `client.onEvent`, so `AppState` cannot see them without the callback.)
    //
    //   2. **Path-driven** (proactive, much faster): `NWPathMonitor` runs the whole time we are
    //      connected (started on auth success) and reacts to network changes — a Wi-Fi↔cellular
    //      flip or the path becoming `.unsatisfied` calls `proactiveReconnect`, which tears the
    //      live session down BEFORE the C core notices the dead TCP path. This is what makes the
    //      30-60 s wait collapse into ~1 s + the backoff tick. While mid-reconnect (no session)
    //      the same monitor arms a fast-fresh retry whenever a path becomes `.satisfied`.
    //
    // Manual Disconnect cancels everything (task + path monitor) and clears `lastSession`.

    /// True at the top of `disconnect()`/`cancelConnect()` — suppresses auto-reconnect for the
    /// `.disconnected` event the core then emits in response to our `vc_disconnect()` call.
    private var userInitiatedDisconnect = false

    /// Snapshot of the live session state needed to restore after a reconnect. Cleared on
    /// successful restore and on user-initiated disconnect.
    private struct LastSession {
        let server: SavedServer
        let channelId: UInt32
        let voiceSubscribed: Bool
        let micMuted: Bool
        let deafened: Bool
    }
    private var lastSession: LastSession?

    /// Reconnect attempt counter — drives exponential backoff. Reset to 0 on successful auth and
    /// on a path-driven fast-fresh retry.
    private var reconnectAttempt = 0

    /// The in-flight reconnect `Task` (sleeps for the backoff, then calls `connectTo`). One
    /// at a time; cancelled on user disconnect / successful restore.
    private var reconnectTask: Task<Void, Never>?

    /// Started on auth success and kept running while connected / mid-reconnect; stopped only on
    /// user-initiated disconnect. Its `pathUpdateHandler` (dispatched to @MainActor) handles two
    /// cases: a path change while connected → proactive reconnect; a satisfied path while
    /// mid-reconnect → fast-fresh retry. See the reconnect-state header comment.
    private var pathMonitor: NWPathMonitor?
    private let pathQueue = DispatchQueue(label: "cat.voice.network.path")

    /// Signature of the last path seen by the monitor (a stable string encoding status + active
    /// interface types). The very first path callback (when the monitor starts) sets this and is
    /// otherwise ignored — it's the baseline; only subsequent CHANGES are reconnect triggers.
    private var lastPathSignature: String?

    // MARK: - Server list management

    func addServer(_ server: SavedServer, password: String?) {
        if let pw = password, !pw.isEmpty {
            ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
        }
        servers.append(server)
        ServerListStore.shared.save(servers)
    }

    func updateServer(_ server: SavedServer, password: String?) {
        if let pw = password, !pw.isEmpty {
            ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
        }
        if let idx = servers.firstIndex(where: { $0.id == server.id }) {
            servers[idx] = server
        }
        ServerListStore.shared.save(servers)
    }

    func removeServer(_ server: SavedServer) {
        ServerListStore.shared.deletePassword(tag: server.keychainTag)
        servers.removeAll(where: { $0.id == server.id })
        ServerListStore.shared.save(servers)
    }

    // MARK: - Connect flow

    /// Public connect entry. Always starts a fresh session (no restore).
    func connectTo(_ server: SavedServer) {
        connectTo(server, restoring: nil)
    }

    /// Internal connect that drives the full TLS/auth saga. `restoring` is non-nil for a
    /// reconnect attempt following an unexpected disconnect; the captured channel + voice/mic
    /// state is handed to the new `SessionState` after auth succeeds.
    private func connectTo(_ server: SavedServer, restoring: LastSession?) {
        guard !isConnecting else { return }
        isConnecting = true
        connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
        connectingServer = server
        identityHandled = false
        userInitiatedDisconnect = false

        // Discard any leftover connecting client. nil'ing the strong ref calls VoiceCatClient's
        // deinit, which synchronously joins the C core's io thread (vc_client_destroy) before
        // freeing the config-string storage — safe from @MainActor because the io thread never
        // blocks on main (it enqueues events via DispatchQueue.main.async and returns).
        connectingClient = nil

        let config = VoiceCatConfig(
            clientName: "VoiceCat-iOS",
            clientVersion: "0.0.1",
            logLevel: .info,
            tofuStorePath: ServerListStore.shared.tofuStorePath)
        let client = VoiceCatClient(config: config)
        connectingClient = client

        client.onEvent = { [weak self] ev in
            Task { @MainActor [weak self] in
                self?.handleConnectEvent(ev, server: server, restoring: restoring)
            }
        }
        // Put the core into external-playback mode BEFORE connect, so the flag is set on the
        // io thread before any message is processed. The server sends AuthResult immediately
        // followed by ServerStateSnapshot; handle_server_state runs ensure_audio_running() on
        // the io thread, and if external_playback_ were still false at that point the core
        // would open a hardware miniaudio playback+capture device (see the matching fix in
        // vc_client::ensure_audio_running). Setting it here — before connect — guarantees the
        // unified external path is in effect from the first frame. setExternalPlayback only
        // flips an atomic + forwards to the engine's setter; both are safe pre-connect.
        client.setExternalPlayback(true)
        client.connect(host: server.host, port: server.port)

        // Auth is queued immediately — the core serialises it behind TLS + TOFU. On a reconnect
        // the TOFU pin already matches (VC_TOFU_MATCHED), so the identity gate auto-confirms
        // inside the .serverIdentity case below and auth proceeds unattended.
        switch server.authMode {
        case .guest:
            let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
            client.authenticateGuest(nick)
        case .password:
            let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
            if let pw = savedPw, !pw.isEmpty {
                client.authenticateUser(server.savedUsername, password: pw)
            } else {
                showPasswordPrompt = true
            }
        }
    }

    func disconnect() {
        // Mark BEFORE we ask the core to disconnect, so the .disconnected event the core emits
        // in response is treated as user-initiated (no reconnect) rather than an unexpected drop.
        userInitiatedDisconnect = true
        cancelReconnect()
        lastSession = nil
        session?.leaveVoice()
        session?.client.disconnect()
        IOSAudioEngine.shared.stop()
        AudioSessionManager.shared.deactivateSession()
        session = nil
        connectingClient?.disconnect()
        connectingClient = nil
        connectingServer = nil
        connectedServer = nil
        isConnecting = false
        connectStatus = ""
        showPasswordPrompt = false
        pendingIdentity = nil
    }

    // MARK: - Auth actions (called from prompt sheets)

    func authenticateUser(username: String, password: String) {
        connectingClient?.authenticateUser(username, password: password)
        showPasswordPrompt = false
    }

    func confirmServerIdentity(accept: Bool) {
        connectingClient?.confirmServerIdentity(accept: accept)
        pendingIdentity = nil
        if !accept { cancelConnect() }
    }

    func cancelConnect() {
        // User explicitly cancelled — no reconnect for the resulting .disconnected event.
        userInitiatedDisconnect = true
        cancelReconnect()
        lastSession = nil
        connectingClient?.disconnect()
        connectingClient = nil
        connectingServer = nil
        connectedServer = nil
        isConnecting = false
        connectStatus = ""
        showPasswordPrompt = false
        pendingIdentity = nil
    }

    // MARK: - Reconnect orchestration

    /// Cancel any in-flight reconnect task and stop the path monitor. Safe to call when nothing
    /// is armed (no-op). Does NOT touch `userInitiatedDisconnect` or `lastSession` — callers set
    /// those as needed (disconnect/cancelConnect clear them; scheduleReconnect keeps them).
    ///
    /// `AppState` is the @Observable app root owned by the SwiftUI `App`; it lives for the
    /// whole app process and is torn down only on process exit, at which point OS cleanup
    /// suffices. This method is driven by `disconnect()`/`cancelConnect()` and on successful
    /// restore — those run on user-initiated teardown, which is the only path that matters.
    /// (The reconnect `Task` captures `[weak self]` and guards on `nil`/`userInitiatedDisconnect`,
    /// so a stray task left running when AppState is gone is a no-op; the monitor similarly guards.)
    private func cancelReconnect() {
        reconnectTask?.cancel()
        reconnectTask = nil
        stopPathMonitor()
        // Don't reset `reconnectAttempt` here: scheduleReconnect resets it on successful auth,
        // and the path monitor resets it to 0 for a fast fresh attempt on a path-satisfied event.
        // If a fresh user connect follows, connectTo() doesn't reset it either, but it doesn't
        // need to — `reconnectAttempt` only matters while we're mid-reconnect.
    }

    /// Arm the next reconnect attempt with exponential backoff (1s → 2s → 4s → 8s → 16s → 30s
    /// cap). Cancelled cleanly by `cancelReconnect()` on user disconnect or successful auth.
    /// Idempotent: a new call supersedes any in-flight one. The path monitor is armed here and
    /// disarmed on cancel; on a path-satisfied event it resets the attempt counter to 0 and
    /// re-arms via this same method, yielding a fast refresh after Wi-Fi ↔ cellular transitions.
    private func scheduleReconnect() {
        guard !userInitiatedDisconnect, let last = lastSession else { return }
        reconnectTask?.cancel()
        reconnectAttempt = max(1, reconnectAttempt + 1)
        let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0)
        connectStatus = "Reconnecting (attempt \(reconnectAttempt))…"

        startPathMonitor()

        let task = Task { [weak self, last] in
            guard let self else { return }
            try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000))
            if Task.isCancelled { return }
            // Re-check under Task: a user disconnect between the sleep and this line must abort.
            guard !self.userInitiatedDisconnect else { return }
            guard self.lastSession != nil else { return }
            guard self.session == nil else { return }
            self.connectTo(last.server, restoring: last)
        }
        reconnectTask = task
    }

    /// Start (if not already running) the network path monitor. Runs the whole time we are
    /// connected (started on auth success) and stays armed across reconnects; stopped only on
    /// user-initiated disconnect. The handler dispatches to @MainActor before touching state and
    /// does two distinct things:
    ///   - **While connected** (`session != nil`): a Wi-Fi↔cellular interface change OR the path
    ///     becoming `.unsatisfied` triggers `proactiveReconnect()` — tearing the live session
    ///     down before the C core notices the dead TCP read. Without this the disconnect would
    ///     take 30-60 s (the TCP keepalive/reaper timeout); proactive teardown collapses that to
    ///     ~1 s + the first backoff tick. Same-interface path refreshes (e.g. a Wi-Fi roam
    ///     without an IP change) are ignored — likely the connection is still good.
    ///   - **While mid-reconnect** (`session == nil`, `lastSession != nil`): a path becoming
    ///     `.satisfied` arms a fast-fresh retry (backoff counter reset, `scheduleReconnect`).
    ///     This is what makes a Wi-Fi→cellular flip reconnect on roughly the next tick instead
    ///     of waiting out a long backoff.
    private func startPathMonitor() {
        guard pathMonitor == nil else { return }
        let monitor = NWPathMonitor()
        monitor.pathUpdateHandler = { [weak self] path in
            Task { @MainActor [weak self] in
                guard let self else { return }
                guard !self.userInitiatedDisconnect else { return }
                let sig = Self.pathSignature(path)
                let prevSig = self.lastPathSignature
                self.lastPathSignature = sig
                // The first callback (when the monitor starts) is the baseline, not a change.
                if prevSig == nil { return }

                if self.session != nil {
                    // See this method's doc comment for why these conditions trigger a
                    // proactive reconnect.
                    if path.status != .satisfied || sig != prevSig {
                        self.proactiveReconnect()
                    }
                } else if self.lastSession != nil {
                    if path.status == .satisfied {
                        self.reconnectAttempt = 0
                        self.scheduleReconnect()
                    }
                }
            }
        }
        // Listen on a dedicated queue — the path monitor can't share the main queue (it would
        // re-enter main if any handler dispatched to main synchronously).
        monitor.start(queue: pathQueue)
        pathMonitor = monitor
    }

    private func stopPathMonitor() {
        pathMonitor?.cancel()
        pathMonitor = nil
        lastPathSignature = nil
    }

    /// A stable string signature of a network path: the path status plus the set of interface
    /// types it uses. Two paths with the same signature are treated as equivalent — no
    /// reconnect. A signature change is the trigger for `proactiveReconnect`. Used to ignore
    /// same-interface refreshes (signal-strength changes, BSSID roams) which usually don't break
    /// the TCP connection.
    private static func pathSignature(_ path: NWPath) -> String {
        guard path.status == .satisfied else { return "unsatisfied" }
        var parts: [String] = []
        if path.usesInterfaceType(.wifi)           { parts.append("wifi") }
        if path.usesInterfaceType(.cellular)       { parts.append("cellular") }
        if path.usesInterfaceType(.wiredEthernet)  { parts.append("wired") }
        if path.usesInterfaceType(.other)          { parts.append("other") }
        return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+")
    }

    // MARK: - Live-session disconnect (called by SessionState)

    /// Called by `SessionState.handleEvent` `.disconnected` after the audible cue has already
    /// played. Once `SessionState` is created (auth success) it owns `client.onEvent`, so
    /// `AppState.handleConnectEvent` never sees live-session events — this callback is the only
    /// way AppState learns that a live session dropped. Snapshots the live session state,
    /// tears the session down, and arms `scheduleReconnect` so the backoff loop drives a fresh
    /// TLS/auth/restoration. Guarded against user-initiated disconnect (which nil's `session`
    /// synchronously, so SessionState is gone before the event could fire this callback) — but
    /// the guard is cheap insurance.
    func onLiveSessionDisconnected() {
        guard !userInitiatedDisconnect else { return }
        teardownLiveSessionAndReconnect(sound: false)
    }

    /// Called by `NWPathMonitor` when a path change is detected while a live session exists.
    /// Tears the session down immediately — nil'ing `session` releases `VoiceCatClient`, whose
    /// `deinit` calls `vc_client_destroy`; that closes the socket and joins the C core's io
    /// thread, so the io thread exits in milliseconds rather than blocking on a dead read for
    /// ~30-60 s. The proactive tear-down is what collapses the long TCP-reaper wait into a
    /// ~1 s reconnect. Plays the audible cue (no `.disconnected` event fires through to
    /// `SessionState` for this path, since `SessionState` is being torn down here — so the cue
    /// would otherwise be missing).
    private func proactiveReconnect() {
        guard !userInitiatedDisconnect else { return }
        guard session != nil else { return }
        teardownLiveSessionAndReconnect(sound: true)
    }

    /// Shared teardown for a live-session disconnect (event- or path-driven). Snapshots the live
    /// session into `lastSession`, stops audio, deactivates the AVAudioSession, releases the
    /// session (which releases `VoiceCatClient` → io-thread join), resets the backoff counter,
    /// and arms `scheduleReconnect`. `sound` is true for the proactive (path-driven) case — the
    /// `.disconnected` event that would have played it never fires because we're tearing down
    /// ahead of the C core noticing. The event-driven caller (`onLiveSessionDisconnected`) has
    /// ALREADY played the cue via `SessionState.handleEvent`, so it passes `sound: false`.
    private func teardownLiveSessionAndReconnect(sound: Bool) {
        // Snapshot BEFORE nil'ing `session` — we need the channel + voice/mic state to restore.
        if let s = session, let srv = connectedServer {
            lastSession = LastSession(
                server: srv,
                channelId: s.currentChannelId,
                voiceSubscribed: s.voiceState.voiceSubscribed,
                micMuted: s.voiceState.selfMuted,
                deafened: s.voiceState.selfDeafened)
        }
        IOSAudioEngine.shared.stop()
        AudioSessionManager.shared.deactivateSession()
        // Releasing `session` releases `VoiceCatClient`; its deinit joins the C core's io thread.
        // For a path-driven proactive teardown this is what avoids the 30-60 s reaper timeout.
        session = nil
        isConnecting = false
        connectingClient = nil
        connectedServer = nil
        if sound {
            EventFeedback.shared.play(.connectionLost)
            EventFeedback.shared.speak("Network changed — reconnecting")
        }
        // Reset the backoff counter so the first reconnect attempt after a drop uses the short
        // 1 s delay (the immediate path-driven attempt matters most; sustained-outage backoff is
        // driven by `scheduleReconnect`'s increment).
        reconnectAttempt = 0
        scheduleReconnect()
    }

    // MARK: - Connect event handler

    private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
                                    restoring: LastSession?) {
        switch ev.type {
        case .connectionState:
            switch ev.connectionState {
            case .connecting:        connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
            case .tlsHandshake:      connectStatus = "TLS handshake…"
            case .authenticating:    connectStatus = "Authenticating…"
            case .verifyingIdentity: connectStatus = "Verifying server identity…"
            case .connected:        connectStatus = "Connected"
            default: break
            }
        case .serverIdentity:
            guard !identityHandled else { break }
            let tofuStatus = ev.tofuStatus ?? .firstConnect
            if tofuStatus == .matched {
                connectingClient?.confirmServerIdentity(accept: true)
            } else {
                identityHandled = true
                let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
                pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
            }
        case .authResult:
            if ev.result == .ok {
                guard let client = connectingClient else { break }
                let perms = client.getPermissions()
                let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
                newSession.appState = self
                connectingClient = nil
                isConnecting = false
                connectStatus = ""
                showPasswordPrompt = false
                connectedServer = server
                self.session = newSession
                EventFeedback.shared.play(.login)
                EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
                // External-playback mode was enabled before connect() so the core never opens a
                // miniaudio device on iOS (the single ordering rule of the unified audio path).
                // Now activate the session and start the engine in listening mode so remote audio
                // plays the moment someone talks, even before we join voice (no "can't hear anyone").
                do {
                    try AudioSessionManager.shared.ensureSessionActive()
                } catch {
                    print("Audio session activate on connect failed: \(error)")
                }
                IOSAudioEngine.shared.startListening(client: client)
                // The path monitor runs the whole time we're connected so a network change fires
                // proactiveReconnect immediately instead of waiting for the C core's TCP keepalive
                // timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is
                // stopped only on user-initiated disconnect.
                startPathMonitor()

                // Reconnect restore: rejoin the prior channel and re-enable voice/mic if they
                // were on. The session is fresh (server auto-places us in Lobby), so the restore
                // is driven through SessionState.requestRestore, which issues a JoinChannel then
                // (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth
                // means the server is reachable, so the backoff counter resets and `lastSession`
                // clears; the path monitor keeps watching for the next change.
                if let restoring {
                    newSession.requestRestore(channelId: restoring.channelId,
                                              voiceSubscribed: restoring.voiceSubscribed,
                                              micMuted: restoring.micMuted,
                                              deafened: restoring.deafened)
                    reconnectAttempt = 0
                    lastSession = nil
                }
            } else {
                connectStatus = "Auth failed: \(ev.result.description)"
                showPasswordPrompt = true
            }
        case .disconnected:
            // This handler runs ONLY during the connecting phase — after auth success
            // `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect
            // reaches `SessionState.handleEvent` and comes back via
            // `onLiveSessionDisconnected`, not here. Two outcomes for this branch:
            //   - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior
            //     teardown) → re-arm `scheduleReconnect` so the backoff loop continues.
            //   - A fresh connect failed before auth (`lastSession == nil`) → show the error, do
            //     not auto-reconnect (the user should retry manually once the server is reachable).
            connectingClient = nil
            isConnecting = false
            IOSAudioEngine.shared.stop()
            AudioSessionManager.shared.deactivateSession()

            if userInitiatedDisconnect {
                connectStatus = ""
                showPasswordPrompt = false
                pendingIdentity = nil
                lastSession = nil
                connectedServer = nil
                cancelReconnect()
            } else if lastSession != nil {
                // Mid-reconnect drop — keep the backoff loop going.
                EventFeedback.shared.play(.connectionLost)
                EventFeedback.shared.speak("Connection lost — reconnecting")
                scheduleReconnect()
            } else {
                // Fresh connect failed before auth. Surface the reason; no auto-reconnect.
                connectStatus = ev.text ?? "Disconnected"
                showPasswordPrompt = false
                pendingIdentity = nil
                connectedServer = nil
                cancelReconnect()
            }
        case .error:
            connectStatus = ev.text ?? "Unknown error"
            // Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
        default:
            break
        }
    }
}
