import AppKit
import SwiftUI
import UniformTypeIdentifiers
import os

// MARK: - Export

/// **File ▸ Export ▸ {Obsidian Kanban Markdown…, Markdown Outline…, CSV…}** — the frontmost board
/// written out as one foreign document (15-import-export.md ▸ Export is three rows).
///
/// ### Three rows rather than one row with a format popup
///
/// A format popup inside a save panel is a control the user has to find before they can answer the
/// question the panel is asking, and it makes the *command* ambiguous in the menu — "Export…" gives no
/// hint that CSV is on offer. Three named rows are three discoverable verbs, each of which opens a panel
/// already pointed at the right extension. It also keeps the titles stable, which matters more here than
/// tidiness: **a menu title is API** (`KanbanApp.menuCommands` — the App Shortcuts remap mechanism keys
/// on it), and a row whose meaning lived in a popup would have one title for three commands.
///
/// ### The sequence
///
/// 1. **The flush first** — `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own opening
///    step, for its reason: an export serializes the snapshot, and a card window holding unsaved
///    keystrokes would export the card as it was before they were typed.
/// 2. **The save panel**, after the flush, so the document the panel is about to write is the one the
///    user just finished editing. Cancelling it says nothing at all — "the user declined, nothing
///    failed" (Duplicate's own rule).
/// 3. **The write**, then the omissions notice — and only when there are any (`BannerCenter
///    .postExportOmissions`). A lossless export is silent: the file is where the user pointed, and a row
///    saying so would be noise.
///
/// ### Validation is Share…'s, and for Share…'s reasons
///
/// **An export is a read.** It never writes a byte into the board's own tree, so the read-only lock does
/// not close it — a board on a read-only volume is exactly the board somebody wants a copy of, which is
/// `PrintCommand`'s "a print is a read" posture one command over. The one carve-out kept is the
/// focused-inline-editor half: an open rename or new-card placeholder holds the one pending change no
/// flush can reach, and exporting mid-rename would write out a title the user is still typing.
struct ExportBoardMenu: View {

    let appModel: AppModel

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.boardWindowRef) private var ref

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "interchange")

    var body: some View {
        Menu("Export") {
            Button("Obsidian Kanban Markdown…") { export(.obsidianKanban) }
            Button("Markdown Outline…") { export(.markdownOutline) }
            Button("CSV…") { export(.csv) }
        }
        .disabled(!canExport)
    }

    /// The submenu's whole decision, as a pure function of the three facts it turns on —
    /// `ShareBoardCommand.isEnabled`'s shape and its reason: a rule reachable only through a menu is a
    /// rule nobody tests.
    static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool {
        hasStore && hasRef && !isEditingInline
    }

    private var canExport: Bool {
        Self.isEnabled(
            hasStore: store != nil,
            hasRef: ref != nil,
            isEditingInline: store?.isEditingInline == true
        )
    }

    private func export(_ format: InterchangeFormat) {
        guard canExport, let store, let ref else { return }
        let name = AppModel.displayName(of: store)

        Task { @MainActor in
            await appModel.flushPendingWork(for: ref)

            // Read *after* the flush: that is the whole point of running one.
            let snapshot = store.snapshot
            guard let url = Self.chooseDestination(boardTitle: name, format: format) else { return }

            let text = BoardExporter.text(
                for: InterchangeBoard.from(snapshot, titled: name),
                format: format
            )
            do throws(BoardWriteError) {
                try BoardExporter.write(text, to: url, boardTitle: name)
                store.banners.postExportOmissions(InterchangeOmissions.of(snapshot), format: format)
            } catch {
                Self.logger.error("export failed: \(error.description, privacy: .public)")
                store.banners.post(error)
            }
        }
    }

    /// The save panel. `allowsOtherFileTypes` stays on so a user who wants `.markdown` or `.txt` gets it
    /// — the panel's answer is honored verbatim, exactly as Duplicate's is, because second-guessing a
    /// name the user typed is how a save panel stops being one.
    private static func chooseDestination(boardTitle: String, format: InterchangeFormat) -> URL? {
        let panel = NSSavePanel()
        panel.nameFieldStringValue = BoardExporter.suggestedFileName(boardTitle: boardTitle, format: format)
        panel.canCreateDirectories = true
        panel.isExtensionHidden = false
        panel.allowsOtherFileTypes = true
        panel.prompt = "Export"
        panel.message = "Choose where to write the \(format.displayName) file."
        if let type = UTType(filenameExtension: format.fileExtension) {
            panel.allowedContentTypes = [type]
        }

        guard panel.runModal() == .OK, let url = panel.url else { return nil }
        return url
    }
}

// MARK: - Import

/// **File ▸ Import Board…** — a foreign document read into a brand-new board (15-import-export.md
/// ▸ Import is one row).
///
/// ### One row, and no format picker
///
/// The format is **detected**, never asked (`InterchangeFormat.detect`): the plugin's frontmatter marker
/// identifies an Obsidian Kanban file outright, an extension answers for the rest, and the fallback is
/// the lenient outline parser that cannot fail. Asking the user to classify their own file would be the
/// app making them do the one part of this it can do reliably — and getting it wrong is cheap here,
/// because an import that lands badly is one folder to delete.
///
/// ### Two panels, in the order the questions arise
///
/// Open panel (which file), then save panel (where the board goes), then the board opens through the
/// ordinary open path — so it registers, bookmarks and titles itself like any other. The **save panel's
/// name wins**: 01-storage-format.md § Board naming wants display name and folder name matching, and the
/// parsed title is what seeded the suggestion rather than what overrides the answer
/// (09-templates.md ▸ Instantiation, verbatim).
///
/// ### Available everywhere, like New Board…
///
/// An import needs no board in front — it *makes* one — so the row never disables. That is also why it
/// sits beside File ▸ Open… rather than beside Export: the two rows next to each other are the two ways
/// a board arrives from disk, while Export belongs with the board-scoped commands that act on the board
/// already in front.
///
/// ### Failure is an alert, deliberately
///
/// `TemplateChooserView`'s own reasoning, one flow over: there is no board window yet, so there is no
/// banner strip to carry a row — and the user is mid-conversation with two modal panels, which is where
/// the answer belongs. A **cancelled** import says nothing at all (the partial is already gone, the
/// duplicate rule verbatim).
///
/// There is no in-progress row for the same reason there is no banner: nothing on screen owns this
/// operation yet. The materialization still runs off the main actor so a large CSV cannot freeze the
/// app while it lands.
struct ImportBoardCommand: View {

    let appModel: AppModel

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "interchange")

    var body: some View {
        Button("Import Board…") {
            runImport()
        }
    }

    private func runImport() {
        guard let sourceURL = Self.chooseSource() else { return }

        Task { @MainActor in
            do {
                let source = try BoardImporter.read(contentsOf: sourceURL)
                Self.logger.info("importing as \(source.format.rawValue, privacy: .public)")

                guard let destination = Self.chooseDestination(for: source) else { return }
                let title = TemplateEngine.documentName(of: destination)

                // Detached for `DuplicateBoardCommand`'s two-part reason, minus the spinner: a board's
                // worth of folder creates is real I/O, and a detached task inherits no cancellation from
                // whatever else the enclosing task is doing.
                let board = source.board
                let landed = try await Task.detached(priority: .userInitiated) {
                    try BoardImporter.materialize(board, to: destination, title: title)
                }.value

                appModel.openBoard(at: landed)
            } catch let failure as BoardImporter.Failure {
                switch failure {
                case .cancelled:
                    Self.logger.notice("import cancelled — the partial board was removed")
                case let .failed(error):
                    Self.logger.error("import failed: \(error.description, privacy: .public)")
                    Self.present(error)
                }
            } catch {
                let write = BoardWriteError(
                    operation: .importBoard(fileName: sourceURL.lastPathComponent),
                    path: sourceURL.path,
                    reason: .io(message: error.localizedDescription)
                )
                Self.logger.error("import failed: \(write.description, privacy: .public)")
                Self.present(write)
            }
        }
    }

    /// The open panel. **No `allowedContentTypes`**, on purpose: detection is by content first and
    /// extension second, so an extension-less export somebody emailed is exactly the file this feature
    /// should accept — and a filter that hid it would make the detector's leniency unreachable.
    private static func chooseSource() -> URL? {
        let panel = NSOpenPanel()
        panel.canChooseFiles = true
        panel.canChooseDirectories = false
        panel.allowsMultipleSelection = false
        panel.prompt = "Import"
        panel.message = "Choose a Markdown or CSV file to import as a new board."

        guard panel.runModal() == .OK, let url = panel.url else { return nil }
        return url
    }

    private static func chooseDestination(for source: BoardImporter.Source) -> URL? {
        let panel = NSSavePanel()
        panel.nameFieldStringValue = BoardImporter.suggestedFileName(for: source)
        panel.canCreateDirectories = true
        panel.isExtensionHidden = false
        panel.allowsOtherFileTypes = true
        panel.prompt = "Create"
        panel.message = "Choose where to keep the imported board."

        guard panel.runModal() == .OK, let url = panel.url else { return nil }
        return url
    }

    /// The alert — `TemplateChooserView.present(_:)`'s, verbatim in shape and voice: the banner's own
    /// sentence for the failure, the path underneath it, one button.
    private static func present(_ error: BoardWriteError) {
        let alert = NSAlert()
        alert.alertStyle = .warning
        alert.messageText = BannerCenter.headline(for: error)
        alert.informativeText = error.path
        alert.addButton(withTitle: "OK")
        alert.runModal()
    }
}
