import Foundation

// MARK: - CommentTarget

/// Which of a card's two **authoring surfaces** a write is aimed at: the composer's draft, or one
/// posted comment being edited inline (05-card-window.md ▸ The comments column — the hover-target
/// carve-out's two destinations).
///
/// It exists because the pair is a *choice the view makes* and the store must not re-derive: which
/// surface the pointer was over when a file was dropped is knowledge only the window has, and the
/// alternative — two near-identical store methods — would put the choice in the call site's name
/// instead of in a value a test can hold.
///
/// `comments/.trash/` is deliberately not a case: a deleted comment is undo's backing store and
/// "never a UI surface" (01-storage-format.md § Enhanced schema), so there is no gesture that could
/// aim at one.
public enum CommentTarget: Sendable, Equatable {
    /// `comments/.draft/` — the composer's backing file.
    case draft
    /// `comments/<uuid>/` — a posted comment with an inline edit session open over it.
    case comment(ItemID)

    /// Where the target lives, given the card's folder. One resolution, so a view and a write can
    /// never disagree about which folder "the composer" means.
    public func folder(inCard cardFolder: URL) -> URL {
        switch self {
        case .draft: CommentThread.draftFolder(inCard: cardFolder)
        case let .comment(id): CommentThread.commentFolder(id, inCard: cardFolder)
        }
    }
}

// MARK: - The comment gestures

/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with
/// its inverse registered where 13-native-undo.md says one belongs.
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every
/// window over a board (board window, its card windows) shares the store and shares the stack"
/// (13 ▸ Rules). A comment posted in a card window is undone by ⌘Z in the board window, which is only
/// true if the step was registered here.
///
/// ### What registers a step, and what deliberately does not
///
/// Two of the five (13 ▸ Interaction with the trash, the comment clause; 13 ▸ Rules):
///
/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule
///   rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in
///   every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start
///   bytes — 05-card-window.md), which is a live buffer, not a stack entry.
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes
///   the interaction with the stack correct for free: leftover comment steps go stale after a purge
///   and skip with the ordinary info-tone banner, because the folders their expectations name are
///   gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is
///   exactly why this works — comments are not in the snapshot at all.
@MainActor
extension BoardStore {

    // MARK: Reading

    /// One card's thread, read fresh from disk — **window-scoped**, never cached on the store and
    /// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never
    /// loads comment content", so the board walk stays O(cards)).
    ///
    /// `.empty` for an id that names no card on the board — the same vanished-target answer every
    /// other card-scoped call gives (`boardItem`).
    public func commentThread(inCard id: ItemID) -> CommentThread {
        guard let card = commentSubject(id) else { return .empty }
        return CommentThread.load(inCard: card.folder, path: card.path)
    }

    /// **The composer's own file, read** — `comments/.draft/`, excluded from the thread listing and
    /// therefore asked for by name (`CommentThread.loadDraft`).
    ///
    /// `nil` for a card with no draft, an unreadable one, or an id that names no card — the same
    /// vanished-target answer `commentThread(inCard:)` gives, and the same "nothing to restore".
    public func commentDraft(inCard id: ItemID) -> CommentDraft? {
        guard let card = commentSubject(id) else { return nil }
        return CommentThread.loadDraft(inCard: card.folder)
    }

    // MARK: The draft

    /// Saves the composer's draft — one bracket, no step.
    ///
    /// - Returns: what landed, or `nil` when the write failed (the banner is already
    ///   `performWrite`'s) or the card is gone.
    @discardableResult
    public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? {
        guard let card = commentSubject(id) else { return nil }
        return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in
            try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title)
        }
    }

    /// **Posts the draft, and registers the one step the gesture owes** (⌘↩ or the Comment button).
    ///
    /// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still
    /// at its path **and `.draft` still absent** — a draft the user has started typing since is not
    /// this step's to overwrite — and the redo needs the mirror. Both are `HistoryExpectation`s over
    /// disk, and the `.absent` half is the same one an undone create uses.
    ///
    /// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
    /// that re-minted would post a *different* comment, and any step registered above this one naming
    /// the posted id would name nothing.
    @discardableResult
    public func postComment(inCard id: ItemID) -> ItemID? {
        guard let card = commentSubject(id) else { return nil }
        guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
            try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
        }) else {
            return nil
        }

        let folder = card.folder
        let title = card.title
        let draftFolder = CommentThread.draftFolder(inCard: folder)
        let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
        registerStep(
            HistoryPhrase.comment,
            subject: title,
            undoExpects: [.present(postedFolder), .absent(draftFolder)],
            redoExpects: [.present(draftFolder), .absent(postedFolder)]
        ) { _ in
            try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
        } redo: { _ in
            try BoardWriter.repostComment(
                as: posted.id,
                inCard: folder,
                stamping: posted.posted,
                cardTitle: title
            )
        }
        return posted.id
    }

    // MARK: Editing

    /// An inline edit session's save — one bracket, **no step** (13's no-capture rule; the session
    /// owns Cancel).
    ///
    /// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
    @discardableResult
    public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool {
        guard let card = commentSubject(id) else { return false }
        let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
        let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in
            try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title)
        }
        return wrote ?? false
    }

    // MARK: Delete and its inverse

    /// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable**
    /// (01-storage-format.md § Enhanced schema; 05-card-window.md ▸ The comments column).
    ///
    /// The step is the move read backwards, `moveToTrash`'s registration one level down and without
    /// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
    /// and existence is the whole predicate. The container rides in the path exactly as it does at
    /// board level — the undo expects the comment in `comments/.trash/`, the redo expects it back in
    /// the thread — so a foreign restore or a foreign re-delete skips the right half by itself.
    @discardableResult
    public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool {
        guard let card = commentSubject(id) else { return false }
        let folder = card.folder
        let title = card.title
        let live = CommentThread.commentFolder(commentID, inCard: folder)

        let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.deleteComment(at: live, cardTitle: title)
        }
        guard landed != nil else { return false }

        let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
        registerStep(
            HistoryPhrase.name(.delete, kind: .comment),
            subject: title,
            undoExpects: [.present(trashed)],
            redoExpects: [.present(live)]
        ) { _ in
            try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
        } redo: { _ in
            _ = try BoardWriter.deleteComment(at: live, cardTitle: title)
        }
        return true
    }

    // MARK: The purge, and the crash residue it leaves

    /// **Empties one card's `comments/.trash/`** — the card window's close flush (01-storage-format.md
    /// § Enhanced schema: "purged when the card window closes (rides the close flush)").
    ///
    /// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must
    /// not be: invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with
    /// the ordinary info-tone banner the first time one is crossed.
    public func purgeCommentTrash(inCard id: ItemID) {
        guard let card = commentSubject(id) else { return }
        try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.purgeCommentTrash(inCard: card.folder)
        }
    }

    /// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
    /// sweeps at the next card-window open, armed-then-cleared like every heal memo").
    ///
    /// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash
    /// is empty (which is every open on a board that closed cleanly, and costs no bracket at all),
    /// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear
    /// on success.
    ///
    /// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
    /// the app's own leftovers from a session that died; there is nothing here a user could act on.
    ///
    /// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each
    /// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write
    /// half re-verifies against disk, and a sweep with nothing to remove is a no-op.
    public func sweepCommentTrashResidue(inCard id: ItemID) {
        guard let card = commentSubject(id) else { return }
        let folder = card.folder
        let residue = CommentThread.trashedCommentIDs(inCard: folder)
        heals.run(
            .commentTrashResidue,
            signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
            on: self
        ) { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.purgeCommentTrash(inCard: folder)
        }
    }

    // MARK: The authoring surfaces' attachments

    /// **Imports files into the draft's or one comment's `attachments/`** — the composer's and the
    /// inline editor's drop carve-out and paperclip (05-card-window.md ▸ The comments column).
    ///
    /// One bracket, **no step**: an attachment import registers nothing at card level either
    /// (`importAttachments`), and 13-native-undo.md's inventory does not grow because a file landed
    /// one folder deeper.
    ///
    /// A vanished card, or a target folder that is not an authoring surface, writes nothing — the
    /// Writer's own guard, reached through the ordinary bracket so a failure banners like any other.
    public func importCommentAttachments(_ urls: [URL], inCard id: ItemID, target: CommentTarget) {
        guard !urls.isEmpty, let card = commentSubject(id) else { return }
        let folder = target.folder(inCard: card.folder)
        try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.importCommentAttachments(urls, intoComment: folder)
        }
    }

    /// **Moves one authoring chip's file to the system Trash** — never a hard delete, the sidebar
    /// row's rule one level down (05-card-window.md ▸ The comments column).
    ///
    /// A name that is no longer there is a silent no-op rather than a failure: the reload is the
    /// authority on what a folder holds (`BoardWriter.trashAttachment`).
    public func removeCommentAttachment(named name: String, inCard id: ItemID, target: CommentTarget) {
        guard !name.isEmpty, let card = commentSubject(id) else { return }
        let folder = target.folder(inCard: card.folder)
        try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.removeCommentAttachment(named: name, fromComment: folder)
        }
    }

    // MARK: The thread's claimed names

    /// Displaces the claimed names one thread read found squatted — `comments/.draft`,
    /// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout
    /// ▸ Rules, the level-uniform displacement, applied at the two levels the thread adds).
    ///
    /// **One bracket over the batch, and no memo**, which is the one place this heal differs from its
    /// board-level twin (`displaceClaimedNames`) — deliberately, and worth stating:
    /// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as
    /// the board walk's. Sharing the key would have one card's thread picture overwrite the board's
    /// and back again on every reload, so the two would spend their memos fighting instead of guarding.
    /// What replaces the memo here is the trigger: a thread is re-read when its window opens or its
    /// files change, not on a timer, so there is no hot loop for a memo to break.
    ///
    /// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered).
    /// - Returns: what was actually moved aside, for the caller's warning-tone notice — the Writer
    ///   re-verifies each against disk and answers `nil` for one that freed itself.
    @discardableResult
    public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] {
        guard !squatters.isEmpty else { return [] }
        let root = rootURL
        var displaced: [BannerCenter.Displacement] = []
        try? performWrite { () throws(BoardWriteError) -> Void in
            for squatter in squatters {
                guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
                    continue
                }
                displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
            }
        }
        return displaced
    }

    // MARK: - Resolving the card

    /// Where a card's thread lives and what to call the gesture — the three facts every comment write
    /// needs, resolved off the snapshot the way every other card-scoped call resolves them
    /// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard.
    ///
    /// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session
    /// writes at its new location.
    private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? {
        guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil }
        return (
            folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL),
            path: "\(item.laneID.rawValue)/\(cardID.rawValue)",
            title: item.title
        )
    }
}
