import XCTest
@testable import Rosetta

/// Tests for MessageRepository sliding window: soft cache limits, bidirectional pagination,
/// and batch re-decryption. Ensures cache doesn't grow unbounded during scroll and that
/// pagination trim removes messages from the correct edge.
@MainActor
final class SlidingWindowTests: XCTestCase {
    private var ctx: DBTestContext!
    private let opponent = "02peer_sliding_window_test"

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

    override func tearDownWithError() throws {
        ctx.teardown()
        ctx = nil
    }

    // MARK: - Helpers

    /// Inserts `count` incoming messages with sequential timestamps into DB + cache.
    private func insertMessages(count: Int, startTimestamp: Int64 = 1000) async throws {
        try await ctx.bootstrap()

        var events: [FixtureEvent] = []
        for i in 0..<count {
            events.append(.incoming(
                opponent: opponent,
                messageId: "msg-\(String(format: "%04d", i))",
                timestamp: startTimestamp + Int64(i),
                text: "Message \(i)"
            ))
        }
        try await ctx.runScenario(FixtureScenario(name: "bulk insert", events: events))
    }

    // MARK: - maxCacheSize Sanity

    /// Verify that maxCacheSize is 200 (Telegram parity).
    func testMaxCacheSizeIs200() {
        XCTAssertEqual(MessageRepository.maxCacheSize, 200)
    }

    /// Verify that pageSize is 50 (Android parity).
    func testPageSizeIs50() {
        XCTAssertEqual(MessageRepository.pageSize, 50)
    }

    // MARK: - Initial Load

    /// Initial cache load uses pageSize (50), not maxCacheSize (200).
    /// After async initial load optimization, messages(for:) returns [] on cache miss
    /// and triggers background decryption. Wait for Combine emission.
    func testInitialLoadUsesPageSize() async throws {
        try await insertMessages(count: 100)

        // Clear cache, force reload
        MessageRepository.shared.reset()
        try await ctx.bootstrap()

        // Trigger async initial load
        let _ = MessageRepository.shared.messages(for: opponent)

        // Wait for background decrypt to complete and update cache
        for _ in 0..<20 {
            try? await Task.sleep(for: .milliseconds(50))
            let cached = MessageRepository.shared.messages(for: opponent)
            if !cached.isEmpty {
                XCTAssertEqual(cached.count, MessageRepository.pageSize,
                    "Initial load must use pageSize (\(MessageRepository.pageSize)), got \(cached.count)")
                return
            }
        }
        XCTFail("Async initial load did not complete within 1 second")
    }

    // MARK: - loadOlderMessages Soft Trim

    /// When cache exceeds 3× maxCacheSize via loadOlderMessages,
    /// newest messages are trimmed to 2× maxCacheSize.
    func testLoadOlderTrimsCacheWhenExceeding3xMax() async throws {
        // Insert enough messages to trigger soft trim
        // We need > 3 * 200 = 600 messages in cache
        let totalMessages = 700
        try await insertMessages(count: totalMessages)

        // Reset and reload latest 200
        MessageRepository.shared.reloadLatest(for: opponent)
        let initial = MessageRepository.shared.messages(for: opponent)
        XCTAssertEqual(initial.count, MessageRepository.maxCacheSize)

        // Paginate up multiple times to grow cache beyond 3× maxCacheSize
        var loadCount = 0
        while loadCount < 15 {
            guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break }
            let older = MessageRepository.shared.loadOlderMessages(
                for: opponent,
                beforeTimestamp: earliest.timestamp,
                beforeMessageId: earliest.id,
                limit: MessageRepository.pageSize
            )
            if older.isEmpty { break }
            loadCount += 1
        }

        let finalCache = MessageRepository.shared.messages(for: opponent)
        let hardLimit = MessageRepository.maxCacheSize * 3

        XCTAssertLessThanOrEqual(finalCache.count, hardLimit,
            "Cache must not exceed 3× maxCacheSize (\(hardLimit)), got \(finalCache.count)")
    }

    /// loadOlderMessages does NOT trim when cache is within 3× maxCacheSize.
    func testLoadOlderDoesNotTrimBelowThreshold() async throws {
        try await insertMessages(count: 300)

        // Load initial 200
        MessageRepository.shared.reloadLatest(for: opponent)

        // One pagination = +50, total ~250 (below 600 threshold)
        guard let earliest = MessageRepository.shared.messages(for: opponent).first else {
            XCTFail("No messages")
            return
        }
        let older = MessageRepository.shared.loadOlderMessages(
            for: opponent,
            beforeTimestamp: earliest.timestamp,
            beforeMessageId: earliest.id,
            limit: MessageRepository.pageSize
        )

        let cached = MessageRepository.shared.messages(for: opponent)
        // 200 + 50 = 250 (no dedup issues since IDs are unique)
        // Some may be deduped, but should be > 200
        XCTAssertGreaterThan(cached.count, MessageRepository.maxCacheSize,
            "Cache should grow beyond maxCacheSize during pagination")
        XCTAssertLessThanOrEqual(cached.count, MessageRepository.maxCacheSize * 3,
            "Cache should stay below soft limit")
    }

    // MARK: - loadNewerMessages Soft Trim

    /// When cache exceeds 3× maxCacheSize via loadNewerMessages,
    /// oldest messages are trimmed to 2× maxCacheSize.
    func testLoadNewerTrimsCacheWhenExceeding3xMax() async throws {
        let totalMessages = 700
        try await insertMessages(count: totalMessages)

        // Load oldest 200 (simulate user scrolled all the way up)
        // We'll manually load from the beginning
        MessageRepository.shared.reloadLatest(for: opponent)

        // Paginate up to load oldest messages first
        for _ in 0..<12 {
            guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break }
            let older = MessageRepository.shared.loadOlderMessages(
                for: opponent,
                beforeTimestamp: earliest.timestamp,
                beforeMessageId: earliest.id,
                limit: MessageRepository.pageSize
            )
            if older.isEmpty { break }
        }

        // Now paginate DOWN (loadNewerMessages) to grow cache from the other direction
        for _ in 0..<15 {
            guard let latest = MessageRepository.shared.messages(for: opponent).last else { break }
            let newer = MessageRepository.shared.loadNewerMessages(
                for: opponent,
                afterTimestamp: latest.timestamp,
                afterMessageId: latest.id,
                limit: MessageRepository.pageSize
            )
            if newer.isEmpty { break }
        }

        let finalCache = MessageRepository.shared.messages(for: opponent)
        let hardLimit = MessageRepository.maxCacheSize * 3

        XCTAssertLessThanOrEqual(finalCache.count, hardLimit,
            "Cache must not exceed 3× maxCacheSize (\(hardLimit)) after bidirectional pagination, got \(finalCache.count)")
    }

    // MARK: - reloadLatest Resets Cache

    /// reloadLatest must reset cache to exactly maxCacheSize.
    func testReloadLatestResetsCacheSize() async throws {
        try await insertMessages(count: 300)

        // reloadLatest loads maxCacheSize (200) messages
        MessageRepository.shared.reloadLatest(for: opponent)
        let initial = MessageRepository.shared.messages(for: opponent)
        XCTAssertEqual(initial.count, MessageRepository.maxCacheSize,
            "reloadLatest must load exactly maxCacheSize messages")

        // Paginate up to grow cache beyond maxCacheSize
        for _ in 0..<3 {
            guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break }
            let _ = MessageRepository.shared.loadOlderMessages(
                for: opponent,
                beforeTimestamp: earliest.timestamp,
                beforeMessageId: earliest.id,
                limit: MessageRepository.pageSize
            )
        }

        let beforeReload = MessageRepository.shared.messages(for: opponent).count
        XCTAssertGreaterThan(beforeReload, MessageRepository.maxCacheSize,
            "Cache should have grown via pagination, got \(beforeReload)")

        // Jump to bottom = reloadLatest resets cache
        MessageRepository.shared.reloadLatest(for: opponent)

        let afterReload = MessageRepository.shared.messages(for: opponent).count
        XCTAssertEqual(afterReload, MessageRepository.maxCacheSize,
            "reloadLatest must reset cache to maxCacheSize (\(MessageRepository.maxCacheSize)), got \(afterReload)")
    }

    // MARK: - Message Order Preserved

    /// Messages must stay sorted by timestamp ASC after pagination trim.
    func testMessageOrderPreservedAfterTrim() async throws {
        try await insertMessages(count: 700)

        MessageRepository.shared.reloadLatest(for: opponent)

        // Paginate up to trigger trim
        for _ in 0..<15 {
            guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break }
            let older = MessageRepository.shared.loadOlderMessages(
                for: opponent,
                beforeTimestamp: earliest.timestamp,
                beforeMessageId: earliest.id,
                limit: MessageRepository.pageSize
            )
            if older.isEmpty { break }
        }

        let cached = MessageRepository.shared.messages(for: opponent)
        // Verify ascending timestamp order
        for i in 1..<cached.count {
            XCTAssertGreaterThanOrEqual(cached[i].timestamp, cached[i - 1].timestamp,
                "Messages must be sorted by timestamp ASC after trim. " +
                "Index \(i): \(cached[i].timestamp) < \(cached[i - 1].timestamp)")
        }
    }

    // MARK: - No Duplicate Messages After Pagination

    /// Pagination must not create duplicate message IDs in cache.
    func testNoDuplicatesAfterPagination() async throws {
        try await insertMessages(count: 400)

        MessageRepository.shared.reloadLatest(for: opponent)

        // Paginate up
        for _ in 0..<5 {
            guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break }
            let _ = MessageRepository.shared.loadOlderMessages(
                for: opponent,
                beforeTimestamp: earliest.timestamp,
                beforeMessageId: earliest.id,
                limit: MessageRepository.pageSize
            )
        }

        let cached = MessageRepository.shared.messages(for: opponent)
        let ids = cached.map(\.id)
        let uniqueIds = Set(ids)
        XCTAssertEqual(ids.count, uniqueIds.count,
            "Cache must not contain duplicate message IDs. \(ids.count) total, \(uniqueIds.count) unique")
    }

    // MARK: - In-Memory Patch (Delivery Status)

    /// Delivery status update patches cache in-memory without full refresh.
    func testDeliveryStatusPatchesInMemory() async throws {
        try await ctx.bootstrap()
        try await ctx.runScenario(FixtureScenario(name: "patch test", events: [
            .outgoing(opponent: opponent, messageId: "patch-1", timestamp: 5000, text: "hello"),
        ]))

        let before = MessageRepository.shared.messages(for: opponent)
        XCTAssertEqual(before.first?.deliveryStatus, .waiting)

        // Patch delivery status
        MessageRepository.shared.updateDeliveryStatus(messageId: "patch-1", status: .delivered)

        let after = MessageRepository.shared.messages(for: opponent)
        XCTAssertEqual(after.first?.deliveryStatus, .delivered,
            "Delivery status must be patched in-memory without full cache refresh")
    }

    // MARK: - In-Memory Patch (Read Status)

    /// markOutgoingAsRead patches cache in-memory.
    func testMarkOutgoingReadPatchesInMemory() async throws {
        try await ctx.bootstrap()
        try await ctx.runScenario(FixtureScenario(name: "read patch", events: [
            .outgoing(opponent: opponent, messageId: "read-1", timestamp: 6000, text: "hello"),
            .markDelivered(opponent: opponent, messageId: "read-1"),
        ]))

        let before = MessageRepository.shared.messages(for: opponent)
        XCTAssertFalse(before.first?.isRead ?? true)

        MessageRepository.shared.markOutgoingAsRead(opponentKey: opponent, myPublicKey: ctx.account)

        let after = MessageRepository.shared.messages(for: opponent)
        XCTAssertTrue(after.first?.isRead ?? false,
            "Read status must be patched in-memory")
    }
}
