import AVFAudio
import CallKit
import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging
import Intents
import os
import PushKit
import SQLite3
import SwiftUI
import UserNotifications

private extension Logger {
    static let voip = Logger(subsystem: "com.rosetta.messenger", category: "VoIP")
}

// MARK: - Firebase AppDelegate

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate,
    MessagingDelegate
{
    /// Pending chat route from notification tap — consumed by ChatListView.
    /// Handles terminated app (notification posted before ChatListView exists)
    /// and background app (didBecomeActiveNotification fallback).
    /// Timestamp prevents stale routes from being consumed on tab switches.
    static var pendingChatRoute: ChatRoute?
    static var pendingChatRouteTimestamp: Date?

    /// Max age (seconds) for a pending route to be considered fresh.
    static let pendingRouteExpirySeconds: TimeInterval = 3.0

    /// Foreground transition timestamp — suppresses notification sounds for 3 seconds
    /// after entering foreground to prevent FCM burst vibrations.
    private static var lastForegroundTransitionTime: Date?

    /// Consume pending notification route only if it was set recently.
    /// Returns the route if fresh (< `pendingRouteExpirySeconds`), nil otherwise.
    /// Always clears both statics regardless of freshness.
    static func consumeFreshPendingRoute() -> ChatRoute? {
        defer {
            pendingChatRoute = nil
            pendingChatRouteTimestamp = nil
        }
        guard let route = pendingChatRoute,
              let ts = pendingChatRouteTimestamp,
              Date().timeIntervalSince(ts) < pendingRouteExpirySeconds else {
            return nil
        }
        return route
    }

    /// PushKit registry — must be retained for VoIP push token delivery.
    private var voipRegistry: PKPushRegistry?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        if UITestLaunchConfiguration.isVoiceRecordingFixtureEnabled {
            return true
        }

        FirebaseApp.configure()

        // Set delegates
        Messaging.messaging().delegate = self
        UNUserNotificationCenter.current().delegate = self

        // Register notification category with CarPlay support.
        let messageCategory = UNNotificationCategory(
            identifier: "message",
            actions: [],
            intentIdentifiers: [],
            options: [.allowInCarPlay]
        )
        UNUserNotificationCenter.current().setNotificationCategories([messageCategory])

        // Track foreground transitions to suppress notification sound bursts.
        // FCM delivers queued pushes all at once on foreground — each triggers willPresent.
        // Sound is suppressed for 3 seconds after transition, banners still shown.
        NotificationCenter.default.addObserver(
            forName: UIApplication.willEnterForegroundNotification,
            object: nil,
            queue: .main
        ) { _ in
            AppDelegate.lastForegroundTransitionTime = Date()
        }

        // Clear caches on memory pressure to prevent system from killing the app.
        NotificationCenter.default.addObserver(
            forName: UIApplication.didReceiveMemoryWarningNotification,
            object: nil,
            queue: .main
        ) { _ in
            Task { @MainActor in
                AvatarRepository.shared.clearCache()
            }
        }

        // Request notification permission (including CarPlay display).
        // .carPlay enables "Show in CarPlay" toggle in Settings > Notifications > Rosetta.
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .badge, .sound, .carPlay]
        ) { granted, _ in
            if granted {
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            }
        }

        // Register for VoIP push notifications (PushKit).
        // Apple requires CallKit integration: every VoIP push MUST result in
        // reportNewIncomingCall or the app gets terminated.
        let registry = PKPushRegistry(queue: .main)
        registry.delegate = self
        registry.desiredPushTypes = [.voIP]
        voipRegistry = registry

        return true
    }

    // Forward APNs token to Firebase Messaging + SessionManager
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        Messaging.messaging().apnsToken = deviceToken
    }

    // MARK: - Data-Only Push (Server parity: type/from/dialog fields)

    /// Server sends data-only push (`content-available: 1`) with custom fields:
    /// - `type`: `personal_message` | `group_message` | `read`
    /// - `from`: sender public key (personal) or group ID (group)
    /// - `dialog`: filled only for `type=read` — the dialog that was read on another device
    ///
    /// See `MessageDispatcher.java` in server for push payload construction.
    /// Android parity: 10-second dedup window per sender.
    private static var lastNotifTimestamps: [String: TimeInterval] = [:]
    private static let dedupWindowSeconds: TimeInterval = 10

    func application(
        _ application: UIApplication,
        didReceiveRemoteNotification userInfo: [AnyHashable: Any],
        fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
    ) {
        let pushType = userInfo["type"] as? String ?? ""
        let appState = application.applicationState
        Logger.voip.info("Push received: type=\(pushType, privacy: .public) appState=\(appState.rawValue, privacy: .public)")

        // MARK: type=read — clear notifications for dialog (read on another device).
        // Handle even in foreground: if user reads on Desktop, phone clears its notifications.
        if pushType == "read" {
            handleReadPush(userInfo: userInfo, completionHandler: completionHandler)
            return
        }

        // MARK: type=call — incoming call wake-up (high priority, no badge).
        // Server sends this when someone calls and the recipient's WebSocket is not connected.
        // In foreground: skip (CallManager handles calls via WebSocket protocol).
        // In background: show notification so user can tap to open app and receive the call.
        if pushType == "call" {
            guard application.applicationState != .active else {
                completionHandler(.noData)
                return
            }
            handleCallPush(userInfo: userInfo, completionHandler: completionHandler)
            return
        }

        // For message notifications, skip in both foreground states (.active and .inactive).
        // Only create local notifications when truly in .background (NSE might not have run).
        // .inactive occurs during app transition (e.g., tapping a notification) — creating
        // local notifications here causes duplicate sounds.
        guard application.applicationState == .background else {
            completionHandler(.noData)
            return
        }

        let shared = UserDefaults(suiteName: "group.com.rosetta.dev")

        // MARK: Sender identification
        // Server sends `dialog` = sender public key (personal_message) or group ID (group_message).
        let senderKey = userInfo["dialog"] as? String
            ?? Self.extractSenderKey(from: userInfo)

        // Resolve sender display name from App Group cache (synced by DialogRepository).
        let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
        let senderName = contactNames[senderKey]
            ?? Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"])
            ?? "Rosetta"

        // Android parity: 10-second dedup per sender.
        let dedupKey = senderKey.isEmpty ? "__no_sender__" : senderKey
        let now = Date().timeIntervalSince1970
        if let lastTs = Self.lastNotifTimestamps[dedupKey], now - lastTs < Self.dedupWindowSeconds {
            completionHandler(.noData)
            return
        }
        Self.lastNotifTimestamps[dedupKey] = now

        // Check if the server already sent a visible alert (aps.alert exists)
        // or if NSE already handled this push (mutable-content: 1).
        let aps = userInfo["aps"] as? [String: Any]
        let hasVisibleAlert = aps?["alert"] != nil
        let hasMutableContent: Bool = {
            if let mc = aps?["mutable-content"] as? Int { return mc == 1 }
            if let mc = aps?["mutable-content"] as? Bool { return mc }
            return false
        }()

        // Don't notify for muted chats.
        let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys")
            ?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
        let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)

        // If server sent visible alert or mutable-content, NSE handles everything.
        // Don't create duplicate local notification — prevents sound/vibration spam.
        if hasVisibleAlert || hasMutableContent || isMuted {
            completionHandler(.newData)
            return
        }

        // MARK: Increment badge + create local notification
        let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
        let newBadge = currentBadge + 1
        shared?.set(newBadge, forKey: "app_badge_count")
        UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
        UNUserNotificationCenter.current().setBadgeCount(newBadge)

        let messageText = Self.firstNonBlank(userInfo, keys: [
            "message_preview", "message", "text", "body"
        ]) ?? "New message"

        let content = UNMutableNotificationContent()
        content.title = senderName
        content.body = messageText
        content.sound = .default
        content.badge = NSNumber(value: newBadge)
        content.categoryIdentifier = "message"
        content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]

        // Communication Notification via INSendMessageIntent (CarPlay + Focus parity).
        let handle = INPersonHandle(value: senderKey, type: .unknown)
        let sender = INPerson(
            personHandle: handle,
            nameComponents: nil,
            displayName: senderName,
            image: nil,
            contactIdentifier: nil,
            customIdentifier: senderKey
        )
        let intent = INSendMessageIntent(
            recipients: nil,
            outgoingMessageType: .outgoingMessageText,
            content: messageText,
            speakableGroupName: nil,
            conversationIdentifier: senderKey,
            serviceName: "Rosetta",
            sender: sender,
            attachments: nil
        )
        let interaction = INInteraction(intent: intent, response: nil)
        interaction.direction = .incoming
        interaction.donate(completion: nil)

        let finalContent: UNNotificationContent
        if let updated = try? content.updating(from: intent) {
            finalContent = updated
        } else {
            finalContent = content
        }

        let request = UNNotificationRequest(
            identifier: "msg_\(senderKey)_\(Int(now))",
            content: finalContent,
            trigger: nil
        )
        UNUserNotificationCenter.current().add(request) { _ in
            completionHandler(.newData)
        }
    }

    // MARK: - Read Push Handler

    /// Handles `type=read` push: clears delivered notifications for the specified dialog.
    /// Server sends this to the READER's other devices when they read a dialog on Desktop/Android.
    /// `dialog` field = opponent public key (personal) or group ID (may have `#group:` prefix).
    private func handleReadPush(
        userInfo: [AnyHashable: Any],
        completionHandler: @escaping (UIBackgroundFetchResult) -> Void
    ) {
        guard var dialogKey = userInfo["dialog"] as? String, !dialogKey.isEmpty else {
            completionHandler(.noData)
            return
        }

        // Strip #group: prefix — notification userInfo stores raw group ID.
        if dialogKey.hasPrefix("#group:") {
            dialogKey = String(dialogKey.dropFirst("#group:".count))
        }

        // Desktop-active suppression: mark this dialog as "recently read on another device".
        // NSE checks this flag — if a new message arrives for the same dialog within 30s,
        // it suppresses the notification (user is actively reading on Desktop).
        // NOTE: When server sends mutable-content:1 for READ, NSE also writes this flag.
        // Both writes are idempotent (same dialogKey → same timestamp). Badge decrement
        // is safe: NSE removes notifications first, AppDelegate finds 0 remaining → no double-decrement.
        if let shared = UserDefaults(suiteName: "group.com.rosetta.dev") {
            let now = Date().timeIntervalSince1970
            var recentlyRead = shared.dictionary(forKey: "nse_recently_read_dialogs") as? [String: Double] ?? [:]
            recentlyRead[dialogKey] = now
            // Evict stale entries (> 60s) to prevent unbounded growth.
            recentlyRead = recentlyRead.filter { now - $0.value < 60 }
            shared.set(recentlyRead, forKey: "nse_recently_read_dialogs")
        }

        let center = UNUserNotificationCenter.current()
        center.getDeliveredNotifications { delivered in
            let idsToRemove = delivered
                .filter { notification in
                    let key = notification.request.content.userInfo["sender_public_key"] as? String ?? ""
                    return key == dialogKey
                }
                .map { $0.request.identifier }

            if !idsToRemove.isEmpty {
                center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
            }

            // Decrement badge by the number of cleared notifications.
            let clearedCount = idsToRemove.count
            if clearedCount > 0 {
                let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
                let current = shared?.integer(forKey: "app_badge_count") ?? 0
                let newBadge = max(current - clearedCount, 0)
                shared?.set(newBadge, forKey: "app_badge_count")
                UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
                UNUserNotificationCenter.current().setBadgeCount(newBadge)
            }

            completionHandler(.newData)
        }
    }

    // MARK: - Call Push Handler

    /// Handles `type=call` FCM push: triggers CallKit + sets up call state + reconnects WebSocket.
    /// Android parity: `handleIncomingCallPush()` in RosettaFirebaseMessagingService.kt.
    /// Previously just showed a local notification — now matches VoIP push behavior.
    private func handleCallPush(
        userInfo: [AnyHashable: Any],
        completionHandler: @escaping (UIBackgroundFetchResult) -> Void
    ) {
        // Dedup: if VoIP push already reported this call to CallKit, skip FCM path.
        // Server sends BOTH VoIP APNs AND FCM simultaneously. Processing both creates
        // two WebSocket connections → every signal delivered twice → audio breaks.
        if CallKitManager.shared.hasPendingCall() {
            Logger.voip.info("FCM call push: VoIP push already handled — skipping")
            completionHandler(.noData)
            return
        }

        let callerKey = userInfo["dialog"] as? String
            ?? Self.extractSenderKey(from: userInfo)
        guard !callerKey.isEmpty else {
            Logger.voip.warning("FCM call push: empty callerKey — ignoring")
            completionHandler(.noData)
            return
        }

        let callId = userInfo["callId"] as? String
        let joinToken = userInfo["joinToken"] as? String

        let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
        let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
        let callerName: String = {
            if let cached = contactNames[callerKey], !cached.isEmpty { return cached }
            if let fromPush = Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"]) {
                return fromPush
            }
            if let creds = SessionCredentialsManager.shared.load(),
               let dbName = Self.resolveCallerNameFromDB(callerKey: callerKey, accountKey: creds.publicKey),
               !dbName.isEmpty {
                return dbName
            }
            return "Rosetta"
        }()

        Logger.voip.info("FCM call push: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)")

        // 1. Report to CallKit — shows native incoming call UI (same as VoIP push path).
        CallKitManager.shared.reportIncomingCallSynchronously(
            callerKey: callerKey,
            callerName: callerName,
            callId: callId
        ) { error in
            if let error {
                Logger.voip.error("FCM call push: CallKit report failed: \(error.localizedDescription, privacy: .public)")
            }
            completionHandler(error == nil ? .newData : .failed)
        }

        // 2. Set up call state + reconnect WebSocket (same as VoIP push path).
        Task { @MainActor in
            // Guard: only process calls for the active account.
            let activeKey = AccountManager.shared.activeAccountPublicKey ?? ""
            if !activeKey.isEmpty,
               let creds = SessionCredentialsManager.shared.load(),
               creds.publicKey != activeKey {
                Logger.voip.warning("FCM call push: ignoring — inactive account")
                CallKitManager.shared.reportCallEndedByRemote(reason: .unanswered)
                return
            }

            if CallManager.shared.ownPublicKey.isEmpty,
               let creds = SessionCredentialsManager.shared.load() {
                CallManager.shared.bindAccount(publicKey: creds.publicKey)
            }
            if !callerKey.isEmpty, CallManager.shared.uiState.phase == .idle {
                CallManager.shared.setupIncomingCallFromPush(
                    callerKey: callerKey,
                    callerName: callerName,
                    callId: callId,
                    joinToken: joinToken
                )
            }

            // Restore WebSocket so call signaling can proceed.
            if ProtocolManager.shared.connectionState == .authenticated {
                return
            }
            if ProtocolManager.shared.publicKey == nil,
               let creds = SessionCredentialsManager.shared.load() {
                Logger.voip.info("FCM call push: restoring session from Keychain")
                ProtocolManager.shared.connect(
                    publicKey: creds.publicKey,
                    privateKeyHash: creds.privateKeyHash
                )
            } else {
                ProtocolManager.shared.forceReconnectOnForeground()
            }
        }
    }

    // MARK: - Caller Name from SQLite (VoIP push fallback)

    /// Reads caller display name directly from SQLite when app is killed and
    /// UserDefaults hasn't been loaded yet. Database file persists on disk.
    static func resolveCallerNameFromDB(callerKey: String, accountKey: String) -> String? {
        let key = accountKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !key.isEmpty else { return nil }

        let normalized = String(key.unicodeScalars.map {
            CharacterSet.alphanumerics.contains($0) ? Character($0) : "_"
        })

        let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
        let dbPath = baseURL
            .appendingPathComponent("Rosetta/Database/rosetta_\(normalized).sqlite")
            .path

        guard FileManager.default.fileExists(atPath: dbPath) else { return nil }

        var db: OpaquePointer?
        guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { return nil }
        defer { sqlite3_close(db) }

        var stmt: OpaquePointer?
        let sql = "SELECT opponent_title, opponent_username FROM dialogs WHERE opponent_key = ? LIMIT 1"
        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
        defer { sqlite3_finalize(stmt) }

        sqlite3_bind_text(stmt, 1, (callerKey as NSString).utf8String, -1, nil)

        guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }

        let title = sqlite3_column_text(stmt, 0).map { String(cString: $0) } ?? ""
        let username = sqlite3_column_text(stmt, 1).map { String(cString: $0) } ?? ""
        return title.isEmpty ? (username.isEmpty ? nil : username) : title
    }

    // MARK: - Push Payload Helpers (Android parity)

    /// Android parity: extract sender public key from multiple possible key names.
    /// Server may use different key names across versions.
    /// Note: server currently sends `from` field — checked first in didReceiveRemoteNotification,
    /// this helper is a fallback for other contexts (notification tap, etc.).
    static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
        firstNonBlank(userInfo, keys: [
            "dialog", "sender_public_key", "from_public_key", "fromPublicKey",
            "public_key", "publicKey"
        ]) ?? ""
    }

    /// Android parity: `firstNonBlank(data, ...)` — try multiple key names, return first non-empty.
    private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? {
        for key in keys {
            if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty {
                return value
            }
        }
        return nil
    }

    // MARK: - MessagingDelegate

    /// Called when FCM token is received or refreshed.
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        guard let token = fcmToken else { return }
        Task { @MainActor in
            SessionManager.shared.setAPNsToken(token)
        }
    }

    // MARK: - UNUserNotificationCenterDelegate

    /// Handle foreground notifications — show system banner unless chat is active or muted.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) ->
            Void
    ) {
        let userInfo = notification.request.content.userInfo
        let senderKey = userInfo["dialog"] as? String
            ?? Self.extractSenderKey(from: userInfo)

        // Telegram parity: suppress ALL foreground notifications for current account.
        // Messages arrive via WebSocket in real-time — no need for system banners.
        // This prevents vibration/sound spam from FCM burst on foreground entry.
        completionHandler([])
    }

    /// Determines whether a foreground notification should be suppressed.
    /// Testable: used by unit tests to verify suppression logic.
    /// Telegram parity: always returns [] — all foreground notifications suppressed.
    static func foregroundPresentationOptions(
        for userInfo: [AnyHashable: Any]
    ) -> UNNotificationPresentationOptions {
        // Telegram parity: suppress all foreground notifications.
        // Messages arrive via WebSocket — system banners are redundant.
        return []
    }

    /// Handle notification tap — navigate to the sender's chat or expand call.
    /// Android parity: extracts sender key with multi-key fallback.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo

        // Call notification tap → expand call overlay (not chat).
        if let pushType = userInfo["type"] as? String, pushType == "call" {
            Task { @MainActor in
                if CallManager.shared.uiState.phase != .idle {
                    CallManager.shared.expandCall()
                }
            }
            completionHandler()
            return
        }

        // Android parity: try multiple key names for sender identification.
        var senderKey = Self.extractSenderKey(from: userInfo)

        // Server sends group ID without #group: prefix (CLAUDE.md line 480).
        // Add prefix so ChatRoute.isGroup detects it correctly.
        let pushType = userInfo["type"] as? String
        if pushType == "group_message",
           !senderKey.isEmpty,
           !senderKey.hasPrefix("#group:"),
           !senderKey.hasPrefix("group:") {
            senderKey = "#group:\(senderKey)"
        }

        if !senderKey.isEmpty {
            let senderName = Self.firstNonBlank(userInfo, keys: [
                "sender_name", "from_title", "sender", "title", "name"
            ]) ?? ""
            let route = ChatRoute(
                publicKey: senderKey,
                title: senderName,
                username: "",
                verified: 0
            )
            // Store pending route BEFORE posting — handles terminated app case
            // where ChatListView doesn't exist yet, and background app case
            // where .onReceive might miss the synchronous post.
            Self.pendingChatRoute = route
            Self.pendingChatRouteTimestamp = Date()
            NotificationCenter.default.post(
                name: .openChatFromNotification,
                object: route
            )
            // Clear all delivered notifications from this sender
            center.getDeliveredNotifications { delivered in
                let idsToRemove = delivered
                    .filter { notification in
                        let info = notification.request.content.userInfo
                        let key = Self.extractSenderKey(from: info)
                        return key == senderKey
                    }
                    .map { $0.request.identifier }
                if !idsToRemove.isEmpty {
                    center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
                }
            }
        }

        completionHandler()
    }
}

// MARK: - PKPushRegistryDelegate (VoIP Push)

extension AppDelegate: PKPushRegistryDelegate {

    /// Called when PushKit delivers a VoIP token (or refreshes it).
    func pushRegistry(
        _ registry: PKPushRegistry,
        didUpdate pushCredentials: PKPushCredentials,
        for type: PKPushType
    ) {
        guard type == .voIP else { return }
        let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
        Task { @MainActor in
            SessionManager.shared.setVoIPToken(token)
        }
    }

    /// Called when a VoIP push arrives. MUST call reportNewIncomingCall or Apple
    /// terminates the app. Server sends: { "dialog": callerKey, "title": callerName }.
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingPushWith payload: PKPushPayload,
        for type: PKPushType,
        completion: @escaping () -> Void
    ) {
        guard type == .voIP else {
            completion()
            return
        }
        let data = payload.dictionaryPayload
        Logger.voip.info("VoIP push received: \(data.description, privacy: .public)")
        // Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>", "joinToken": "<token>" }
        // Fallback to "dialog" for backward compat with older server versions.
        let callerKey = data["from"] as? String
            ?? data["dialog"] as? String
            ?? ""
        let callId = data["callId"] as? String
        let joinToken = data["joinToken"] as? String
        // Resolve caller display name from multiple sources.
        let callerName: String = {
            // 1. Push payload (if server sends title)
            if let title = data["title"] as? String, !title.isEmpty { return title }
            // 2. UserDefaults (synced by DialogRepository.syncContactNamesToDefaults)
            for defaults in [UserDefaults(suiteName: "group.com.rosetta.dev"), UserDefaults.standard] {
                if let names = defaults?.dictionary(forKey: "contact_display_names") as? [String: String],
                   let name = names[callerKey], !name.isEmpty {
                    return name
                }
            }
            // 3. SQLite direct read (data persists on disk even when app was killed)
            if let creds = SessionCredentialsManager.shared.load() {
                let name = Self.resolveCallerNameFromDB(callerKey: callerKey, accountKey: creds.publicKey)
                if let name, !name.isEmpty { return name }
            }
            return "Rosetta"
        }()
        Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinTokenPresent=\((joinToken?.isEmpty == false).description, privacy: .public)")

        // Telegram parity: pre-configure RTCAudioSession BEFORE reporting to CallKit.
        // This tells the system what audio configuration we need (.playAndRecord,
        // .voiceChat mode). Without this, CallKit may fail to deliver didActivate
        // for background VoIP push calls because the audio session is in an
        // unknown state (.soloAmbient). Telegram: OngoingCallThreadLocalContext.mm
        // setupAudioSession() called before reportNewIncomingCall.
        do {
            let avSession = AVAudioSession.sharedInstance()
            let options: AVAudioSession.CategoryOptions = [.allowBluetooth, .defaultToSpeaker, .mixWithOthers]
            try avSession.setCategory(.playAndRecord, mode: .voiceChat, options: options)
        } catch {
            Logger.voip.error("Failed to pre-configure audio session: \(error.localizedDescription)")
        }

        // Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY.
        // Using Task { @MainActor } would introduce an async hop that may be
        // delayed if the main actor is busy, causing Apple to terminate the app.
        CallKitManager.shared.reportIncomingCallSynchronously(
            callerKey: callerKey.isEmpty ? "unknown" : callerKey,
            callerName: callerName,
            callId: callId
        ) { error in
            completion()

            // If callerKey is empty/invalid, immediately end the orphaned call.
            // Apple still required us to call reportNewIncomingCall, but we can't
            // connect a call without a valid peer key. Without this, the CallKit
            // call stays visible → user taps Accept → pendingCallKitAccept stuck
            // forever → app in broken state until force-quit.
            if callerKey.isEmpty || error != nil {
                Task { @MainActor in
                    CallKitManager.shared.reportCallEndedByRemote(reason: .failed)
                    // Clear stale accept flag — user may have tapped Accept
                    // on the orphaned CallKit UI before it was dismissed.
                    // Without this, the flag persists and auto-accepts the NEXT call.
                    CallManager.shared.pendingCallKitAccept = false
                }
                return
            }

            // Restore WebSocket connection so the .call signal packet arrives
            // and CallManager can handle the call. When app was killed, SessionManager
            // has no credentials in memory — load from Keychain (saved during startSession).
            Task { @MainActor in
                // Guard: only process calls for the ACTIVE account.
                // When multiple accounts exist, VoIP token may still be registered
                // for a passive account → server sends push for wrong account.
                let activeKey = AccountManager.shared.activeAccountPublicKey ?? ""
                if !activeKey.isEmpty,
                   let creds = SessionCredentialsManager.shared.load(),
                   creds.publicKey != activeKey {
                    Logger.voip.warning("VoIP push: ignoring — push woke inactive account \(creds.publicKey.prefix(8), privacy: .public), active is \(activeKey.prefix(8), privacy: .public)")
                    CallKitManager.shared.reportCallEndedByRemote(reason: .unanswered)
                    return
                }

                // Set up incoming call state from push payload IMMEDIATELY.
                // Don't wait for WebSocket .call signal — it's fire-and-forget
                // and may have been sent before our WebSocket connected.
                if !callerKey.isEmpty, CallManager.shared.uiState.phase == .idle {
                    // Ensure account is bound for acceptIncomingCall()
                    if CallManager.shared.ownPublicKey.isEmpty,
                       let creds = SessionCredentialsManager.shared.load() {
                        CallManager.shared.bindAccount(publicKey: creds.publicKey)
                    }
                    CallManager.shared.setupIncomingCallFromPush(
                        callerKey: callerKey,
                        callerName: callerName,
                        callId: callId,
                        joinToken: joinToken
                    )
                }

                // Restore WebSocket so keyExchange can be sent when user accepts.
                if ProtocolManager.shared.connectionState == .authenticated {
                    return
                }
                if ProtocolManager.shared.publicKey == nil,
                   let creds = SessionCredentialsManager.shared.load() {
                    Logger.voip.info("Restoring session from Keychain for VoIP wake-up")
                    ProtocolManager.shared.connect(
                        publicKey: creds.publicKey,
                        privateKeyHash: creds.privateKeyHash
                    )
                } else {
                    ProtocolManager.shared.forceReconnectOnForeground()
                }
            }
        }
    }

    func pushRegistry(
        _ registry: PKPushRegistry,
        didInvalidatePushTokenFor type: PKPushType
    ) {
        guard type == .voIP else { return }
        // Notify server to unsubscribe the stale VoIP token before clearing it.
        let oldToken = UserDefaults.standard.string(forKey: "voip_push_token")
        if let oldToken, !oldToken.isEmpty {
            Task { @MainActor in
                SessionManager.shared.unsubscribeVoIPToken(oldToken)
            }
        }
        UserDefaults.standard.removeObject(forKey: "voip_push_token")
    }
}

// MARK: - App State

private enum AppState {
    case onboarding
    case auth
    case unlock
    case main
}

// MARK: - RosettaApp

@main
struct RosettaApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate

    init() {
        if UITestLaunchConfiguration.isVoiceRecordingFixtureEnabled {
            return
        }

        UIWindow.appearance().backgroundColor = UIColor { traits in
            traits.userInterfaceStyle == .dark ? .black : .white
        }

        // Suppress iOS 26 Liquid Glass on navigation bars — unified look across all iOS versions.
        UINavigationBar.appearance().preferredBehavioralStyle = .pad

        // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not.
        // If this is the first launch after install, clear any stale Keychain data.
        if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") {
            try? AccountManager.shared.deleteAccount()
            try? KeychainManager.shared.delete(forKey: Account.KeychainKey.allAccounts)
            UserDefaults.standard.removeObject(forKey: Account.KeychainKey.activeAccountKey)
            UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
        }

        // Avoid heavy startup work on MainActor; Lottie assets load lazily on first use.
        #if DEBUG
        DebugPerformanceBenchmarks.runIfRequested()
        #endif

        // Eager init: in-app banner manager now owns a UIKit overlay window.
        _ = InAppBannerManager.shared
    }

    @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
    @AppStorage("isLoggedIn") private var isLoggedIn = false
    @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
    @State private var appState: AppState?
    @State private var transitionOverlay: Bool = false

    var body: some Scene {
        WindowGroup {
            if UITestLaunchConfiguration.isVoiceRecordingFixtureEnabled {
                VoiceRecordingUITestFixtureView(
                    modeRawValue: UITestLaunchConfiguration.voiceRecordingFixtureMode
                )
            } else {
                DarkModeWrapper {
                    ZStack {
                        RosettaColors.Adaptive.background
                            .ignoresSafeArea()

                        if let appState {
                            rootView(for: appState)
                        }

                        // Fade overlay for smooth screen transitions.
                        // Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions.
                        RosettaColors.Adaptive.background
                            .ignoresSafeArea()
                            .opacity(transitionOverlay ? 1 : 0)
                            .allowsHitTesting(transitionOverlay)
                            .animation(.easeInOut(duration: 0.035), value: transitionOverlay)
                    }
                }
                // NOTE: preferredColorScheme removed — DarkModeWrapper is the single
                // source of truth via window.overrideUserInterfaceStyle. Having both
                // caused snapshot races where the hosting controller's stale
                // preferredColorScheme(.dark) blocked the window's .light override,
                // making dark→light circular reveal animation invisible.
                .onAppear {
                    if appState == nil {
                        appState = initialState()
                    }
                    // Set hosting controller & window background to match app background.
                    // Default .systemBackground (dark gray) leaks as a line in the safe area.
                    DispatchQueue.main.async {
                        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
                              let window = scene.windows.first(where: { $0.tag != 0320 }) else { return }
                        // Dynamic UIColor — auto-resolves on trait change (theme switch).
                        // Static color here caused white/black flash: rootVC.view.backgroundColor
                        // stayed stale after overrideUserInterfaceStyle changed.
                        let bgColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
                        window.backgroundColor = bgColor
                        window.rootViewController?.view.backgroundColor = bgColor
                    }
                }
                .onOpenURL { url in
                    handleDeepLink(url)
                }
            }
        }
    }

    @ViewBuilder
    private func rootView(for state: AppState) -> some View {
        switch state {
        case .onboarding:
            OnboardingView {
                hasCompletedOnboarding = true
                fadeTransition(to: .auth)
            }

        case .auth:
            AuthCoordinator(
                onAuthComplete: {
                    isLoggedIn = true
                    fadeTransition(to: .main)
                },
                onBackToUnlock: AccountManager.shared.hasAccount ? {
                    fadeTransition(to: .unlock)
                } : nil
            )

        case .unlock:
            UnlockView(
                onUnlocked: {
                    isLoggedIn = true
                    fadeTransition(to: .main)
                },
                onCreateNewAccount: {
                    // Go to auth flow (Welcome screen with back button)
                    // Does NOT delete the old account — Android keeps multiple accounts
                    fadeTransition(to: .auth)
                }
            )

        case .main:
            MainTabView(
                onLogout: {
                    isLoggedIn = false
                    // Desktop parity: if other accounts remain after deletion, go to unlock.
                    // Only go to onboarding if no accounts left.
                    if AccountManager.shared.hasAccount {
                        fadeTransition(to: .unlock)
                    } else {
                        hasCompletedOnboarding = false
                        fadeTransition(to: .onboarding)
                    }
                },
            )
            // Force full view recreation on account switch. Without this,
            // SwiftUI may reuse the old MainTabView's @StateObject instances
            // (SettingsViewModel, ChatListViewModel) when appState cycles
            // .main → .unlock → .main, causing stale profile data to persist.
            .id(AccountManager.shared.currentAccount?.publicKey ?? "")
        }
    }

    /// Fade-through-black transition: overlay fades in → swap content → overlay fades out.
    /// Avoids UIKit-hosted views (Lottie, UIPageViewController) fighting SwiftUI transitions.
    private func handleDeepLink(_ url: URL) {
        guard url.scheme == "rosetta" else { return }
        if url.host == "call" && url.path == "/end" {
            print("[CallBar] Deep link rosetta://call/end → endCall()")
            CallManager.shared.endCall()
        }
    }

    private func fadeTransition(to newState: AppState) {
        guard !transitionOverlay else { return }
        transitionOverlay = true
        Task { @MainActor in
            try? await Task.sleep(nanoseconds: 35_000_000) // wait for overlay fade-in
            appState = newState
            try? await Task.sleep(nanoseconds: 10_000_000) // brief settle
            transitionOverlay = false
        }
    }

    private func initialState() -> AppState {
        if AccountManager.shared.hasAccount {
            return .unlock
        } else {
            hasCompletedOnboarding = false
            return .onboarding
        }
    }
}

// MARK: - Notification Names

extension Notification.Name {
    /// Posted when user taps a push notification — carries a `ChatRoute` as `object`.
    static let openChatFromNotification = Notification.Name("openChatFromNotification")
    /// Posted when own profile (displayName/username) is updated from the server.
    static let profileDidUpdate = Notification.Name("profileDidUpdate")
    /// Posted when user taps an attachment in the bubble overlay — carries attachment ID (String) as `object`.
    /// MessageImageView / MessageFileView listen and trigger download/share.
    static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
    /// Posted when user taps "Chats" toolbar title — triggers scroll-to-top.
    static let chatListScrollToTop = Notification.Name("chatListScrollToTop")
    /// Posted immediately when an outgoing message is inserted into the DB cache.
    /// Bypasses the 100ms repo + 50ms ViewModel debounce for instant bubble appearance.
    /// userInfo: ["opponentKey": String]
    static let sentMessageInserted = Notification.Name("sentMessageInserted")
    /// Posted after a member is kicked from group via OpponentProfile.
    /// userInfo: ["groupDialogKey": String, "memberKey": String]
    static let groupMemberKicked = Notification.Name("groupMemberKicked")
}
