import Foundation
import NucleicCore

/// Inputs that can change a transcript segment's vertical length without changing its row ids.
///
/// Width is the actual centered prose width, rounded to a whole point. Wide windows therefore all
/// share the 720pt layout while narrower windows retain an exact wrapping-specific snapshot.
struct TranscriptRenderLengthContext: Codable, Equatable, Hashable, Sendable {
    let contentWidth: Int
    let rowsPerSegment: Int
    let showDebugLines: Bool
    let showLockEvents: Bool
    let revertEpoch: UInt64

    /// A nearby-width snapshot is a much better cold estimate than the generic per-row fallback.
    /// Exact matches still win; this tolerance only bridges small window/sidebar differences.
    func isCompatible(with other: Self, widthTolerance: Int = 24) -> Bool {
        rowsPerSegment == other.rowsPerSegment
            && showDebugLines == other.showDebugLines
            && showLockEvents == other.showLockEvents
            && revertEpoch == other.revertEpoch
            && abs(contentWidth - other.contentWidth) <= widthTolerance
    }
}

/// Stable description of the rows occupying one render segment. The boundary plus endpoint ids
/// prevents a cached height from being applied after filtering/rechunking changes its contents.
struct TranscriptRenderSegmentIdentity: Codable, Equatable, Hashable, Sendable {
    let lowerBound: Int
    let rowCount: Int
    let firstItemID: String
    let lastItemID: String
}

struct TranscriptRenderSegmentLength: Codable, Equatable, Sendable {
    let segment: TranscriptRenderSegmentIdentity
    /// The measured content height in points (native-list insets are outside this value).
    let height: Double
}

/// One immediately-loadable canvas-length snapshot. It is derived UI data, never canonical chat
/// state: deleting it merely makes the next open use estimates and measure the rows again.
struct TranscriptRenderLengthLayout: Codable, Equatable, Sendable {
    let context: TranscriptRenderLengthContext
    var segments: [TranscriptRenderSegmentLength]
    var updatedAt: Date

    func height(for segment: TranscriptRenderSegmentIdentity) -> Double? {
        segments.first(where: { $0.segment == segment })?.height
    }

    mutating func record(height: Double, for segment: TranscriptRenderSegmentIdentity, at: Date) {
        let value = TranscriptRenderSegmentLength(segment: segment, height: height)
        if let index = segments.firstIndex(where: { $0.segment == segment }) {
            segments[index] = value
        } else {
            segments.append(value)
            segments.sort { $0.segment.lowerBound < $1.segment.lowerBound }
        }
        updatedAt = at
    }
}

/// Dictionary form used by the live transcript. Building it can touch thousands of persisted
/// records, so callers construct it off-main and install the copy-on-write value in one assignment.
struct TranscriptRenderLengthLookup: Sendable {
    let heights: [
        TranscriptRenderLengthContext: [TranscriptRenderSegmentIdentity: Double]
    ]
    let updatedAt: [TranscriptRenderLengthContext: Date]

    init(layouts: [TranscriptRenderLengthLayout]) {
        var heights: [
            TranscriptRenderLengthContext: [TranscriptRenderSegmentIdentity: Double]
        ] = [:]
        var updatedAt: [TranscriptRenderLengthContext: Date] = [:]
        for layout in layouts {
            var records: [TranscriptRenderSegmentIdentity: Double] = [:]
            records.reserveCapacity(layout.segments.count)
            for record in layout.segments { records[record.segment] = record.height }
            heights[layout.context] = records
            updatedAt[layout.context] = layout.updatedAt
        }
        self.heights = heights
        self.updatedAt = updatedAt
    }
}

private struct TranscriptRenderLengthFile: Codable, Equatable, Sendable {
    let schemaVersion: Int
    let layouts: [TranscriptRenderLengthLayout]
}

/// Durable per-session vertical-length metadata for progressive transcript hydration.
///
/// Files are tiny binary plists on a private serial executor. The transcript and GRDB remain free
/// of renderer-specific points/widths; this sidecar is owner-only, excluded from backup, bounded,
/// and disposable in exactly the same way as the Markdown render cache.
actor TranscriptRenderLengthCache {
    static let schemaVersion = 1
    static let shared = TranscriptRenderLengthCache()

    static let maxLayoutsPerSession = 6
    private static let memorySessionLimit = 12
    private static let diskBudgetBytes = 16 * 1_024 * 1_024
    private static let pruneTargetBytes = 12 * 1_024 * 1_024

    private let directory: URL
    private var loadedBySession: [SessionID: [TranscriptRenderLengthLayout]] = [:]
    private var loadedOrder: [SessionID] = []
    private var hasPruned = false
    private nonisolated let ioExecutor = SerialQueueExecutor(
        label: "com.nucleic.transcript-render-length-cache", qos: .utility)

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

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

    func load(sessionID: SessionID) -> [TranscriptRenderLengthLayout] {
        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(
                TranscriptRenderLengthFile.self, from: data),
              file.schemaVersion == Self.schemaVersion
        else {
            remember([], for: sessionID)
            return []
        }
        remember(file.layouts, for: sessionID)
        return file.layouts
    }

    func save(_ layout: TranscriptRenderLengthLayout, sessionID: SessionID) {
        var layouts = load(sessionID: sessionID)
        layouts.removeAll { $0.context == layout.context }
        layouts.append(layout)
        layouts.sort { $0.updatedAt > $1.updatedAt }
        if layouts.count > Self.maxLayoutsPerSession {
            layouts.removeLast(layouts.count - Self.maxLayoutsPerSession)
        }

        do {
            try prepareDirectory()
            if !hasPruned {
                hasPruned = true
                pruneIfNeeded()
            }
            let file = TranscriptRenderLengthFile(
                schemaVersion: Self.schemaVersion, layouts: layouts)
            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(layouts, for: sessionID)
        } catch {
            // Derived layout metadata is never correctness-critical. Retain it in memory for this
            // process and retry after a later measurement/open if the sidecar write fails.
            remember(layouts, for: sessionID)
        }
    }

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

    nonisolated func fileURL(for sessionID: SessionID) -> URL {
        let key = MarkdownRenderDiskCache.documentKey(for: sessionID.rawValue)
        return directory.appendingPathComponent(key).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)
    }

    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 }
        }
    }
}
