import Foundation

/// The immutable snapshot shape `BoardLoader` produces and every view/store reads
/// (02-architecture.md § Layering). `BoardModel`, `Lane`, and `Card` are plain value types —
/// no class, no shared mutable state — encoding the frontmatter tables from
/// 01-storage-format.md § Frontmatter as data. Level is position (root = board, depth 1 =
/// lane, depth 2 = card); there is deliberately no `type`/`level` discriminator field —
/// the three distinct Swift types encode it structurally.

/// The immutable identity of a lane or card folder: its exact name, byte-for-byte — compared as
/// a UUID *value*.
///
/// **`rawValue` is the folder's exact spelling.** It builds URLs, it must round-trip
/// byte-perfect (it is the primary key on disk), and it is the display-order tie-break
/// (`Ranks.sortedForDisplay`), so it is never normalized on the way in. Deliberately **not**
/// Foundation's `UUID`, which re-renders as uppercase and would silently corrupt that
/// round-trip.
///
/// **Equality and hashing are by UUID value, not by spelling** (01-storage-format.md § Fractal
/// layout ▸ Rules, settled): *two case-spellings of one UUID are one identity everywhere* —
/// selection membership, the import-boundary collision check, every `Set`/`Dictionary` keyed by
/// this type. That matches default-APFS case-insensitivity, where the two spellings name one
/// folder anyway. Lowercasing `rawValue` *is* the canonical form: `BoardLoader` only ever mints
/// an `ItemID` for a folder name that passed the shape gate (`isUUIDShaped` — hex, `8-4-4-4-12`,
/// any case, any version), and `BoardWriter` only ever mints one for a name it just wrote, so
/// the string is always ASCII hex and hyphens, where case folding is exactly UUID-value
/// canonicalization. Nothing asserts that — the type stays total on any string a caller hands it;
/// off-shape input simply compares by its own lowercasing, which is the harmless reading.
///
/// Consequences, all intended: `RawRepresentable` is unaffected — only `==` and `hash(into:)`
/// are hand-written, and `rawValue` still reads back exactly as it was stored;
/// SwiftUI `Identifiable` diffing keys on this same value equality, so a case-respelled folder
/// is the *same* row rather than a delete plus an insert; and a `Set<ItemID>` holding both
/// spellings collapses them to one member, keeping whichever arrived first.
///
/// Board roots don't get one of these: a board's folder name is a human/Finder-assigned
/// `.kanban` package name, not a UUID (01-storage-format.md § Board naming) — its identity is
/// `BoardModel.rootURL`, resolved elsewhere via a security-scoped bookmark
/// (02-architecture.md § Per-board app state), not a folder-name key.
public struct ItemID: Hashable, Sendable, RawRepresentable {
    public let rawValue: String

    public init(rawValue: String) {
        self.rawValue = rawValue
    }

    /// The comparison key: `rawValue` case-folded, through the **one** canonicalization
    /// (`IntegrityRules.canonicalIdentity`, settled 2026-07-29 — the fold). Computed rather than
    /// stored so `ItemID` stays one string wide and `rawValue` remains the single source of truth
    /// for what is on disk.
    ///
    /// The Writer compares *paths* rather than model values and reaches the same function directly;
    /// before the fold it carried a private copy of this line, which is one line too many for a rule
    /// that decides whether two folders are the same item.
    var canonicalValue: String { IntegrityRules.canonicalIdentity(rawValue) }

    public static func == (lhs: ItemID, rhs: ItemID) -> Bool {
        lhs.canonicalValue == rhs.canonicalValue
    }

    public func hash(into hasher: inout Hasher) {
        hasher.combine(canonicalValue)
    }
}

extension ItemID: CustomStringConvertible {
    public var description: String { rawValue }
}

/// A board: `<root>/index.md` plus every lane beneath it. `<root>` is the `.kanban` package
/// (or an extension-less folder — both open).
public struct BoardModel: Sendable, Equatable {
    /// The board's identity — see `ItemID`'s doc comment for why boards don't have one of
    /// those instead.
    public let rootURL: URL

    public let schema: Int
    public let title: FieldValue<String>
    public let created: FieldValue<Date>
    public let modified: FieldValue<Date>
    public let modifiedBy: FieldValue<String>

    /// Legal per the common frontmatter table but meaningless at board level — a board can't
    /// tombstone itself out of its own window (01-storage-format.md § Deletion). `BoardLoader`
    /// ignores it (and emits a `LoadWarning`) rather than acting on it; the model keeps the
    /// field only so the value still round-trips. There is deliberately no `isDeleted` here —
    /// contrast `Lane`/`Card`.
    public let deleted: FieldValue<Date>

    public let background: FieldValue<String>

    /// The `background` mapping's `image` subkey — a path **relative to `rootURL`**
    /// (01-storage-format.md § Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
    ///
    /// **Board-level only**, which is why `Lane` and `Card` carry no twin: a lane's and a card's
    /// colour are edge accents, and there is nothing at those levels an image could fill. The
    /// shared reader still accepts the mapping at every level for the colour's sake — one key, one
    /// reading — but this half has exactly one consumer, the board window's backdrop.
    ///
    /// A **reading, not a location**: the path is resolved (and required to stay inside the board)
    /// where it is drawn, `BoardBackdrop.imageURL(named:inBoardRoot:)`, so a value that leads
    /// nowhere paints nothing and stays on disk exactly as written — the same lenient degrade an
    /// unrecognized colour gets.
    public let backgroundImage: FieldValue<String>

    public let icon: FieldValue<String>
    public let iconColor: FieldValue<String>

    /// `{order: N}` — picker position when this board lives in a template store
    /// (09-templates.md). Opaque by design: exposed as the engine's raw `YAMLValue`, never
    /// parsed into a dedicated Swift shape, so future subkeys need no model change.
    public let template: YAMLValue?

    /// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
    ///
    /// **A lane carrying a legacy `deleted:` key is here, live** (01-storage-format.md § Deletion,
    /// lane clause re-ruled 2026-07-29): the key is ignored outright — no migration, no notice, no
    /// write — so `Lane.isDeleted` describes the bytes and decides nothing. A *card* carrying one
    /// still rides along flagged until its migration relocates it (`LoadResult.legacyTombstones` —
    /// see `BoardLoader`'s "The migration window" note).
    public let lanes: [Lane]

    /// The board's **materialized trash**, card side: the card folders sitting directly in
    /// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
    /// 2026-07-28; 03-board-ui.md § Trash).
    ///
    /// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name
    /// at board root that "holds card and lane folders interleaved directly, no `index.md` of its
    /// own", so it has no identity, no title, no `order`, and no frontmatter to model: the
    /// container *is* the list. That is why this is `[Card]` rather than a `Lane` or a `Trash`
    /// struct — there is nothing for either to carry that this array does not.
    ///
    /// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too).
    /// The two are separate arrays rather than one list of a sum type because they are separate
    /// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
    /// and a trashed lane is an opaque row that none of them may. Interleaving the two is a
    /// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash
    /// column by `modified` descending"), and both arrays carry the stamp that answers it —
    /// `BoardModel.trashEntries` is the one merge.
    ///
    /// **Display order is `modified` descending** — `Ranks.sortedForTrash`, tie-broken by title then
    /// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one
    /// container in the app whose sequence is not a rank sequence: the delete move stamps, and that
    /// stamp *is* the position, with each entry's `order` riding along untouched for its restore.
    /// There is deliberately no `deleted:` key on anything in here — a trashed card is an ordinary
    /// card in a special place.
    ///
    /// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by
    /// the first delete), and empty when it holds nothing the loader recognizes as a card.
    ///
    /// **Defaulted so a snapshot can be built without one.** The one construction site is
    /// `BoardLoader.load`, which always supplies it; the default keeps the memberwise initializer
    /// usable from tests and future fixtures that have no trash to describe.
    public var trash: [Card] = []

    /// The board's materialized trash, **lane side**: the lane folders sitting directly in
    /// `<root>/.trash/`, in display order (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes
    /// trash too", retiring the design's sole destructive delete).
    ///
    /// See `TrashedLane` for why a trashed lane is not a `Lane`, and `trash` for why the container's
    /// two kinds are two arrays.
    public var trashedLanes: [TrashedLane] = []

    /// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
    /// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
    /// can round-trip them without this model knowing what they mean.
    public let document: FrontmatterDocument

    /// The board description (free Markdown) — equivalent to `document.body`.
    public var body: String { document.body }
}

/// A lane: `<root>/<guid>/index.md` plus every card beneath it.
public struct Lane: Identifiable, Sendable, Equatable {
    public let id: ItemID

    public let schema: Int
    public let title: FieldValue<String>
    public let created: FieldValue<Date>
    public let modified: FieldValue<Date>
    public let modifiedBy: FieldValue<String>
    public let deleted: FieldValue<Date>
    public let background: FieldValue<String>
    public let icon: FieldValue<String>
    public let iconColor: FieldValue<String>

    /// Rank among lanes, ascending = left-to-right — **the reading, not necessarily the key**
    /// (01-storage-format.md § Ordering, re-ruled 2026-07-31: `order` is optional below the board
    /// root, and "missing or unusable reads as append-at-end").
    ///
    /// A lane whose `index.md` carries a usable `order` reads as that number. One that carries none
    /// — no key, an explicit null, a non-numeric or non-finite value — reads as a rank past every
    /// ordered sibling, materialized by `Ranks.resolvedOrders(of:stored:name:)` and tie-broken among
    /// the other order-less lanes by folder name. Either way this is a plain, finite `Double` by the
    /// time a `Lane` exists, which is why it is not a `FieldValue<Double>`: resolving the reading is
    /// the loader's job, and the snapshot carries answers rather than shapes.
    ///
    /// The value is real enough to write down, and the Writer does exactly that on the file's first
    /// touch (`IntegrityRules.OnTouchHeal.rankStamped`) — so the stamp changes nothing on screen.
    public let order: Double

    /// Width multiplier ≥ 1 (default 1 when missing or malformed). Lenient — it styles layout,
    /// it isn't structure — so a malformed value is preserved (`.malformed`) rather than
    /// failing the load (01-storage-format.md § Frontmatter).
    public let width: FieldValue<Int>

    /// **Folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — `width`'s sibling in
    /// every respect that matters here: lenient (a value with no boolean reading renders as expanded,
    /// bytes untouched), document state rather than window state, and preserved verbatim beside it —
    /// a collapsed lane keeps its `width` so expanding restores the lane the user had.
    ///
    /// The *reading* is `LaneLayoutMath.isCollapsed(_:)`, which is where the rest of the app asks;
    /// this is the field, and it carries the shape so the coerce tier can report on it.
    public let collapsed: FieldValue<Bool>

    /// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
    /// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
    /// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`.
    public let cards: [Card]

    /// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
    public let document: FrontmatterDocument

    /// The lane description / WIP policy / notes — equivalent to `document.body`.
    public var body: String { document.body }

    /// Whether this lane carries a legacy `deleted:` key — presence, not validity
    /// (`FieldValue.isMissing` already treats an explicit `deleted: null` as absent).
    ///
    /// **Retired, and on a lane the key is now inert** (01-storage-format.md § Deletion, lane clause
    /// re-ruled 2026-07-29): the app never writes `deleted:`, and a lane found carrying one "simply
    /// loads live with the key ignored — no migration machinery, no key-strip write, no notice",
    /// preserved verbatim like any unhandled key and logged (`LoadWarning.laneLevelDeletedIgnored`).
    /// So this reads the key without meaning anything by it: nothing hides a lane, nothing rewrites
    /// it, and the field survives only so the value round-trips.
    public var isDeleted: Bool { !deleted.isMissing }
}

/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments and a *count* of
/// its comments. Structurally still a leaf — the attachment files' contents and every comment's
/// own frontmatter and body live alongside `index.md` on disk and are not modeled here; `comments/`
/// stays window-scoped exactly as 01-storage-format.md § Enhanced schema rules ("the board snapshot
/// never loads comment content"), and `commentCount` does not change that — it is a readdir, not a
/// parse. `attachments` and `commentCount` are what the snapshot reaches inside a card folder for,
/// because board-window surfaces need them before any card window exists (see each field's own doc
/// comment).
public struct Card: Identifiable, Sendable, Equatable {
    public let id: ItemID

    public let schema: Int
    public let title: FieldValue<String>
    public let created: FieldValue<Date>
    public let modified: FieldValue<Date>
    public let modifiedBy: FieldValue<String>
    public let deleted: FieldValue<Date>
    public let background: FieldValue<String>
    public let icon: FieldValue<String>
    public let iconColor: FieldValue<String>

    /// **The card's hero image** (03-board-ui.md § Card face ▸ Hero image) — the bare filename of one
    /// of this card's own attachments, drawn as a banner across the top of its face.
    ///
    /// **Card-level only**, which is why `Lane` and `BoardModel` carry no twin: a lane has no face to
    /// band and a board already has a backdrop. The reading is `FrontmatterDocument.hero`'s — a bare
    /// filename or nothing — and this carries the *shape* rather than the answer so the coerce tier
    /// can report on a value that had no reading.
    ///
    /// A **name, not a location**, and not a promise: where it resolves is the face's question
    /// (`CardHero.imageURL(for:inContainer:)`), and a name that leads to a missing file, an
    /// unreadable one or a non-image draws no banner at all — the card renders exactly as one with no
    /// key, and the bytes stay as written.
    public let hero: FieldValue<String>

    /// **The card's labels** — the names the card is tagged with (`FrontmatterKeys.labels`, whose doc
    /// comment carries the key's own story; `CardLabels` has the rules; the reading is
    /// `FrontmatterDocument.labels`).
    ///
    /// **Card-level only**, `hero`'s posture and for its kind of reason: a label is a property of a
    /// piece of work, and neither a lane nor a board is one. The key on a lane or a board stays an
    /// ordinary unknown one, preserved verbatim and shown in no sidebar the way it always was.
    ///
    /// The *shape* rather than the answer, so the coerce tier can report a value that had no list
    /// reading at all (a mapping); `.missing` and `.valid([])` are both "no labels" and render
    /// identically, which is why nothing in the app branches on the difference — but the two are
    /// distinct bytes on disk and the field keeps them apart.
    ///
    /// **The board face does not draw these** (as of the activation, 2026-08-09). Chips on the card
    /// face are their own design question — the attachments and comments chips set that vocabulary and
    /// a label list is a different shape of thing — so this rides in the snapshot for the card window's
    /// sidebar, the context menu's submenu, and the board-wide used-labels universe those two share.
    public let labels: FieldValue<[String]>

    /// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See
    /// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
    /// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
    /// (08-agent-integration.md), and it lands at the bottom of its lane.
    public let order: Double

    /// The card's attachment file names — **flat: top-level regular files only, in Finder
    /// order** (01-storage-format.md § Attachments: "the app's attachment surfaces … are flat:
    /// top-level files only", and "subfolders are tolerated, preserved verbatim, never created
    /// by the app, and not surfaced"). Empty when the card has no `attachments/` folder, and
    /// empty when it has one that couldn't be listed — the field is cosmetic, so a
    /// directory-listing race degrades to "nothing to show" rather than failing a load.
    ///
    /// Names, not URLs: the board-window consumer only needs to know *whether*, *how many*, and
    /// in *what order* — the face's quiet paperclip indicator (03-board-ui.md § Card face). The
    /// card window's sidebar does its own listing through `BoardWriter.listAttachments`, since it
    /// acts on the files rather than showing them; that call answers through the very same
    /// enumeration (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by
    /// construction.
    ///
    /// Freshness is the watcher's, by construction: it reloads on any change anywhere under the
    /// board root, so an attachment added in Finder rebuilds the snapshot exactly like an edited
    /// `index.md` does — no separate invalidation path to keep honest.
    public let attachments: [String]

    /// The card's comment count — **a readdir, not a parse** (design ruling 2026-08-09, card
    /// e729e30a; WISHLIST #9's own suggested shape). Counts `comments/`'s identity-shaped children
    /// that carry a readable `index.md` (`BoardLoader.commentCount(in:)`, `identityShapedChildren`'s
    /// pattern) — the same cost class as `attachments` above, so the walk stays O(cards) exactly as
    /// 01-storage-format.md § Enhanced schema requires. It agrees with `CommentThread.load`'s parsed
    /// count in the overwhelming case; the one divergence is a comment whose `index.md` exists but
    /// fails to parse (bad YAML, non-UTF-8), which the thread read excludes as a `Stray` and this
    /// count does not pay to detect — the face may then read one comment high until that folder is
    /// fixed or removed. `.draft` and `.trash/` are excluded for free, the way they are everywhere
    /// else this thread is read: both are dot-prefixed, and the loader's directory listing skips
    /// hidden entries.
    ///
    /// The board-window comments pane feeds the face's chip nothing — this field is the one and only
    /// source, so a chip and the pane it opens onto can never quietly show two different numbers for
    /// the same reason (the divergence above aside, which is a stray on disk, not a bug in either
    /// reader).
    public let commentCount: Int

    /// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
    public let document: FrontmatterDocument

    /// The card's content — the whole point. Equivalent to `document.body`.
    public var body: String { document.body }

    /// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not
    /// validity" rule applies here. A card's key is still migration input (a card carrying it
    /// relocates into `.trash/` with the key removed), which is the one half of the legacy rule
    /// that survives; a trashed card carries no `deleted:` key at all, so this reads `false` for
    /// every card in the container that replaced the flag.
    public var isDeleted: Bool { !deleted.isMissing }
}

/// A lane in the board's trash: `<root>/.trash/<guid>/index.md` — **an opaque unit**
/// (03-board-ui.md § Trash, re-ruled 2026-07-29: "A trashed lane is an opaque unit: one distinct
/// dimmed row showing its title and held-card count … never expandable; its cards are invisible to
/// search and not individually addressable — it restores whole or purges whole").
///
/// ### Why it is not a `Lane`
///
/// A `Lane` carries its cards, its styling and its width because the board renders all three. None
/// of that is true here: the row shows a title and a count, takes no styling accents, and its
/// subtree is deliberately **not walked into the snapshot** — the loader stops at the trash entry
/// exactly as it stops at a card under a lane. A `Lane` with an empty `cards` array would be a lie
/// the first consumer to read it would believe; this type can only answer what the design says the
/// row knows.
///
/// The kind itself is `kind:`'s to answer, never position's: the trash is flat, and an empty lane
/// folder is shape-identical to a card folder (01-storage-format.md § Deletion —
/// `IntegrityRules.trashKind`).
public struct TrashedLane: Identifiable, Sendable, Equatable {
    public let id: ItemID

    public let schema: Int
    public let title: FieldValue<String>

    /// **When the row entered the trash** — and therefore *where it sits*: the trash sorts by
    /// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
    /// move is the container-changing write that stamps it. Missing on a foreign mover that skipped
    /// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`).
    public let modified: FieldValue<Date>

    /// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites
    /// no `order` at all, so this is still the strip position a restore would want and the value the
    /// undo of a restore puts back. It is deliberately *not* what orders this row in the column
    /// (`modified` is), and it is the same optional-but-always-read field a live lane carries
    /// (`Lane.order`): an entry that reaches the trash without one reads as append-at-end over the
    /// container's own entries, which is inert here by construction — a restore computes a fresh
    /// rank at its destination.
    public let order: Double

    /// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
    ///
    /// Counted from disk at load, never derived from a walked subtree: the count is the one fact
    /// about the freight the snapshot carries, and counting is what keeps the entry opaque. The
    /// unit counted is what the loader *would* render as a card — an identity-shaped child holding
    /// its own `index.md` — so the row's number and a restore's outcome agree.
    public let heldCards: Int

    /// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted, so a restore
    /// (an ordinary move out) returns the lane exactly as it went in.
    public let document: FrontmatterDocument
}
