import Combine
import SwiftUI
import UIKit

// MARK: - Navigation State (survives parent re-renders)

@MainActor
final class ChatListNavigationState: ObservableObject {
    @Published var path: [ChatRoute] = []
}

// MARK: - ChatListView

/// The root chat list screen.
///
/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton
/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`)
/// directly. Such reads create implicit Observation tracking, causing the
/// NavigationStack to rebuild on every property change (e.g. during handshake)
/// and triggering "Update NavigationRequestObserver tried to update multiple
/// times per frame" → app freeze.
///
/// All `@Observable` access is isolated in dedicated child views:
/// - `DeviceVerificationContentRouter` → `ProtocolManager`
/// - `ToolbarStoriesAvatar` → `AccountManager` / `SessionManager`
/// - `ChatListDialogContent` → `DialogRepository` (via ViewModel)
struct ChatListView: View {
    @Binding var isSearchActive: Bool
    @Binding var isDetailPresented: Bool
    @StateObject private var viewModel = ChatListViewModel()
    @StateObject private var navigationState = ChatListNavigationState()
    @State private var searchText = ""
    @State private var hasPinnedChats = false
    @State private var showRequestChats = false
    @State private var showNewGroupSheet = false
    @State private var showJoinGroupSheet = false
    @State private var showNewChatActionSheet = false
    @FocusState private var isSearchFocused: Bool

    var body: some View {
        NavigationStack(path: $navigationState.path) {
            VStack(spacing: 0) {
                // Custom search bar
                customSearchBar
                    .padding(.horizontal, 16)
                    .padding(.top, 12)
                    .padding(.bottom, 8)
                    .background(
                        (hasPinnedChats && !isSearchActive
                            ? RosettaColors.Adaptive.pinnedSectionBackground
                            : Color.clear
                        ).ignoresSafeArea(.all, edges: .top)
                    )

                if isSearchActive {
                    ChatListSearchContent(
                        searchText: searchText,
                        viewModel: viewModel,
                        onSelectRecent: { searchText = $0 },
                        onOpenDialog: { route in
                            navigationState.path.append(route)
                            // Delay search dismissal so NavigationStack processes
                            // the push before the search overlay is removed.
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
                                isSearchActive = false
                                isSearchFocused = false
                                searchText = ""
                                viewModel.setSearchQuery("")
                            }
                        }
                    )
                } else {
                    normalContent
                }
            }
            .background(RosettaColors.Adaptive.background.ignoresSafeArea())
            .navigationBarTitleDisplayMode(.inline)
            .toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
            .toolbar { toolbarContent }
            .modifier(ChatListToolbarBackgroundModifier())
            .onChange(of: searchText) { _, newValue in
                viewModel.setSearchQuery(newValue)
            }
            .navigationDestination(for: ChatRoute.self) { route in
                ChatDetailView(
                    route: route,
                    onPresentedChange: { presented in
                        isDetailPresented = presented
                    }
                )
            }
            .navigationDestination(isPresented: $showRequestChats) {
                RequestChatsView(
                    viewModel: viewModel,
                    navigationState: navigationState
                )
            }
            .onAppear {
                isDetailPresented = !navigationState.path.isEmpty || showRequestChats
            }
            .onChange(of: navigationState.path) { _, newPath in
                isDetailPresented = !newPath.isEmpty || showRequestChats
            }
            .onChange(of: showRequestChats) { _, showing in
                isDetailPresented = !navigationState.path.isEmpty || showing
            }
        }
        .tint(RosettaColors.figmaBlue)
        .confirmationDialog("New", isPresented: $showNewChatActionSheet) {
            Button("New Group") { showNewGroupSheet = true }
            Button("Join Group") { showJoinGroupSheet = true }
            Button("Cancel", role: .cancel) {}
        }
        .sheet(isPresented: $showNewGroupSheet) {
            NavigationStack {
                GroupSetupView { route in
                    showNewGroupSheet = false
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                        navigationState.path = [route]
                    }
                }
            }
            .presentationDetents([.large])
        }
        .sheet(isPresented: $showJoinGroupSheet) {
            NavigationStack {
                GroupJoinView { route in
                    showJoinGroupSheet = false
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                        navigationState.path = [route]
                    }
                }
            }
            .presentationDetents([.large])
        }
        .onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
            guard let route = notification.object as? ChatRoute else { return }
            AppDelegate.pendingChatRoute = nil
            AppDelegate.pendingChatRouteTimestamp = nil
            // If already inside a chat, pop first then push after animation.
            // Direct path replacement reuses the same ChatDetailView (SwiftUI optimization),
            // which only updates the toolbar but keeps the old messages.
            if !navigationState.path.isEmpty {
                navigationState.path = []
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
                    navigationState.path = [route]
                }
            } else {
                navigationState.path = [route]
            }
        }
        .onAppear {
            // Cold start fallback: ChatListView didn't exist when notification was posted.
            // Expiry guard (3s) prevents stale routes from firing on tab switches —
            // critical for iOS < 26 pager (ZStack opacity 0→1 re-fires .onAppear).
            consumePendingRouteIfFresh()
        }
        .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
            // Background→foreground fallback: covers edge cases where .onReceive
            // subscription hasn't re-activated after background→foreground transition.
            // Harmless if .onReceive already consumed the route (statics are nil).
            consumePendingRouteIfFresh()
        }
    }

    // MARK: - Cancel Search

    private func cancelSearch() {
        isSearchActive = false
        isSearchFocused = false
        searchText = ""
        viewModel.setSearchQuery("")
    }

    /// Consume pending notification route only if it was set within the last 3 seconds.
    /// Prevents stale routes (from failed .onReceive) from being consumed on tab switches.
    private func consumePendingRouteIfFresh() {
        guard let route = AppDelegate.consumeFreshPendingRoute() else { return }
        Task { @MainActor in
            try? await Task.sleep(nanoseconds: 100_000_000) // 100ms for NavigationStack settle
            navigationState.path = [route]
        }
    }
}

// MARK: - Custom Search Bar

private extension ChatListView {
    var customSearchBar: some View {
        HStack(spacing: 10) {
            // Search bar capsule
            ZStack {
                // Centered placeholder: magnifier + "Search"
                if searchText.isEmpty && !isSearchActive {
                    HStack(spacing: 6) {
                        Image(systemName: "magnifyingglass")
                            .font(.system(size: 15, weight: .medium))
                            .foregroundStyle(Color.gray)
                        Text("Search")
                            .font(.system(size: 17))
                            .foregroundStyle(Color.gray)
                    }
                    .allowsHitTesting(false)
                }

                // Active: left-aligned magnifier + TextField
                if isSearchActive {
                    HStack(spacing: 6) {
                        Image(systemName: "magnifyingglass")
                            .font(.system(size: 15, weight: .medium))
                            .foregroundStyle(Color.gray)

                        TextField("Search", text: $searchText)
                            .font(.system(size: 17))
                            .foregroundStyle(RosettaColors.Adaptive.text)
                            .focused($isSearchFocused)
                            .submitLabel(.search)

                        if !searchText.isEmpty {
                            Button {
                                searchText = ""
                            } label: {
                                Image(systemName: "xmark.circle.fill")
                                    .font(.system(size: 15))
                                    .foregroundStyle(Color.gray)
                            }
                        }
                    }
                    .padding(.horizontal, 12)
                }
            }
            .frame(height: 42)
            .frame(maxWidth: .infinity)
            .contentShape(Rectangle())
            .onTapGesture {
                if !isSearchActive {
                    withAnimation(.easeInOut(duration: 0.25)) {
                        isSearchActive = true
                    }
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        isSearchFocused = true
                    }
                }
            }
            .background {
                if isSearchActive {
                    RoundedRectangle(cornerRadius: 24, style: .continuous)
                        .fill(RosettaColors.Adaptive.searchBarFill)
                        .overlay {
                            RoundedRectangle(cornerRadius: 24, style: .continuous)
                                .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
                        }
                } else {
                    RoundedRectangle(cornerRadius: 24, style: .continuous)
                        .fill(RosettaColors.Adaptive.searchBarFill)
                }
            }
            .onChange(of: isSearchFocused) { _, focused in
                if focused && !isSearchActive {
                    withAnimation(.easeInOut(duration: 0.25)) {
                        isSearchActive = true
                    }
                }
            }

            // Circular X button (visible only when search is active)
            if isSearchActive {
                Button {
                    cancelSearch()
                } label: {
                    Image("toolbar-xmark")
                        .renderingMode(.template)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 19, height: 19)
                        .foregroundStyle(RosettaColors.Adaptive.text)
                        .frame(width: 36, height: 36)
                        .padding(3)
                }
                .buttonStyle(.plain)
                .background {
                    Circle()
                        .fill(RosettaColors.Adaptive.searchBarFill)
                        .overlay {
                            Circle()
                                .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
                        }
                }
                .transition(.opacity.combined(with: .scale(scale: 0.5)))
            }
        }
    }
}

// MARK: - Normal Content

private extension ChatListView {
    @ViewBuilder
    var normalContent: some View {
        // Observation-isolated router — reads ProtocolManager in its own scope.
        // Shows full-screen DeviceConfirmView when awaiting approval,
        // or normal chat list with optional device approval banner otherwise.
        DeviceVerificationContentRouter(
            viewModel: viewModel,
            navigationState: navigationState,
            onShowRequests: { showRequestChats = true },
            onPinnedStateChange: { pinned in
                if hasPinnedChats != pinned {
                    withAnimation(.easeInOut(duration: 0.25)) {
                        hasPinnedChats = pinned
                    }
                }
            }
        )
    }
}

// MARK: - Toolbar

private extension ChatListView {
    @ToolbarContentBuilder
    var toolbarContent: some ToolbarContent {
        if !isSearchActive {
            if #available(iOS 26, *) {
                // iOS 26+ — original compact toolbar (no capsules, system icons)
                ToolbarItem(placement: .navigationBarLeading) {
                    Button { } label: {
                        Text("Edit")
                            .font(.system(size: 17, weight: .medium))
                            .foregroundStyle(RosettaColors.Adaptive.text)
                    }
                }

                ToolbarItem(placement: .principal) {
                    HStack(spacing: 4) {
                        ToolbarStoriesAvatar()
                        ToolbarTitleView()
                    }
                }

                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    HStack(spacing: 8) {
                        Button { } label: {
                            Image(systemName: "camera")
                                .font(.system(size: 16, weight: .regular))
                                .foregroundStyle(RosettaColors.Adaptive.text)
                        }
                        .accessibilityLabel("Camera")
                        Button { showNewChatActionSheet = true } label: {
                            Image(systemName: "square.and.pencil")
                                .font(.system(size: 17, weight: .regular))
                                .foregroundStyle(RosettaColors.Adaptive.text)
                        }
                        .padding(.bottom, 2)
                        .accessibilityLabel("New chat")
                    }
                }
            } else {
                // iOS < 26 — capsule-styled toolbar with custom icons
                ToolbarItem(placement: .navigationBarLeading) {
                    Button { } label: {
                        Text("Edit")
                            .font(.system(size: 17, weight: .medium))
                            .foregroundStyle(RosettaColors.Adaptive.text)
                            .frame(height: 40)
                            .padding(.horizontal, 10)
                    }
                    .buttonStyle(.plain)
                    .glassCapsule()
                }

                ToolbarItem(placement: .principal) {
                    HStack(spacing: 4) {
                        ToolbarStoriesAvatar()
                        ToolbarTitleView()
                    }
                }

                ToolbarItem(placement: .navigationBarTrailing) {
                    HStack(spacing: 0) {
                        Button { } label: {
                            Image("toolbar-add-chat")
                                .renderingMode(.template)
                                .resizable()
                                .scaledToFit()
                                .frame(width: 22, height: 22)
                                .frame(width: 40, height: 40)
                        }
                        .buttonStyle(.plain)
                        .accessibilityLabel("Add chat")

                        Button { showNewChatActionSheet = true } label: {
                            Image("toolbar-compose")
                                .renderingMode(.template)
                                .resizable()
                                .scaledToFit()
                                .frame(width: 20, height: 20)
                                .frame(width: 40, height: 40)
                        }
                        .buttonStyle(.plain)
                        .accessibilityLabel("New chat")
                    }
                    .foregroundStyle(RosettaColors.Adaptive.text)
                    .glassCapsule()
                }
            }
        }
    }
}

// MARK: - Toolbar Background Modifier

struct ChatListToolbarBackgroundModifier: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 26, *) {
            content
                .toolbarBackground(.visible, for: .navigationBar)
                .applyGlassNavBar()
        } else {
            content
                .toolbarBackground(.hidden, for: .navigationBar)
        }
    }
}

// MARK: - Toolbar Title (observation-isolated)

/// Reads `ProtocolManager.shared.connectionState` and `SessionManager.shared.syncBatchInProgress`
/// in its own observation scope. State changes are absorbed here,
/// not cascaded to the parent ChatListView / NavigationStack.
private struct ToolbarTitleView: View {
    var body: some View {
        let state = ProtocolManager.shared.connectionState
        let isSyncing = SessionManager.shared.syncBatchInProgress

        if state == .authenticated && isSyncing {
            ToolbarStatusLabel(title: "Updating...")
        } else if state == .authenticated {
            Text("Chats")
                .font(.system(size: 17, weight: .semibold))
                .foregroundStyle(RosettaColors.Adaptive.text)
                .contentTransition(.numericText())
                .animation(.easeInOut(duration: 0.25), value: state)
                .onTapGesture {
                    NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
                }
        } else {
            ToolbarStatusLabel(title: "Connecting...")
        }
    }
}

/// Status text label without spinner (spinner is in ToolbarStoriesAvatar).
private struct ToolbarStatusLabel: View {
    let title: String

    var body: some View {
        Text(title)
            .font(.system(size: 17, weight: .semibold))
            .foregroundStyle(RosettaColors.Adaptive.text)
    }
}

// MARK: - Toolbar Stories Avatar (observation-isolated)

/// Reads `AccountManager`, `SessionManager`, and `ProtocolManager` in its own observation scope.
/// Shows a spinning arc loader during connecting/syncing, then crossfades to avatar.
private struct ToolbarStoriesAvatar: View {
    @State private var isSpinning = false

    var body: some View {
        let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
        let state = ProtocolManager.shared.connectionState
        let isSyncing = SessionManager.shared.syncBatchInProgress
        let isLoading = state != .authenticated || isSyncing

        let initials = RosettaColors.initials(
            name: SessionManager.shared.displayName, publicKey: pk
        )
        let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk)
        let _ = AvatarRepository.shared.avatarVersion
        let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk)

        ZStack {
            // Avatar — visible when loaded
            AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar)
                .opacity(isLoading ? 0 : 1)

            // Spinning arc loader — visible during connecting/syncing
            Circle()
                .trim(from: 0.05, to: 0.78)
                .stroke(
                    RosettaColors.figmaBlue,
                    style: StrokeStyle(lineWidth: 2, lineCap: .round)
                )
                .frame(width: 20, height: 20)
                .rotationEffect(.degrees(isSpinning ? 360 : 0))
                .opacity(isLoading ? 1 : 0)
        }
        .animation(.easeInOut(duration: 0.3), value: isLoading)
        .onAppear { isSpinning = true }
        .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isSpinning)
    }
}

// MARK: - Sync-Aware Empty State (observation-isolated)

/// Shows "Syncing..." indicator when sync is in progress, otherwise shows empty state.
/// Reads `SessionManager.syncBatchInProgress` in its own observation scope.
private struct SyncAwareEmptyState: View {
    var body: some View {
        let isSyncing = SessionManager.shared.syncBatchInProgress
        if isSyncing {
            VStack(spacing: 16) {
                Spacer().frame(height: 120)
                ProgressView()
                    .tint(RosettaColors.Adaptive.textSecondary)
                Text("Syncing conversations…")
                    .font(.system(size: 15))
                    .foregroundStyle(RosettaColors.Adaptive.textSecondary)
                Spacer()
            }
            .frame(maxWidth: .infinity)
        } else {
            ChatEmptyStateView(searchText: "")
        }
    }
}

// MARK: - Request Chats Row (Telegram Archive style)

/// Shown at the top of the chat list when there are incoming message requests.
/// Matches ChatRowView sizing: height 78, pl-10, pr-16, avatar 62px.
private struct RequestChatsRow: View {
    let count: Int
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 0) {
                // Avatar: solid blue circle with white icon (62px)
                ZStack {
                    Circle()
                        .fill(RosettaColors.primaryBlue)
                        .frame(width: 62, height: 62)

                    Image(systemName: "tray.and.arrow.down")
                        .font(.system(size: 24, weight: .medium))
                        .foregroundStyle(.white)
                }
                .padding(.trailing, 10)

                // Content section — matches ChatRowView.contentSection layout
                VStack(alignment: .leading, spacing: 0) {
                    // Title row
                    Text("Request Chats")
                        .font(.system(size: 17, weight: .medium))
                        .tracking(-0.43)
                        .foregroundStyle(RosettaColors.Adaptive.text)
                        .lineLimit(1)

                    // Subtitle row (count)
                    Text(count == 1 ? "1 request" : "\(count) requests")
                        .font(.system(size: 15))
                        .tracking(-0.23)
                        .foregroundStyle(RosettaColors.Adaptive.textSecondary)
                        .lineLimit(1)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .frame(height: 63, alignment: .top)
                .padding(.top, 8)
            }
            .padding(.leading, 10)
            .padding(.trailing, 16)
            .frame(height: 78)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

// MARK: - Device Verification Content Router (observation-isolated)

/// Reads `ProtocolManager` in its own observation scope.
/// During handshake, `connectionState` changes 4+ times rapidly — this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
///
/// Device confirmation (THIS device waiting) is handled by full-screen overlay
/// in MainTabView (DeviceConfirmOverlay). This router only handles the
/// approval banner (ANOTHER device requesting access on primary device).
private struct DeviceVerificationContentRouter: View {
    @ObservedObject var viewModel: ChatListViewModel
    @ObservedObject var navigationState: ChatListNavigationState
    var onShowRequests: () -> Void = {}
    var onPinnedStateChange: (Bool) -> Void = { _ in }

    var body: some View {
        let proto = ProtocolManager.shared

        VStack(spacing: 0) {
            // Banner for approving ANOTHER device (primary device side)
            if let pendingDevice = proto.pendingDeviceVerification {
                DeviceApprovalBanner(
                    device: pendingDevice,
                    onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
                    onDecline: { proto.declineDevice(pendingDevice.deviceId) }
                )
            }

            ChatListDialogContent(
                viewModel: viewModel,
                navigationState: navigationState,
                onShowRequests: onShowRequests,
                onPinnedStateChange: onPinnedStateChange
            )
        }
    }
}

// MARK: - Dialog Content (observation-isolated)

/// Reads `DialogRepository` (via ViewModel) in its own observation scope.
/// Changes to dialogs only re-render this list, not the NavigationStack.
private struct ChatListDialogContent: View {
    @ObservedObject var viewModel: ChatListViewModel
    @ObservedObject var navigationState: ChatListNavigationState
    var onShowRequests: () -> Void = {}
    var onPinnedStateChange: (Bool) -> Void = { _ in }

    /// Desktop parity: track typing dialogs from MessageRepository (@Published).
    @State private var typingDialogs: [String: Set<String>] = [:]

    var body: some View {
        let _ = PerformanceLogger.shared.track("chatList.bodyEval")
        // CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking.
        // Without this, ChatListDialogContent only observes viewModel (ObservableObject)
        // which never publishes objectWillChange for dialog mutations.
        // The read forces SwiftUI to re-evaluate body when dialogs dict changes.
        let _ = DialogRepository.shared.dialogs.count
        // Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
        let pinned = viewModel.allModePinned
        let unpinned = viewModel.allModeUnpinned
        let requestsCount = viewModel.requestsCount

        Group {
            if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
                SyncAwareEmptyState()
            } else {
                dialogList(
                    pinned: pinned,
                    unpinned: unpinned,
                    requestsCount: requestsCount
                )
            }
        }
        .onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
        .onAppear {
            onPinnedStateChange(!pinned.isEmpty)
        }
    }

    // MARK: - Dialog List

    private static let topAnchorId = "chatlist_top"

    private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
        ScrollViewReader { scrollProxy in
        List {
            if viewModel.isLoading {
                ForEach(0..<8, id: \.self) { _ in
                    ChatRowShimmerView()
                        .listRowInsets(EdgeInsets())
                        .listRowBackground(Color.clear)
                        .listRowSeparator(.hidden)
                }
            } else {
                // Telegram-style "Request Chats" row at top (like Archived Chats)
                if requestsCount > 0 {
                    RequestChatsRow(count: requestsCount, onTap: onShowRequests)
                        .listRowInsets(EdgeInsets())
                        .listRowBackground(Color.clear)
                        .listRowSeparator(.visible, edges: .bottom)
                        .listRowSeparatorTint(RosettaColors.Adaptive.divider)
                        .alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
                }

                if !pinned.isEmpty {
                    ForEach(pinned, id: \.id) { dialog in
                        chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0)
                            .environment(\.rowBackgroundColor, RosettaColors.Adaptive.pinnedSectionBackground)
                            .listRowBackground(RosettaColors.Adaptive.pinnedSectionBackground)
                    }
                }
                ForEach(unpinned, id: \.id) { dialog in
                    chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0)
                }
            }

            Color.clear.frame(height: 80)
                .listRowInsets(EdgeInsets())
                .listRowBackground(Color.clear)
                .listRowSeparator(.hidden)
        }
        .listStyle(.plain)
        .scrollContentBackground(.hidden)
        .scrollDismissesKeyboard(.immediately)
        .scrollIndicators(.hidden)
        .modifier(ClassicSwipeActionsModifier())
        // Scroll-to-top: tap "Chats" in toolbar
        .onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
            // Scroll to first dialog ID (pinned or unpinned)
            let firstId = pinned.first?.id ?? unpinned.first?.id
            if let firstId {
                withAnimation(.easeOut(duration: 0.3)) {
                    scrollProxy.scrollTo(firstId, anchor: .top)
                }
            }
        }
        } // ScrollViewReader
    }

    private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
        /// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
        /// of SessionManager.syncBatchInProgress from this view's observation scope.
        /// viewModel + navigationState passed as plain `let` (not @ObservedObject) —
        /// stable class references don't trigger row re-evaluation on parent re-render.
        SyncAwareChatRow(
            dialog: dialog,
            isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
            typingSenderNames: {
                guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
                return senderKeys.map { sk in
                    DialogRepository.shared.dialogs[sk]?.opponentTitle
                        ?? String(sk.prefix(8))
                }
            }(),
            isFirst: isFirst,
            viewModel: viewModel,
            navigationState: navigationState
        )
    }
}

// MARK: - Sync-Aware Chat Row (observation-isolated)

/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
///
/// **Performance:** `viewModel` and `navigationState` are stored as plain `let`
/// (not @ObservedObject). Class references compare by pointer in SwiftUI's
/// memcmp-based view diffing — stable pointers mean unchanged rows are NOT
/// re-evaluated when the parent body rebuilds. Closures are defined inline
/// (not passed from parent) to avoid non-diffable closure props that force
/// every row dirty on every parent re-render.
struct SyncAwareChatRow: View {
    let dialog: Dialog
    let isTyping: Bool
    let typingSenderNames: [String]
    let isFirst: Bool
    let viewModel: ChatListViewModel
    let navigationState: ChatListNavigationState

    var body: some View {
        let isSyncing = SessionManager.shared.syncBatchInProgress
        Button {
            navigationState.path.append(ChatRoute(dialog: dialog))
        } label: {
            ChatRowView(
                dialog: dialog,
                isSyncing: isSyncing,
                isTyping: isTyping,
                typingSenderNames: typingSenderNames
            )
        }
        .buttonStyle(.plain)
        .listRowInsets(EdgeInsets())
        .listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
        .listRowSeparator(.visible, edges: .bottom)
        .listRowSeparatorTint(RosettaColors.Adaptive.divider)
        .alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
            Button(role: .destructive) {
                withAnimation { viewModel.deleteDialog(dialog) }
            } label: {
                Label("Delete", systemImage: "trash")
            }

            if !dialog.isSavedMessages {
                Button {
                    withAnimation { viewModel.toggleMute(dialog) }
                } label: {
                    Label(
                        dialog.isMuted ? "Unmute" : "Mute",
                        systemImage: dialog.isMuted ? "bell" : "bell.slash"
                    )
                }
                .tint(dialog.isMuted ? .green : .indigo)
            }
        }
        .swipeActions(edge: .leading, allowsFullSwipe: true) {
            Button {
                withAnimation { viewModel.togglePin(dialog) }
            } label: {
                Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
            }
            .tint(.orange)
        }
    }
}


// MARK: - Device Approval Banner

/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.
/// Desktop: DeviceVerify.tsx — height 65px, centered text (dimmed), two transparent buttons.
private struct DeviceApprovalBanner: View {
    let device: DeviceEntry
    let onAccept: () -> Void
    let onDecline: () -> Void

    @State private var showAcceptConfirmation = false

    var body: some View {
        VStack(spacing: 8) {
            Text("New login from \(device.deviceName) (\(device.deviceOs))")
                .font(.system(size: 13, weight: .regular))
                .foregroundStyle(.white.opacity(0.45))
                .multilineTextAlignment(.center)

            HStack(spacing: 24) {
                Button("Accept") {
                    showAcceptConfirmation = true
                }
                .font(.system(size: 14, weight: .medium))
                .foregroundStyle(RosettaColors.primaryBlue)

                Button("Decline") {
                    onDecline()
                }
                .font(.system(size: 14, weight: .medium))
                .foregroundStyle(RosettaColors.error.opacity(0.8))
            }
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 10)
        .alert("Accept new device", isPresented: $showAcceptConfirmation) {
            Button("Accept") { onAccept() }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text("Are you sure you want to accept this device? This will allow it to access your account.")
        }
    }
}

// MARK: - iOS 26+ Classic Swipe Actions

/// iOS 26: disable Liquid Glass on the List so swipe action buttons use
/// solid colors (same as iOS < 26). Uses UIAppearance override.
private struct ClassicSwipeActionsModifier: ViewModifier {
    func body(content: Content) -> some View {
        content.onAppear {
            if #available(iOS 26, *) {
                // Disable glass on UITableView-backed List swipe actions.
                let appearance = UITableView.appearance()
                appearance.backgroundColor = .clear
            }
        }
    }
}

#Preview("Chat List") {
    ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false))
        .preferredColorScheme(.dark)
}

#Preview("Search Active") {
    ChatListView(isSearchActive: .constant(true), isDetailPresented: .constant(false))
        .preferredColorScheme(.dark)
}

