import Foundation

// MARK: - UnwritableCause

/// **Why** a board's location refuses writes — the half of `ReadOnlyLockReason.unwritableLocation`
/// the user is actually told about (02-architecture.md § Write-failure surfacing, settled: "the
/// probe distinguishes read-only volume from permission-denied folder and the lock reason carries
/// it … the fixes being different acts").
///
/// Two cases and no `.unknown`: the probe below is total — it asks two questions of the filesystem
/// and every answer maps onto one of these or onto "writable". A third case would be a shrug in the
/// one place the design forbids one, since the whole point of naming the cause is that ejecting a
/// DMG and running `chmod` are not the same repair.
public enum UnwritableCause: String, Sendable, Equatable, CaseIterable {
    /// The volume the board sits on is mounted read-only — a DMG, an APFS snapshot, a read-only
    /// share. Nothing on it can be written, by anybody, until it is remounted.
    case readOnlyVolume

    /// The volume is writable; this *folder* is not — POSIX permissions, an ACL, an owner that is
    /// not us. A `chmod`, a Get Info panel, or a move somewhere else fixes it.
    case permissionDenied
}

// MARK: - WritabilityProbe

/// The board root's writability, asked of the filesystem and classified — the open-time probe of
/// 02-architecture.md § Write-failure surfacing ("An unwritable board location enters the read-only
/// lock at open"), re-run on every reconciling reload because that rule is **symmetric**: a fixed
/// permission or a rewritable remount clears the lock, and a volume gone read-only mid-session
/// raises it.
///
/// ### Two questions, and the order they are asked in matters
///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` with `W_OK`: a real-uid permission question
/// answered by the filesystem itself, which is why it is trustworthy where a stat of the mode bits
/// would not be (ACLs, sandbox denials, and read-only mounts all show up in it). But it answers
/// `false` for **both** halves of this case — a read-only volume fails `access(2)` exactly like a
/// `r-x` folder does — so on its own it can only say "no", never "why".
///
/// `URLResourceKey.volumeIsReadOnlyKey` is what separates them, and it is therefore asked **first**.
/// The precedence is not a tie-break: a board on a mounted DMG *is* on a read-only volume and it is
/// also permission-denied by `access(2)`, and telling the user "you don't have permission to change
/// this folder" would send them to a Get Info panel that cannot help. The volume's answer is the
/// more fundamental fact and it names the repair that works, so it wins whenever it is `true`.
///
/// A volume that will not answer at all (`nil` — an exotic filesystem, a URL whose volume is gone)
/// falls through to `access(2)`, which is the honest degradation: we still know whether the board
/// can be written, we just describe it as the folder's doing.
///
/// ### Why the classifier is separate from the I/O
///
/// `classify(volumeIsReadOnly:isWritable:)` is a pure function of the two answers, so the truth
/// table — including the read-only-volume-that-somehow-passes-`access` row, which `root` can
/// produce — is testable without a DMG. `probe(_:)` is only the two syscalls and this call.
public enum WritabilityProbe {

    /// The cause, or `nil` when the location accepts writes.
    ///
    /// Deliberately optional-returning rather than `Bool`-returning: every caller either raises a
    /// lock carrying the cause or clears one, and a `Bool` would force a second question at each
    /// site to find out which line to show.
    public nonisolated static func classify(volumeIsReadOnly: Bool?, isWritable: Bool) -> UnwritableCause? {
        if volumeIsReadOnly == true { return .readOnlyVolume }
        return isWritable ? nil : .permissionDenied
    }

    /// Asks the filesystem about `root` and classifies the answer.
    ///
    /// **The URL is rebuilt from its path** before the resource value is read. `URL` bridges to
    /// `NSURL`, which caches resource values it has already been asked for — and this probe's whole
    /// job is to notice that an answer *changed* since last time. A cached `volumeIsReadOnly` would
    /// make the symmetric rule silently one-way again, in a way no test on a fresh URL would catch.
    /// `access(2)` has no such cache, which is why only the resource value needs the ceremony.
    public nonisolated static func probe(_ root: URL) -> UnwritableCause? {
        let fresh = URL(fileURLWithPath: root.path, isDirectory: true)
        let volumeIsReadOnly = (try? fresh.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?.volumeIsReadOnly
        return classify(
            volumeIsReadOnly: volumeIsReadOnly,
            isWritable: FileManager.default.isWritableFile(atPath: root.path)
        )
    }
}
