import Foundation

/// **The board as a table** — one row per card, the lane repeated down the column
/// (15-import-export.md ▸ The formats ▸ CSV).
///
/// ```csv
/// lane,title,body,created,modified
/// To Do,Fix login,"The button does nothing
/// on the second click.",2026-08-01T09:14:00Z,2026-08-07T11:02:00Z
/// To Do,Ship the beta,,2026-08-02T10:00:00Z,2026-08-02T10:00:00Z
/// Done,Write the changelog,,2026-07-30T08:30:00Z,2026-08-05T16:45:00Z
/// ```
///
/// **Order is row order**, exactly as it is line order in the Markdown flavours: lanes appear in board
/// order, and within a lane its cards appear in card order, so a reader that preserves rows preserves
/// the board. Nothing writes a rank down and nothing reads one back.
///
/// **The lane is a repeated string, not a key.** Two lanes that happen to share a title merge into one
/// on re-import — the one place the CSV round trip is not faithful, and an inherent property of a flat
/// table rather than a choice made here. Recorded as a limitation in 15.
public enum CSVBoardWriter {

    /// The header row, in the order the columns are written. `created` and `modified` are export-only —
    /// no importer reads them (`InterchangeCard.created`).
    public static let header = ["lane", "title", "body", "created", "modified"]

    public static func text(for board: InterchangeBoard) -> String {
        var records: [[String]] = [header]
        for lane in board.lanes {
            for card in lane.cards {
                records.append([
                    lane.title ?? "",
                    card.title ?? "",
                    card.body,
                    stamp(card.created),
                    stamp(card.modified)
                ])
            }
        }
        return CSVDocument.encode(records)
    }

    /// ISO 8601 in UTC — `FrontmatterValue.date`'s own rendering, so a stamp reads in the export exactly
    /// as it reads in the file it came from. An absent stamp is an empty cell rather than a zero date.
    private static func stamp(_ date: Date?) -> String {
        date.map { $0.formatted(.iso8601) } ?? ""
    }
}

/// **A table read as a board** — the CSV importer (15 ▸ Import is one row).
///
/// ### Header sniffing
///
/// The first record is a header when **any** of its cells names a column this importer knows. Matching
/// is case-insensitive, trims whitespace, and accepts the small synonym set the spreadsheets people
/// actually arrive with use — Asana and monday.com export `Name` and `Notes`, Trello's CSV calls the
/// lane a `List`, and a hand-kept sheet says `Status` or `Column`. Recognizing them costs a few lines
/// and is the difference between the feature working and the feature needing the user to rename their
/// headers first.
///
/// Columns this importer does not know are **ignored, not refused** — a sheet with a dozen columns
/// imports its three useful ones rather than failing.
///
/// ### Headerless files
///
/// A first record with no recognized name at all is data, and then **column 0 is the title** and nothing
/// else is interpreted. Guessing that column 1 is a body would as easily import a due date or an
/// assignee into the card's prose; the minimum honest reading is the one that cannot be wrong about what
/// a cell means.
///
/// ### The lane column
///
/// Lanes are created **in first-appearance order** and rows land in the lane their cell names. A row
/// with an empty lane cell — and every row of a file with no lane column — goes to a single lane called
/// "Imported" (`MarkdownBoardParser.defaultLaneTitle`, deliberately the same word the outline parser
/// falls back to).
public enum CSVBoardParser {

    public static func parse(_ text: String) -> InterchangeBoard {
        var records = CSVDocument.decode(text).filter { record in
            record.contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
        }
        guard !records.isEmpty else { return InterchangeBoard() }

        let columns: Columns
        if let header = headerColumns(records[0]) {
            columns = header
            records.removeFirst()
        } else {
            // Headerless: the first column is the title, and nothing else is claimed.
            columns = Columns(lane: nil, title: 0, body: nil)
        }

        var lanes: [InterchangeLane] = []
        var indexByTitle: [String: Int] = [:]

        for record in records {
            let laneTitle = columns.lane.flatMap { cell(record, $0) }?
                .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            let key = laneTitle.isEmpty ? MarkdownBoardParser.defaultLaneTitle : laneTitle

            // Line endings inside a quoted cell arrive exactly as the source file spelled them, and a
            // board's files are LF (01-storage-format.md § Encoding and line endings) — so the fold
            // happens here, on the way in, rather than leaving a CRLF body for the Writer to store.
            let card = InterchangeCard(
                title: columns.title
                    .flatMap { cell(record, $0) }
                    .map(MarkdownBoardParser.normalized)
                    .flatMap { $0.isEmpty ? nil : $0 },
                body: columns.body
                    .flatMap { cell(record, $0) }
                    .map(MarkdownBoardParser.normalized) ?? ""
            )

            if let existing = indexByTitle[key] {
                lanes[existing].cards.append(card)
            } else {
                indexByTitle[key] = lanes.count
                lanes.append(InterchangeLane(title: key, cards: [card]))
            }
        }

        return InterchangeBoard(title: nil, lanes: lanes)
    }

    /// Which column holds what. Every one is optional: a file may name a lane and no body, a body and no
    /// lane, or — the minimum this importer accepts — nothing but a title.
    struct Columns: Equatable {
        var lane: Int?
        var title: Int?
        var body: Int?
    }

    /// The header record read as column positions, or `nil` when it names nothing recognizable — which
    /// is how a headerless file is detected, since there is no other signal in a CSV that could say so.
    static func headerColumns(_ record: [String]) -> Columns? {
        var columns = Columns()
        for (index, cell) in record.enumerated() {
            let name = cell.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
            if columns.lane == nil, laneNames.contains(name) { columns.lane = index; continue }
            if columns.title == nil, titleNames.contains(name) { columns.title = index; continue }
            if columns.body == nil, bodyNames.contains(name) { columns.body = index; continue }
        }
        guard columns.lane != nil || columns.title != nil || columns.body != nil else { return nil }
        // A header that names a lane and a body but no title still imports: every row becomes an
        // untitled card carrying its body, which is content preserved rather than a refusal.
        return columns
    }

    private static let laneNames: Set<String> = ["lane", "list", "column", "status", "group", "section", "stage"]
    private static let titleNames: Set<String> = ["title", "name", "card", "task", "subject", "summary"]
    private static let bodyNames: Set<String> = ["body", "description", "notes", "note", "content", "details"]

    /// A cell by position, `nil` for a short row — a ragged table imports its complete columns rather
    /// than trapping on the row that stopped early.
    private static func cell(_ record: [String], _ index: Int) -> String? {
        index < record.count ? record[index] : nil
    }
}
