import Foundation
import NucleicProtocol

/// An on-device cache of the phone's recent chat history — the session list and the transcripts of
/// the sessions the user has actually opened — so the app can show a read-only history while it's
/// disconnected from every Mac host (UX_IOS offline). It is a *projection cache*, never canonical
/// state: a live host's data always supersedes it, and it's written straight from the same wire
/// types the sync stream delivers (`SessionSummary` / `AgentEvent`), so nothing is invented.
///
/// Stored as JSON files under Application Support (durable across launches, unlike Caches, which the
/// OS may purge under storage pressure):
///
///   NucleicSessionCache/summaries.json          — the merged session list, recency-capped + pruned
///   NucleicSessionCache/transcripts/<id>.json   — one opened session's event tail
///
/// File I/O is synchronous, but the write/read paths hop onto a utility `Task.detached` so nothing
/// touches disk on the main actor; the wire types are `Sendable`, so the arrays cross the boundary
/// cleanly. Per-session transcript files keep an append-heavy live session from rewriting the whole
/// history on every event, and cap the tail so a long run can't grow unbounded.
enum SessionCache {
    /// Keep the most-recently-updated N sessions' summaries; older ones (and their transcript
    /// files) are pruned on every save so the cache stays bounded.
    private static let sessionLimit = 50
    /// Keep the newest N events per session — the tail is what a returning reader wants, and it
    /// bounds a long-running session's file. Internal (not private) so the background transcript
    /// prefetch can bound a cold session's pull to the same window this cache would keep anyway.
    static let eventLimit = 1500

    // MARK: - Paths

    private static var root: URL? {
        guard let base = try? FileManager.default.url(
            for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
        else { return nil }
        return base.appendingPathComponent("NucleicSessionCache", isDirectory: true)
    }
    private static var summariesURL: URL? { root?.appendingPathComponent("summaries.json") }
    private static var transcriptsDir: URL? {
        root?.appendingPathComponent("transcripts", isDirectory: true)
    }
    private static func transcriptURL(_ id: SessionID) -> URL? {
        transcriptsDir?.appendingPathComponent(transcriptFileName(id))
    }
    private static func transcriptFileName(_ id: SessionID) -> String {
        // Session ids are UUID strings today, but sanitize defensively so an id can never escape
        // the cache directory or collide with the summaries file.
        let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
        return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json"
    }

    // MARK: - Summaries

    /// Persist the recent session list (recency-capped) and prune transcript files whose session is
    /// no longer cached. Off the main actor.
    static func saveSummaries(_ summaries: [WireSessionSummary]) async {
        await Task.detached(priority: .utility) { writeSummaries(summaries) }.value
    }

    /// Load the cached session list — called once at launch to seed the list before any host
    /// connects. Small (≤ `sessionLimit`), so a synchronous read is fine.
    static func loadSummaries() -> [WireSessionSummary] {
        guard let url = summariesURL, let data = try? Data(contentsOf: url),
              let list = try? JSONDecoder().decode([WireSessionSummary].self, from: data)
        else { return [] }
        return list
    }

    private static func writeSummaries(_ summaries: [WireSessionSummary]) {
        guard let root, let url = summariesURL, let dir = transcriptsDir else { return }
        try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit))
        if let data = try? JSONEncoder().encode(kept) { try? data.write(to: url, options: .atomic) }
        // Drop transcript files for sessions that fell out of the recent set.
        let keep = Set(kept.map { transcriptFileName($0.sessionID) })
        if let files = try? FileManager.default.contentsOfDirectory(
            at: dir, includingPropertiesForKeys: nil) {
            for file in files where !keep.contains(file.lastPathComponent) {
                try? FileManager.default.removeItem(at: file)
            }
        }
    }

    // MARK: - Transcripts

    /// Persist one session's transcript (newest `eventLimit` events by seq). Off the main actor.
    static func saveEvents(_ events: [AgentEvent], for id: SessionID) async {
        await Task.detached(priority: .utility) { writeEvents(events, id) }.value
    }

    /// Load a session's cached transcript — used to seed the open transcript instantly, before the
    /// host's snapshot/backfill arrives (and merges on top by seq). Off the main actor.
    static func loadEvents(_ id: SessionID) async -> [AgentEvent] {
        await Task.detached(priority: .utility) { readEvents(id) }.value
    }

    private static func writeEvents(_ events: [AgentEvent], _ id: SessionID) {
        guard !events.isEmpty, let dir = transcriptsDir, let url = transcriptURL(id) else { return }
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let sorted = events.sorted { $0.seq < $1.seq }
        let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted
        if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) }
    }

    /// Drop one session's cached transcript. Used when a revert/undo clears the whole transcript:
    /// `saveEvents` refuses an empty write (so it can't overwrite with nothing), but a reopen-from-
    /// cache seeds via a grow-only merge, so a stale cache would resurrect the reverted events — this
    /// removes the file outright so the reopened transcript starts empty and the live stream refills.
    static func removeEvents(for id: SessionID) {
        guard let url = transcriptURL(id) else { return }
        try? FileManager.default.removeItem(at: url)
    }

    /// Shrink one session's cached transcript to `throughSeq` — drop every event past it — so a
    /// revert/undo on the owner doesn't leave a later reopen-from-cache reseeding the dropped tail
    /// via the grow-only seed merge. Removes the file when nothing is left. No-op if uncached or
    /// already at/below `throughSeq`.
    static func truncateEvents(for id: SessionID, throughSeq: UInt64) {
        guard let url = transcriptURL(id) else { return }
        let cached = readEvents(id)
        guard cached.contains(where: { $0.seq > throughSeq }) else { return }
        let kept = cached.filter { $0.seq <= throughSeq }
        if kept.isEmpty {
            try? FileManager.default.removeItem(at: url)
        } else if let data = try? JSONEncoder().encode(kept) {
            try? data.write(to: url, options: .atomic)
        }
    }

    private static func readEvents(_ id: SessionID) -> [AgentEvent] {
        guard let url = transcriptURL(id), let data = try? Data(contentsOf: url),
              let events = try? JSONDecoder().decode([AgentEvent].self, from: data)
        else { return [] }
        return events
    }

    // MARK: - Clear

    /// Drop the whole cache — on full unpair, when there's no longer any Mac whose history this
    /// device should hold.
    static func clear() {
        guard let root else { return }
        try? FileManager.default.removeItem(at: root)
    }
}
