import Foundation

// MARK: - Tier

/// Which tier a board session composes under (12-editions.md ▸ The tiers).
///
/// **Two cases, and there will never be a third here.** Lanework Teams is deferred and will "never
/// share an app group or any cross-app state with Lanework" (12 ▸ The tiers, ruled 2026-07-30) —
/// whatever it becomes, it is a different app, not a third case of this enum.
///
/// Nothing about this type is a *feature flag*. It is the answer to one question — free or Pro —
/// asked once per board session at composition (`AppModel.beginSession`), recorded on the session,
/// and never asked again for that board. What consumes it is the provider seam
/// (12 ▸ The provider seam); see `AppModel.makeHistoryProvider`.
public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {

    /// Lanework. Boards are plain folders, mode `none` everywhere, undo is macOS-native
    /// (13-native-undo.md), and any `.git` the app meets is inert (12 ▸ The free tier and `.git`).
    ///
    /// **This is also the lapsed tier.** "Unsubscribed and lapsed are one state — the inert posture,
    /// nothing lost, histories frozen not forfeited" (12 ▸ The entitlement). There is deliberately no
    /// `.lapsed` case: a case nothing may act on differently is a distinction the design forbids from
    /// existing at all.
    case free

    /// Lanework Pro — an active auto-renewable subscription. Binds the git history provider when
    /// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md).
    case pro
}

// MARK: - SubscriptionFacts

/// **What the app knows locally about the subscription** — the whole input to the tier decision,
/// beside a date.
///
/// ### Why a cached fact struct rather than a live StoreKit read
///
/// 12-editions.md ▸ The entitlement makes two demands that pull in the same direction. Pro state is
/// "a local read, never a network call ... the open path gains no network dependency"; and offline
/// grace "resolves toward the paying user" — "an on-disk expiry passing while offline, with the last
/// known state *active and auto-renew on*, holds the entitlement until StoreKit actually refreshes
/// and answers."
///
/// The second demand is the reason this type exists as *stored* state rather than as a view onto
/// `Transaction.currentEntitlements`. StoreKit computes entitlement validity locally, so a
/// subscription whose expiry has passed drops out of `currentEntitlements` **whether or not the
/// device has been able to ask the App Store about it** — an offline device and a genuinely lapsed
/// subscription look identical from that property alone. Holding the last answer ourselves is what
/// lets the two be told apart in the only direction the design cares about: a *cancellation* (auto
/// renew off) lapses at its expiry with no network needed, while a *renewal we simply have not heard
/// about yet* keeps the user paid-up until StoreKit says otherwise (`ProEntitlement.adopt`).
///
/// ### Never-subscribed and never-online are one shape, on purpose
///
/// `expiration == nil` means "no cached transaction" and covers both the user who has never
/// subscribed and the fresh install that "has no cached transactions and reads as the free tier
/// until the first refresh — honest and self-correcting" (12). Nothing distinguishes them because
/// nothing may: they are the same tier, reached by the same route, correcting themselves the same
/// way.
///
/// `Codable` because these facts are cached across launches in `UserDefaults`
/// (`AppPreferences.subscriptionFactsKey`) — that cache *is* the "local read" the open path performs.
public struct SubscriptionFacts: Codable, Sendable, Equatable {

    /// When the current subscription period ends, as StoreKit last reported it.
    ///
    /// `nil` is the no-cached-transaction state — see the type's note. A non-`nil` value is never
    /// evidence on its own that the subscription is *live*: an expiry in the past is either a lapse
    /// or an offline hold, and `willAutoRenew` is what decides which.
    public var expiration: Date?

    /// Whether the subscription was set to renew, at the last moment StoreKit told us anything.
    ///
    /// This is the whole of the offline-grace rule. Auto-renew **on** with a passed expiry is a
    /// renewal the device has not heard about — hold. Auto-renew **off** with a passed expiry is a
    /// cancellation that has run out — lapse, "offline or not" (12 ▸ The entitlement).
    public var willAutoRenew: Bool

    public init(expiration: Date?, willAutoRenew: Bool) {
        self.expiration = expiration
        self.willAutoRenew = willAutoRenew
    }

    /// No cached transaction: never subscribed, never online, or an entitlement StoreKit has
    /// definitively withdrawn (a refund, a revocation). All three read as the free tier, and that is
    /// the point — see the type's note.
    public static let none = SubscriptionFacts(expiration: nil, willAutoRenew: false)
}

// MARK: - The decision

public extension Tier {

    /// **The tier decision, as a pure function of cached facts and a date.**
    ///
    /// Every semantic here is 12-editions.md ▸ The entitlement's, in its own order:
    ///
    /// 1. **No cached transaction → free.** "A fresh install that has never been online has no
    ///    cached transactions and reads as the free tier until the first refresh." The
    ///    never-subscribed user takes the identical branch, which is what makes unsubscribed and
    ///    lapsed one state.
    /// 2. **Expiry in the future → Pro.** "Offline with an active subscription is indistinguishable
    ///    from online" — there is no reachability term in this function because there is no
    ///    reachability term in the rule.
    /// 3. **Expiry passed, auto-renew on → Pro.** The offline-grace hold: "an on-disk expiry passing
    ///    while offline, with the last known state active and auto-renew on, holds the entitlement
    ///    until StoreKit actually refreshes and answers." The *answering* is `ProEntitlement`'s job
    ///    — this function's job is only to resolve toward the paying user until it happens.
    /// 4. **Expiry passed, auto-renew off → free.** "A cancellation (auto-renew off) lapses at
    ///    expiry, offline or not."
    ///
    /// The design weighs both wrong-for-a-window directions and accepts them: "a wrong lapse pauses
    /// auto-commits into one catch-up commit; a wrong hold gives away days of local commits — Apple's
    /// own billing grace makes the same trade."
    ///
    /// `nonisolated` and `static` because it is exactly as pure as that reads: no stored state, no
    /// clock of its own, no StoreKit. `now` is a parameter rather than a `Date()` inside for the
    /// reason `AppModel.shouldRestoreAtLaunch` takes its two `Bool`s — a decision worth this much
    /// prose is worth being provable without a machine in a particular state.
    ///
    /// The expiry comparison is strict (`>`), so an expiry falling exactly on `now` is *past*: a
    /// StoreKit expiration date is the instant the period ends, not the last instant it covers, and
    /// resolving the boundary the other way would extend every subscription by a tick for no reason.
    /// At that boundary rule 3 is usually what answers anyway, which is the paying user's direction.
    static func resolve(from facts: SubscriptionFacts, now: Date) -> Tier {
        guard let expiration = facts.expiration else { return .free }
        if expiration > now { return .pro }
        return facts.willAutoRenew ? .pro : .free
    }
}
