import Foundation
import os

// MARK: - Timestamps

/// The one shape a `Date` takes in the registry file — ISO 8601 **with fractional seconds**, UTC.
///
/// **Why fractional seconds** rather than `JSONEncoder.DateEncodingStrategy.iso8601`'s whole ones:
/// `lastOpened` is not a display stamp, it is the recents list's *sort key* (02-architecture.md
/// § Per-board app state, "The recents list *is* this registry sorted by last-opened"). Restoring
/// several boards at launch happens well inside one second, and at whole-second resolution those
/// rows would tie and order arbitrarily.
///
/// **Why `ISO8601DateFormatter`** and not `Date.ISO8601FormatStyle`, the modern value type that
/// would be `Sendable` and need none of the ceremony below: the format style *truncates* to the
/// millisecond while its own parser rounds, so `parse(format(d)) != d` for about half of all dates
/// (measured, not assumed — it drifts a millisecond earlier on each round trip). This formatter is
/// an exact fixed point, which is what lets `BoardRegistry.stamp(_:)` guarantee that a record in
/// memory and the same record reloaded from disk are *equal*, not merely close.
///
/// **Built per use rather than shared**: the formatter is a non-`Sendable` class and the encoder's
/// and decoder's date strategies are `@Sendable` closures, so a captured or global instance would
/// be an unchecked concurrency promise. One costs microseconds and this file holds a handful of
/// records — the safe answer is also the cheap one.
private func boardRegistryTimestampFormatter() -> ISO8601DateFormatter {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    formatter.timeZone = TimeZone(identifier: "UTC")
    return formatter
}

// MARK: - Records

/// A board's window frame, remembered per board (02-architecture.md § Windows, "per-board frame
/// memory").
///
/// Stored as four `Double`s rather than an `NSRect`/`CGRect` so the persisted JSON is a shape this
/// file owns, independent of any framework's `Codable` conformance. Repositioning a frame onto a
/// live screen when the saved one is gone is the window layer's job, not this type's — nothing here
/// validates the numbers.
public struct WindowFrame: Codable, Sendable, Equatable {
    public var x: Double
    public var y: Double
    public var width: Double
    public var height: Double

    public init(x: Double, y: Double, width: Double, height: Double) {
        self.x = x
        self.y = y
        self.width = width
        self.height = height
    }
}

/// One known board: everything the app keeps *about* a board that must never be written *into* it.
///
/// **Files-first is absolute** (02-architecture.md § Per-board app state): no frontmatter key, no
/// sidecar, no xattr — this record lives wholly in Application Support, which is also why two
/// machines sharing a board through a remote each keep their own (push-on-commit and window frames
/// are genuinely per-machine choices).
///
/// The **bookmark is the identity**; `lastKnownPath` is display text and nothing else. Matching an
/// opened folder to its record resolves the bookmark and compares file identity, so a board renamed
/// or moved on the same volume keeps its settings and its place in recents.
///
/// ### Evolving this struct
///
/// The synthesized `Codable` conformance rejects a file missing any non-optional key, and
/// `BoardRegistry` responds to a rejected file by quarantining it — every record lost. So **a key
/// added here must be optional or have a decoding default**, or the addition silently empties every
/// existing user's recents on upgrade. That policy, not a version field, is what keeps this format
/// readable across releases.
public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
    public let id: UUID

    /// The security-scoped bookmark — **the board's identity**, refreshed on every open. Empty only
    /// in the degenerate case where the system refused to make one at all, which is a record born
    /// orphaned: it shows in recents with Forget and never matches an open.
    public var bookmark: Data

    /// Last-known title or folder name, for the recents row. Display only.
    public var displayName: String

    /// Where the board was last seen, for the recents row when the bookmark no longer resolves and
    /// there is nothing else to show. **Never used for matching** — that is the bookmark's job, and
    /// a path that matched would reintroduce exactly the identity-by-string bug this design excludes.
    public var lastKnownPath: String

    public var lastOpened: Date

    /// Counts stamped at last close, `nil` until a close has stamped them.
    ///
    /// Registry-cached on purpose: the welcome window renders these instead of scanning
    /// (§ Per-board app state, "no directory scan at welcome time" — which would be slow on a big
    /// board and would hang on an unavailable one). Staleness until the next open is accepted, and a
    /// board that has never been closed simply shows no counts.
    public var laneCount: Int?
    public var cardCount: Int?

    public var windowFrame: WindowFrame?

    /// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision,
    /// not a side effect.
    public var pushOnCommit: Bool

    /// Whether this board's once-per-board iCloud/network-volume warning has been shown
    /// (07-sync-collab.md). Once-per-*board*, which is why it lives on the record rather than in
    /// `UserDefaults`.
    public var remoteLocationWarned: Bool

    public init(
        id: UUID = UUID(),
        bookmark: Data,
        displayName: String,
        lastKnownPath: String,
        lastOpened: Date,
        laneCount: Int? = nil,
        cardCount: Int? = nil,
        windowFrame: WindowFrame? = nil,
        pushOnCommit: Bool = false,
        remoteLocationWarned: Bool = false
    ) {
        self.id = id
        self.bookmark = bookmark
        self.displayName = displayName
        self.lastKnownPath = lastKnownPath
        self.lastOpened = lastOpened
        self.laneCount = laneCount
        self.cardCount = cardCount
        self.windowFrame = windowFrame
        self.pushOnCommit = pushOnCommit
        self.remoteLocationWarned = remoteLocationWarned
    }
}

/// A recents row: the record, plus whether its board can be reached right now.
///
/// The classification is a **bookmark resolution and nothing else** — no `fileExists`, no listing,
/// no counting. That is what makes `BoardRegistry.recents()` safe to call while a network volume is
/// offline (§ Per-board app state, and § Graceful orphaning for what `unavailable` means to the UI:
/// the row shows with Forget rather than disappearing).
public enum RecentBoard: Sendable, Equatable {
    /// Resolved. `at` is where the board lives *now*, which is not necessarily `lastKnownPath` — a
    /// bookmark follows renames and moves on the same volume.
    case available(BoardRecord, at: URL)
    /// The bookmark no longer resolves: deleted, or moved across a volume boundary a bookmark
    /// cannot follow. Orphaned — "its settings are conveniences and die with it".
    case unavailable(BoardRecord)

    public var record: BoardRecord {
        switch self {
        case let .available(record, _): record
        case let .unavailable(record): record
        }
    }

    /// Where the board is now, or `nil` for an orphan.
    public var url: URL? {
        switch self {
        case let .available(_, url): url
        case .unavailable: nil
        }
    }
}

// MARK: - BoardRegistry

/// The persistent side of per-board app state: one record per known board, in Application Support
/// (02-architecture.md § Per-board app state).
///
/// ### Three rules do most of the work
///
/// 1. **Identity, never paths.** An opened folder finds its record by resolving each record's
///    bookmark and comparing file identity. A renamed board keeps its settings; a board reopened
///    through a symlink or a moved parent does not acquire a second record.
/// 2. **Nothing it does touches a board folder.** Not a byte, not an xattr. The one file it writes
///    is its own, and it lives somewhere else entirely.
/// 3. **It never takes the app down.** A missing file is an empty registry; a corrupt one is moved
///    aside and the registry starts empty; a save that fails is logged. Every method here is
///    non-throwing on purpose — "its settings are conveniences", and losing a window frame must
///    never cost the user a board.
///
/// ### Deliberately not here
///
/// App-wide state that is not board-scoped — quick-style recents, the SSH host-key table, the
/// last-used card-window size — lives *beside* this file, not in it (§ Per-board app state, "App-wide
/// state has the same home"). Secrets live in the Keychain and nowhere near here.
@MainActor
public final class BoardRegistry {

    /// The JSON file this registry is. Public because a diagnostic ("Reveal registry in Finder") and
    /// every test want to name it, and because injecting it is how a test stays out of the real
    /// Application Support directory.
    public let storageURL: URL

    private var records: [BoardRecord]

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

    /// `~/Library/Application Support/<bundle id>/board-registry.json`, inside the sandbox
    /// container. The bundle-id subfolder is Apple's convention and keeps the app's files together
    /// as the app-wide neighbours arrive.
    public static var defaultStorageURL: URL {
        let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
            ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
                .appendingPathComponent("Library/Application Support", isDirectory: true)
        let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
        return support
            .appendingPathComponent(bundleIdentifier, isDirectory: true)
            .appendingPathComponent("board-registry.json", isDirectory: false)
    }

    /// Loads the registry, tolerating everything a file on disk can be.
    ///
    /// Missing file: an empty registry — the first-launch case, and not an error. **Corrupt file:
    /// renamed aside with a timestamp and the registry starts empty.** Renamed rather than deleted
    /// because the file may be the only trace of a user's board list, and a support request can read
    /// it even when this app cannot; empty rather than fatal because a truncated convenience file
    /// must not stand between the user and their boards.
    public init(storageURL: URL) {
        self.storageURL = storageURL
        self.records = []
        self.records = loadFromDisk()
    }

    // MARK: - Opening and closing

    /// Records that a board was opened, and answers with the id of the record it belongs to.
    ///
    /// Matching is the interesting half: every existing record's bookmark is resolved and its file
    /// identity compared with the opened folder's. A hit is *the* record no matter what path either
    /// side is spelled with — the done-when criterion for a renamed board keeping its settings.
    /// Records whose bookmarks no longer resolve are skipped rather than repaired: an orphan is a
    /// recents row with Forget, not a candidate for a board that is demonstrably somewhere else.
    ///
    /// A match is updated in place with a **fresh bookmark** (subsuming the stale-refresh case), the
    /// caller's `displayName`, the path it was opened at, and `lastOpened` = now. No match creates a
    /// record. Either way the file is saved before returning.
    @discardableResult
    public func recordOpen(of rootURL: URL, displayName: String) -> UUID {
        let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data()
        if bookmark.isEmpty {
            // Both the security-scoped and the plain attempt failed — vanishingly unlikely for a
            // folder the app has just opened. The record is still created: a row the user can see
            // and Forget beats a board that silently never joins recents.
            Self.logger.error("no bookmark could be created for the opened board; its record is born orphaned")
        }

        if let index = indexOfRecord(matching: rootURL) {
            records[index].bookmark = bookmark
            records[index].displayName = displayName
            records[index].lastKnownPath = rootURL.path
            records[index].lastOpened = Self.stamp()
            save()
            return records[index].id
        }

        let record = BoardRecord(
            bookmark: bookmark,
            displayName: displayName,
            lastKnownPath: rootURL.path,
            lastOpened: Self.stamp()
        )
        records.append(record)
        save()
        return record.id
    }

    /// Stamps the counts the welcome window will render for this board until it is opened again
    /// (§ Per-board app state, "Recents counts are registry-cached ... stamped at last close").
    ///
    /// Called from the close-flush sequence (02-architecture.md § Launch and window lifecycle),
    /// where the store's last snapshot is still in hand — which is the whole reason the counts are
    /// free here and expensive anywhere else.
    public func recordClose(id: UUID, laneCount: Int, cardCount: Int) {
        update(id) { record in
            record.laneCount = laneCount
            record.cardCount = cardCount
        }
    }

    // MARK: - Per-board settings

    public func updateWindowFrame(id: UUID, frame: WindowFrame) {
        update(id) { $0.windowFrame = frame }
    }

    public func setPushOnCommit(id: UUID, _ value: Bool) {
        update(id) { $0.pushOnCommit = value }
    }

    /// Marks this board's once-per-board remote-location warning as shown (07-sync-collab.md).
    /// One-way: there is no un-warn, because the warning is about a location the board is already at.
    public func setRemoteLocationWarned(id: UUID) {
        update(id) { $0.remoteLocationWarned = true }
    }

    // MARK: - Reading

    /// Every known board, most recently opened first, each classified by whether its bookmark
    /// resolves — the recents list, which *is* this registry sorted by last-opened.
    ///
    /// **No directory is scanned and nothing is counted here.** Counts come from the records; the
    /// only filesystem work is bookmark resolution, done with `.withoutUI` and `.withoutMounting`
    /// so an offline volume classifies as unavailable instead of blocking the welcome window on a
    /// mount.
    ///
    /// Two quiet side effects, both of them the "stale is fine — refresh silently" rule: a bookmark
    /// that resolves *stale* is replaced with a fresh one, and if any was, the file is saved. A
    /// stale bookmark still works, but only for a while — refreshing it here is what keeps a board
    /// that moves around from eventually orphaning itself. `lastKnownPath` is deliberately **not**
    /// refreshed: it is the display fallback for a record that *cannot* be resolved, so the resolved
    /// URL, not the record, is what an available row shows.
    ///
    /// The sort is total — `lastOpened` descending, then id — because `sorted(by:)` is not stable
    /// and two rows that tie should still come back in the same order every call.
    public func recents() -> [RecentBoard] {
        var resolvedURLs: [UUID: URL] = [:]
        var refreshedAny = false

        for index in records.indices {
            guard let resolution = Self.resolve(records[index].bookmark) else { continue }
            resolvedURLs[records[index].id] = resolution.url
            guard resolution.isStale else { continue }
            if let refreshed = Self.withScopedAccess(to: resolution.url, { Self.makeBookmark(for: $0) }) {
                records[index].bookmark = refreshed.data
                refreshedAny = true
            }
        }
        if refreshedAny {
            save()
        }

        return records
            .sorted { lhs, rhs in
                lhs.lastOpened == rhs.lastOpened
                    ? lhs.id.uuidString > rhs.id.uuidString
                    : lhs.lastOpened > rhs.lastOpened
            }
            .map { record in
                if let url = resolvedURLs[record.id] {
                    .available(record, at: url)
                } else {
                    .unavailable(record)
                }
            }
    }

    public func record(id: UUID) -> BoardRecord? {
        records.first { $0.id == id }
    }

    /// Drops a record — the Forget action on an orphaned recents row, and the only way a record
    /// leaves. Nothing else prunes: a board that is merely unavailable today may be a remounted
    /// volume tomorrow, so forgetting is always the user's call.
    public func forget(id: UUID) {
        guard let index = indexOfRecord(id) else { return }
        records.remove(at: index)
        save()
    }

    // MARK: - Matching

    /// The index of the record whose bookmark resolves to the same file as `url`, if any.
    private func indexOfRecord(matching url: URL) -> Int? {
        guard let target = FileIdentity(of: url) else { return nil }
        return records.firstIndex { record in
            guard let resolution = Self.resolve(record.bookmark) else { return false }
            // Scope is started around the identity read and stopped immediately. Resolving a
            // security-scoped bookmark grants nothing by itself, and in the sandbox an unscoped
            // `resourceValues` call on some *other* board's folder is exactly the read that gets
            // refused — which would silently turn "the same board" into "a new one" and duplicate
            // the record. The pairing is balanced, so an outer scope this app holds elsewhere is
            // unaffected.
            return Self.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) == target }
        }
    }

    private func indexOfRecord(_ id: UUID) -> Int? {
        records.firstIndex { $0.id == id }
    }

    /// Mutates a record and saves. An unknown id is a no-op: a window that outlived its record —
    /// the user pressed Forget while the board was open — must not crash on its way out.
    private func update(_ id: UUID, _ mutate: (inout BoardRecord) -> Void) {
        guard let index = indexOfRecord(id) else {
            Self.logger.debug("update: no record for this id — ignored")
            return
        }
        mutate(&records[index])
        save()
    }

    // MARK: - Bookmarks

    /// A bookmark and which flavor the system actually gave us.
    struct Bookmark {
        let data: Data
        let isSecurityScoped: Bool
    }

    /// Creates a bookmark for `url`, security-scoped if the system allows it.
    ///
    /// **The security-scoped path is the real one.** In production every board URL arrives through
    /// NSOpenPanel, a Finder drag, or a previously resolved bookmark, so the app already holds
    /// access and `.withSecurityScope` succeeds — which is what lets the board reopen after a
    /// relaunch at all (02-architecture.md § Platform, "security-scoped bookmarks for reopening
    /// boards across launches"; the `com.apple.security.files.bookmarks.app-scope` entitlement is
    /// what backs it).
    ///
    /// **The plain fallback exists for the cases where that is not true**: a unit test pointing at a
    /// temp directory the panel never blessed, and a non-sandboxed debug build where the option is
    /// meaningless. A plain bookmark still tracks renames and moves — the identity story is intact —
    /// it simply grants no access on resolution, which outside the sandbox is nothing to grant.
    /// Returning `nil` from both attempts is left to the caller, which records an orphan rather than
    /// dropping the board.
    static func makeBookmark(for url: URL) -> Bookmark? {
        if let data = try? url.bookmarkData(options: [.withSecurityScope]) {
            return Bookmark(data: data, isSecurityScoped: true)
        }
        if let data = try? url.bookmarkData(options: []) {
            Self.logger.debug("security-scoped bookmark unavailable; fell back to a plain bookmark")
            return Bookmark(data: data, isSecurityScoped: false)
        }
        return nil
    }

    struct Resolution {
        let url: URL
        let isStale: Bool
    }

    /// Resolves a bookmark, mirroring `makeBookmark(for:)`'s two flavors in the same order.
    ///
    /// `.withoutUI` and `.withoutMounting`: resolution runs during a recents listing, and a recents
    /// listing must never put up a dialog or block for seconds mounting a server. A board on an
    /// unmounted volume is *unavailable right now*, which is precisely what the row should say.
    ///
    /// Resolution alone does **not** start security-scoped access — the caller does that around the
    /// use, balanced. That is why `recents()` can classify every record without ever holding a scope
    /// open.
    static func resolve(_ bookmark: Data) -> Resolution? {
        guard !bookmark.isEmpty else { return nil }

        var isStale = false
        if let url = try? URL(
            resolvingBookmarkData: bookmark,
            options: [.withSecurityScope, .withoutUI, .withoutMounting],
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        ) {
            return Resolution(url: url, isStale: isStale)
        }

        isStale = false
        if let url = try? URL(
            resolvingBookmarkData: bookmark,
            options: [.withoutUI, .withoutMounting],
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        ) {
            return Resolution(url: url, isStale: isStale)
        }

        return nil
    }

    /// Runs `body` with security-scoped access held, if this URL has any to hold.
    ///
    /// `startAccessingSecurityScopedResource()` returns `false` for a URL that is not
    /// security-scoped (a plain bookmark's, or anything already inside the container), and then
    /// there is nothing to stop — so the pairing stays balanced either way.
    static func withScopedAccess<T>(to url: URL, _ body: (URL) -> T) -> T {
        let started = url.startAccessingSecurityScopedResource()
        defer {
            if started {
                url.stopAccessingSecurityScopedResource()
            }
        }
        return body(url)
    }

    // MARK: - Persistence

    /// Now, at the resolution the file records — so the registry in memory and the registry on disk
    /// are *identical*, not merely close.
    ///
    /// Stamping through the same formatter the encoder uses makes the round trip exact by
    /// construction: what `string(from:)` produces, `date(from:)` maps back to the value that will
    /// be decoded, and that value formats to the same string again. Without this a freshly stamped
    /// record and its reloaded self would differ in the microseconds no format carries — an equality
    /// that fails only sometimes, which is the worst kind.
    private static func stamp(_ date: Date = Date()) -> Date {
        let formatter = boardRegistryTimestampFormatter()
        return formatter.date(from: formatter.string(from: date)) ?? date
    }

    /// Writes the whole file, atomically, and never throws.
    ///
    /// Whole-file because it is a handful of small records and a partial writer would be a
    /// consistency problem in exchange for nothing. Atomic because a crash mid-write must leave the
    /// previous file, not half of this one — the same temp-then-rename discipline `BoardWriter` uses
    /// on the user's own files. A failure is logged and swallowed: the caller is a window close or a
    /// settings toggle, and neither has anything useful to do about a full disk.
    private func save() {
        let encoder = JSONEncoder()
        // `.sortedKeys` for a byte-stable file (the same records always produce the same bytes) and
        // `.prettyPrinted` because the one time anybody reads this file by hand is when something
        // has gone wrong.
        encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
        encoder.dateEncodingStrategy = .custom { date, encoder in
            var container = encoder.singleValueContainer()
            try container.encode(boardRegistryTimestampFormatter().string(from: date))
        }

        // Sorted by id, not by recency: the array's order carries no meaning (`recents()` sorts),
        // so pinning it keeps the file from churning every time a board is opened.
        let ordered = records.sorted { $0.id.uuidString < $1.id.uuidString }

        do {
            try FileManager.default.createDirectory(
                at: storageURL.deletingLastPathComponent(),
                withIntermediateDirectories: true
            )
            try encoder.encode(ordered).write(to: storageURL, options: .atomic)
        } catch {
            Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)")
        }
    }

    private func loadFromDisk() -> [BoardRecord] {
        guard FileManager.default.fileExists(atPath: storageURL.path) else { return [] }

        guard let data = try? Data(contentsOf: storageURL) else {
            quarantine("unreadable")
            return []
        }

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .custom { decoder in
            let container = try decoder.singleValueContainer()
            let text = try container.decode(String.self)
            guard let date = boardRegistryTimestampFormatter().date(from: text) else {
                throw DecodingError.dataCorruptedError(
                    in: container,
                    debugDescription: "not an ISO 8601 timestamp: \(text)"
                )
            }
            return date
        }

        do {
            return try decoder.decode([BoardRecord].self, from: data)
        } catch {
            quarantine(error.localizedDescription)
            return []
        }
    }

    /// Renames the unreadable file aside so the next save starts clean.
    ///
    /// Renamed, never deleted: this file may be the only record of which boards a user had, and a
    /// bug that corrupts it should leave evidence to diagnose rather than a hole. The timestamp
    /// keeps repeat corruptions from overwriting each other; the id suffix covers two in the same
    /// millisecond.
    private func quarantine(_ reason: String) {
        Self.logger.error("board registry is unreadable (\(reason, privacy: .public)); quarantining it")

        // The timestamp's colons are legal in a path but read as `/` in Finder, so they go.
        let stamp = boardRegistryTimestampFormatter()
            .string(from: Date())
            .replacingOccurrences(of: ":", with: "-")
        let folder = storageURL.deletingLastPathComponent()
        let base = storageURL.deletingPathExtension().lastPathComponent
        let fileExtension = storageURL.pathExtension

        func destination(_ name: String) -> URL {
            let file = folder.appendingPathComponent(name, isDirectory: false)
            return fileExtension.isEmpty ? file : file.appendingPathExtension(fileExtension)
        }

        var target = destination("\(base)-corrupt-\(stamp)")
        if FileManager.default.fileExists(atPath: target.path) {
            target = destination("\(base)-corrupt-\(stamp)-\(UUID().uuidString.prefix(8))")
        }

        do {
            try FileManager.default.moveItem(at: storageURL, to: target)
        } catch {
            // Nothing further to do: the next `save()` overwrites the file atomically anyway, so a
            // failed quarantine costs the evidence, not the registry.
            Self.logger.error("could not quarantine it: \(error.localizedDescription, privacy: .public)")
        }
    }
}
