import Foundation

/// **The format-neutral board** — what every exporter writes from and every importer produces
/// (15-import-export.md).
///
/// ### Why a value in the middle
///
/// Three formats times two directions is six converters; routed through one intermediate shape it is
/// three writers and three parsers, none of which knows about the others, and — the part that matters
/// for testing — none of which needs a board on disk, a store, or a window to exercise. It is
/// `PrintSource`'s seam one door over, and for the same reason that type gives: what crosses into the
/// conversion layer should be the smallest thing that can answer every question the formats ask,
/// because anything richer invites a serializer to start making decisions the extraction should have
/// made.
///
/// ### What it deliberately cannot hold
///
/// Comments, attachments, colours, icons, widths, collapsed state, ids, `modified-by`, and every
/// reserved key. **None of the v1 formats can carry any of them** (15 ▸ The formats), so a shape that
/// carried them would be a promise three writers would have to break one by one. The export commands
/// account for the two the user would actually miss — comments and attachments — separately, off the
/// snapshot, and say so out loud (`InterchangeOmissions`).
///
/// ### Order is position
///
/// Lanes and cards are **arrays in display order**, and that is the whole of the ordering contract in
/// both directions: every format below writes them out in sequence, and document position is what a
/// parse reads back. Nothing here carries a rank — an import mints fresh ones in parse order
/// (`BoardImporter`), which is exactly where `Ranks.append(toVisible:)` would have put them.
public struct InterchangeBoard: Sendable, Equatable {

    /// The board's display name — `AppModel.displayName(of:)`'s answer on the way out, and the
    /// document's own `#` heading (when it has one) on the way in. `nil` for an import that found no
    /// title, which is the importer's cue to fall back to the source file's name.
    public var title: String?

    public var lanes: [InterchangeLane]

    public init(title: String? = nil, lanes: [InterchangeLane] = []) {
        self.title = title
        self.lanes = lanes
    }

    /// Every card in the board, lane by lane — the count the CSV writer's row loop and the tests both
    /// want, without either re-deriving the walk.
    public var cards: [InterchangeCard] { lanes.flatMap(\.cards) }

    // MARK: Extraction

    /// **A live board, narrowed** — `snapshot.lanes` in display order, each lane's `cards` in display
    /// order, exactly as the loader ranked them (`Ranks.sortedForDisplay`).
    ///
    /// **The trash is excluded by construction rather than by a filter**, which is `PrintSource.board`'s
    /// own note: `BoardModel.trash` and `trashedLanes` are sibling containers of `lanes`, not members of
    /// it, so a walk of `lanes` cannot reach them. An export is an export of the board; deleted cards
    /// are deleted.
    public static func from(_ snapshot: BoardModel, titled boardTitle: String) -> InterchangeBoard {
        InterchangeBoard(
            title: boardTitle,
            lanes: snapshot.lanes.map { lane in
                InterchangeLane(
                    title: lane.title.value,
                    cards: lane.cards.map { card in
                        InterchangeCard(
                            title: card.title.value,
                            body: card.body,
                            created: card.created.value,
                            modified: card.modified.value
                        )
                    }
                )
            }
        )
    }
}

/// One lane: a heading and its cards, top to bottom.
///
/// **No body.** A lane's `index.md` body is its description or WIP policy, and none of the three v1
/// formats has anywhere to put it: a Markdown outline's lane *is* the heading line, and CSV's grain is
/// one row per card. Carrying it here would mean three writers each deciding to drop it. It is listed
/// with the rest of the omissions in 15 rather than accounted for in a banner, because unlike comments
/// and attachments it is a field most boards leave empty.
public struct InterchangeLane: Sendable, Equatable {

    /// The lane's title as written, or `nil` for an untitled lane. **The placeholder is never stored** —
    /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled lane
    /// exports as an empty heading rather than as a lane somebody named that.
    public var title: String?

    public var cards: [InterchangeCard]

    public init(title: String? = nil, cards: [InterchangeCard] = []) {
        self.title = title
        self.cards = cards
    }
}

/// One card: its title, its Markdown body, and the two stamps CSV has columns for.
public struct InterchangeCard: Sendable, Equatable {

    /// The title as written, `nil` for an untitled card — `InterchangeLane.title`'s rule exactly.
    public var title: String?

    /// The card's body, verbatim, with `\n` line endings. Empty for a card that has none.
    public var body: String

    /// **Export-only, both of them.** The CSV writer has a column for each; no importer reads either,
    /// because an imported board is **born today** — the tree is materialized by the ordinary Writer,
    /// whose creation path stamps `created`/`modified` from one fresh `Date` like every other board the
    /// app makes (`BoardWriter.createBoard`, and 09-templates.md's instantiation rule read one boundary
    /// over: "a new board is born today, not forked"). Backdating an import would claim a provenance the
    /// app cannot verify from a CSV cell.
    public var created: Date?
    public var modified: Date?

    public init(title: String? = nil, body: String = "", created: Date? = nil, modified: Date? = nil) {
        self.title = title
        self.body = body
        self.created = created
        self.modified = modified
    }
}

// MARK: - What an export leaves behind

/// **What the board holds that the exported document cannot** — the two counts an export owes the user
/// a sentence about (15 ▸ Lossy exports say so).
///
/// Counted off the `BoardModel` rather than off the `InterchangeBoard`, and that is the point: the
/// intermediate shape has already dropped both, so anything derived from it could only ever report
/// zero. `Card.commentCount` is a readdir the snapshot already paid for and `Card.attachments` is a
/// listing it already holds, so this walk costs nothing beyond the addition.
///
/// **The trash is excluded**, on `InterchangeBoard.from`'s reasoning: a notice counting comments on
/// deleted cards would be reporting content the export was never going to include for a second,
/// unrelated reason.
public struct InterchangeOmissions: Sendable, Equatable {

    public var comments: Int
    public var attachments: Int

    public init(comments: Int = 0, attachments: Int = 0) {
        self.comments = comments
        self.attachments = attachments
    }

    /// Nothing was left behind — the export that says nothing at all.
    public var isEmpty: Bool { comments == 0 && attachments == 0 }

    public static func of(_ snapshot: BoardModel) -> InterchangeOmissions {
        var omissions = InterchangeOmissions()
        for lane in snapshot.lanes {
            for card in lane.cards {
                omissions.comments += card.commentCount
                omissions.attachments += card.attachments.count
            }
        }
        return omissions
    }
}
