import AppKit
import Foundation
import Observation
import StoreKit
import os

/// **The networked half of Lanework Pro** — loading the product for its localized price, buying it,
/// restoring it, and pointing at the system's manage-subscription surface (12-editions.md
/// ▸ Distribution: "purchased and managed in a Pro section of Settings (⌘,) — subscribe, manage,
/// restore purchases").
///
/// ### Why it is a separate type from `ProEntitlement`
///
/// Because the network is. 12 ▸ The entitlement makes the *entitlement* a local read so the
/// board-open path gains no network dependency, and the cleanest way to keep a promise like that is
/// to put everything that could break it somewhere the open path cannot reach. Nothing constructs a
/// `ProStorefront` except the Settings Pro section, which builds one when the pane appears and drops
/// it when the pane goes; `AppModel` has no reference to it and no way to acquire one. The split is
/// the enforcement.
///
/// What crosses back the other way is narrow and one-directional: this type hands `ProEntitlement`
/// the conclusions StoreKit reached (`adopt(_:)`), which a *later* board composition may read. It
/// never reaches into an open session — see `ProEntitlement`'s note on why a lapse cannot rebind one.
///
/// ### Offline is a sentence, not a spinner
///
/// A product load that fails leaves `availability` at `.unreachable`, which the section renders as
/// one quiet line with a Try Again button. There is deliberately no retry loop and no indefinite
/// progress view: the App Store being unreachable is an ordinary, temporary, user-legible condition,
/// and a subscriber's *entitlement* is unaffected by it — the cached facts already answered that
/// question before this type existed.
@MainActor
@Observable
public final class ProStorefront {

    // MARK: Availability

    /// Whether the subscription product can be shown, and at what price.
    public enum Availability: Equatable {

        /// Nothing has been asked for yet — the state the pane is built in.
        case idle

        /// A product load is in flight.
        case loading

        /// Loaded. The `Product` carries its own localized `displayPrice`, which is the only place
        /// a price may come from: a price written into the app would be wrong in most of the world
        /// and out of date in the rest.
        case ready(Product)

        /// The App Store could not be reached, or answered with no such product. **One case for
        /// both**, because they are one sentence to the user and neither is actionable beyond
        /// trying again — a missing product id is a configuration mistake that shows up in
        /// development, never in a shipped build.
        case unreachable
    }

    /// What just happened, for the section to react to. Distinct from `availability`, which is about
    /// the *product*; this is about the last thing the user asked for.
    public enum Outcome: Equatable {

        /// A purchase or restore left an active subscription in the cache — the one outcome that
        /// raises the reopen offer (12: "the purchase flow offers to reopen open boards").
        case activated

        /// Ask to Buy, or a payment the App Store has not settled. Nothing to do but wait; the
        /// entitlement will arrive through `Transaction.updates` when it does.
        case pending

        /// The user backed out of the App Store's sheet. Not an error and not worth a word.
        case cancelled

        /// A restore that reached the App Store and found nothing to restore.
        case nothingToRestore

        /// Anything else, carrying the sentence to show.
        case failed(String)
    }

    public private(set) var availability: Availability = .idle

    /// Whether a purchase or a restore is in flight — what the buttons disable on.
    public private(set) var isBusy = false

    /// The last outcome's sentence, or `nil`. Rendered as one quiet line under the buttons.
    public private(set) var message: String?

    @ObservationIgnored
    private let entitlement: ProEntitlement

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

    public init(entitlement: ProEntitlement) {
        self.entitlement = entitlement
    }

    // MARK: - Loading

    /// Loads the subscription product and reconciles the cached facts against what the App Store
    /// says.
    ///
    /// **The reconciliation is the point, as much as the price is.** This is the moment
    /// `ProEntitlement`'s offline-grace hold can end honestly: the app has demonstrably reached the
    /// App Store (the product came back), so the subscription group's status is StoreKit *answering*
    /// rather than StoreKit computing locally from a stale transaction — which is exactly what 12
    /// ▸ The entitlement makes the hold wait for.
    ///
    /// Run from the section's `.task`, so opening Settings is what triggers it. That is also the one
    /// place a user who has been offline for a while goes looking when they wonder about their
    /// subscription, which makes it the right door for this to be behind.
    public func load() async {
        availability = .loading
        do {
            let products = try await Product.products(for: ProProducts.all)
            guard let product = products.first(where: { $0.id == ProProducts.monthly }) else {
                Self.logger.error("the App Store returned no product for \(ProProducts.monthly, privacy: .public)")
                availability = .unreachable
                return
            }
            availability = .ready(product)
            await reconcile(with: product)
        } catch {
            Self.logger.error("product load failed: \(error.localizedDescription, privacy: .public)")
            availability = .unreachable
        }
    }

    /// Folds the subscription group's status into the cached facts.
    ///
    /// Three outcomes, and the middle one is the whole reason this method exists:
    ///
    /// - **A live status** (subscribed, in grace, in billing retry) → re-read the local transactions,
    ///   which moves the cached expiry forward to whatever the renewal actually is.
    /// - **Every status expired or revoked** → `adopt(.none)`. StoreKit has answered, so any hold
    ///   standing on "we haven't heard" is over.
    /// - **No statuses at all** → also `adopt(.none)`, and for the same reason rather than a weaker
    ///   one: the account demonstrably reached the App Store, and the App Store knows of no
    ///   subscription in this group. For a user who never subscribed this is a no-op on facts that
    ///   are already empty.
    ///
    /// A status read that throws changes nothing. That is silence again, not an answer — the same
    /// posture `ProEntitlement.refreshFromLocalTransactions()` takes toward an empty local store.
    private func reconcile(with product: Product) async {
        guard let subscription = product.subscription else { return }
        guard let statuses = try? await subscription.status else {
            Self.logger.debug("subscription status unavailable; the cached facts stand")
            return
        }

        let isLive = statuses.contains { status in
            switch status.state {
            case .subscribed, .inGracePeriod, .inBillingRetryPeriod: true
            default: false
            }
        }

        if isLive {
            await entitlement.refreshFromLocalTransactions()
        } else {
            entitlement.adopt(.none)
        }
    }

    // MARK: - Buying

    /// The Subscribe button. Returns what happened, so the section can raise the reopen offer on
    /// exactly one outcome.
    ///
    /// The transaction is **finished** on the way through. An auto-renewable subscription has no
    /// content to deliver — the entitlement is the delivery — so an unfinished one is simply a
    /// transaction StoreKit redelivers forever.
    @discardableResult
    public func subscribe() async -> Outcome {
        guard case let .ready(product) = availability, !isBusy else { return .cancelled }
        isBusy = true
        message = nil
        defer { isBusy = false }

        do {
            switch try await product.purchase() {
            case let .success(verification):
                guard case let .verified(transaction) = verification else {
                    return report(.failed("This purchase couldn't be verified."))
                }
                await transaction.finish()
                await entitlement.refreshFromLocalTransactions()
                return report(entitlement.tier == .pro ? .activated : .pending)
            case .pending:
                return report(.pending)
            case .userCancelled:
                return report(.cancelled)
            @unknown default:
                return report(.failed("The App Store returned an unexpected answer."))
            }
        } catch {
            Self.logger.error("purchase failed: \(error.localizedDescription, privacy: .public)")
            return report(.failed(error.localizedDescription))
        }
    }

    /// Restore Purchases — `AppStore.sync()`, then the same reconciliation the load runs.
    ///
    /// This is the app's **only** deliberate App Store refresh, and it is behind a button the user
    /// pressed, which is where 12 puts the network. It exists for the account that owns a
    /// subscription this device's transaction store has never seen: a new Mac, a reinstall, a signed
    /// out-and-in Apple Account.
    @discardableResult
    public func restore() async -> Outcome {
        guard !isBusy else { return .cancelled }
        isBusy = true
        message = nil
        defer { isBusy = false }

        do {
            try await AppStore.sync()
        } catch {
            // A cancelled authentication sheet arrives here too, and is not a failure worth a
            // sentence — the user closed a dialog.
            Self.logger.error("App Store sync failed: \(error.localizedDescription, privacy: .public)")
            return report(.failed(error.localizedDescription))
        }

        await entitlement.refreshFromLocalTransactions()
        if case let .ready(product) = availability {
            await reconcile(with: product)
        }
        return report(entitlement.tier == .pro ? .activated : .nothingToRestore)
    }

    // MARK: - Managing

    /// Opens the system's subscription-management surface.
    ///
    /// **The Mac App Store's account page, not a StoreKit sheet.** StoreKit 2's
    /// `AppStore.showManageSubscriptions(in:)` takes a `UIWindowScene` and has no macOS counterpart;
    /// on the Mac the surface is the App Store app's Account ▸ Subscriptions, and the
    /// `macappstore:` URL is how an app asks for it. The `https:` form is the fallback for a machine
    /// where that scheme is unhandled, and lands on the same page in a browser.
    public func openManageSubscriptions() {
        let candidates = [
            "macappstore://apps.apple.com/account/subscriptions",
            "https://apps.apple.com/account/subscriptions"
        ]
        for candidate in candidates {
            guard let url = URL(string: candidate) else { continue }
            if NSWorkspace.shared.open(url) { return }
        }
        Self.logger.error("no handler for the App Store subscriptions page")
    }

    // MARK: - Messages

    /// Records an outcome's sentence and hands the outcome straight back, so every return site is
    /// one line.
    @discardableResult
    private func report(_ outcome: Outcome) -> Outcome {
        message = Self.sentence(for: outcome)
        return outcome
    }

    /// The one place outcome wording lives. Calm and factual — 12 ▸ Tier naming keeps this whole
    /// section free of upsell, and that applies to its failure lines as much as to its heading.
    static func sentence(for outcome: Outcome) -> String? {
        switch outcome {
        case .activated: nil
        case .pending: "This subscription is waiting for approval."
        case .cancelled: nil
        case .nothingToRestore: "No subscription was found for this Apple Account."
        case let .failed(message): message
        }
    }
}
