// ScrollbackCacheTests.swift
// Unit tests for ScrollbackCache — the rolling 5 MB on-disk ANSI cache.
//
// Each test creates its own ScrollbackCache with a unique UUID session ID so
// instances never share the same file. All test files are deleted in tearDown.
//
// Tests are synchronous because ScrollbackCache's public API is
// synchronous (guarded internally by a serial DispatchQueue).

import XCTest
@testable import piRemote

final class ScrollbackCacheTests: XCTestCase {

    // Track session IDs created so tearDown can clean every one up.
    private var sessionIds: [String] = []

    override func setUp() {
        super.setUp()
        sessionIds = []
    }

    override func tearDown() {
        // Remove every test cache file, regardless of test outcome.
        for id in sessionIds {
            ScrollbackCache(sessionId: id).clear()
        }
        sessionIds.removeAll()
        super.tearDown()
    }

    // Convenience: create a cache for a fresh UUID session and track it.
    private func makeCache() -> ScrollbackCache {
        let id = UUID().uuidString
        sessionIds.append(id)
        return ScrollbackCache(sessionId: id)
    }

    // MARK: - Append + Read round-trip

    /// A single `append` followed by `read` returns exactly the same bytes.
    func testAppendAndRead_roundTrip() {
        let cache = makeCache()
        let data = Data("hello world".utf8)
        cache.append(data)
        XCTAssertEqual(cache.read(), data)
    }

    /// Multiple successive appends are concatenated in order.
    func testMultipleAppends_areOrdered() {
        let cache = makeCache()
        cache.append(Data("foo".utf8))
        cache.append(Data("bar".utf8))
        XCTAssertEqual(cache.read(), Data("foobar".utf8))
    }

    /// `sizeBytes` reflects the total bytes written.
    func testSizeBytes_reflectsAppend() {
        let cache = makeCache()
        let data = Data(repeating: 0xAB, count: 1_024)
        cache.append(data)
        XCTAssertEqual(cache.sizeBytes, 1_024)
    }

    // MARK: - Cap enforcement

    private let fiveMB = 5 * 1024 * 1024

    /// Writing > 5 MB of data keeps the on-disk size at or below 5 MB.
    func testCap_sizeStaysWithinFiveMB() {
        let cache = makeCache()
        // Write three 2 MB chunks (6 MB total) — must trim.
        let chunk = Data(repeating: 0xCC, count: 2 * 1_024 * 1_024)
        cache.append(chunk)
        cache.append(chunk)
        cache.append(chunk)

        XCTAssertLessThanOrEqual(
            cache.sizeBytes, fiveMB,
            "sizeBytes (\(cache.sizeBytes)) should be ≤ 5 MB after overflow"
        )
    }

    /// After overflow, the last bytes in the file come from the newest chunk (oldest dropped).
    func testCap_dropsOldestBytes() {
        let cache = makeCache()
        // 3 MB of 'A' then 3 MB of 'B' = 6 MB total; trim must keep the tail.
        let chunkA = Data(repeating: 0x41, count: 3 * 1_024 * 1_024) // 'A'
        let chunkB = Data(repeating: 0x42, count: 3 * 1_024 * 1_024) // 'B'
        cache.append(chunkA)
        cache.append(chunkB)

        let result = cache.read()
        XCTAssertFalse(result.isEmpty, "result must not be empty after overflow")
        XCTAssertLessThanOrEqual(result.count, fiveMB)

        // The tail of the retained data must be the newest bytes ('B').
        XCTAssertEqual(result.last, 0x42, "last byte should be from the newest chunk")
        // The very first byte of 'A'-only content should have been trimmed.
        XCTAssertEqual(result.first, 0x41,
            "some 'A' bytes may remain at the head, but 'A'-only prefix was trimmed")
    }

    /// `read()` count stays ≤ 5 MB even after many small appends that accumulate.
    func testCap_manySmallAppends() {
        let cache = makeCache()
        // 1 024 appends × 6 KB = ~6 MB
        let chunk = Data(repeating: 0x55, count: 6 * 1_024)
        for _ in 0..<1_024 {
            cache.append(chunk)
        }
        XCTAssertLessThanOrEqual(cache.read().count, fiveMB)
    }

    // MARK: - clear

    /// After `clear()`, `sizeBytes` is 0.
    func testClear_zeroesSizeBytes() {
        let cache = makeCache()
        cache.append(Data(repeating: 0x00, count: 1_024))
        XCTAssertGreaterThan(cache.sizeBytes, 0) // precondition

        cache.clear()
        XCTAssertEqual(cache.sizeBytes, 0)
    }

    /// After `clear()`, `read()` returns empty Data.
    func testClear_emptiesData() {
        let cache = makeCache()
        cache.append(Data("test".utf8))
        cache.clear()
        XCTAssertTrue(cache.read().isEmpty)
    }

    /// Appending after `clear()` works as if the cache were freshly created.
    func testClear_thenAppend_worksCorrectly() {
        let cache = makeCache()
        cache.append(Data("old".utf8))
        cache.clear()
        cache.append(Data("new".utf8))
        XCTAssertEqual(cache.read(), Data("new".utf8))
        XCTAssertEqual(cache.sizeBytes, 3)
    }

    // MARK: - Isolation between instances

    /// Two caches with different session IDs do not share state.
    func testTwoInstances_doNotInterfere() {
        let cacheA = makeCache()
        let cacheB = makeCache()

        cacheA.append(Data("alpha".utf8))
        cacheB.append(Data("beta".utf8))

        XCTAssertEqual(cacheA.read(), Data("alpha".utf8))
        XCTAssertEqual(cacheB.read(), Data("beta".utf8))
    }

    /// Clearing one cache does not affect another.
    func testClearOneCache_doesNotAffectOther() {
        let cacheA = makeCache()
        let cacheB = makeCache()

        cacheA.append(Data("kept".utf8))
        cacheB.append(Data("cleared".utf8))
        cacheB.clear()

        XCTAssertEqual(cacheA.read(), Data("kept".utf8))
        XCTAssertTrue(cacheB.read().isEmpty)
    }

    // MARK: - Edge cases

    /// Appending empty Data is a no-op (no file created, size stays 0).
    func testAppendEmptyData_isNoOp() {
        let cache = makeCache()
        cache.append(Data())
        XCTAssertEqual(cache.sizeBytes, 0)
        XCTAssertTrue(cache.read().isEmpty)
    }
}
