import Foundation

/// **Everything a Quick Look preview knows about a board** — its name, its tint, and each lane's
/// first few card titles — read straight off the folder tree with no store, no watcher and no
/// window behind it.
///
/// ### Why this is not `BoardLoader.load`
///
/// The snapshot the app opens a board with is the wrong shape for a preview in three ways, and each
/// one is a reason rather than a preference:
///
/// - **It fails.** `BoardLoader` collects fail-fast defects and throws a `BoardLoadFailure` for a
///   missing root `index.md`, an unreadable `schema`, a lane whose YAML will not parse
///   (01-storage-format.md § Malformed input). That is exactly right for opening a board — the user
///   is about to edit it — and exactly wrong for pressing Space in Finder, where the honest answer to
///   a half-broken board is *the part that reads*. Nothing in this file throws: every failure
///   degrades to the empty answer for the thing that failed and the walk carries on.
/// - **It is unbounded.** The loader visits every card, and inside each one it lists `attachments/`
///   and counts `comments/` (`BoardModel.Card`) — three directory reads per card, plus a parse, on
///   a board that may hold thousands. A preview has a few hundred milliseconds and no second
///   chance, so this walk is *capped at every level* (`BoardOutlineLimits`) and opens no file it
///   does not put on screen.
/// - **It carries what a preview cannot use.** Trash, tombstone migration, the identity dedupe, the
///   parse memo, the integrity defect stream, the coerce-tier trace — none of it renders, and all of
///   it costs.
///
/// What this does reuse is everything that decides *what the answer is*: `FrontmatterDocument` for
/// the parse and every field reading, `IntegrityRules.isIdentityShaped` for what counts as a lane or
/// a card, `BoardLoader.directoryCandidates` for the stray tolerance (hidden entries and symlinks
/// out, folder-name order in), and `Ranks` for display order. A second walker that re-derived any of
/// those would be a second storage format; this one only decides *how far to look*.
///
/// ### The name
///
/// "Outline", not "summary": `KanbanMobile/Cloud/BoardSummary.swift` already owns that word for the
/// phone's board *list* row — a name, a size, a download state, nothing about the contents — and the
/// phone compiles this directory into its own module, so the two would collide outright. The words
/// are worth keeping apart anyway: that one summarizes a board's *file*, and this one outlines its
/// *contents*.
public struct BoardOutline: Sendable, Equatable {

    /// The board's name — its `title`, falling back to the folder name with `.kanban` stripped
    /// (01-storage-format.md § Board naming). Non-optional, deliberately: this is
    /// `AppModel.displayName(of:)`'s rule read one layer down, and "Untitled" appears nowhere on a
    /// board because a board always has a folder (BoardInfoPopover ▸ Rename — "boards do not have
    /// it"). Contrast `LaneOutline.title`.
    public let title: String

    /// The `background` mapping's colour as written — a palette name or a `#RRGGBB[AA]` hex — or
    /// `nil` for a board that carries none.
    ///
    /// **The value, not a colour**: resolving it is the renderer's job through `Palette`, exactly as
    /// it is in the app, so an unrecognized value degrades to "no tint" at the one place that knows
    /// what no tint looks like (Palette.swift — "Lenient, never an error").
    public let background: String?

    /// The board's `icon` (an SF Symbol name) and `iconColor`, both as written and both lenient in
    /// the same way `background` is: a name the running system cannot draw is the renderer's problem
    /// to degrade, not this walk's to validate.
    public let icon: String?
    public let iconColor: String?

    /// The lanes the preview shows, in display order — **a prefix**, capped by
    /// `BoardOutlineLimits.lanes`. `hiddenLaneCount` is the rest.
    public let lanes: [LaneOutline]

    /// Every lane the walk found, shown or not — the number the preview's header line states.
    ///
    /// `laneCountIsCapped` marks the walk that stopped counting (`BoardOutlineLimits.laneScan`), so
    /// a renderer can say "100+" rather than a number it would be lying about. On every board that
    /// has ever existed this is `false`; it is here because a preview must have a bounded worst case
    /// even on a folder nobody meant to be a board.
    public let laneCount: Int
    public let laneCountIsCapped: Bool

    /// The lanes counted but not shown — the "+N more lanes" the preview ends on, and `0` on a board
    /// whose lanes all fit.
    public var hiddenLaneCount: Int { max(0, laneCount - lanes.count) }

    public init(
        title: String,
        background: String? = nil,
        icon: String? = nil,
        iconColor: String? = nil,
        lanes: [LaneOutline] = [],
        laneCount: Int = 0,
        laneCountIsCapped: Bool = false
    ) {
        self.title = title
        self.background = background
        self.icon = icon
        self.iconColor = iconColor
        self.lanes = lanes
        self.laneCount = laneCount
        self.laneCountIsCapped = laneCountIsCapped
    }
}

/// One lane in a board summary: a title, a colour, a count, and the first few card titles.
public struct LaneOutline: Sendable, Equatable {

    /// The lane's `title` as written, or `nil` where it carries none — **"Untitled" is a rendering,
    /// never a value** (03-board-ui.md § Card face; the same rule `Lane`/`Card` follow throughout the
    /// app), so the placeholder is applied where the text is drawn and never stored here. An empty
    /// or whitespace-only title reads as `nil` for the same reason a blank lane header does.
    public let title: String?

    /// The lane's `background` colour as written — see `BoardOutline.background`; a lane's is an edge
    /// accent rather than a backdrop (03-board-ui.md § Styling ▸ Capabilities).
    public let background: String?

    /// The first few cards' titles, in display order, each `nil` where the card carries no title.
    /// Capped by `BoardOutlineLimits.cardTitles`.
    ///
    /// **In display order over the *scanned* cards**, which is every card in the overwhelming case
    /// and the first `BoardOutlineLimits.cardScan` of them otherwise — see that field for the cost
    /// the cap accepts.
    public let cardTitles: [String?]

    /// How many cards the lane holds — a `readdir` plus one `stat` per child, never a parse, which is
    /// `BoardLoader.commentCount(in:)`'s own arrangement one level up: a card is an identity-shaped
    /// child carrying its own `index.md`, and whether that file *parses* costs a read this count does
    /// not pay.
    public let cardCount: Int

    /// Whether the count stopped at `BoardOutlineLimits.cardCount` — a lane holding more cards than
    /// the walk was willing to stat, which a renderer states as "2000+" rather than as a number.
    public let cardCountIsCapped: Bool

    /// The cards counted but not titled — the "+N more" under a lane's last shown title.
    public var hiddenCardCount: Int { max(0, cardCount - cardTitles.count) }

    public init(
        title: String? = nil,
        background: String? = nil,
        cardTitles: [String?] = [],
        cardCount: Int = 0,
        cardCountIsCapped: Bool = false
    ) {
        self.title = title
        self.background = background
        self.cardTitles = cardTitles
        self.cardCount = cardCount
        self.cardCountIsCapped = cardCountIsCapped
    }
}

/// **How far the summary walk looks** — the whole of what makes a preview bounded.
///
/// Quick Look gives an extension a short budget and no way to say "still working"; a board is a
/// folder tree of unknown size that a user can point Finder at. So every level of the walk has a
/// ceiling, and the ceilings are here rather than scattered through the walk so the answer to "what
/// does a preview cost, worst case?" is one struct: at most `laneScan` lane `index.md` parses, plus
/// per shown lane at most `cardCount` stats and `cardScan` card `index.md` parses.
///
/// The defaults are `preview`'s. Every one is generous enough that a real board is summarized
/// exactly — the caps exist for the pathological folder, not for the ordinary one.
public struct BoardOutlineLimits: Sendable, Equatable {

    /// Lanes rendered, in display order. The rest become `BoardOutline.hiddenLaneCount`.
    public var lanes: Int

    /// Root children the walk will consider a lane at all. Past this the count itself stops
    /// (`BoardOutline.laneCountIsCapped`) — it is the one cap that bounds the lane parse, since
    /// display order cannot be known without reading every sibling's `order`.
    public var laneScan: Int

    /// Card titles shown per lane. The rest become `LaneOutline.hiddenCardCount`.
    public var cardTitles: Int

    /// Card `index.md` files parsed per lane, taken in folder-name order.
    ///
    /// **The one cap with a visible cost**, stated plainly: a card's rank lives in its own file, so
    /// the true first-`cardTitles` of a lane cannot be known without reading all of them. A lane
    /// holding more cards than this shows the top of the *scanned* subset instead — its **count
    /// stays exact**, and the titles are still cards in that lane, just not provably the topmost.
    /// Set high enough that no lane a person has ever built reaches it.
    public var cardScan: Int

    /// Children stat'd per lane while counting. Past this the count stops
    /// (`LaneOutline.cardCountIsCapped`).
    public var cardCount: Int

    public init(lanes: Int, laneScan: Int, cardTitles: Int, cardScan: Int, cardCount: Int) {
        self.lanes = lanes
        self.laneScan = laneScan
        self.cardTitles = cardTitles
        self.cardScan = cardScan
        self.cardCount = cardCount
    }

    /// The Quick Look preview's own ceilings — the only set anything but a test uses.
    public static let preview = BoardOutlineLimits(
        lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000
    )
}

// MARK: - The walk

extension BoardOutline {

    /// Summarizes the board rooted at `boardRoot` — **total, and never throwing**.
    ///
    /// A preview has no error surface: there is no alert to raise, no Repair sheet to offer and no
    /// second attempt, so every failure this walk can meet degrades in place. A root that is not a
    /// directory, an `index.md` that is missing or unparseable or not UTF-8, a lane folder that
    /// vanishes mid-walk, a permissions race on a directory listing — each yields the empty answer
    /// for that one thing and nothing else changes. The floor is a summary carrying the folder's own
    /// name and no lanes, which is still a truthful preview of a folder that is not a board.
    ///
    /// A lane or card whose `index.md` will not parse is **kept, not skipped**: it reads as untitled
    /// and order-less (append-at-end, `Ranks.resolvedOrders`), so a board with one broken file
    /// previews with one blank title rather than with a lane silently missing and a count that
    /// disagrees with Finder.
    public static func read(boardRoot: URL, limits: BoardOutlineLimits = .preview) -> BoardOutline {
        let document = parsedIndex(in: boardRoot)

        // The lane ceiling is applied to the *name-shaped* children, before the per-child `stat` that
        // asks whether each holds an `index.md` — `readLane`'s ordering, and for its reason.
        let identityShaped = directoryCandidates(in: boardRoot)
            .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) }
        let considered = identityShaped.prefix(max(0, limits.laneScan))
        let candidates = considered.filter(hasIndex)

        let walked = candidates.map { (url: $0, document: parsedIndex(in: $0)) }
        let orders = Ranks.resolvedOrders(
            of: walked,
            stored: { $0.document?.order.value },
            name: { $0.url.lastPathComponent }
        )
        let ranked = Array(zip(walked, orders))
        let ordered = Ranks.sortedForDisplay(
            ranked,
            order: { $0.1 },
            name: { $0.0.url.lastPathComponent }
        )

        let shown = ordered.prefix(max(0, limits.lanes))
        return BoardOutline(
            title: text(document?.title) ?? boardRoot.deletingPathExtension().lastPathComponent,
            background: text(document?.background),
            icon: text(document?.icon),
            iconColor: text(document?.iconColor),
            lanes: shown.map { readLane(at: $0.0.url, document: $0.0.document, limits: limits) },
            laneCount: ordered.count,
            laneCountIsCapped: identityShaped.count > considered.count
        )
    }

    /// One lane: its own fields off an already-parsed `index.md`, then its cards — counted by
    /// listing, titled by parsing, and both capped.
    private static func readLane(
        at laneURL: URL,
        document: FrontmatterDocument?,
        limits: BoardOutlineLimits
    ) -> LaneOutline {
        // The count's two halves, in this order on purpose: the name-shape filter is a string test
        // and free, so the ceiling is applied to *it* and the per-child `stat` below never runs more
        // than `limits.cardCount` times.
        let identityShaped = directoryCandidates(in: laneURL)
            .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) }
        let considered = identityShaped.prefix(max(0, limits.cardCount))
        let cards = considered.filter(hasIndex)

        let scanned = cards.prefix(max(0, limits.cardScan))
        let walked = scanned.map { (url: $0, document: parsedIndex(in: $0)) }
        let orders = Ranks.resolvedOrders(
            of: walked,
            stored: { $0.document?.order.value },
            name: { $0.url.lastPathComponent }
        )
        let ordered = Ranks.sortedForDisplay(
            Array(zip(walked, orders)),
            order: { $0.1 },
            name: { $0.0.url.lastPathComponent }
        )

        return LaneOutline(
            title: text(document?.title),
            background: text(document?.background),
            cardTitles: ordered.prefix(max(0, limits.cardTitles)).map { text($0.0.document?.title) },
            cardCount: cards.count,
            cardCountIsCapped: identityShaped.count > considered.count
        )
    }

    // MARK: - Filesystem helpers

    /// `BoardLoader.directoryCandidates(in:)` with its typed throw flattened to the empty answer —
    /// the function never actually throws (an unlistable folder already degrades to `[]` inside it),
    /// and this walk has nowhere to put an error even if it did.
    private static func directoryCandidates(in folder: URL) -> [URL] {
        (try? BoardLoader.directoryCandidates(in: folder)) ?? []
    }

    private static func hasIndex(_ folder: URL) -> Bool {
        FileManager.default.fileExists(
            atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path
        )
    }

    /// A folder's `index.md`, parsed — `nil` for every way that can fail: no file, unreadable bytes,
    /// not UTF-8, unparseable YAML. The strict UTF-8 decode is `BoardLoader.parseDocument`'s, so a
    /// BOM'd file reads here exactly as it loads there: as nothing.
    private static func parsedIndex(in folder: URL) -> FrontmatterDocument? {
        let url = folder.appendingPathComponent(IntegrityRules.indexFileName)
        guard let data = try? Data(contentsOf: url),
              let text = String(validating: data, as: UTF8.self)
        else { return nil }
        return try? FrontmatterDocument.parse(text)
    }

    /// A lenient string field's text, with **blank read as absent** — a title of `""` or of spaces is
    /// a key that says nothing, and the summary's whole vocabulary for "says nothing" is `nil`
    /// (`AppModel.displayName(of:)` applies the same `!title.isEmpty` test to a board's own name).
    /// A missing key and a malformed one collapse to `nil` here too, exactly as they do at every
    /// other lenient call site in the app.
    private static func text(_ field: FieldValue<String>?) -> String? {
        guard let value = field?.value else { return nil }
        return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
    }
}
