import AppKit
import Observation
import QuickLookThumbnailing

// MARK: - AttachmentThumbnailKey

/// What a generated thumbnail is filed under: **which file, at which drawn size, as of which
/// bytes** (05-card-window.md ▸ Attachments — "small QuickLook thumbnail (Finder-icon fallback)").
///
/// The third component is what makes the cache honest rather than merely fast. A card folder is a
/// live view over a directory anyone may write to (02-architecture.md), so a cache keyed on the path
/// alone would happily show a picture of a file that has since been replaced. Modification date
/// *and* size, because either alone misses a case: a same-second rewrite keeps the date, and an edit
/// that preserves the length keeps the size.
///
/// **Missing values are a legitimate key, not a refusal.** A file that cannot be stat'd — it went
/// away between the listing and the render, a volume dropped — keys as `(nil, nil)` and simply
/// misses on the next attempt, which is the ordinary "no thumbnail, show the icon" path rather than
/// a second error surface.
struct AttachmentThumbnailKey: Hashable, Sendable {

    /// The half a *view* can name without touching the disk — the path and the size it is drawn at.
    ///
    /// The cache is looked up by this during a render and completed by the full key in a task,
    /// which is the whole reason it is a type: a render must never `stat`, and a stale-check must
    /// never be skipped.
    struct Slot: Hashable, Sendable {
        let path: String
        let side: Int

        /// The row's thumbnail is square and **buckets to whole points**: the row height derives
        /// from the body font's metrics (`CardWindowMetrics`), which can land on a fraction, and a
        /// thumbnail regenerated because the row grew by a third of a point would be a cache that
        /// never hits.
        init(path: String, side: CGFloat) {
            self.path = path
            self.side = max(1, Int(side.rounded()))
        }
    }

    let slot: Slot
    let modified: Date?
    let size: Int64?
}

// MARK: - AttachmentThumbnailCache

/// One card window's thumbnail memory — **per window**, held by `CardWindowHost` and read by the
/// attachments section beneath it.
///
/// ### Why the window
///
/// Per *row* would defeat the point: rows are rebuilt on every snapshot the store applies (a body
/// edit in another window, a watcher reload, a lane move), and a cache that died with the view would
/// regenerate every thumbnail each time. Per *app* would outlive the thing it is caching — a card
/// window closing is the natural moment to forget its files. The window is the scope where "the
/// files I am looking at" is true.
///
/// ### What it does not do
///
/// It never watches, invalidates or evicts on change: the key carries the file's stamp, so a
/// rewritten file simply keys differently and the stale entry ages out under the cap below. There is
/// deliberately no invalidation path to keep honest — the same posture `Card.attachments` takes.
///
/// This is the **minimal** re-creation of a cache the board face once had and that went with the
/// carousel (commit `1020d9f`, sole consumer): small square rows instead of page-sized pictures, and
/// nothing about paging, so the cap and the drawn size are both an order smaller.
@MainActor
@Observable
final class AttachmentThumbnailCache {

    /// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a
    /// card may hold hundreds of attachments; a plain insertion-ordered drop rather than a recency
    /// policy because a sidebar's access pattern is "the rows on screen", which is the recent set.
    static let limit = 128

    /// The resolved stamp for each drawn slot — the render-time half of the lookup.
    private var keys: [AttachmentThumbnailKey.Slot: AttachmentThumbnailKey] = [:]

    private var images: [AttachmentThumbnailKey: CGImage] = [:]

    /// Insertion order over `images`, for the cap.
    private var order: [AttachmentThumbnailKey] = []

    /// Keys QuickLook declined — an unknown type, an unreadable one. Remembered so a
    /// non-previewable attachment costs one generation attempt per version of itself rather than one
    /// per redraw; the row shows its Finder icon and stops asking.
    private var unpreviewable: Set<AttachmentThumbnailKey> = []

    /// Finder icons, by path. **Deliberately outside observation** (`@ObservationIgnored`): this one
    /// is filled *during* a render, because an icon is the fallback a row draws while its thumbnail
    /// is still being made, and a tracked write there would invalidate the view that just read it.
    /// Nothing depends on an icon changing — `NSWorkspace` answers the same image for the same path
    /// until the app is relaunched.
    @ObservationIgnored private var icons: [String: NSImage] = [:]

    // MARK: - Reading, during a render

    /// This slot's thumbnail, or `nil` when there is not one *yet* — the two dictionary reads a
    /// render is allowed to do. A miss is not a failure; it is the icon fallback's cue.
    func thumbnail(for slot: AttachmentThumbnailKey.Slot) -> CGImage? {
        guard let key = keys[slot] else { return nil }
        return images[key]
    }

    /// The file's Finder icon — 05's "Finder-icon fallback", shown while a thumbnail is being
    /// generated and kept for anything QuickLook cannot preview.
    ///
    /// `icon(forFile:)` rather than an icon for the file's *type*, deliberately: it is what Finder
    /// itself shows, custom icons and application bundles included, and an attachment that looks
    /// different in Finder than in the sidebar would be the app disagreeing with the substrate it is
    /// a view over.
    func icon(forFileAt url: URL) -> NSImage {
        if let cached = icons[url.path] { return cached }
        let icon = NSWorkspace.shared.icon(forFile: url.path)
        icons[url.path] = icon
        return icon
    }

    // MARK: - Filling, off the render

    /// Resolves this slot's key and generates its thumbnail if that key has none — the whole of the
    /// cache's write side, called from a row's `.task` and never from a body.
    ///
    /// Both halves run **off the main actor** (`nonisolated`): the `stat` because a render is
    /// waiting on this task, and the generation because it is a round trip to a QuickLook extension
    /// in another process. Only `CGImage` crosses back, which is `Sendable`; the representation the
    /// generator hands out is not, and never leaves the function that made it.
    func load(_ slot: AttachmentThumbnailKey.Slot, url: URL, scale: CGFloat) async {
        let key = await Self.resolve(slot, url: url)
        keys[slot] = key
        guard images[key] == nil, !unpreviewable.contains(key) else { return }

        let side = CGFloat(slot.side)
        guard let image = await Self.generate(url: url, size: CGSize(width: side, height: side), scale: scale) else {
            unpreviewable.insert(key)
            return
        }
        remember(image, for: key)
    }

    private func remember(_ image: CGImage, for key: AttachmentThumbnailKey) {
        if images.updateValue(image, forKey: key) == nil {
            order.append(key)
        }
        while order.count > Self.limit {
            images.removeValue(forKey: order.removeFirst())
        }
    }

    private nonisolated static func resolve(
        _ slot: AttachmentThumbnailKey.Slot,
        url: URL
    ) async -> AttachmentThumbnailKey {
        let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
        return AttachmentThumbnailKey(
            slot: slot,
            modified: values?.contentModificationDate,
            size: values?.fileSize.map(Int64.init)
        )
    }

    /// One QuickLook request, at the row's own size. `.thumbnail` alone rather than `.all`: the icon
    /// representations QuickLook would fall back to are exactly what `icon(forFileAt:)` already
    /// draws, and taking them here would cache a decorated icon under a thumbnail's key and never
    /// try for a real one again. `iconMode` is off for the same reason — the row wants the picture,
    /// not a page-curled document.
    private nonisolated static func generate(url: URL, size: CGSize, scale: CGFloat) async -> CGImage? {
        let request = QLThumbnailGenerator.Request(
            fileAt: url,
            size: size,
            scale: scale,
            representationTypes: .thumbnail
        )
        request.iconMode = false
        guard let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
        else { return nil }
        return representation.cgImage
    }
}
