import Foundation
import SwiftUI
import WidgetKit

// Single observable source of `(instance, accessToken, accountId, …)`.
//
// Phase A.1 hydrated only from `Generated/DevAuth.swift` — a build-
// time stub produced from `.env.watch.local`. Phase B keeps DevAuth
// as a dev-mode fallback but prefers the watch's Keychain (written
// by `WatchSession` on every successful WCSession handoff from the
// phone). This means: a one-time handoff survives app force-quits
// and cold launches even when the phone is unreachable, and dev mode
// without a paired phone still lights up via DevAuth.
//
// Lifecycle:
//   - Init: try keychain → fall back to DevAuth → fall back to nil
//     (lands the user on EmptyAuthView). Keychain reads are
//     synchronous; no async dance on first paint.
//   - Phone push: WCSession delegate writes to keychain THEN calls
//     `apply(creds, lastHandoffAt:, supportsTapestryHint:)` so the
//     in-memory state and the on-disk persistence move together.
//   - Phone revoke / Sign out on Watch: `clear()` wipes both halves.

@MainActor
final class AuthState: ObservableObject {
    struct Credentials: Equatable {
        let instance: String
        let accessToken: String
        let accountId: String
        let username: String
        let accountType: String
        let avatarUrl: String?
        let accentVariant: Int
    }

    @Published private(set) var credentials: Credentials?
    @Published private(set) var supportsTapestry: Bool = false
    /// True once the watch (or the phone-supplied hint) has produced a
    /// concrete tapestry-capability answer for the active account. Drives
    /// `RootView`'s conditional Tapestry tab: while unprobed we keep the
    /// tab visible so a known-supporting server's first-launch doesn't
    /// shrink-then-expand the pager when capabilities resolve.
    @Published private(set) var tapestryProbed: Bool = false
    @Published private(set) var lastHandoffAt: Date?

    private(set) var client: WatchAPIClient?

    init() {
        // Prefer a real handoff over DevAuth — if the phone has ever
        // pushed a context, that's what we want even after a relaunch.
        if let persisted = WatchKeychain.read() {
            let creds = Credentials(
                instance: persisted.instance,
                accessToken: persisted.accessToken,
                accountId: persisted.accountId,
                username: persisted.username,
                accountType: persisted.accountType,
                avatarUrl: persisted.avatarUrl,
                accentVariant: persisted.accentVariant
            )
            apply(
                creds,
                lastHandoffAt: persisted.issuedAt,
                supportsTapestryHint: persisted.supportsTapestry
            )
            return
        }
        // Dev fallback. `.env.watch.local` is gitignored; production
        // builds without the file see `DevAuth.isPresent == false` and
        // land on EmptyAuthView until the phone pushes a real handoff.
        if DevAuth.isPresent {
            let creds = Credentials(
                instance: DevAuth.instance,
                accessToken: DevAuth.accessToken,
                accountId: DevAuth.accountId,
                username: DevAuth.username,
                accountType: DevAuth.accountType,
                avatarUrl: nil,
                accentVariant: 1
            )
            apply(creds)
        }
    }

    /// Apply a fresh credentials payload. Optional hints from the phone
    /// (`lastHandoffAt`, `supportsTapestryHint`) seed the corresponding
    /// `@Published` so first paint after a handoff has the right
    /// values without waiting for the nodeinfo round-trip; the
    /// async `refreshCapabilities()` then overwrites the hint with
    /// the watch's own capability check (the watch-side check is the
    /// source of truth — the hint is purely the seed).
    ///
    /// Fast-paths a same-credentials re-apply to ONLY the timestamp
    /// update — the phone-side bridge fans `pushActiveHandoff` out
    /// across many triggers and the WC application-context channel
    /// re-delivers on every reachability/foreground tick. Without the
    /// short-circuit, every same-value re-apply would tear down
    /// `client`, wipe URLCache, kick `refreshCapabilities`, and emit
    /// 4–5 `@Published` invalidations — which then ripple through
    /// every view's `@StateObject`s in the same render tick that
    /// SwiftUI's `.page` TabView is trying to reconcile.
    func apply(
        _ creds: Credentials?,
        lastHandoffAt: Date? = nil,
        supportsTapestryHint: Bool? = nil
    ) {
        // Fast path: identical credentials. Only the handoff timestamp
        // moves; everything else is by-construction unchanged.
        if creds == credentials {
            DebugRecorder.shared.authApplyFastPath()
            if let ts = lastHandoffAt { self.lastHandoffAt = ts }
            return
        }

        DebugRecorder.shared.authApply(creds: creds, hint: supportsTapestryHint)

        // Real change. Tear down the previous client's URLCache only on
        // a same-instance account switch — the diskPath is keyed by
        // instance, so cross-instance switches already have separate
        // caches. Same-instance switches risk an Authorization-keyed
        // response cached for account A being served to account B if
        // the server's Cache-Control / Vary headers don't fully
        // separate them.
        if let prev = credentials, let new = creds, prev.instance == new.instance {
            client?.clearCache()
            DebugRecorder.shared.authCacheCleared()
        }

        credentials = creds
        if let c = creds {
            client = WatchAPIClient(
                instance: c.instance,
                accessToken: c.accessToken,
                accountType: c.accountType
            )
            self.lastHandoffAt = lastHandoffAt ?? Date()
            if let hint = supportsTapestryHint, hint != supportsTapestry {
                supportsTapestry = hint
                tapestryProbed = true
            }
            // No-hint and hint-matches-current paths intentionally
            // leave `tapestryProbed` for `refreshCapabilities()` to
            // flip — keeps the @Published emission count to the
            // genuinely-changed fields per apply() call.
            Task { await refreshCapabilities() }
        } else {
            client = nil
            supportsTapestry = false
            tapestryProbed = false
            self.lastHandoffAt = nil
        }
    }

    /// Local sign-out. Wipes the keychain so a relaunch doesn't
    /// rehydrate, but does NOT touch the phone — that's the user's
    /// expressed intent ("sign out on this Watch"). The phone-side
    /// account stays signed in; the next manual "Re-sync from iPhone"
    /// rebuilds the watch's session from the phone's current handoff.
    /// Also drops the App Group complication snapshot so the watch
    /// face stops showing the previous account's letters; WidgetKit
    /// is asked to reload so the empty-state lands immediately.
    func clear() {
        DebugRecorder.shared.authClear()
        // Drop the per-instance tapestry history before tokens go — the
        // active credentials' instance is the one the user has been
        // viewing, and the wipe should match the sign-out intent.
        if let instance = credentials?.instance {
            AppGroupCache.clearTapestryHistory(instance: instance)
        }
        WatchKeychain.clear()
        AppGroupCache.clearSnapshot()
        AppGroupCache.clearLatestMediaSnapshot()
        // Reload both complication kinds so the empty / signed-out
        // state lands immediately — without the explicit reloads the
        // watch face would keep rendering the previous account's
        // letters / thumbnail until the next budgeted timeline tick.
        WidgetCenter.shared.reloadTimelines(ofKind: BackgroundRefresh.widgetKind)
        WidgetCenter.shared.reloadTimelines(ofKind: LatestMediaRefresh.widgetKind)
        apply(nil)
    }

    /// "Re-sync from iPhone" affordance. Asks the phone to re-push the
    /// active account's handoff payload. Fire-and-forget — the
    /// `application-context` callback lands on `WatchSession` and
    /// hot-reloads this AuthState in turn.
    func requestReHandoff() {
        WatchSession.shared.sendResyncRequest()
    }

    func refreshCapabilities() async {
        guard let client else { return }
        let supports = await client.fetchSupportsTapestry()
        await MainActor.run {
            DebugRecorder.shared.authCapabilityRefresh(supports: supports)
            // `@Published` doesn't auto-dedupe; the manual `!=` guards
            // prevent the nodeinfo round-trip from re-invalidating the
            // view tree when the answer matches the keychain hint
            // (which is the steady state).
            if self.supportsTapestry != supports { self.supportsTapestry = supports }
            if !self.tapestryProbed { self.tapestryProbed = true }
        }
    }
}
