import XCTest
@testable import Rosetta

/// Tests for the read eligibility lifecycle fix:
/// readEligibleDialogs must be cleared on background entry to prevent
/// stale eligibility from marking messages as read during background sync
/// or premature foreground resume.
@MainActor
final class ReadEligibilityTests: XCTestCase {
    private var ctx: DBTestContext!
    private let peer = "02peer_read_test"

    override func setUpWithError() throws {
        ctx = DBTestContext()
    }

    override func tearDownWithError() throws {
        // Clean up dialog state
        MessageRepository.shared.setDialogActive(peer, isActive: false)
        MessageRepository.shared.clearAllReadEligibility()
        ctx.teardown()
        ctx = nil
    }

    // MARK: - clearAllReadEligibility

    func testClearAllReadEligibility_removesAllEntries() async throws {
        try await ctx.bootstrap()

        // Set up two dialogs as active + read-eligible
        let peer2 = "02peer_read_test_2"
        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogActive(peer2, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        MessageRepository.shared.setDialogReadEligible(peer2, isEligible: true)

        XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))
        XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer2))

        // Clear all
        MessageRepository.shared.clearAllReadEligibility()

        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer2))

        // Active state is preserved (only eligibility cleared)
        XCTAssertTrue(MessageRepository.shared.isDialogActive(peer))
        XCTAssertTrue(MessageRepository.shared.isDialogActive(peer2))

        // Cleanup
        MessageRepository.shared.setDialogActive(peer2, isActive: false)
    }

    // MARK: - Stale eligibility → messages inserted as read (the bug)

    func testStaleEligibility_marksIncomingAsReadOnInsert() async throws {
        try await ctx.bootstrap()

        // Simulate: user opens chat, scrolls to bottom → read-eligible
        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        // Simulate: message arrives while eligibility is stale (e.g., background sync)
        try await ctx.runScenario(FixtureScenario(name: "stale read", events: [
            .incoming(opponent: peer, messageId: "stale-1", timestamp: 1000, text: "hello"),
        ]))

        let snapshot = try ctx.normalizedSnapshot()
        // BUG: message is marked read because readEligibleDialogs had stale entry
        XCTAssertEqual(snapshot.messages.first?.read, true,
            "With stale eligibility, incoming message is incorrectly marked as read")
        XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0,
            "With stale eligibility, unread count is 0 (should be 1)")
    }

    func testClearedEligibility_keepsIncomingAsUnread() async throws {
        try await ctx.bootstrap()

        // Simulate: user opens chat, scrolls to bottom → read-eligible
        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        // Simulate: app enters background → eligibility cleared (THE FIX)
        MessageRepository.shared.clearAllReadEligibility()

        // Simulate: message arrives during background sync
        try await ctx.runScenario(FixtureScenario(name: "cleared read", events: [
            .incoming(opponent: peer, messageId: "cleared-1", timestamp: 2000, text: "world"),
        ]))

        let snapshot = try ctx.normalizedSnapshot()
        // FIXED: message stays unread because eligibility was cleared
        XCTAssertEqual(snapshot.messages.first?.read, false,
            "After clearing eligibility, incoming message must stay unread")
        XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1,
            "After clearing eligibility, unread count must be 1")
    }

    // MARK: - Re-enabling eligibility after foreground resume

    func testReEnableEligibility_afterClear_marksAsRead() async throws {
        try await ctx.bootstrap()

        // Simulate: user opens chat → active + eligible
        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        // Background → clear
        MessageRepository.shared.clearAllReadEligibility()

        // Message arrives while cleared → stays unread
        try await ctx.runScenario(FixtureScenario(name: "pre-reenable", events: [
            .incoming(opponent: peer, messageId: "re-1", timestamp: 3000, text: "hey"),
        ]))

        let snapshot1 = try ctx.normalizedSnapshot()
        XCTAssertEqual(snapshot1.messages.first?.read, false)
        XCTAssertEqual(snapshot1.dialogs.first?.unreadCount, 1)

        // Foreground → ChatDetailView re-enables eligibility + marks as read
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account)
        DialogRepository.shared.markAsRead(opponentKey: peer)

        let snapshot2 = try ctx.normalizedSnapshot()
        XCTAssertEqual(snapshot2.messages.first?.read, true,
            "After re-enabling eligibility and marking, message must be read")
        XCTAssertEqual(snapshot2.dialogs.first?.unreadCount, 0)
    }

    // MARK: - Notification tap scenario (Chat A open, tap notification for Chat B)

    func testNotificationTap_differentChat_doesNotMarkOriginalAsRead() async throws {
        try await ctx.bootstrap()

        let chatA = peer
        let chatB = "02peer_chat_b"

        // User is in Chat A, scrolled to bottom
        MessageRepository.shared.setDialogActive(chatA, isActive: true)
        MessageRepository.shared.setDialogReadEligible(chatA, isEligible: true)

        // App goes to background → eligibility cleared
        MessageRepository.shared.clearAllReadEligibility()

        // Messages arrive for Chat A during background
        try await ctx.runScenario(FixtureScenario(name: "chatA bg", events: [
            .incoming(opponent: chatA, messageId: "a-1", timestamp: 4000, text: "from A"),
        ]))

        // Messages arrive for Chat B during background
        try await ctx.runScenario(FixtureScenario(name: "chatB bg", events: [
            .incoming(opponent: chatB, messageId: "b-1", timestamp: 4001, text: "from B"),
        ]))

        let snapshot = try ctx.normalizedSnapshot()

        // Chat A messages must be unread (user didn't view them)
        let chatAMsg = snapshot.messages.first(where: { $0.messageId == "a-1" })
        XCTAssertEqual(chatAMsg?.read, false, "Chat A message must be unread after background")

        let chatADialog = snapshot.dialogs.first(where: { $0.opponentKey == chatA })
        XCTAssertEqual(chatADialog?.unreadCount, 1, "Chat A must have 1 unread")

        // Chat B messages must also be unread
        let chatBMsg = snapshot.messages.first(where: { $0.messageId == "b-1" })
        XCTAssertEqual(chatBMsg?.read, false, "Chat B message must be unread")

        let chatBDialog = snapshot.dialogs.first(where: { $0.opponentKey == chatB })
        XCTAssertEqual(chatBDialog?.unreadCount, 1, "Chat B must have 1 unread")

        // Now simulate: user taps notification for Chat B → Chat B becomes active + eligible
        MessageRepository.shared.setDialogActive(chatA, isActive: false)
        MessageRepository.shared.setDialogActive(chatB, isActive: true)
        MessageRepository.shared.setDialogReadEligible(chatB, isEligible: true)
        MessageRepository.shared.markIncomingAsRead(opponentKey: chatB, myPublicKey: ctx.account)
        DialogRepository.shared.markAsRead(opponentKey: chatB)

        let snapshot2 = try ctx.normalizedSnapshot()

        // Chat A still unread
        let chatAMsg2 = snapshot2.messages.first(where: { $0.messageId == "a-1" })
        XCTAssertEqual(chatAMsg2?.read, false, "Chat A must STILL be unread after tapping Chat B notification")

        let chatADialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatA })
        XCTAssertEqual(chatADialog2?.unreadCount, 1, "Chat A must STILL have 1 unread")

        // Chat B is now read
        let chatBMsg2 = snapshot2.messages.first(where: { $0.messageId == "b-1" })
        XCTAssertEqual(chatBMsg2?.read, true, "Chat B message must be read after opening")

        let chatBDialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatB })
        XCTAssertEqual(chatBDialog2?.unreadCount, 0, "Chat B must have 0 unread after opening")

        // Cleanup
        MessageRepository.shared.setDialogActive(chatB, isActive: false)
    }

    // MARK: - setDialogActive(false) also clears eligibility

    func testSetDialogActiveOff_clearsEligibility() async throws {
        try await ctx.bootstrap()

        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))

        MessageRepository.shared.setDialogActive(peer, isActive: false)
        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
        XCTAssertFalse(MessageRepository.shared.isDialogActive(peer))
    }

    // MARK: - Eligibility requires active dialog

    func testSetDialogReadEligible_requiresActiveDialog() async throws {
        try await ctx.bootstrap()

        // Try to set eligible without active → should be ignored
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer),
            "Eligibility must not be set for inactive dialogs")

        // Now activate and set eligible
        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))
    }

    // MARK: - Multiple messages during background

    func testMultipleMessagesWhileBackgrounded_allStayUnread() async throws {
        try await ctx.bootstrap()

        // User in chat, at bottom
        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        // Background → clear
        MessageRepository.shared.clearAllReadEligibility()

        // Multiple messages arrive during background
        try await ctx.runScenario(FixtureScenario(name: "batch bg", events: [
            .incoming(opponent: peer, messageId: "batch-1", timestamp: 5000, text: "msg 1"),
            .incoming(opponent: peer, messageId: "batch-2", timestamp: 5001, text: "msg 2"),
            .incoming(opponent: peer, messageId: "batch-3", timestamp: 5002, text: "msg 3"),
        ]))

        let snapshot = try ctx.normalizedSnapshot()
        let messages = snapshot.messages.filter { $0.messageId.hasPrefix("batch-") }

        XCTAssertEqual(messages.count, 3)
        for msg in messages {
            XCTAssertEqual(msg.read, false, "Message \(msg.messageId) must be unread")
        }

        let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer })
        XCTAssertEqual(dialog?.unreadCount, 3, "All 3 messages must be unread")
    }

    // MARK: - Return to same chat after background

    func testReturnToSameChat_afterReEnable_marksAllRead() async throws {
        try await ctx.bootstrap()

        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        // Background → clear
        MessageRepository.shared.clearAllReadEligibility()

        // Messages during background
        try await ctx.runScenario(FixtureScenario(name: "return same", events: [
            .incoming(opponent: peer, messageId: "ret-1", timestamp: 6000, text: "a"),
            .incoming(opponent: peer, messageId: "ret-2", timestamp: 6001, text: "b"),
        ]))

        // Foreground → user returns to same chat → re-enable eligibility + mark
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account)
        DialogRepository.shared.markAsRead(opponentKey: peer)

        let snapshot = try ctx.normalizedSnapshot()
        let messages = snapshot.messages.filter { $0.messageId.hasPrefix("ret-") }

        for msg in messages {
            XCTAssertEqual(msg.read, true, "Message \(msg.messageId) must be read after re-enable")
        }

        let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer })
        XCTAssertEqual(dialog?.unreadCount, 0)
    }

    // MARK: - clearAllReadEligibility is idempotent

    func testClearAllReadEligibility_idempotent() async throws {
        try await ctx.bootstrap()

        // Call on empty set — no crash
        MessageRepository.shared.clearAllReadEligibility()
        MessageRepository.shared.clearAllReadEligibility()

        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
    }

    // MARK: - Idle detection (Desktop/Android parity: 20s)

    func testIdleTimer_clearsEligibilityAfterTimeout() async throws {
        try await ctx.bootstrap()

        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
        XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))

        // Simulate idle timeout (call clearAllReadEligibility directly,
        // since we can't wait 20s in a unit test)
        SessionManager.shared.resetIdleTimer()
        XCTAssertFalse(SessionManager.shared.isUserIdle, "User should not be idle right after reset")

        // Simulate what the idle timer callback does
        MessageRepository.shared.clearAllReadEligibility()

        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer),
            "After idle timeout, eligibility must be cleared")

        // Messages arriving during idle should be unread
        try await ctx.runScenario(FixtureScenario(name: "idle msg", events: [
            .incoming(opponent: peer, messageId: "idle-1", timestamp: 7000, text: "idle msg"),
        ]))

        let snapshot = try ctx.normalizedSnapshot()
        let msg = snapshot.messages.first(where: { $0.messageId == "idle-1" })
        XCTAssertEqual(msg?.read, false, "Message during idle must be unread")

        let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer })
        XCTAssertEqual(dialog?.unreadCount, 1, "Unread count must be 1 during idle")

        // Cleanup
        SessionManager.shared.stopIdleTimer()
    }

    func testIdleTimer_resetRestoresEligibility() async throws {
        try await ctx.bootstrap()

        MessageRepository.shared.setDialogActive(peer, isActive: true)
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        // Simulate idle → clear eligibility
        MessageRepository.shared.clearAllReadEligibility()
        XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))

        // Simulate user interaction → reset timer + re-enable eligibility
        SessionManager.shared.resetIdleTimer()
        MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)

        XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer),
            "After user interaction, eligibility must be restored")

        // Cleanup
        SessionManager.shared.stopIdleTimer()
    }

    func testStopIdleTimer_resetsIdleState() async throws {
        try await ctx.bootstrap()

        SessionManager.shared.resetIdleTimer()
        XCTAssertFalse(SessionManager.shared.isUserIdle)

        SessionManager.shared.stopIdleTimer()
        XCTAssertFalse(SessionManager.shared.isUserIdle, "stopIdleTimer must clear idle state")
    }
}
