import Foundation
import Observation
import StoreKit
import os

/// **Lanework Pro's entitlement** — the thin StoreKit 2 adapter over the pure decision in
/// `Tier.resolve(from:now:)` (12-editions.md ▸ The entitlement).
///
/// ### The open path never waits on this, and never reaches the network through it
///
/// "Pro state is read from StoreKit's signed on-device transaction store at board-session
/// composition — the open path gains no network dependency" (12). That is enforced by shape, not by
/// care: what a composing board session reads is `tier`, a synchronous computed property over
/// `facts`, which is an in-memory value restored from `UserDefaults` at init. No `await`, no
/// `AsyncSequence`, no `AppStore.sync()`, no product load. The two methods that *do* touch StoreKit
/// — `start()` and `refreshFromLocalTransactions()` — are called from the app's launch and from the
/// Settings Pro section, and both only ever *write* `facts` for a later composition to read.
///
/// Even those two are local reads. `Transaction.currentEntitlements` is StoreKit's own signed
/// on-device transaction store; it answers offline, which is the whole reason 12 could rule the open
/// path network-free while still gating on a subscription. The genuinely networked calls — loading
/// products for their localized price, `AppStore.sync()` — live in `ProStorefront`, which only the
/// Settings section ever builds.
///
/// ### A lapse never rebinds an open session
///
/// "A lapse never interrupts an open session: an open board finishes with the provider it composed;
/// the next open composes the native stack over inert `.git`" (12). This type is `@Observable`
/// because the Settings section renders from it — and *nothing else* observes it. `AppModel` reads
/// it through `currentTier`, once, inside `beginSession`; the answer is then a `let` on
/// `AppModel.BoardSession`. There is deliberately no path from a `facts` change back into an open
/// session: no observer registration, no notification, no delegate. Adding one would be the bug, not
/// the feature.
///
/// ### Why the facts are cached rather than re-derived
///
/// See `SubscriptionFacts` — in short, StoreKit ages an expired subscription out of
/// `currentEntitlements` locally, so an offline device and a real lapse are indistinguishable from
/// that property alone, and 12's offline grace has to tell them apart. The cache is what "the last
/// known state" means, and `adopt(_:)` is what "until StoreKit actually refreshes and answers" means.
@MainActor
@Observable
public final class ProEntitlement {

    // MARK: The facts

    /// What StoreKit last told the app, cached across launches.
    ///
    /// `private(set)`: every write goes through `adopt(_:)`, which is also the write-through to
    /// `UserDefaults`. A second way to set this would be a second way for the cache and the memory to
    /// disagree.
    public private(set) var facts: SubscriptionFacts

    /// **The composition-time read.** Synchronous, local, and the only member the board-open path
    /// ever touches — see the type's note.
    ///
    /// `Date()` rather than an injected clock: the *decision* takes its date as a parameter and is
    /// tested that way (`Tier.resolve`), so the one place a real clock has to enter is here, at the
    /// edge, where there is nothing left to get wrong.
    public var tier: Tier {
        Tier.resolve(from: facts, now: Date())
    }

    // MARK: Storage

    @ObservationIgnored
    private let defaults: UserDefaults

    /// The `Transaction.updates` listener, held so it can be cancelled and so `start()` is idempotent.
    @ObservationIgnored
    private var updatesTask: Task<Void, Never>?

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

    /// The app builds one of these over `UserDefaults.standard`; a test passes its own, for the
    /// reason every other injected store in this app takes its home as a parameter (`AppStateHome`)
    /// — a suite that wrote the real domain would be editing the developer's own subscription state.
    ///
    /// **The initializer reads the cache and nothing else.** It starts no task and touches no
    /// StoreKit API, so constructing an `AppModel` — which a unit-test host does on every launch —
    /// costs one `UserDefaults` read.
    public init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        facts = Self.cachedFacts(in: defaults)
    }

    deinit {
        updatesTask?.cancel()
    }

    // MARK: - Adopting an answer

    /// Records what StoreKit said, in memory and in the cache.
    ///
    /// **This is the "until StoreKit actually refreshes and answers" of 12's offline-grace rule**, in
    /// both directions. Handed live facts it moves the expiry forward, so a renewal the device just
    /// learned about ends any hold that was standing. Handed `.none` it ends the entitlement outright,
    /// which is what a *definitive* withdrawal looks like: a refund, a revocation, or a subscription
    /// group whose status reads expired (`ProStorefront.reconcile`). What it is never called with is
    /// "`currentEntitlements` came back empty" — see `refreshFromLocalTransactions()` for why that is
    /// silence rather than an answer.
    ///
    /// Public because the Settings section's storefront is the other half of this: the networked
    /// reads live over there (12: the open path stays local), and this is where their conclusions
    /// land.
    public func adopt(_ newFacts: SubscriptionFacts) {
        guard newFacts != facts else { return }
        facts = newFacts
        Self.cache(newFacts, in: defaults)
        Self.logger.debug("entitlement facts adopted; tier is now \(self.tier.rawValue, privacy: .public)")
    }

    // MARK: - StoreKit

    /// Begins listening for transaction changes, and takes one local reading.
    ///
    /// Called once, from the app's launch (`AppDelegate.applicationDidFinishLaunching`) — never from
    /// `AppModel.init`, so that a unit-test host constructing a model does not acquire a StoreKit
    /// listener as a side effect.
    ///
    /// `Transaction.updates` is StoreKit's out-of-band channel: a renewal that landed while the app
    /// was closed, a purchase made on another device, a refund the App Store processed. It is *not*
    /// polling and it is not a network call this app makes — it is a delivery. Handling it here is
    /// what keeps the cached facts from needing anybody to visit Settings.
    ///
    /// Idempotent: a second call is a no-op, because a second listener would finish every transaction
    /// twice.
    public func start() {
        guard updatesTask == nil else { return }
        updatesTask = Task { [weak self] in
            for await result in Transaction.updates {
                guard let self else { return }
                await handle(result)
            }
        }
        Task { [weak self] in
            await self?.refreshFromLocalTransactions()
        }
    }

    /// Re-reads StoreKit's **local** signed transaction store and adopts what it finds.
    ///
    /// ### An empty result is silence, not an answer
    ///
    /// The one subtle line in this file. `Transaction.currentEntitlements` yields only entitlements
    /// StoreKit currently considers valid, and that validity is computed on-device from the last
    /// signed transaction it holds — so a subscription drops out of it the moment its cached expiry
    /// passes, **whether or not the device has been able to ask the App Store whether it renewed**.
    /// Treating an empty result as "the subscription is over" would therefore lapse every offline
    /// user at their renewal date, which is precisely the outcome 12 ▸ The entitlement rules out:
    /// "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."
    ///
    /// So an empty result **leaves the cache alone** and the pure decision does the rest: an expiry
    /// still in the future keeps the user Pro (rule 2), a passed expiry with auto-renew on holds
    /// (rule 3), and a passed expiry with auto-renew off — a cancellation, which StoreKit told us
    /// about *before* it ran out — lapses on its own (rule 4). The definitive endings arrive through
    /// `adopt(.none)`: a revocation seen here, or the subscription-group status the Settings section
    /// reads when the user goes looking.
    ///
    /// The honest residual is 12's own accepted trade: a user who cancels while offline, quits, and
    /// never opens Settings again holds Pro until StoreKit delivers an update. "A wrong hold gives
    /// away days of local commits — Apple's own billing grace makes the same trade."
    public func refreshFromLocalTransactions() async {
        var newest: SubscriptionFacts?

        for await result in Transaction.currentEntitlements {
            guard case let .verified(transaction) = result else {
                // An unverified transaction is not evidence of anything. Skipped rather than treated
                // as a lapse, for the empty-result reason above.
                continue
            }
            guard ProProducts.all.contains(transaction.productID) else { continue }

            if transaction.revocationDate != nil {
                // A refund or a family-sharing withdrawal — StoreKit answering, definitively.
                adopt(.none)
                return
            }

            let candidate = await Self.facts(of: transaction)
            if newest == nil || Self.expiry(of: candidate) > Self.expiry(of: newest!) {
                newest = candidate
            }
        }

        guard let newest else {
            Self.logger.debug("no current Pro entitlement in the local store; the cached facts stand")
            return
        }
        adopt(newest)
    }

    /// One current entitlement, reduced to the two facts the decision needs.
    ///
    /// `willAutoRenew` comes from the subscription group's renewal info, which is the only place
    /// StoreKit publishes it. When it cannot be read — an unverified renewal info, a status call that
    /// throws — the answer defaults to `true`, which is the paying user's direction and the same one
    /// 12's grace rule already chose everywhere else it had to pick.
    ///
    /// A current entitlement with **no** expiration date is given `.distantFuture` rather than `nil`,
    /// because `nil` means "no cached transaction" to `SubscriptionFacts` and this is the opposite of
    /// that: a live entitlement that does not expire. Not a state an auto-renewable subscription
    /// reaches today; spelled out so it cannot become one silently.
    private static func facts(of transaction: Transaction) async -> SubscriptionFacts {
        var willAutoRenew = true
        if let status = try? await transaction.subscriptionStatus,
           case let .verified(renewalInfo) = status.renewalInfo {
            willAutoRenew = renewalInfo.willAutoRenew
        }
        return SubscriptionFacts(
            expiration: transaction.expirationDate ?? .distantFuture,
            willAutoRenew: willAutoRenew
        )
    }

    /// A transaction delivered out of band. Finished, then folded into the facts by a fresh local
    /// read — the same read every other path uses, so there is one reduction rule and not two.
    ///
    /// Finishing is not optional bookkeeping: an unfinished transaction is redelivered forever, and
    /// for an auto-renewable subscription there is no content to deliver first — the entitlement
    /// *is* the delivery.
    private func handle(_ result: VerificationResult<Transaction>) async {
        guard case let .verified(transaction) = result else { return }
        guard ProProducts.all.contains(transaction.productID) else { return }
        await transaction.finish()
        await refreshFromLocalTransactions()
    }

    private static func expiry(of facts: SubscriptionFacts) -> Date {
        facts.expiration ?? .distantPast
    }

    // MARK: - The cache

    /// Read at init, written by `adopt(_:)`. JSON in a scalar default, beside the app's other
    /// scalars (`AppPreferences`) rather than in `AppStateHome` — this is two fields, not a file
    /// store, and `AppStateHome`'s own note draws that line.
    ///
    /// A cache that will not decode is treated as no cache at all: the tier reads free and the next
    /// refresh rebuilds it. Losing it costs a user with an active subscription nothing (StoreKit's
    /// own store still holds the transaction) and costs a user mid-grace their hold, which is the
    /// safe direction to fail in.
    private static func cachedFacts(in defaults: UserDefaults) -> SubscriptionFacts {
        guard let data = defaults.data(forKey: AppPreferences.subscriptionFactsKey),
              let decoded = try? JSONDecoder().decode(SubscriptionFacts.self, from: data) else {
            return .none
        }
        return decoded
    }

    private static func cache(_ facts: SubscriptionFacts, in defaults: UserDefaults) {
        guard let data = try? JSONEncoder().encode(facts) else {
            logger.error("subscription facts could not be encoded; the cache is unchanged")
            return
        }
        defaults.set(data, forKey: AppPreferences.subscriptionFactsKey)
    }
}
