import CoreGraphics
import ImageIO
import SwiftUI
import os

// MARK: - BoardBackdrop

/// **The board background's image half** (03-board-ui.md § Styling ▸ Capabilities; the `background`
/// mapping's `image` subkey, 01-storage-format.md § Frontmatter).
///
/// ### The path is relative, and it stays inside the board
///
/// `image: sunset.jpg` names a file in the board folder; `image: art/sunset.jpg` names one in a
/// subfolder of it. An absolute path, or any path that climbs out with `..`, resolves to **nothing**
/// — the same lenient degrade as an unrecognized colour, and for the same two reasons. A `.kanban`
/// folder is a document: it is what gets copied, zipped, synced and handed to somebody else, and a
/// background pointing at `/Users/someone/Pictures` would silently stop working the moment it left
/// this Mac. And the sandbox would refuse the read anyway — the board's own security-scoped access
/// is the only thing this app holds — so the rule the containment check states is the rule the
/// system would enforce one layer down, stated where it can be explained instead of failing.
///
/// The check is **lexical**, which is what makes it testable without a filesystem, and it is not the
/// security boundary: a symlink inside the board pointing anywhere at all still resolves here and is
/// still refused by the sandbox when the bytes are asked for. That is the correct division — 01's
/// "symlinks are never traversed" governs what the *loader* renders as items, and this reads bytes
/// nobody has an identity claim on.
///
/// ### Nothing here decides whether the file is any good
///
/// A path that resolves, a file that is missing, and a file that is not an image all end the same
/// way: no image, no banner, no defect, bytes untouched. There is no editing UI for the field at all
/// (Controls: "the raw file is the escape hatch"), so the one person who can be wrong about it is
/// the one person looking at the folder.
enum BoardBackdrop {

    /// The longest edge, in pixels, the backdrop is ever decoded at.
    ///
    /// Generous enough for a 6K display's short side and for the Retina backing of any window a
    /// board is realistically shown in, and small enough that a 60-megapixel photo dropped in the
    /// folder never becomes a 240 MB decode on a window resize. ImageIO does the reduction while it
    /// reads (`decode`), so the full-size bitmap is never materialized at all.
    static let maximumPixelSize = 3072

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-backdrop")

    /// Where `path` lands inside `root`, or `nil` when it lands nowhere this board may read.
    ///
    /// Standardized before the comparison so `art/../sunset.jpg` is recognized as the file it names
    /// — the check is about where the path *ends up*, not how it is spelled. The trailing separator
    /// on the root is what keeps a sibling board named `Boards/Work.kanban.backup` from passing a
    /// prefix test against `Boards/Work.kanban`.
    ///
    /// `~` is not expanded and is not special: a file honestly named `~notes.png` sitting in the
    /// board folder resolves, because only the shell ever meant anything else by that character.
    static func imageURL(named path: String, inBoardRoot root: URL) -> URL? {
        // An absolute path has to be rejected before it is appended, not after: appending `/etc/x`
        // to a root yields `<root>/etc/x`, which *passes* containment while naming a file the author
        // plainly did not mean.
        guard !path.isEmpty, !path.hasPrefix("/") else { return nil }
        let root = root.standardizedFileURL
        let candidate = root.appendingPathComponent(path).standardizedFileURL
        guard candidate.path.hasPrefix(root.path + "/") else { return nil }
        return candidate
    }

    /// This board's backdrop image, where it has a readable one to name.
    static func imageURL(for board: BoardModel, root: URL) -> URL? {
        guard let path = board.backgroundImage.value else { return nil }
        return imageURL(named: path, inBoardRoot: root)
    }

    /// **Where the app writes its own background images** — a hidden dot-name at board root, the
    /// `.trash/` pattern applied one concept over (03-board-ui.md § Styling ▸ Capabilities; ruled
    /// 2026-08-09: "New app-written background images go into an app-claimed folder").
    ///
    /// `image:` still names a path *relative to the board root*, unchanged: a generated or pasted
    /// picture is written under this folder and referenced by the qualified relative path
    /// (`.backgrounds/facets.png`), which `imageURL(named:inBoardRoot:)` above already resolves
    /// without any change of its own — a subfolder was always a legal `image:` target
    /// (`art/sunset.jpg` resolves exactly the same way, and the read-side coverage for it already
    /// existed). The folder buys tidiness, not new resolution machinery.
    ///
    /// **Not a claimed name, and deliberately so.** Nothing in the board loader ever has to be told
    /// about it: `BoardLoader.directoryCandidates` skips hidden entries at every level it walks
    /// (`.skipsHiddenFiles`) — the same mechanism that already keeps `.trash` out of the lane walk
    /// before that folder's own name-based exclusion is ever consulted — so a hidden, non-UUID-shaped
    /// `.backgrounds` is invisible to the tree walk by construction, with nothing further to add. A
    /// file squatting the name simply fails the next background write like any other I/O error (the
    /// same "the app degrades to correctness without it" posture `.gitignore` has); nothing needs the
    /// name to exist for the app to keep working.
    ///
    /// **Legacy images are not moved here.** A `background.image` written before this folder existed
    /// — or a hand-placed one anywhere else in the board — keeps resolving exactly where it sits; only
    /// a *new* app write (`BoardStore.boardImageName`) chooses this folder, and a regeneration over a
    /// legacy reference leaves the old file orphaned in place rather than migrating it, the same
    /// leftover an overwritten `facets.png` already could.
    static let backgroundsFolderName = ".backgrounds"

    /// **The bare filename `reference` names, when it is a direct child of `.backgrounds/`** — the
    /// read the orphan tidy needs (01-storage-format.md § Validation and healing, ruled 2026-08-09:
    /// "Tidy scopes to `.backgrounds/` ONLY … app-written by construction") to tell "one of ours,
    /// still in service" from everything else a `background.image` could name.
    ///
    /// `nil` for every reading that is not that one shape, folded together on purpose — a missing
    /// reference, a legacy bare name at board root, a hand-placed image in a subfolder of its own, a
    /// reference naming a grandchild of `.backgrounds/` (`.backgrounds/art/x.png`) — because every one
    /// of those means the same thing to a caller asking "which file in `.backgrounds/` is spoken for
    /// right now": none of them are, so every file the folder holds is a candidate. The shared
    /// prefix-and-single-component test is exactly `boardImageName`'s own "only the qualified form
    /// reads as ours" rule, restated as a pure function so both tidy mechanisms — the in-flow trim and
    /// the open-time sweep — read it the same way without either owning the other's copy.
    static func qualifiedBareName(of reference: String?) -> String? {
        guard let reference, reference.hasPrefix("\(backgroundsFolderName)/") else { return nil }
        let rest = reference.dropFirst(backgroundsFolderName.count + 1)
        guard !rest.isEmpty, !rest.contains("/") else { return nil }
        return String(rest)
    }

    /// Whether this board paints a background of its own — **the window-chrome predicate**
    /// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
    /// runs its content under a transparent title bar, and a board without one keeps the standard
    /// chrome exactly as it has always looked.
    ///
    /// It asks the *resolved* image URL rather than merely whether the key reads, so a path that
    /// could never paint anything — absolute, or climbing out of the board — leaves the chrome alone
    /// instead of producing a transparent title bar over the standard background. It does **not**
    /// ask whether the file exists: that is a disk touch, this is read on every board render, and a
    /// declared-but-missing image renders as the frosted strip alone — which is the honest picture of
    /// a board that asked for a backdrop it has not got.
    static func isCustom(_ board: BoardModel, root: URL) -> Bool {
        Palette.color(for: board.background) != nil || imageURL(for: board, root: root) != nil
    }

    // MARK: Reading the bytes

    /// The file's identity as far as reloading is concerned — modification date and size.
    ///
    /// Both, because either alone is forgeable by an ordinary copy: a file replaced within the
    /// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
    /// byte count too. Missing values (a file that is not there) compare equal to each other, which
    /// is what stops a board naming a missing image from re-decoding on every reload.
    /// `Hashable` because a stamp is half of a cache key as well as a comparison: the card face's
    /// hero cache files a decoded picture under "this path, as of these bytes" (`CardHeroCache`).
    struct Stamp: Hashable, Sendable {
        var modified: Date?
        var size: Int?
    }

    static func stamp(of url: URL) -> Stamp {
        // **The cached resource values are dropped first, and that is load-bearing.** A `URL` value
        // memoizes what it was last told about the file behind it, so a stamp taken twice from *one*
        // URL value answers with the first read's date and size however many times the bytes were
        // replaced in between — and noticing exactly that is the only thing a stamp is for. A caller
        // that happens to rebuild its URL each time was never affected; one that holds a URL and
        // re-stats it (the card face's hero cache) would silently never see a change.
        var url = url
        url.removeAllCachedResourceValues()
        let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
        return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
    }

    /// Decodes the file at `url`, downsampled to `limit` pixels on its longest edge — or `nil` for
    /// anything that is not a readable image.
    ///
    /// **`limit` is the caller's, because "how big is big enough" is a question about the surface
    /// being drawn.** A window-filling backdrop wants the default; a card face's hero band is two
    /// orders smaller in area and passes its own (`CardHero.maximumPixelSize`), which is the whole
    /// reason the parameter exists rather than a second copy of these four options.
    ///
    /// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail
    /// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than
    /// the file's. `FromImageAlways` is what makes it a downsample rather than a lottery — without
    /// it a JPEG carrying its own small embedded thumbnail would answer with that instead of the
    /// picture. `WithTransform` applies the EXIF orientation, so a photo shot in portrait is not
    /// laid on its side.
    ///
    /// Never call this on the main actor; see `BoardBackdropImage`'s task.
    static func decode(_ url: URL, limit: Int = maximumPixelSize) -> CGImage? {
        guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
        let options: [CFString: Any] = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceThumbnailMaxPixelSize: limit,
        ]
        guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
            logger.debug("image at \(url.lastPathComponent, privacy: .private) could not be decoded")
            return nil
        }
        return image
    }
}

// MARK: - BoardBackdropImage

/// The decoded backdrop, drawn to fill (03-board-ui.md § Styling ▸ Capabilities).
///
/// **Fill, cropped — never letterboxed and never stretched.** A background is a surface, so it
/// covers the window whatever its aspect ratio; the alternative would put bars of the underlying
/// colour along two edges and make the board look broken rather than styled.
///
/// ### The load is asynchronous, and that is the whole design of this view
///
/// A board is opened by double-clicking a folder, and the folder may contain a 60-megapixel
/// photograph. Decoding that on the main actor during a body evaluation is a visible hitch on open
/// and a worse one on every subsequent reload, so the work happens off it and the view simply has
/// nothing to draw until it lands — under the board's colour, which is already painted beneath.
///
/// The task is keyed on the URL and on the store's landed-reload count, which is the board's
/// FSEvents pulse: replacing `sunset.jpg` in Finder changes no *model* value, so the snapshot comes
/// back equal and `snapshotGeneration` deliberately does not move (`BoardStore.landedReloads`) —
/// keying on the generation would mean an edited image never reloaded. Every re-key costs one
/// `stat`; only a file that actually changed costs a decode.
struct BoardBackdropImage: View {

    let url: URL

    /// The board's landed-reload count — see the type's note. Not read from a store here because
    /// this view has no other reason to hold one.
    let reloads: Int

    /// What is on screen, and what it was decoded from. One value rather than three `@State`s so a
    /// URL, its stamp and its bitmap can never disagree about which file is being shown.
    @State private var loaded: Loaded?

    private struct Loaded {
        let url: URL
        let stamp: BoardBackdrop.Stamp
        let image: CGImage
    }

    var body: some View {
        // `Color.clear` establishes the frame the image fills and is what `clipped` trims against;
        // the overlay is what overflows it. Decorative, because a board background is decoration in
        // the precise sense 10-accessibility.md means — it carries no information VoiceOver could
        // usefully say, and the ink rule keeps the text on it legible on its own.
        Color.clear
            .overlay {
                if let loaded {
                    Image(decorative: loaded.image, scale: 1)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                }
            }
            .clipped()
            .task(id: Key(url: url, reloads: reloads)) { await reload() }
    }

    /// The `.task` identity: the file, and the board's pulse.
    private struct Key: Equatable {
        let url: URL
        let reloads: Int
    }

    /// Re-decodes when the bytes have changed, and only then.
    ///
    /// `Task.detached` rather than a bare `await` on a `nonisolated` function, so the hop off this
    /// view's actor is stated rather than inferred from whatever the language mode currently makes
    /// of an async call. Cancellation is checked on the way back instead of forwarded into it: both
    /// halves are short, and a stale bitmap assigned to a view that has gone away is the failure
    /// worth preventing.
    private func reload() async {
        let url = url
        let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
        if let loaded, loaded.url == url, loaded.stamp == stamp { return }
        guard !Task.isCancelled else { return }

        let decoded = await Task.detached(priority: .userInitiated) { BoardBackdrop.decode(url) }.value
        guard !Task.isCancelled else { return }
        // A failure clears what was there: the file the board names is the file it shows, and
        // holding the previous picture would make a broken path look like a working one.
        loaded = decoded.map { Loaded(url: url, stamp: stamp, image: $0) }
    }
}
