import Foundation
import Observation
import os

/// One open board: its snapshot, and the bracket every write to it goes through.
///
/// **The phone's answer to `BoardStore`.** It keeps that type's discipline — walks run off the main
/// actor, one walk at a time, a stale result never lands, and a failed reload keeps the last good
/// snapshot on screen — and none of its machinery: no FSEvents, no echo verdicts, no heal scheduler,
/// no git. The change signal here is the container's metadata query, relayed by `BoardIndexStore`.
///
/// **One narrow exception**: `open()` fires a single, one-shot `AgentGuide.install(atBoardRoot:)`
/// alongside the first walk (`refreshAgentGuideOnce()`) — never repeated on `reload()` or
/// `containerDidUpdate()`. This is the owner's 2026-08-09 ruling extending the Mac's open/reload
/// guide refresh to the phone, answered as narrowly as that ruling allows: a board still has *no*
/// heal scheduler here (nothing re-checks the guide on every foreign change the way
/// `BoardStore.runScheduledHeals()` does), only a courtesy check the moment a session is opened.
/// `BoardWriter.createBoard` (both platforms' only creation path) already installs a current guide
/// at birth, so this exists for the boards that predate that guarantee or were last touched by an
/// older build.
///
/// Minted and cached by `BoardIndexStore.session(forBoardAt:)`, never constructed directly by a
/// screen: two screens looking at one board must share one snapshot.
@MainActor
@Observable
final class BoardSession {

    /// What the board screen renders.
    enum Phase: Sendable, Equatable {
        /// `open()` has not run.
        case idle

        /// The package is not fully here yet. Carries the sweep's own numbers so the screen can show
        /// progress rather than an indefinite spinner. Not a failure state: it retries itself.
        case materializing(PackageMaterialization.Progress)

        /// First walk in flight, nothing to show yet. A *re*-walk over an existing snapshot does not
        /// enter this phase — the snapshot stays on screen instead.
        case loading

        /// `snapshot` is populated. `lastError` may still be set: that combination means the snapshot
        /// on screen is the last one that loaded and a later walk failed (see `lastError`).
        case ready

        /// The first walk failed and there is nothing to fall back to.
        case failed(BoardSessionError)
    }

    /// The package root. Identity, and the argument every loader and writer call is anchored on.
    let rootURL: URL

    private(set) var phase: Phase = .idle

    /// The last snapshot that loaded. **Deliberately not cleared on a failed reload** — a board that
    /// momentarily will not walk (a file mid-sync, a coordination refusal) must not blank the screen
    /// the user is working in.
    private(set) var snapshot: BoardModel?

    /// The tolerated anomalies of the walk that produced `snapshot` — missing indexes, non-UUID
    /// folders, ignored keys. Never blocks anything; carried so a future notice surface has it.
    private(set) var warnings: [LoadWarning] = []

    /// The most recent failure, load or write. Non-`nil` alongside `phase == .ready` is the
    /// stale-snapshot signal: what is drawn is real but is not the newest state of the disk.
    /// Cleared by the next walk that succeeds.
    private(set) var lastError: BoardSessionError?

    /// Bumped every time a walk lands a snapshot. Comment threads live *outside* the snapshot —
    /// the walk stays O(cards) and never opens comment content, the Mac's own arrangement — so a
    /// screen rendering a thread keys its re-read on this: it moves after the screen's own write
    /// (whose `perform` awaits the reload) and after a foreign change the metadata query relayed,
    /// which are exactly the two moments a thread on screen can have gone stale.
    private(set) var snapshotGeneration = 0

    /// The previous walk's parsed documents, offered to the next one (`BoardLoader.ParseMemo`). Pure
    /// optimization — it cannot change what a walk answers, only how many files it opens — and it is
    /// what keeps the reload after every single write from re-parsing the whole board on a phone.
    private var parseMemo: BoardLoader.ParseMemo?

    /// One walk at a time, with a depth-one bank behind it: any number of requests arriving during a
    /// walk collapse into exactly one follow-up. Awaiting the returned task therefore awaits the
    /// banked walk too, which is what lets `perform` promise a snapshot that includes its own write.
    private var walk: Task<Void, Never>?
    private var banked = false

    /// Only the newest walk's result applies. Serialization makes a stale landing unreachable today;
    /// the guard is written down anyway because "the newest result wins" is the rule every future
    /// overlapping-load change has to hold.
    private var loadGeneration = 0

    private var retry: Task<Void, Never>?

    /// How often a package that is still downloading re-checks itself. The metadata query usually
    /// beats the timer; the timer exists because a download that finishes without moving the
    /// package's own content-change date produces no notification at all.
    private static let retryInterval: Duration = .seconds(2)

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

    init(rootURL: URL) {
        self.rootURL = rootURL
    }

    // MARK: - Loading

    /// Starts the first walk. Idempotent — a screen may call it on every appearance.
    func open() {
        guard case .idle = phase else { return }
        refreshAgentGuideOnce()
        reload()
    }

    /// Re-walks the board. Returns the task that will have landed the newest result, so a caller who
    /// needs the fresh snapshot can `await` it and one who does not can ignore it.
    @discardableResult
    func reload() -> Task<Void, Never> {
        if let walk {
            banked = true
            return walk
        }
        let task = Task { [self] in
            repeat {
                banked = false
                await runLoad()
            } while banked
            walk = nil
        }
        walk = task
        return task
    }

    /// Stops the retry timer. The board screen calls this when it is finished with the session; the
    /// snapshot survives, so a session that is reopened redraws immediately and re-walks behind that.
    func close() {
        cancelRetry()
    }

    private func cancelRetry() {
        retry?.cancel()
        retry = nil
    }

    /// The container changed — relayed by `BoardIndexStore` on every metadata notification.
    ///
    /// Always a reload rather than a filtered one: the query reports a *package*, and the only thing
    /// that can be said from the outside about a package that changed is that something inside it
    /// did. The memo makes the resulting walk cheap, and the coalescing bank makes a burst of
    /// notifications one walk.
    func containerDidUpdate() {
        if case .idle = phase { return }
        reload()
    }

    private func runLoad() async {
        loadGeneration += 1
        let generation = loadGeneration
        let root = rootURL
        let memo = parseMemo

        // Only announce loading when there is nothing to show. A reload over a live board is
        // invisible by design, and a package still downloading keeps its own phase until the sweep
        // says otherwise.
        if snapshot == nil {
            switch phase {
            case .idle, .failed: phase = .loading
            case .loading, .materializing, .ready: break
            }
        }

        let outcome = await Task.detached(priority: .userInitiated) { () -> LoadOutcome in
            // Materialization first, always. A package with a dataless `index.md` anywhere in it would
            // otherwise reach the loader, which is fail-fast — and would report ordinary sync latency
            // as a broken board.
            let progress = PackageMaterialization.sweep(packageAt: root)
            guard progress.isComplete else { return .incomplete(progress) }

            let coordinated = CoordinatedFileAccess.read(itemAt: root) { resolved -> Result<LoadResult, BoardLoadFailure> in
                do throws(BoardLoadFailure) {
                    return .success(try BoardLoader.load(boardRoot: resolved, memo: memo))
                } catch {
                    return .failure(error)
                }
            }
            switch coordinated {
            case let .failure(failure):
                return .failed(.coordination(failure))
            case let .success(.success(result)):
                return .loaded(result)
            case let .success(.failure(failure)):
                return .failed(.load(failure))
            }
        }.value

        guard generation == loadGeneration else { return }
        apply(outcome)
    }

    private func apply(_ outcome: LoadOutcome) {
        switch outcome {
        case let .incomplete(progress):
            phase = .materializing(progress)
            scheduleRetry()

        case let .loaded(result):
            snapshot = result.model
            warnings = result.warnings
            parseMemo = result.memo
            lastError = nil
            phase = .ready
            snapshotGeneration += 1
            cancelRetry()

        case let .failed(error):
            lastError = error
            // The whole point of keeping the last snapshot: a board that is on screen stays on screen,
            // and the error rides alongside it instead of replacing it.
            phase = snapshot == nil ? .failed(error) : .ready
            cancelRetry()
            Self.logger.error("board walk failed: \(error.description, privacy: .public)")
        }
    }

    /// One pending re-check while the package downloads. Replaces itself rather than stacking: a
    /// second timer would double the sweep rate for no extra news.
    private func scheduleRetry() {
        retry?.cancel()
        retry = Task { [weak self] in
            try? await Task.sleep(for: Self.retryInterval)
            guard !Task.isCancelled, let self else { return }
            self.reload()
        }
    }

    // MARK: - The agent guide

    /// The type doc's "one narrow exception": a single `AgentGuide.install(atBoardRoot:)` per
    /// session, fired from `open()` and never again — not a heal, not a scheduler, just the phone's
    /// answer to "should an already-old board catch up the moment somebody opens it".
    ///
    /// **Fire-and-forget, deliberately not awaited by `open()`.** The guide is a courtesy an agent
    /// reads later, not something the board screen's first paint depends on, so it runs alongside
    /// `reload()` rather than gating it — the two detached tasks race, and neither waits on the
    /// other. It still goes through the same `CoordinatedFileAccess.write` bracket `perform(_:)`
    /// uses, because the ubiquity daemon is as much a second writer here as it is for any other
    /// phone write (`CoordinatedFileAccess`'s own doc comment).
    ///
    /// **Silent, on `AgentGuide`'s own reasoning**: "nothing here is a user-facing event." A
    /// coordination refusal or a genuine `BoardWriteError` is logged and dropped — never written to
    /// `lastError` — because that property means *this session's own write failed*, and a courtesy
    /// guide refresh racing the daemon on session open is not the write a screen showing a spinner or
    /// a stale-snapshot notice is asking about. `AgentGuide.install` already re-verifies against disk
    /// before writing anything, so losing a race to a foreign fix — another device's session, an
    /// agent — is success, not a failure this call ever sees.
    private func refreshAgentGuideOnce() {
        let root = rootURL
        // `Task { }`, not a bare detached call: the I/O itself still runs off the main actor
        // (`Task.detached`, `perform(_:)`'s own shape), but logging a failure needs `Self.logger`,
        // which is main-actor-isolated because this whole type is — so the outer task hops back
        // after `.value` the same way `perform(_:)` does, instead of touching it from inside the
        // detached closure.
        Task {
            let outcome: Result<Void, BoardSessionError> = await Task.detached(priority: .utility) {
                let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result<Void, BoardWriteError> in
                    do throws(BoardWriteError) {
                        try AgentGuide.install(atBoardRoot: resolved)
                        return .success(())
                    } catch {
                        return .failure(error)
                    }
                }
                switch coordinated {
                case let .failure(failure):
                    return .failure(.coordination(failure))
                case let .success(inner):
                    return inner.mapError(BoardSessionError.write)
                }
            }.value

            if case let .failure(failure) = outcome {
                Self.logger.error("agent guide refresh failed: \(failure.description, privacy: .public)")
            }
        }
    }

    // MARK: - The comment thread

    /// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
    /// its own thread: window-scoped, outside the board snapshot, coordinated like every other
    /// read on the phone.
    ///
    /// Total, like the read it wraps: `CommentThread.load` never refuses, and a coordinator
    /// refusal answers `.empty` after a log line rather than surfacing — a thread that momentarily
    /// will not read renders as "no comments" for one pass and re-reads on the next
    /// `snapshotGeneration` bump, not an error state the screen has to draw. The defects the read
    /// reports are dropped here: the phone has no heal engine to hand them to, and healing from
    /// two apps at once would be two writers racing over one defect.
    func loadCommentThread(laneID: ItemID, cardID: ItemID) async -> CommentThread {
        let root = rootURL
        let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentThread, CoordinationFailure> in
            CoordinatedFileAccess.read(itemAt: root) { resolved in
                CommentThread.load(
                    inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved),
                    path: "\(laneID.rawValue)/\(cardID.rawValue)"
                )
            }
        }.value
        switch outcome {
        case let .success(thread):
            return thread
        case let .failure(failure):
            Self.logger.error("comment thread read failed: \(failure.description, privacy: .public)")
            return .empty
        }
    }

    /// Reads the card's single draft — what seeds the composer. The draft is a folder inside the
    /// card, so it syncs like everything else: a comment started on the Mac is offered here to
    /// finish, exactly as designed ("the card's single draft", 01-storage-format.md § Enhanced
    /// schema). `nil` is "nothing to restore" — no draft, an unreadable one, or a coordinator
    /// refusal, none of which the composer can do anything about beyond starting empty.
    func loadCommentDraft(laneID: ItemID, cardID: ItemID) async -> CommentDraft? {
        let root = rootURL
        let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentDraft?, CoordinationFailure> in
            CoordinatedFileAccess.read(itemAt: root) { resolved in
                CommentThread.loadDraft(inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved))
            }
        }.value
        switch outcome {
        case let .success(draft):
            return draft
        case let .failure(failure):
            Self.logger.error("comment draft read failed: \(failure.description, privacy: .public)")
            return nil
        }
    }

    /// `<root>/<lane>/<card>` — the derivation every comment call anchors on, spelled once and
    /// `nonisolated` so the detached reads above can use it against the coordinator-resolved root.
    nonisolated static func cardFolder(laneID: ItemID, cardID: ItemID, inRoot root: URL) -> URL {
        root
            .appendingPathComponent(laneID.rawValue, isDirectory: true)
            .appendingPathComponent(cardID.rawValue, isDirectory: true)
    }

    // MARK: - Writing

    /// Runs a closure of `BoardWriter` calls against this board and reloads.
    ///
    /// **The one door for every mutation a screen makes.** It supplies the three things a phone write
    /// needs and a bare `BoardWriter` call does not: a background executor (writes are synchronous
    /// filesystem work and must never run on the main actor), an `NSFileCoordinator` write intent over
    /// the whole package (so the daemon does not push a remote version into a folder mid-write, and so
    /// a two-folder operation like a move lands as one unit), and the reload that follows — views
    /// render only what is on disk, exactly as on the Mac, so a write that is not followed by a walk
    /// changes nothing on screen.
    ///
    /// `work` is handed the coordinator-resolved root and should derive every URL from it. It may
    /// make any number of writer calls; they all land inside the one bracket, and the first one to
    /// throw abandons the rest — which is the writer's own contract, not a policy added here.
    ///
    /// Awaiting the returned result means the reload has already landed, so `snapshot` reflects the
    /// write. A failure is returned *and* recorded in `lastError`; the reload runs either way, because
    /// a partially-applied multi-call write leaves disk in a state the screen must be shown.
    @discardableResult
    func perform<T: Sendable>(
        _ work: @escaping @Sendable (URL) throws(BoardWriteError) -> T
    ) async -> Result<T, BoardSessionError> {
        let root = rootURL

        let outcome: Result<T, BoardSessionError> = await Task.detached(priority: .userInitiated) {
            let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result<T, BoardWriteError> in
                do throws(BoardWriteError) {
                    return .success(try work(resolved))
                } catch {
                    return .failure(error)
                }
            }
            switch coordinated {
            case let .failure(failure):
                return .failure(.coordination(failure))
            case let .success(inner):
                return inner.mapError(BoardSessionError.write)
            }
        }.value

        if case let .failure(error) = outcome {
            lastError = error
            Self.logger.error("board write failed: \(error.description, privacy: .public)")
        }

        await reload().value
        return outcome
    }
}

/// What one walk produced — the `Sendable` currency between the detached load and the main actor.
private enum LoadOutcome: Sendable {
    case incomplete(PackageMaterialization.Progress)
    case loaded(LoadResult)
    case failed(BoardSessionError)
}

/// Everything that can go wrong for one board, in one vocabulary: the coordinator refused, the walk
/// found a fail-fast defect, or a write did not land. The two storage cases carry the storage layer's
/// own typed errors verbatim — a re-worded copy would be a second, worse taxonomy.
enum BoardSessionError: Error, Sendable, Equatable, CustomStringConvertible {
    case coordination(CoordinationFailure)
    case load(BoardLoadFailure)
    case write(BoardWriteError)

    var description: String {
        switch self {
        case let .coordination(failure): failure.description
        case let .load(failure): failure.description
        case let .write(error): error.description
        }
    }
}
