import Foundation
import os

// MARK: - The host

/// What a scheduled heal needs from the board it is healing — the seam that keeps the engine
/// independent of `BoardStore` (which is the only conformer, and a test's fake is the second).
///
/// Deliberately four members and no more: the engine decides *whether and when*, never *what*. The
/// work itself is a closure the store hands over, because only the store knows which Writer call a
/// defect class maps to.
@MainActor
public protocol HealHost: AnyObject {
    /// Where the board is **now** — re-read per heal, because a rename absorbed mid-session moves it.
    var rootURL: URL { get }

    /// The standing read-only condition, `nil` when the board accepts writes.
    var readOnlyLock: ReadOnlyLockReason? { get }

    /// The board window's banner strip — the engine posts through the posture table below, and
    /// `BannerCenter` keeps every word.
    var banners: BannerCenter { get }

    /// The write gate: one watcher bracket, one app-mediated reload, the failure already banner-ed.
    @discardableResult
    func performWrite<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T
}

// MARK: - HealScheduler

/// **The scheduled-heal engine** — the six-step pattern expressed once (02-architecture.md ▸
/// Components ▸ HealScheduler; 01-storage-format.md § Validation and healing, settled 2026-07-29).
///
/// Before this existed, three healers in `BoardStore` re-derived the same pattern by hand — the
/// loose-file relocation, the legacy-tombstone migration, and the agent-guide refresh — and the
/// copies had already drifted: only the guide defended itself against an unwritable location, only
/// the guide cleared its memo on success, and only two of the three fired at board open. The engine
/// is the pattern with the drift taken out.
///
/// ### The six steps, in order
///
/// 1. **Compute the work from the latest defects**, and **rest** when there is none: an empty
///    signature clears the memo, which is what makes a board with nothing to heal forget it ever
///    tried.
/// 2. **The lock-and-writability gate.** A read-only board **defers, never abandons** — it returns
///    having written nothing *and having remembered nothing*, so the reload that lifts the lock is
///    the reload that heals. The `access(2)` check beside it was the guide's private defense and is
///    now everyone's: it covers the window *between* writability probes, where a volume remounted
///    read-only would otherwise let a heal reach the Writer, fail, and banner about work the user
///    never asked for.
/// 3. **Signature compare.** An attempt is made only when the defect picture **differs from the one
///    last acted on** — one failure, one banner row, then silence until something on disk actually
///    changes.
/// 4. **Arm the memo *before* attempting**, so a write that throws leaves it set: the memo's whole
///    job is to remember pictures this store has already failed on.
/// 5. **One write bracket**, whose write half re-verifies each defect against disk and no-ops when
///    it is gone — losing the race to a foreign fix is success, never an error.
/// 6. **Post per the banner-posture table**, then **clear the memo explicitly on success** — the
///    guide's rule, generalized. Clearing is load-bearing rather than tidy: the memo keys on the
///    picture that provoked the write, and a foreign undo restores that exact picture, which a
///    standing memo would make the one thing the self-heal could not heal.
///
/// ### Where it runs
///
/// **At the reload tail and at registry acquire, uniformly** — closing the asymmetry where the
/// tombstone migration never fired at open. Both seams call `BoardStore.runScheduledHeals()`.
///
/// ### What it deliberately is not
///
/// Not the home of *inline* heals, which stay gesture-scoped (`placingRanks` below is a helper for
/// one of them, not a scheduling of it), and not the home of *on-touch* heals, which live at the
/// Writer's `updateIndex` seam. The three timings are different by design (01-storage-format.md
/// § Validation and healing), and only this one needs a scheduler.
@MainActor
public final class HealScheduler {

    /// The picture each defect class was last *acted on* against — armed before an attempt, cleared
    /// on success, and reset to nothing when the class has no work at all.
    ///
    /// **In-memory, per-store, dies with the session**, like every other loop guard here: closing
    /// the board is the reset.
    private var memos: [IntegrityRules.Defect.Class: Set<String>] = [:]

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

    public init() {}

    /// The picture a class was last acted on, for tests and diagnostics. `nil` means "nothing has
    /// been acted on against the current picture" — both the opening state and the resting one.
    public func memo(for healClass: IntegrityRules.Defect.Class) -> Set<String>? {
        memos[healClass]
    }

    // MARK: - The engine

    /// Runs one scheduled heal against `host`, per the six steps above.
    ///
    /// - Parameters:
    ///   - healClass: the memo key and the banner-posture row.
    ///   - signature: the defect picture, as comparable values. **Empty means resting** — nothing to
    ///     heal — and is the one input that clears the memo without writing.
    ///   - work: the write half. Runs inside the host's write bracket, and re-verifies each defect
    ///     against disk itself (the Writer's heal operations do it per call).
    ///   - notice: what to say afterwards, read **whether or not `work` threw** — a batch that moved
    ///     three files and failed on the fourth still names the three, which is what the relocation
    ///     has always done and what a partial heal owes the user.
    public func run(
        _ healClass: IntegrityRules.Defect.Class,
        signature: Set<String>,
        on host: some HealHost,
        performing work: () throws(BoardWriteError) -> Void,
        posting notice: () -> HealNotice = { .none }
    ) {
        // 1. Resting-clear.
        guard !signature.isEmpty else {
            memos[healClass] = nil
            return
        }
        // 2. The lock-and-writability gate — deferral, deliberately *before* the memo is written, so
        //    the attempt a lock refused is not the attempt the guard below remembers.
        guard host.readOnlyLock == nil else {
            Self.logger.debug("\(String(describing: healClass), privacy: .public) heal deferred — the board is read-only")
            return
        }
        guard FileManager.default.isWritableFile(atPath: host.rootURL.path) else {
            Self.logger.debug("\(String(describing: healClass), privacy: .public) heal skipped — the board's location is not writable")
            return
        }
        // 3. Signature compare, and 4. arm before attempting.
        guard memos[healClass] != signature else { return }
        memos[healClass] = signature

        // 5. One bracket. `performWrite` posts every `BoardWriteError` before it rethrows, and the
        //    rethrow is swallowed here like every other app-initiated write with nothing else to do
        //    about a failure — the armed memo is what keeps it from being posted again every reload.
        let landed: Void? = try? host.performWrite(work)
        // 6. Post, then clear on success.
        Self.post(notice(), to: host.banners)
        if landed != nil {
            memos[healClass] = nil
        }
    }

    // MARK: - The banner-posture table

    /// What a heal has to say for itself — **one row per defect class, declared once**
    /// (02-architecture.md ▸ Components: "post per one banner-posture table … BannerCenter still
    /// owns all phrasing").
    ///
    /// Every case here is a *loss row* or silence; there is no failure row, because failures are
    /// `performWrite`'s and reach the strip as one-shots without anyone here deciding. The payloads
    /// are values, never strings: the phrasing lives in `BannerCenter` and nowhere else, so a
    /// sentence cannot be spelled two ways.
    public enum HealNotice: Sendable, Equatable {
        /// Silent — nothing was healed, or the heal is one the user has no business hearing about
        /// (the guide's ordinary refresh: a courtesy file the user did not create and may not know
        /// exists).
        case none
        /// Loss row, warning tone: files were moved into a card's `attachments/`.
        case relocatedLooseFiles([BannerCenter.Relocation])
        /// Loss row, warning tone: legacy `deleted:` keys on **cards** were resolved (the lane half
        /// retired 2026-07-29 — a lane's key is inert, and nothing is written for it).
        case migratedTombstones(cards: [String?])
        /// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
        case displacedClaimedNames([BannerCenter.Displacement])
        /// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
        ///
        /// **Announced rather than silent, and that is the ruling's own word** (01-storage-format.md
        /// § Validation and healing, re-ruled 2026-07-29): a heal that changes user content — and an
        /// identity is content — "announces itself with a warning-tone notice … never a consent gate".
        /// This row is what replaced the retired user-gated Repair banner: the same information, none
        /// of the waiting.
        case remintedDuplicateIDs(titles: [String?])
        /// Loss row, warning tone: `.backgrounds/` files nothing on the board named any more were
        /// moved to the system Trash, named by filename (ruled 2026-08-09 — the open-time heal half
        /// of the orphan tidy; the in-flow trim rides silently inside the gesture that triggers it and
        /// posts nothing here).
        case tidiedBackgroundImages([String])
    }

    private static func post(_ notice: HealNotice, to banners: BannerCenter) {
        switch notice {
        case .none:
            break
        case let .relocatedLooseFiles(relocations):
            banners.postRelocatedLooseFiles(relocations)
        case let .migratedTombstones(cards):
            banners.postMigratedTombstones(cards: cards)
        case let .displacedClaimedNames(displacements):
            banners.postDisplacedClaimedNames(displacements)
        case let .remintedDuplicateIDs(titles):
            banners.postRemintedDuplicateIDs(titles)
        case let .tidiedBackgroundImages(names):
            banners.postTidiedBackgroundImages(names)
        }
    }

    // MARK: - The inline renumber-and-retry

    /// **The midpoint-exhaustion two-step, once** — ask, renumber, ask again (01-storage-format.md
    /// § Ordering; 02-architecture.md ▸ Components: "the renumber's ask-renumber-ask-again two-step
    /// as one shared helper instead of today's nine hand-rolled copies").
    ///
    /// An **inline** heal, and it stays gesture-scoped: this runs inside the caller's own
    /// `performWrite` bracket, inside the gesture that triggered it, and composes no event of its
    /// own (the renumber is bookkeeping — 06-history-undo.md ▸ Commit messages). It is a helper here
    /// rather than a method on the engine because inline heals are not scheduled; what they share
    /// with the scheduled ones is the vocabulary, not the machinery.
    ///
    /// - Parameters:
    ///   - ladder: the ranks to place among, in display order.
    ///   - parentFolder: whose visible children compact when the first ask fails — a lane
    ///     (renumbering its cards) or the board root (renumbering its lanes).
    ///   - ask: computes the placement from a ladder. **`nil` means the gap is exhausted, not that
    ///     the insertion is illegal** (`Ranks`' own convention).
    /// - Returns: what `ask` answered, plus the ladder it answered against — the *renumbered* one
    ///   when a compaction happened, which callers need because a renumber moves ranks they had
    ///   already read (a dragged item's own prior rank, the undo step's inverse). `nil` when even
    ///   the compacted ladder had no answer, which every call site treats as "write nothing".
    ///
    /// `nonisolated` because it is not the engine's state, only its vocabulary: an inline heal has
    /// no memo, no gate and no bracket of its own, and tying it to the main actor would claim
    /// otherwise.
    public nonisolated static func placingRanks<Placement>(
        amongVisible ladder: [Double],
        compacting parentFolder: URL,
        _ ask: ([Double]) -> Placement?
    ) throws(BoardWriteError) -> (placement: Placement, ladder: [Double], renumbered: Bool)? {
        if let placement = ask(ladder) {
            return (placement: placement, ladder: ladder, renumbered: false)
        }
        // Compact, then place against the fresh ranks. The renumber assigns in display order, so the
        // compacted ladder lines up one-for-one with the one that was passed in.
        try BoardWriter.renumberVisibleChildren(of: parentFolder)
        let compacted = Ranks.renumbered(count: ladder.count)
        guard let placement = ask(compacted) else { return nil }
        return (placement: placement, ladder: compacted, renumbered: true)
    }
}
