import Foundation

/// File ▸ Share…'s staging half (design ruling 2026-08-09, card 72691b11 "create a share sheet
/// target for board") — everything that happens **before** `NSSharingServicePicker` ever appears
/// (`BoardSharePresentation`, the AppKit half this deliberately has none of).
///
/// ### A faithful copy, `.git` the sole exclusion
///
/// The ruling's own words: content rules follow **Save as Template's exclusion precedent**
/// applied more narrowly — "exclude any inert `.git`; include everything else verbatim
/// (attachments, comments, trash included — it's a faithful copy)". That is `BoardDuplicator`'s
/// posture, not `TemplateEngine`'s: Save as Template drops **two** things (`.git` *and*
/// `.trash/`) because a template is content, not a fork; Duplicate drops neither, because a
/// duplicate *is* a fork. A share is neither of those — nobody is forking history (a recipient
/// with no push access to gain nothing from a `.git` folder full of it, hence dropping it) or
/// building a content-only template (the recipient is meant to see the board exactly as it
/// stands, hence keeping the trash) — so it lands in between, one exclusion, everything else
/// carried.
///
/// **The trash inclusion is flagged for owner review** (recorded in the card's DECISIONS
/// comment): a shared board carries the sender's `.trash/` — cards they deleted — to the
/// recipient, which is a real question ("does a shared board maybe not want to carry the
/// recipient your deleted cards?") this file answers one way (include, matching Duplicate's own
/// "it's a faithful copy" reasoning literally) without pretending the other answer is unreasonable.
///
/// ### Two steps, and a temp tree that never survives the zip
///
/// `stage(boardAt:titled:)` runs `stageTree(boardAt:into:)` (a plain `BoardTreeCopy` walk — the
/// exact machinery `BoardDuplicator` and `TemplateEngine` already share, `.trash/`'s exclusion
/// list narrowed to one entry) and then `DittoZipArchiver.zip(contentsOf:to:)` over the result,
/// **removing the unzipped tree the moment the zip exists** — the ruling asks for one file, the
/// `.zip`, at the destination the picker is handed, and there is no reason a decompressed second
/// copy of the board (trash included) should keep occupying the temp volume for the whole life of
/// the share sheet.
///
/// The two are split into their own functions rather than folded into one, because the split is
/// what makes the exclusion rule testable without the ditto subprocess in the loop at all
/// (`BoardShareStagerTests`'s tree-only suite) — `DittoZipArchiverTests` is where the subprocess
/// itself gets its own, separate verification.
///
/// ### Atomicity: construct-then-clean, `TemplateEngine`'s own note
///
/// "A half-staged share must never be left for the picker to find." The whole staging directory is
/// created by this call and removed by this call on every exit that is not a `StagedArchive` —
/// cancellation and failure alike (09-templates.md's atomicity note, applied one boundary over).
/// The one thing this file never owns is the **successful** exit's cleanup: a `StagedArchive` that
/// lands is handed to `BoardSharePresentation`, which is the one thing that knows when the
/// picker's interaction has actually finished — dismissed, failed, or a completed share — and
/// therefore the one thing that calls `cleanup(_:)` on that path.
///
/// ### Not `@MainActor`
///
/// A board's `.trash/` and attachments can be real bytes, and `ditto` zipping them is real I/O —
/// the same reason `BoardTreeCopy` and `BoardDuplicator` stay off the main actor so the banner's
/// in-progress row can actually spin. Safe by construction, for the identical reason: this touches
/// only the two URLs it is handed and a staging directory it alone created.
enum BoardShareStager {

    // MARK: - Outcomes

    /// The two ways staging ends without a `StagedArchive` — `TemplateEngine.Failure`'s own
    /// vocabulary, for the same reason: the destination is a temp directory this app always owns,
    /// so there is no save-panel-shaped `.refused` question to ask, only "the user cancelled" or
    /// "something else went wrong".
    enum Failure: Error, Sendable, Equatable {
        /// The user cancelled (the banner row's Cancel). The staging directory is already gone —
        /// "a cancelled share never happened", `BoardDuplicator`'s rule verbatim.
        case cancelled

        /// Anything else: a full disk, an unreadable board, `ditto` itself refusing. The ordinary
        /// one-shot banner's vocabulary.
        case failed(BoardWriteError)
    }

    /// What a successful stage hands `BoardSharePresentation`: the whole staging directory (which
    /// `cleanup(_:)` removes wholesale) and the one `.zip` file inside it the picker actually
    /// shares.
    struct StagedArchive: Sendable, Equatable {
        let root: URL
        let zipURL: URL
    }

    // MARK: - Naming

    /// `"<Board Title>.zip"`'s left half — the board's display title, made safe as a path
    /// component.
    ///
    /// Neither `BoardDuplicator` nor `TemplateEngine` sanitizes a name: both build their
    /// destination from a folder name already legal on disk (`copyDestination(for:)` reads the
    /// source `URL`'s own `lastPathComponent`), never from the frontmatter `title:` string this
    /// function takes. A share's filename is the first place in the app a **freeform title**
    /// becomes a **filename** on its own, so this is genuinely new, not a convention borrowed from
    /// either sibling — checked first, per the card's instructions, and found absent rather than
    /// found and mirrored.
    ///
    /// `/` is the one byte APFS/HFS+ actually forbids in a path component; `:` is legal on disk
    /// but has read as a path separator in Finder since the Carbon HFS colon-mapping, and is the
    /// one other character this codebase already treats as filename-hostile
    /// (`BoardRegistry.quarantine`'s own colon replacement, on a timestamp rather than a title, but
    /// the same character for the same reason). Both become `-`, trimmed, and an all-whitespace or
    /// empty result falls back to `"Board"` — the same word `WriterFixture.board(title:)`'s own
    /// default uses, so an untitled board's zip reads as "a board" rather than as an empty string
    /// AppKit would have to reject the file at all.
    static func sanitizedFilenameComponent(from title: String) -> String {
        let replaced = title
            .replacingOccurrences(of: "/", with: "-")
            .replacingOccurrences(of: ":", with: "-")
        let trimmed = replaced.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? "Board" : trimmed
    }

    // MARK: - Staging

    /// A fresh, empty staging directory nobody else could be using — `NSTemporaryDirectory()`
    /// rather than beside the board (unlike Duplicate's sibling): there is no Finder-visible
    /// destination here to place next to anything, and the app's own temp directory is always
    /// writable, which is exactly why sharing carries no `.refused` outcome at all.
    private static func makeStagingRoot() throws -> URL {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("dev.rzen.indie.Kanban-share-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        return root
    }

    /// The copy half, alone and testable without a subprocess: `rootURL`'s tree, minus `.git`,
    /// landing at `destination` — `BoardTreeCopy`'s ordinary per-item walk, carrying folder
    /// attributes the way `BoardDuplicator` does ("a faithful copy", the ruling's own words,
    /// echoing Duplicate's rather than the template engine's stripped-attributes posture).
    static func stageTree(
        boardAt rootURL: URL,
        into destination: URL,
        isCancelled: () -> Bool
    ) throws(BoardTreeCopy.Stop) {
        try BoardTreeCopy.copy(
            contentsOf: rootURL,
            into: destination,
            excludingTopLevel: [".git"],
            carryingFolderAttributes: true,
            isCancelled: isCancelled
        )
    }

    /// Stages `rootURL` for sharing: a temp directory holding one `.zip`, named from `title`.
    ///
    /// `title` is the board's resolved display name (`AppModel.displayName(of:)`, the same read
    /// `DuplicateBoardCommand` takes) — never optional here, unlike the `WriteOperation` payload it
    /// feeds: a share's filename needs *some* word, and `AppModel.displayName(of:)` already
    /// guarantees one (title, falling back to the folder name), so there is no genuinely titleless
    /// case for this function to represent.
    ///
    /// `isCancelled` is read between the walk's items and nowhere else, `BoardDuplicator`'s own
    /// seam — the zip step itself, once started, runs to completion rather than mid-process
    /// (`DittoZipArchiver`'s own note: this app does not partially zip a file any more than
    /// `BoardTreeCopy` partially copies one).
    static func stage(
        boardAt rootURL: URL,
        titled title: String,
        isCancelled: () -> Bool = { Task.isCancelled }
    ) throws(Failure) -> StagedArchive {
        let operation = WriteOperation.shareBoard(title: title)
        let name = sanitizedFilenameComponent(from: title)

        func failure(at url: URL, _ message: String) -> Failure {
            .failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
        }

        let root: URL
        do {
            root = try makeStagingRoot()
        } catch {
            throw failure(at: rootURL, "could not create a staging folder: \(error.localizedDescription)")
        }

        // Cancelled before it began is still cancelled — checked before the tree walk starts so an
        // empty staging directory never lingers for a share nobody asked to continue.
        if isCancelled() {
            try? FileManager.default.removeItem(at: root)
            throw .cancelled
        }

        let treeCopy = root.appendingPathComponent(name, isDirectory: true)
        let zipURL = root.appendingPathComponent("\(name).zip", isDirectory: false)

        do {
            try BoardTreeCopy.createDirectory(at: treeCopy)
            try stageTree(boardAt: rootURL, into: treeCopy, isCancelled: isCancelled)
        } catch let stop as BoardTreeCopy.Stop {
            try? FileManager.default.removeItem(at: root)
            switch stop {
            case .cancelled:
                throw .cancelled
            case let .failed(url, underlying):
                throw failure(at: url, "could not copy the board: \(underlying.localizedDescription)")
            }
        } catch {
            try? FileManager.default.removeItem(at: root)
            throw failure(at: treeCopy, "could not create the staging folder: \(error.localizedDescription)")
        }

        // Between the copy and the zip, once more — a Cancel pressed the instant the walk finished
        // must not still spawn `ditto` over a tree nobody wants shared.
        if isCancelled() {
            try? FileManager.default.removeItem(at: root)
            throw .cancelled
        }

        do {
            try DittoZipArchiver.zip(contentsOf: treeCopy, to: zipURL)
        } catch let zipFailure as DittoZipArchiver.Failure {
            try? FileManager.default.removeItem(at: root)
            switch zipFailure {
            case let .launchFailed(message):
                throw failure(at: treeCopy, "could not start the archiver: \(message)")
            case let .nonZeroExit(status, message):
                throw failure(at: treeCopy, "the archiver exited with status \(status): \(message)")
            }
        } catch {
            try? FileManager.default.removeItem(at: root)
            throw failure(at: treeCopy, "could not create the zip: \(error.localizedDescription)")
        }

        // The zip is what the picker shares; the decompressed tree underneath it was only ever a
        // means to that one file, and leaving it beside the zip would be a second, needless copy of
        // the board's attachments and trash sitting in temp storage for the picker's whole lifetime.
        try? FileManager.default.removeItem(at: treeCopy)

        return StagedArchive(root: root, zipURL: zipURL)
    }

    /// Removes a staged archive's whole directory — `BoardSharePresentation`'s call, once the
    /// picker's interaction has genuinely finished (dismissed without choosing, a service failed,
    /// or a service reported success). Best-effort: a cleanup that cannot remove a temp file it no
    /// longer needs is not a reason to trouble the user with a banner about it.
    static func cleanup(_ archive: StagedArchive) {
        try? FileManager.default.removeItem(at: archive.root)
    }
}
