import Foundation
import WatchConnectivity

// WCSession delegate singleton — the watch end of the phone-side
// `src/lib/watch/handoff.ts` bridge. Activated once at app launch with
// a reference to the live `AuthState` so inbound handoff payloads
// can hot-reload credentials and inbound revoke messages can wipe
// them. Outbound: a single fire-and-forget `request-resync` message
// driven from the AccountView's "Re-sync from iPhone" affordance.
//
// The phone sends three kinds of payload (plan §5):
//   - `application-context` with `type: "handoff"` — full credentials
//     pack pushed via `WCSession.updateApplicationContext`. Latest-
//     value-wins, queued for delivery, no requirement that both sides
//     be reachable now.
//   - `message` with `type: "revoke"` and an `accountId` — phone-side
//     per-account sign out.
//   - `message` with `type: "revoke-all"` — phone-side sign-out-of-all.
//
// The class itself is not `@MainActor` because WCSessionDelegate
// callbacks land on a private dispatch queue managed by WC (not
// guaranteed to be the main one). The handlers bounce to the main
// actor before touching `AuthState` (which IS `@MainActor`).

final class WatchSession: NSObject, WCSessionDelegate {
    static let shared = WatchSession()

    @MainActor private weak var auth: AuthState?
    @MainActor private var hasActivated = false

    /// Wire the WCSession delegate and replay any pre-launch context
    /// the phone might have left in `WCSession.receivedApplicationContext`.
    /// Idempotent — repeat calls (every RootView appearance fires `.task`)
    /// are no-ops after the first activation.
    @MainActor
    func activate(auth: AuthState) {
        self.auth = auth
        guard WCSession.isSupported() else { return }
        if hasActivated { return }
        hasActivated = true
        let session = WCSession.default
        session.delegate = self
        session.activate()
        let pre = session.receivedApplicationContext
        if !pre.isEmpty { handleApplicationContext(pre) }
    }

    /// "Re-sync from iPhone" affordance on the AccountView. Tries the
    /// reachable channel first (`sendMessage` — phone delivers the
    /// resync push within seconds when reachable); falls back to
    /// `transferUserInfo` so an unreachable phone still picks the
    /// request up on its next foreground / WC reachability cycle.
    @MainActor
    func sendResyncRequest() {
        guard WCSession.isSupported() else { return }
        let session = WCSession.default
        guard session.activationState == .activated else { return }
        let payload: [String: Any] = ["type": "request-resync"]
        if session.isReachable {
            session.sendMessage(payload, replyHandler: nil) { _ in
                // sendMessage's delivery guarantees only hold while
                // the channel stays reachable; a watch-to-phone race
                // can drop the call here. Fall back to userInfo so
                // the request still lands eventually.
                session.transferUserInfo(payload)
            }
        } else {
            session.transferUserInfo(payload)
        }
    }

    // MARK: WCSessionDelegate

    func session(
        _ session: WCSession,
        activationDidCompleteWith activationState: WCSessionActivationState,
        error: Error?
    ) {
        Task { @MainActor in
            DebugRecorder.shared.wcActivation(state: activationState, error: error)
        }
    }

    func session(
        _ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]
    ) {
        Task { @MainActor in
            DebugRecorder.shared.wcContext(applicationContext)
            self.handleApplicationContext(applicationContext)
        }
    }

    func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
        Task { @MainActor in
            DebugRecorder.shared.wcMessage(message)
            self.handleIncoming(message)
        }
    }

    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
        Task { @MainActor in
            DebugRecorder.shared.wcUserInfo(userInfo)
            self.handleIncoming(userInfo)
        }
    }

    // MARK: - Inbound dispatch (main actor — touches AuthState + keychain)

    @MainActor
    private func handleApplicationContext(_ ctx: [String: Any]) {
        guard let type = ctx["type"] as? String, type == "handoff" else { return }
        guard
            let instance = ctx["instance"] as? String,
            let accountId = ctx["accountId"] as? String,
            let accessToken = ctx["accessToken"] as? String,
            let accountType = ctx["accountType"] as? String,
            let username = ctx["username"] as? String
        else { return }
        let supportsTapestry = ctx["supportsTapestry"] as? Bool ?? false
        // The phone ships `issuedAt` as ms since epoch (matches
        // Date.now() on the JS side). NSNumber bridges to Double here.
        let issuedAtMs = ctx["issuedAt"] as? Double ?? Date().timeIntervalSince1970 * 1000
        let issuedAt = Date(timeIntervalSince1970: issuedAtMs / 1000)
        // `avatarUrl` is `String | null` on the phone — null becomes
        // NSNull on the WC bridge, which `as? String` falls through to
        // nil. The result is the same for both serialised forms.
        let avatarUrl = ctx["avatarUrl"] as? String
        // Accent variant id (1..8). Falls back to 1 (Electric Blue) if
        // the phone-side payload is from an older build that didn't
        // include the field.
        let accentVariant = ctx["accentVariant"] as? Int ?? 1
        WatchKeychain.save(
            WatchKeychain.Persisted(
                instance: instance,
                accessToken: accessToken,
                accountId: accountId,
                username: username,
                accountType: accountType,
                issuedAt: issuedAt,
                supportsTapestry: supportsTapestry,
                avatarUrl: avatarUrl,
                accentVariant: accentVariant
            )
        )
        let creds = AuthState.Credentials(
            instance: instance,
            accessToken: accessToken,
            accountId: accountId,
            username: username,
            accountType: accountType,
            avatarUrl: avatarUrl,
            accentVariant: accentVariant
        )
        auth?.apply(creds, lastHandoffAt: issuedAt, supportsTapestryHint: supportsTapestry)
        // Prime the complication snapshot from the fresh credentials so
        // the watch face reflects the new account within seconds rather
        // than waiting for the next ~20-min refresh tick. Fire-and-
        // forget; errors fall back to the regular refresh chain.
        Task { @MainActor in
            await BackgroundRefresh.runOnce()
            BackgroundRefresh.scheduleNext()
        }
    }

    @MainActor
    private func handleIncoming(_ payload: [String: Any]) {
        let type = payload["type"] as? String ?? ""
        switch type {
        case "revoke":
            // Defensive: only wipe if the revoke targets our currently-
            // held account. A stray revoke for a different accountId
            // would otherwise drop a session the user just signed
            // into via a fresh handoff.
            let revokeId = payload["accountId"] as? String
            let current = auth?.credentials?.accountId
            if revokeId == nil || revokeId == current {
                WatchKeychain.clear()
                auth?.clear()
            }
        case "revoke-all":
            WatchKeychain.clear()
            auth?.clear()
        default:
            break
        }
    }
}
