import Foundation

/// **File ▸ Import Board…, minus the menu** — read a foreign file, work out what it is, and materialize
/// a real board folder from it (15-import-export.md ▸ Import is one row).
///
/// ### An import always creates a fresh board
///
/// There is no merge-into-the-open-board in v1 (15 ▸ Rulings): the user picks a source file, then a
/// destination, and the result opens through the ordinary open path like any other board. That keeps the
/// whole feature reversible by deleting one folder, and it keeps this file out of the reconciliation
/// business — an import that landed lanes into a live board would need every rule the paste path already
/// has, for a gesture nobody has asked for yet.
///
/// ### The tree is built by the ordinary Writer, not by this file
///
/// `BoardWriter.createBoard` / `createLane` / `createCard` / `writeBody` — the single write door
/// (02-architecture.md § Layering), which is what makes an imported board indistinguishable from a
/// hand-built one without this file knowing a single thing about frontmatter. Everything comes along for
/// free: `schema: 1`, the `kind` key, one `Date` per file for `created`/`modified`, lowercase-UUID folder
/// names, the seeded `.gitignore`, and the agent guide.
///
/// **Ranks are minted by the create path itself.** Each `createCard` appends after its lane's current
/// members (`Ranks.append(toVisible:)`), so cards materialized in parse order land at 1024, 2048, 3072 —
/// exactly the gapped ladder the app would have produced had the user typed them in that order. Document
/// position becomes rank without anything here computing one.
///
/// ### Atomicity is the template engine's, verbatim
///
/// Construct-then-clean: the destination is created by this call and **removed by this call on every
/// exit that is not a board** — cancellation and failure alike — "because a half-copied board is pure
/// residue: nothing was there before, so there is no true state for a reload to show"
/// (`TemplateEngine`). The one thing never removed is a destination this call did not create: an
/// existing name is the user's.
public enum BoardImporter {

    /// A parsed file, ready to materialize: what it turned out to be, what it said, and what to call the
    /// board if the document did not say.
    public struct Source: Sendable, Equatable {
        public let format: InterchangeFormat
        public let board: InterchangeBoard
        /// The source file's base name — the fallback title, and the save panel's suggested name.
        public let fallbackTitle: String

        public init(format: InterchangeFormat, board: InterchangeBoard, fallbackTitle: String) {
            self.format = format
            self.board = board
            self.fallbackTitle = fallbackTitle
        }

        /// The board's title: the document's own when it named one (a Markdown outline's `#` heading),
        /// the file's base name otherwise. Both Obsidian Kanban files and CSVs always take the file
        /// name, because neither format has anywhere to put a board title.
        public var title: String {
            guard let title = board.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else {
                return fallbackTitle
            }
            return title
        }
    }

    /// The two ways an import ends without a board — `TemplateEngine.Failure`'s shape, and for its
    /// reasons: a cancelled import never happened and says nothing, and everything else is an ordinary
    /// failure carrying the banner's own vocabulary.
    ///
    /// **There is no `.refused`**: this flow's destination came from a save panel, and the panel's grant
    /// *is* the sandbox's answer — asking the same question again would be a loop.
    public enum Failure: Error, Sendable, Equatable {
        case cancelled
        case failed(BoardWriteError)
    }

    // MARK: - Reading

    /// Reads and parses the chosen file.
    ///
    /// **UTF-8 or nothing.** All three formats are text formats and the app is a UTF-8 app
    /// (01-storage-format.md § Encoding and line endings); a file in some other encoding fails here with
    /// a sentence rather than importing as mojibake, which is the same posture `readRawSource` takes for
    /// the one other place foreign bytes reach the app as text. A UTF-8 BOM is tolerated and dropped
    /// (every reader below normalizes it away).
    ///
    /// The security-scoped dance is the open panel's: a panel-chosen URL is readable without it, but a
    /// URL that arrived any other way may not be, and starting a scope that was never needed costs
    /// nothing.
    public static func read(contentsOf url: URL) throws(Failure) -> Source {
        let scoped = url.startAccessingSecurityScopedResource()
        defer { if scoped { url.stopAccessingSecurityScopedResource() } }

        let data: Data
        do {
            data = try Data(contentsOf: url)
        } catch {
            throw .failed(BoardWriteError(
                operation: .importBoard(fileName: url.lastPathComponent),
                path: url.path,
                reason: .unreadable(message: error.localizedDescription)
            ))
        }

        guard let text = String(data: data, encoding: .utf8) else {
            throw .failed(BoardWriteError(
                operation: .importBoard(fileName: url.lastPathComponent),
                path: url.path,
                reason: .unreadable(message: "this file isn't UTF-8 text")
            ))
        }

        return parse(text: text, fileName: url.lastPathComponent)
    }

    /// Detect, then parse — the pure half, and the one the suite exercises.
    ///
    /// **Nothing here can fail.** Detection always answers, and all three parsers are total: the two
    /// Markdown flavours share the lenient outline parser (`MarkdownBoardParser` — "it never fails"), and
    /// the CSV parser answers an empty board for input it cannot make rows out of. A file that is not any
    /// of these three imports as *something* — usually one lane of cards — which is a result the user can
    /// look at and delete, where a refusal would be a dead end.
    public static func parse(text: String, fileName: String?) -> Source {
        let format = InterchangeFormat.detect(text: text, fileName: fileName)
        let board = switch format {
        case .obsidianKanban, .markdownOutline: MarkdownBoardParser.parse(text)
        case .csv: CSVBoardParser.parse(text)
        }
        let fallback = (fileName as NSString?)?.deletingPathExtension ?? ""
        return Source(
            format: format,
            board: board,
            fallbackTitle: fallback.isEmpty ? "Imported Board" : fallback
        )
    }

    /// The save panel's suggested name for the new board — the source's title with the package
    /// extension, so an import lands as a `.kanban` document by default (`TemplateEngine
    /// .suggestedFileName(for:)`'s rule, one flow over).
    public static func suggestedFileName(for source: Source) -> String {
        let name = source.title
            .replacingOccurrences(of: "/", with: "-")
            .replacingOccurrences(of: ":", with: "-")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return "\(name.isEmpty ? "Imported Board" : name).kanban"
    }

    // MARK: - Materializing

    /// Writes `board` as a real board folder at `destination`, titled `title`, and answers where it
    /// landed — the caller opens it through the ordinary open path.
    ///
    /// `title` is the **user-chosen document name** off the save panel's URL, never the parsed one:
    /// 01-storage-format.md § Board naming wants display name and folder name to start out matching, and
    /// the parsed title is what seeded the panel's *suggested* name rather than what overrides its
    /// answer (09-templates.md ▸ Instantiation's own rule).
    ///
    /// `isCancelled` is read between items and nowhere else, defaulting to the ambient task's own
    /// cancellation — `TemplateEngine.instantiate`'s seam, and the in-progress row's Cancel at the other
    /// end of it. A five-thousand-row CSV is real work, and 02-architecture.md's Cancel-on-safe-copies
    /// rule is about exactly this shape of it.
    @discardableResult
    public static func materialize(
        _ board: InterchangeBoard,
        to destination: URL,
        title: String,
        isCancelled: () -> Bool = { Task.isCancelled }
    ) throws(Failure) -> URL {
        let operation = WriteOperation.createBoard

        // **Refused, never clobbered**, and checked before anything is created so the cleanup below can
        // never reach a destination this call did not make (`TemplateEngine.instantiate`'s guard,
        // verbatim — the save panel's replace prompt grants access, it does not delete).
        guard !FileManager.default.fileExists(atPath: destination.path) else {
            throw .failed(BoardWriteError(
                operation: operation,
                path: destination.path,
                reason: .io(message: "something already exists here")
            ))
        }

        if isCancelled() { throw .cancelled }

        do {
            try build(board, at: destination, title: title, isCancelled: isCancelled)
        } catch {
            // Cancelled or failed, the partial goes — the whole of this call's atomicity.
            try? FileManager.default.removeItem(at: destination)
            throw error
        }

        return destination
    }

    /// The tree, one ordinary create at a time.
    private static func build(
        _ board: InterchangeBoard,
        at destination: URL,
        title: String,
        isCancelled: () -> Bool
    ) throws(Failure) {
        do {
            try BoardWriter.createBoard(at: destination, title: title)

            for lane in board.lanes {
                if isCancelled() { throw CancellationMarker.stop }
                let laneID = try BoardWriter.createLane(inBoard: destination, title: lane.title)
                let laneFolder = destination.appendingPathComponent(laneID.rawValue, isDirectory: true)

                for card in lane.cards {
                    if isCancelled() { throw CancellationMarker.stop }
                    let cardID = try BoardWriter.createCard(inLane: laneFolder, title: card.title)
                    guard !card.body.isEmpty else { continue }
                    let cardFolder = laneFolder.appendingPathComponent(cardID.rawValue, isDirectory: true)
                    // Two writes per bodied card — the mint, then the body — because the create path
                    // writes frontmatter only. The alternative (a `createCard` that took a body) would
                    // widen the single write door for one caller's convenience.
                    try BoardWriter.writeBody(inItemFolder: cardFolder, body: card.body)
                }
            }
        } catch let error as BoardWriteError {
            throw .failed(error)
        } catch {
            throw .cancelled
        }
    }

    /// The cancel signal, thrown out of the same `do` the Writer's failures leave through.
    ///
    /// A marker rather than an early `return`, because the unwinding is the point: every exit from
    /// `build` that is not a finished board has to reach `materialize`'s `catch`, which is the one place
    /// the half-made destination is removed. Two exits, one cleanup.
    private enum CancellationMarker: Error { case stop }
}
