// ScrollbackCache.swift
// Rolling on-disk cache of raw ANSI bytes per session.
//
// Design:
//   • Storage: <caches>/pi-remote/scrollback/<sessionId>.bin
//   • Cap: 5 MB. When an append would exceed the cap the oldest bytes are
//     dropped from the front (slice off the head) so the file stays below cap.
//   • Thread safety: a serial DispatchQueue guards all reads and writes.
//     No async/await — this is called from both main and background contexts.

import Foundation

/// Rolling on-disk ANSI scrollback cache for a single session.
///
/// - `append(_:)` is O(n) on the file size only when the 5 MB cap is hit
///   (the head-trim path). In the common case (data fits) it is a simple
///   `FileHandle.seekToEnd` + `write`.
/// - All public methods are safe to call from any thread or queue.
public final class ScrollbackCache: @unchecked Sendable {

    // MARK: - Constants

    private static let maxBytes = 5 * 1024 * 1024   // 5 MB

    // MARK: - State

    private let fileURL: URL
    private let queue = DispatchQueue(label: "pi.scrollback", qos: .utility)

    // Cached file size tracked in memory to avoid repeated stat() calls.
    private var _sizeBytes: Int = 0

    // MARK: - Init

    /// Creates (or reopens) a cache for `sessionId` stored at
    /// `<caches>/pi-remote/scrollback/<sessionId>.bin`.
    public init(sessionId: String) {
        let cachesDir = FileManager.default.urls(
            for: .cachesDirectory, in: .userDomainMask
        ).first ?? URL(fileURLWithPath: NSTemporaryDirectory())

        let dir = cachesDir
            .appendingPathComponent("pi-remote", isDirectory: true)
            .appendingPathComponent("scrollback", isDirectory: true)

        // Best-effort directory creation — ignore errors, writes will surface
        // any real problem.
        try? FileManager.default.createDirectory(
            at: dir, withIntermediateDirectories: true
        )

        fileURL = dir.appendingPathComponent("\(sessionId).bin")

        // Seed the in-memory size counter from the existing file (if any).
        _sizeBytes = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))
            .flatMap { $0.fileSize } ?? 0
    }

    // MARK: - Public API

    /// Appends `data` to the cache, trimming the head when the 5 MB cap
    /// would be exceeded.
    public func append(_ data: Data) {
        guard !data.isEmpty else { return }
        queue.sync {
            _append(data)
        }
    }

    /// Returns the full current cache contents (may be empty).
    public func read() -> Data {
        queue.sync {
            (try? Data(contentsOf: fileURL)) ?? Data()
        }
    }

    /// Deletes the cache file and resets the in-memory size counter.
    public func clear() {
        queue.sync {
            try? FileManager.default.removeItem(at: fileURL)
            _sizeBytes = 0
        }
    }

    /// Current cache size in bytes (in-memory approximation, always accurate
    /// after any `append` / `clear` call).
    public var sizeBytes: Int {
        queue.sync { _sizeBytes }
    }

    // MARK: - Private (always called on `queue`)

    private func _append(_ data: Data) {
        let newSize = _sizeBytes + data.count

        if newSize <= Self.maxBytes {
            // Fast path: just append.
            _write(data)
        } else {
            // Slow path: we need to drop bytes from the head.
            // Read the existing content, combine with new data, then keep
            // only the last `maxBytes` bytes so the result fits within cap.
            let existing = (try? Data(contentsOf: fileURL)) ?? Data()
            var combined = existing
            combined.append(data)

            let excess = combined.count - Self.maxBytes
            if excess > 0 {
                combined = combined.dropFirst(excess).withUnsafeBytes { Data($0) }
            }

            // Overwrite the file with the trimmed data.
            try? combined.write(to: fileURL, options: .atomic)
            _sizeBytes = combined.count
        }
    }

    private func _write(_ data: Data) {
        if FileManager.default.fileExists(atPath: fileURL.path) {
            // Append to existing file via FileHandle.
            if let handle = try? FileHandle(forWritingTo: fileURL) {
                defer { try? handle.close() }
                handle.seekToEndOfFile()
                handle.write(data)
                _sizeBytes += data.count
            }
        } else {
            // Create new file.
            try? data.write(to: fileURL, options: .atomic)
            _sizeBytes = data.count
        }
    }
}
