import Foundation
import Combine
import os
import QuartzCore

/// Per-dialog observation isolation for ChatDetailView.
///
/// Instead of `@ObservedObject messageRepository` (which re-renders on ANY dialog change),
/// this ViewModel subscribes only to the specific dialog's messages via Combine pipeline
/// with `removeDuplicates()`. The view re-renders ONLY when its own dialog's data changes.
@MainActor
final class ChatDetailViewModel: ObservableObject {
    let dialogKey: String

    @Published private(set) var messages: [ChatMessage] = []
    @Published private(set) var isTyping: Bool = false
    @Published private(set) var typingSenderNames: [String] = []
    /// Android parity: true while loading messages from DB. Shows skeleton placeholder.
    @Published private(set) var isLoading: Bool = true
    /// Pagination: true while older messages are available in SQLite.
    @Published private(set) var hasMoreMessages: Bool = true
    /// Pagination: guard against concurrent loads.
    @Published private(set) var isLoadingMore: Bool = false
    /// Reverse pagination: true while newer messages exist below current window.
    @Published private(set) var hasNewerMessages: Bool = false
    /// True when user scrolled up past the initial window (not showing latest).
    @Published private(set) var isDetachedFromBottom: Bool = false
    /// Guard against concurrent downward loads.
    private var isLoadingNewer: Bool = false

    private var cancellables = Set<AnyCancellable>()

    private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")

    init(dialogKey: String) {
        self.dialogKey = dialogKey

        let repo = MessageRepository.shared

        // Smart reloadLatest: DB read (~5ms) + reuse cached decrypted messages.
        // Only genuinely new messages get decrypted (~10-60ms total vs ~600-1000ms old).
        // Fixes stale pagination window (user scrolled up → cache trimmed → old messages).
        let vmStart = CACurrentMediaTime()
        repo.reloadLatest(for: dialogKey)
        let initial = repo.messages(for: dialogKey)
        messages = initial
        isTyping = repo.isTyping(dialogKey: dialogKey)
        let vmMs = (CACurrentMediaTime() - vmStart) * 1000
        Self.perfLog.notice("⚡ ViewModel.init: \(initial.count, privacy: .public) msgs, reloadLatest in \(String(format: "%.1f", vmMs), privacy: .public)ms")
        // Android parity: if we already have messages, skip skeleton.
        // Otherwise keep isLoading=true until first Combine emission or timeout.
        isLoading = initial.isEmpty
        // If initial load returned fewer than pageSize, no more to load.
        if initial.count < MessageRepository.pageSize {
            hasMoreMessages = false
        }

        // Subscribe to messagesByDialog changes, filtered to our dialog only.
        // Broken into steps to help the Swift type-checker.
        let key = dialogKey
        // Android parity: debounce(50ms) batches rapid message mutations
        // (delivery status, read marks, sync bursts) into fewer UI updates.
        let messagesPublisher = repo.$messagesByDialog
            .map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
                dict[key] ?? []
            }
            .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main)
            .removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
                // O(1) fast path for common cases (insert/delete).
                guard lhs.count == rhs.count else { return false }
                guard lhs.first?.id == rhs.first?.id,
                      lhs.last?.id == rhs.last?.id else { return false }
                // O(n) scan for status/read changes — needed to detect mid-array
                // delivery ACK and read receipt updates. ~0.3ms for 3000 messages,
                // runs at most once per 50ms debounce — negligible vs layout cost.
                for i in lhs.indices {
                    if lhs[i].id != rhs[i].id ||
                       lhs[i].deliveryStatus != rhs[i].deliveryStatus ||
                       lhs[i].isRead != rhs[i].isRead {
                        return false
                    }
                }
                return true
            }

        messagesPublisher
            .sink { [weak self] newMessages in
                PerformanceLogger.shared.track("chatDetail.messagesEmit")
                self?.messages = newMessages
                if self?.isLoading == true {
                    self?.isLoading = false
                }
            }
            .store(in: &cancellables)

        // Fast path: immediately update messages when a sent message is inserted,
        // bypassing the 50ms Combine debounce for instant bubble appearance.
        // No .receive(on:) — notification is always posted from @MainActor
        // (SessionManager → upsertFromMessagePacket), so subscriber fires
        // synchronously on main thread. This is critical: the message must be
        // in ViewModel.messages BEFORE sendCurrentMessage() clears replyingToMessage,
        // so SwiftUI batches both changes into one render pass.
        NotificationCenter.default.publisher(for: .sentMessageInserted)
            .compactMap { $0.userInfo?["opponentKey"] as? String }
            .filter { $0 == key }
            .sink { [weak self] _ in
                let fresh = repo.messages(for: key)
                self?.messages = fresh
                if self?.isLoading == true {
                    self?.isLoading = false
                }
            }
            .store(in: &cancellables)

        // Subscribe to typing state changes, filtered to our dialog
        repo.$typingDialogs
            .map { (dialogs: [String: Set<String>]) -> [String] in
                guard let senderKeys = dialogs[key], !senderKeys.isEmpty else { return [] }
                return senderKeys.map { sk in
                    DialogRepository.shared.dialogs[sk]?.opponentTitle
                        ?? String(sk.prefix(8))
                }
            }
            .removeDuplicates()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] names in
                self?.isTyping = !names.isEmpty
                self?.typingSenderNames = names
            }
            .store(in: &cancellables)
    }

    /// Pagination: load older messages from SQLite when user scrolls to top.
    /// Async: DB read + AES decryption run on background thread — zero main thread freeze.
    func loadMore() async {
        guard !isLoadingMore, hasMoreMessages else { return }
        guard let earliest = messages.first else { return }
        isLoadingMore = true

        let older = await MessageRepository.shared.loadOlderMessagesAsync(
            for: dialogKey,
            beforeTimestamp: earliest.timestamp,
            beforeMessageId: earliest.id,
            limit: MessageRepository.pageSize
        )

        if older.count < MessageRepository.pageSize {
            hasMoreMessages = false
        }
        // Mark as detached from bottom when cache grows past initial window.
        // Sliding window soft-trim may remove newest messages — jumpToBottom
        // needs to know it must reload from DB, not just scroll.
        let cached = MessageRepository.shared.messages(for: dialogKey)
        if cached.count > MessageRepository.maxCacheSize {
            isDetachedFromBottom = true
            hasNewerMessages = true
        }
        isLoadingMore = false
    }

    /// Reverse pagination: load newer messages when scrolling back toward bottom.
    /// Async: DB read + AES decryption run on background thread.
    func loadNewer() async {
        guard !isLoadingNewer, hasNewerMessages else { return }
        guard let latest = messages.last else { return }
        isLoadingNewer = true

        let newer = await MessageRepository.shared.loadNewerMessagesAsync(
            for: dialogKey,
            afterTimestamp: latest.timestamp,
            afterMessageId: latest.id,
            limit: MessageRepository.pageSize
        )

        if newer.count < MessageRepository.pageSize {
            hasNewerMessages = false
            isDetachedFromBottom = false
        }
        isLoadingNewer = false
    }

    /// Jump to latest messages (scroll-to-bottom button).
    /// Always reloads from DB — smart merge makes it fast (~10-30ms) when cache is fresh.
    func jumpToBottom() {
        let before = messages.last?.timestamp ?? 0
        let start = CACurrentMediaTime()
        MessageRepository.shared.reloadLatest(for: dialogKey)
        let ms = (CACurrentMediaTime() - start) * 1000
        let after = MessageRepository.shared.messages(for: dialogKey).last?.timestamp ?? 0
        let afterCount = MessageRepository.shared.messages(for: dialogKey).count
        Self.perfLog.notice("⚡ jumpToBottom: \(String(format: "%.0f", ms), privacy: .public)ms | before_ts=\(before, privacy: .public) after_ts=\(after, privacy: .public) count=\(afterCount, privacy: .public)")
        hasNewerMessages = false
        isDetachedFromBottom = false
        hasMoreMessages = true
    }

    /// Ensures a target message is present in current dialog cache before scroll-to-message.
    /// Returns true when the message is available to the UI list.
    func ensureMessageLoaded(messageId: String) async -> Bool {
        guard !messageId.isEmpty else { return false }
        if messages.contains(where: { $0.id == messageId }) {
            return true
        }

        let repo = MessageRepository.shared
        guard repo.ensureMessageLoaded(for: dialogKey, messageId: messageId) else {
            return false
        }

        // Wait briefly for Combine debounce (50ms) to propagate to this view model.
        for _ in 0..<8 {
            if messages.contains(where: { $0.id == messageId }) {
                return true
            }
            try? await Task.sleep(for: .milliseconds(16))
        }

        // Fallback: force a direct snapshot refresh from repository.
        let refreshed = repo.messages(for: dialogKey)
        if refreshed.contains(where: { $0.id == messageId }) {
            messages = refreshed
            return true
        }

        return false
    }
}
