import Foundation

// MARK: - WelcomeRow

/// One row of the welcome window's recents list: a `RecentBoard` joined with whatever launch failure
/// names the same board.
///
/// ### Why the join is a value, derived by a pure function
///
/// 02-architecture.md § Launch and window lifecycle makes the *row* the failure surface:
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations
/// > proceed unaffected — never a launch-time modal chain, never a silent drop.
///
/// That is a rule about *data*: which of three captions a row wears, which of its actions are live,
/// and — the clause a list of rows cannot express by itself — what happens to a failure that matches
/// no row at all. Derived here, every one of those cases is stateable in a test without a window,
/// which is the only way the "never a silent drop" half is checkable at all.
///
/// The same derivation feeds File ▸ Open Recent, so the submenu and the list can never disagree
/// about a board's name or about whether it can be opened.
struct WelcomeRow: Identifiable, Equatable {

    /// The registry record's id — the row's identity, the selection's value, and what Forget names.
    let id: UUID

    /// The board's name: the record's cached `displayName`, falling back to the folder name for a
    /// record that somehow carries none (01-storage-format.md § Board naming's fallback, applied to
    /// the cached string rather than to a board this window must never open).
    let displayName: String

    /// The board's cached `icon`/`iconColor` (02-architecture.md § Per-board app state: "The row's
    /// title and icon are registry-cached too — with live write-through"). `nil` reads as "draw the
    /// board-default glyph" — the same lenient fallback the board window's own icon field gets, and
    /// exactly what an unstamped or override-free record means. The row never resolves these itself;
    /// that is the renderer's job (`ItemSymbol`, `Palette`), so the same record decodes into one
    /// answer for both the welcome row and File ▸ Open Recent.
    let icon: String?
    let iconColor: String?

    /// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
    /// of the row's availability: Open and Reveal need a URL, and an orphan has none.
    let url: URL?

    /// The containing folder, for the row's location line — Xcode's welcome shows where a project
    /// lives, not its own path repeated under its name. Home-abbreviated where it can be.
    let location: String

    /// The counts stamped at last close, `nil` until a close has stamped them
    /// (02 § Per-board app state: registry-cached, never a directory scan at welcome time).
    let laneCount: Int?
    let cardCount: Int?

    /// The message of the launch failure naming this board, if one does.
    let failure: String?

    var isAvailable: Bool { url != nil }

    /// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because
    /// it is enabled on every row — an orphan the user can never open is exactly the row that most
    /// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget").
    var canOpen: Bool { isAvailable }
    var canReveal: Bool { isAvailable }

    /// The row's third line — one line, so the three states are alternatives rather than a stack.
    ///
    /// The precedence is 02's sentence read in order: a failure is what the row is *for* at that
    /// moment and outranks both the orphan state (which the failure message already describes in
    /// better words) and the counts (facts about a board the user cannot currently get into).
    enum Caption: Equatable {
        /// The ordinary row: "3 lanes · 12 cards", or an em dash where nothing has been stamped.
        case counts(lanes: Int?, cards: Int?)
        /// The bookmark no longer resolves (02 § Graceful orphaning).
        case unavailable
        /// Fail-fast's specifics, from the open or restore that failed.
        case failed(String)
    }

    var caption: Caption {
        if let failure { return .failed(failure) }
        if url == nil { return .unavailable }
        return .counts(lanes: laneCount, cards: cardCount)
    }

    /// "3 lanes · 12 cards" — or "—" when the record has never been closed and so carries nothing.
    ///
    /// A single em dash rather than "0 lanes · 0 cards": an unstamped record knows nothing about the
    /// board's size, and zero is a claim.
    var countsSummary: String {
        guard let laneCount, let cardCount else { return "—" }
        let lanes = "\(laneCount) lane\(laneCount == 1 ? "" : "s")"
        let cards = "\(cardCount) card\(cardCount == 1 ? "" : "s")"
        return "\(lanes) · \(cards)"
    }

    // MARK: - Derivation

    /// The rows, and the failures no row could carry.
    struct Derivation: Equatable {
        var rows: [WelcomeRow]

        /// Failures naming no record — a first open of a folder that turned out not to be a board,
        /// which fails before anything is registered and so has no row to render on. They keep a
        /// list of their own on welcome, because the alternative is the silent drop 02 rules out.
        var unmatched: [LaunchFailure]
    }

    /// Joins the recents list with the launch failures, in `recents`' order (which is the registry's
    /// `lastOpened` descending — this function never re-sorts, so the sort rule keeps living in
    /// exactly one place).
    ///
    /// **Matching is by path, and by both of a record's paths.** A record knows where it was last
    /// seen (`lastKnownPath`) and, when its bookmark resolves, where it lives now; a bookmark follows
    /// a move, so a failure recorded before one names the older spelling. Paths are standardized
    /// before comparison — `/tmp/b/../b` and `/tmp/b` are one board — but never resolved through
    /// symlinks: that would be a filesystem round trip per row, which is the cost the registry's
    /// whole design is built to avoid at welcome time.
    ///
    /// **The newest failure wins a row's caption** when several name it (a board that failed, was
    /// retried, and failed again), because it is the one describing the state the file is in now.
    /// All of them are consumed either way — a row carries one message, and the older attempts must
    /// not resurface in the unmatched list as if nothing had shown them.
    static func derive(recents: [RecentBoard], failures: [LaunchFailure]) -> Derivation {
        var claimed: Set<UUID> = []

        let rows = recents.map { recent -> WelcomeRow in
            let record = recent.record
            var keys: Set<String> = [pathKey(record.lastKnownPath)]
            if let url = recent.url {
                keys.insert(pathKey(url.path))
            }

            let matches = failures.filter { keys.contains(pathKey($0.path)) }
            claimed.formUnion(matches.map(\.id))

            return WelcomeRow(
                id: record.id,
                displayName: record.displayName.isEmpty
                    ? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
                    : record.displayName,
                icon: record.icon,
                iconColor: record.iconColor,
                url: recent.url,
                location: location(of: recent.url?.path ?? record.lastKnownPath),
                laneCount: record.laneCount,
                cardCount: record.cardCount,
                failure: matches.last?.message
            )
        }

        return Derivation(rows: rows, unmatched: failures.filter { !claimed.contains($0.id) })
    }

    /// How two paths are compared for "the same board" on this screen. Standardized only — see
    /// `derive` for why nothing here touches the filesystem.
    static func pathKey(_ path: String) -> String {
        URL(fileURLWithPath: path).standardizedFileURL.path
    }

    /// The board's containing folder, with the user's home written as `~`.
    static func location(of path: String) -> String {
        let parent = URL(fileURLWithPath: path).deletingLastPathComponent().path
        guard let home = realHomeDirectory, parent == home || parent.hasPrefix(home + "/") else {
            return parent
        }
        return "~" + parent.dropFirst(home.count)
    }

    /// The user's **real** home directory.
    ///
    /// `NSHomeDirectory()` and `FileManager.homeDirectoryForCurrentUser` both answer with the sandbox
    /// container, which no board is ever inside — abbreviating against either would never once fire.
    /// The password database is where the real path still lives, and this is display text only:
    /// nothing is opened, resolved, or written relative to it, so being wrong costs a longer row.
    private static let realHomeDirectory: String? = {
        guard let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir else { return nil }
        return String(cString: directory)
    }()
}
