import Foundation
import Testing
@testable import Kanban

/// **The tier decision** (12-editions.md ▸ The entitlement).
///
/// Every test here goes through `Tier.resolve(from:now:)` with facts and a date the test wrote down
/// — no StoreKit, no App Store account, no clock. That is the point of the seam: the semantics 12
/// rules are semantics about *cached facts and a date*, and everything else in the Pro stack
/// (`ProEntitlement`, `ProStorefront`) exists to produce those facts honestly. Pinning the decision
/// here is what makes the adapter thin enough to read.
///
/// The five states 12 names are the five suites' worth of cases below, in its own order:
/// active, lapsed, offline-grace, never-online, and free — with free and never-online deliberately
/// arriving at the same input, because 12 says nothing may distinguish them.
@Suite("Tier ▸ the entitlement decision")
struct TierDecisionTests {

    /// A fixed instant, so nothing here depends on when the suite runs.
    static let now = Date(timeIntervalSinceReferenceDate: 800_000_000)

    private static func resolve(_ facts: SubscriptionFacts) -> Tier {
        Tier.resolve(from: facts, now: now)
    }

    // MARK: - Free

    @Test("No cached transaction reads as the free tier")
    func freeTier() {
        #expect(Self.resolve(.none) == .free)
    }

    @Test("An auto-renew flag with no expiry behind it is still the free tier")
    func autoRenewWithoutAnExpiryIsNotAnEntitlement() {
        // The offline-grace hold is about an expiry that *passed*, never about the flag on its own.
        // A fact set that carries a renew flag and no period is not a subscription in any state.
        #expect(Self.resolve(SubscriptionFacts(expiration: nil, willAutoRenew: true)) == .free)
    }

    // MARK: - Active

    @Test("An expiry in the future is Pro")
    func activeSubscription() {
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 20), willAutoRenew: true)
        #expect(Self.resolve(facts) == .pro)
    }

    @Test("An active subscription the user has already cancelled is Pro until its expiry")
    func cancelledButStillInsideThePaidPeriod() {
        // "A cancellation (auto-renew off) lapses **at expiry**" — not when it is made. The paid
        // period is paid for.
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 3), willAutoRenew: false)
        #expect(Self.resolve(facts) == .pro)
    }

    @Test("Offline changes nothing about an active subscription")
    func offlineWithAnActiveSubscription() {
        // 12: "Offline with an active subscription is indistinguishable from online." Pinned by the
        // absence of a reachability parameter as much as by this assertion — there is nothing to
        // pass in that could make this answer differently.
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 400), willAutoRenew: true)
        #expect(Self.resolve(facts) == .pro)
    }

    // MARK: - Lapsed

    @Test("A cancellation whose expiry has passed is the free tier")
    func lapsedSubscription() {
        // 12: "A cancellation (auto-renew off) lapses at expiry, offline or not."
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60), willAutoRenew: false)
        #expect(Self.resolve(facts) == .free)
    }

    @Test("A long-lapsed cancellation is the same free tier as never having subscribed")
    func lapsedAndUnsubscribedAreOneState() {
        // "Unsubscribed and lapsed are **one state** — the inert posture, nothing lost, histories
        // frozen not forfeited." Nothing downstream may tell these two apart, and the decision is
        // where that starts: one answer, from two histories.
        let lapsed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 365), willAutoRenew: false)
        #expect(Self.resolve(lapsed) == Self.resolve(.none))
    }

    // MARK: - Offline grace

    @Test("An expiry that passed with auto-renew still on holds the entitlement")
    func offlineGraceHoldsForThePayingUser() {
        // 12: "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."
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60), willAutoRenew: true)
        #expect(Self.resolve(facts) == .pro)
    }

    @Test("The offline-grace hold does not decay with time")
    func theHoldHasNoTimeout() {
        // Deliberately no expiry-of-the-expiry: the hold ends when StoreKit *answers*
        // (`ProEntitlement.adopt`), not when a timer this function knows nothing about runs out.
        // 12 weighs the cost and takes it: "a wrong hold gives away days of local commits — Apple's
        // own billing grace makes the same trade."
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 90), willAutoRenew: true)
        #expect(Self.resolve(facts) == .pro)
    }

    @Test("Auto-renew is the only thing separating a hold from a lapse")
    func theFlagIsTheWholeDifference() {
        let expired = Self.now.addingTimeInterval(-60 * 60 * 24)
        #expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: true)) == .pro)
        #expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: false)) == .free)
    }

    // MARK: - Never online

    @Test("A fresh install that has never been online reads as the free tier")
    func neverOnline() {
        // 12: "A fresh install that has never been online has no cached transactions and reads as
        // the free tier until the first refresh — honest and self-correcting." The input is
        // identical to `freeTier` above and that is the finding, not a duplicate: a device that has
        // not asked yet and an account that never subscribed are the same state, and the app has no
        // vocabulary for telling them apart.
        #expect(Self.resolve(.none) == .free)
    }

    @Test("The first refresh is what corrects a never-online install")
    func theFirstRefreshCorrectsIt() {
        // The self-correction, as the decision sees it: nothing about the free answer is sticky —
        // the same function over the facts a refresh produces answers Pro immediately.
        #expect(Self.resolve(.none) == .free)
        let refreshed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
        #expect(Self.resolve(refreshed) == .pro)
    }

    // MARK: - The boundary

    @Test("An expiry falling exactly on now is past")
    func theExpiryBoundaryIsExclusive() {
        // A StoreKit expiration date is the instant the period ends, not the last instant it covers.
        // With auto-renew off that means the lapse takes effect at the boundary rather than a tick
        // after it; with auto-renew on the grace hold catches it, which is the paying user's side.
        #expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: false)) == .free)
        #expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: true)) == .pro)
    }

    @Test("The decision is a pure function of its two arguments")
    func theSameInputsAlwaysAnswerTheSame() {
        let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-1), willAutoRenew: true)
        let answers = (0..<5).map { _ in Tier.resolve(from: facts, now: Self.now) }
        #expect(Set(answers).count == 1)
        // And a *different* date genuinely moves it — the clock is an argument, not decoration.
        let before = Tier.resolve(from: facts, now: Self.now.addingTimeInterval(-60))
        #expect(before == .pro, "still inside the paid period")
    }
}

// MARK: - The cached facts

/// The entitlement's cache, exercised through its injected `UserDefaults` — the same injection every
/// other app-side store in this project takes (`AppStateHome`), and here it also keeps a suite from
/// editing the developer's own subscription state.
///
/// StoreKit itself is deliberately absent: `ProEntitlement.start()` and
/// `refreshFromLocalTransactions()` are the only members that touch it, neither is called here, and
/// what is left is exactly the seam worth pinning — that the facts round-trip, that the tier follows
/// them, and that adopting `.none` ends a hold.
@MainActor
@Suite("Tier ▸ the entitlement's cache")
struct ProEntitlementCacheTests {

    /// A defaults domain of this suite's own. Removed at the end of each test so the cases cannot
    /// see each other's writes.
    private static func makeDefaults() -> (UserDefaults, () -> Void) {
        let name = "dev.rzen.indie.Kanban.tier-tests.\(UUID().uuidString)"
        let defaults = UserDefaults(suiteName: name)!
        return (defaults, { UserDefaults.standard.removePersistentDomain(forName: name) })
    }

    @Test("A fresh domain has no facts, and the tier is free")
    func emptyCache() {
        let (defaults, tearDown) = Self.makeDefaults()
        defer { tearDown() }

        let entitlement = ProEntitlement(defaults: defaults)
        #expect(entitlement.facts == .none)
        #expect(entitlement.tier == .free)
    }

    @Test("Adopted facts survive into the next launch")
    func factsRoundTrip() {
        let (defaults, tearDown) = Self.makeDefaults()
        defer { tearDown() }

        let facts = SubscriptionFacts(expiration: Date().addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
        ProEntitlement(defaults: defaults).adopt(facts)

        // A second object over the same domain is what a relaunch is.
        let relaunched = ProEntitlement(defaults: defaults)
        #expect(relaunched.facts == facts)
        #expect(relaunched.tier == .pro)
    }

    @Test("Adopting nothing ends the entitlement — StoreKit's definitive answer")
    func adoptingNoneEndsAHold() {
        let (defaults, tearDown) = Self.makeDefaults()
        defer { tearDown() }

        let entitlement = ProEntitlement(defaults: defaults)
        // An offline-grace hold: the expiry passed, the last we heard it was renewing.
        entitlement.adopt(SubscriptionFacts(expiration: Date().addingTimeInterval(-60), willAutoRenew: true))
        #expect(entitlement.tier == .pro)

        // …and then StoreKit answers (a revocation, or a group status reading expired).
        entitlement.adopt(.none)
        #expect(entitlement.tier == .free)
        #expect(ProEntitlement(defaults: defaults).tier == .free, "and the answer is cached, not just held")
    }

    @Test("A cache that will not decode is treated as no cache")
    func aCorruptCacheIsEmpty() {
        let (defaults, tearDown) = Self.makeDefaults()
        defer { tearDown() }

        defaults.set(Data("not json".utf8), forKey: AppPreferences.subscriptionFactsKey)
        #expect(ProEntitlement(defaults: defaults).tier == .free)
    }
}
