import CryptoKit
import Foundation
import NucleicCore

/// A renderer-neutral snapshot of one inline Markdown run. SwiftUI's `Text` and
/// Foundation's `AttributedString` are deliberately not archived: neither is a stable on-disk
/// rendering ABI. These three values are the complete output of the inline-only Markdown parser
/// that `MarkdownText` consumes, and can be rebuilt into an `AttributedString` without parsing the
/// Markdown source again.
struct MarkdownCachedRun: Codable, Equatable, Sendable {
    let text: String
    let presentationIntent: UInt?
    let link: String?
}

struct MarkdownCachedInline: Codable, Equatable, Sendable {
    let source: String
    let runs: [MarkdownCachedRun]
}

struct MarkdownCachedDocument: Codable, Equatable, Sendable {
    let sourceByteCount: Int
    let inlines: [MarkdownCachedInline]
}

private struct MarkdownCacheFile: Codable, Equatable, Sendable {
    let schemaVersion: Int
    let documents: [String: MarkdownCachedDocument]
}

/// Durable, per-session Markdown parse cache.
///
/// Each session is one binary property-list file keyed by SHA-256 of the full Markdown message.
/// Opening a transcript therefore performs one sequential read rather than one file lookup per
/// line/message. The file lives under Application Support beside Nucleic's other sensitive local
/// state, is excluded from backup because it is derived, and is safe to delete at any time.
///
/// Blocking file I/O runs on a private serial executor, never Swift's cooperative pool or the main
/// actor. A schema bump makes old files cold misses; no migration path is needed for derived data.
actor MarkdownRenderDiskCache {
    static let schemaVersion = 1
    static let shared = MarkdownRenderDiskCache()

    private static let diskBudgetBytes = 128 * 1_024 * 1_024
    private static let pruneTargetBytes = 96 * 1_024 * 1_024
    private static let memorySessionLimit = 8

    private let directory: URL
    private var hasPruned = false
    /// Avoid rereading a cache file every time the user switches back to a recent chat. This is a
    /// small session-level LRU, separate from `MarkdownText`'s line-level `NSCache`.
    private var loadedBySession: [SessionID: [String: MarkdownCachedDocument]] = [:]
    private var loadedOrder: [SessionID] = []
    private nonisolated let ioExecutor = SerialQueueExecutor(
        label: "com.nucleic.markdown-render-cache", qos: .utility)

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        ioExecutor.asUnownedSerialExecutor()
    }

    init(directory: URL = NucleicPaths.dataRoot
        .appendingPathComponent("derived", isDirectory: true)
        .appendingPathComponent("markdown-render-v\(schemaVersion)", isDirectory: true))
    {
        self.directory = directory
    }

    func load(sessionID: SessionID) -> [String: MarkdownCachedDocument] {
        if let loaded = loadedBySession[sessionID] {
            remember(loaded, for: sessionID)
            return loaded
        }
        let url = fileURL(for: sessionID)
        guard let data = try? Data(contentsOf: url),
              let file = try? PropertyListDecoder().decode(MarkdownCacheFile.self, from: data),
              file.schemaVersion == Self.schemaVersion
        else {
            remember([:], for: sessionID)
            return [:]
        }
        remember(file.documents, for: sessionID)
        return file.documents
    }

    func save(_ documents: [String: MarkdownCachedDocument], sessionID: SessionID) {
        do {
            try prepareDirectory()
            if !hasPruned {
                hasPruned = true
                pruneIfNeeded()
            }
            let file = MarkdownCacheFile(
                schemaVersion: Self.schemaVersion,
                documents: documents)
            let encoder = PropertyListEncoder()
            encoder.outputFormat = .binary
            let data = try encoder.encode(file)
            let url = fileURL(for: sessionID)
            try data.write(to: url, options: .atomic)
            try? FileManager.default.setAttributes(
                [.posixPermissions: 0o600], ofItemAtPath: url.path)
            remember(documents, for: sessionID)
        } catch {
            // A render cache is never correctness-critical. A failed write simply leaves the
            // in-memory cache authoritative for this launch and retries after the next open/turn.
        }
    }

    private func remember(
        _ documents: [String: MarkdownCachedDocument], for sessionID: SessionID
    ) {
        loadedBySession[sessionID] = documents
        loadedOrder.removeAll { $0 == sessionID }
        loadedOrder.append(sessionID)
        while loadedOrder.count > Self.memorySessionLimit {
            loadedBySession[loadedOrder.removeFirst()] = nil
        }
    }

    nonisolated static func documentKey(for markdown: String) -> String {
        SHA256.hash(data: Data(markdown.utf8)).map { String(format: "%02x", $0) }.joined()
    }

    /// Internal for focused cache tests; callers otherwise never depend on the storage layout.
    nonisolated func fileURL(for sessionID: SessionID) -> URL {
        let sessionKey = Self.documentKey(for: sessionID.rawValue)
        return directory.appendingPathComponent(sessionKey).appendingPathExtension("plist")
    }

    private func prepareDirectory() throws {
        try FileManager.default.createDirectory(
            at: directory, withIntermediateDirectories: true,
            attributes: [.posixPermissions: 0o700])
        var values = URLResourceValues()
        values.isExcludedFromBackup = true
        var mutableDirectory = directory
        try? mutableDirectory.setResourceValues(values)
    }

    /// Bound orphaned session caches. Modification time is refreshed by each atomic save, making
    /// oldest-first eviction a practical session-level LRU without a second metadata database.
    private func pruneIfNeeded() {
        let keys: Set<URLResourceKey> = [
            .isRegularFileKey, .fileAllocatedSizeKey, .contentModificationDateKey,
        ]
        guard let urls = try? FileManager.default.contentsOfDirectory(
            at: directory, includingPropertiesForKeys: Array(keys), options: [.skipsHiddenFiles])
        else { return }

        var files: [(url: URL, bytes: Int, modified: Date)] = []
        var total = 0
        for url in urls {
            guard let values = try? url.resourceValues(forKeys: keys),
                  values.isRegularFile == true
            else { continue }
            let bytes = values.fileAllocatedSize ?? 0
            total += bytes
            files.append((url, bytes, values.contentModificationDate ?? .distantPast))
        }
        guard total > Self.diskBudgetBytes else { return }
        for file in files.sorted(by: { $0.modified < $1.modified }) {
            try? FileManager.default.removeItem(at: file.url)
            total -= file.bytes
            if total <= Self.pruneTargetBytes { break }
        }
    }
}
