// Watch handoff bridge — phone-side WCSession glue.
//
// The watch app knows ONE account at a time: the phone's currently active
// one. This module turns the active AccountSummary + its keychain access
// token into a HalflightHandoff payload and ships it via
// `WCSession.updateApplicationContext` (latest-value-wins, queued for
// delivery, no requirement that both sides be reachable now). Lifecycle:
// push on login completion, push on account switch, push on token
// refresh, push best-effort on app foreground; one-shot revoke message
// on sign-out / sign-out-of-all.
//
// Platform-gated to iOS: every public function is a no-op on Android.
// The dependency itself (`react-native-watch-connectivity`) is iOS-only
// at the native level and registers an Android stub that returns
// reachable=false; we still short-circuit at the call site so the
// import doesn't drag any cost into the Android binary.
//
// Internal dedupe: identical payloads (same accountId + token prefix +
// instance) skip the WC channel — `updateApplicationContext` is
// "latest wins" but a same-value re-push still allocates and copies
// across the bridge, so we avoid it. A token refresh deliberately
// resets the dedupe signature so the new token reaches the watch even
// when the (accountId, instance) pair is unchanged.

import * as Application from 'expo-application'
import { Platform } from 'react-native'
import {
  getIsPaired,
  getIsWatchAppInstalled,
  getReachability,
  sendMessage,
  updateApplicationContext,
  watchEvents,
} from 'react-native-watch-connectivity'
import { userAgentHeader } from 'src/lib/branding'
import { devlog } from 'src/lib/devnet/log'
import type { HalflightFeatures } from 'src/lib/features'
import { AccountStore } from 'src/state/accountStore'
import { appKeys, appStorage, getFeatures, secureStore } from 'src/state/storage'

// Wire-format handoff payload. The watch's WCSession receiver decodes
// this back into `AuthState.Credentials` and persists it to the watch
// keychain. The shape is locked to plan §5; widening or renaming a key
// must move in lock-step with `targets/watch/State/WatchSession.swift`.
type HandoffPayload = {
  type: 'handoff'
  instance: string
  accountId: string
  accessToken: string
  accountType: string
  username: string
  avatarUrl: string | null
  userAgent: string
  // ms since epoch — staleness-check on the watch's Account screen.
  issuedAt: number
  appVersion: string
  // Pre-computed from nodeinfo on the phone (the watch can also self-
  // check via its own capability fetch; the phone-side prefill is just
  // the first-render hint so the EmptyAuthView vs TapestryFeedView
  // branch lands correctly without an extra round-trip on first paint).
  supportsTapestry: boolean
  // Per-account accent variant id (1..8). Mirrors AccountSummary.accentVariant
  // so the watch's tapestry palette + heart icon flip with the phone's
  // active-account swatch picker. The watch's AccentVariants.swift
  // mirror falls back to variant 1 (Electric Blue) for unknown ids.
  accentVariant: number
}

let lastSentSignature: string | null = null
let lastSentAt: number | null = null
let lastSentAccountId: string | null = null
let listenersAttached = false
let restoredFromStorage = false

// Reasons that bypass the persisted-signature dedupe. The signature proves
// "this process pushed payload P at some point" — not "the watch currently
// holds P." Cold boot + a watch reinstall (or App Group wipe) silently
// starves the complication: phone-side MMKV remembers the old sig, watch-
// side has nothing. These three transitions all signal "we may have lost
// watch state — re-push unconditionally." `account-store-change` and the
// other reasons stay deduped: they fire frequently against an established
// watch state and a same-sig re-push is wasted bridge traffic.
const BYPASS_DEDUPE_REASONS: ReadonlySet<string> = new Set([
  'boot',
  'watch-installed',
  'watch-reachable',
])

// Cheap pub-sub for the Settings → Accounts → Apple Watch row. Fires
// whenever this process's view of "what landed on the watch" mutates
// (push, revoke, revoke-all). Subscribers re-read `getWatchStatus()` /
// the in-memory snapshot on each event. Not exposed as part of the
// handoff contract — purely UI plumbing.
type HandoffListener = () => void
const handoffListeners = new Set<HandoffListener>()

function emitHandoffEvent(): void {
  for (const fn of handoffListeners) fn()
}

export function subscribeToHandoffEvents(fn: HandoffListener): () => void {
  handoffListeners.add(fn)
  return () => {
    handoffListeners.delete(fn)
  }
}

function isSupported(): boolean {
  return Platform.OS === 'ios'
}

// Hydrate `lastSentAt` / `lastSentAccountId` / `lastSentSignature` from
// MMKV on first call. Without restoring `at`/`id`, the Settings →
// Accounts → Apple Watch row reads "Never synced" after every JS
// hot-reload / cold launch even when the watch already holds a fresh
// handoff. The restored `signature` gates pushes that fire before the
// first BYPASS_DEDUPE_REASONS push lands (and pushes when there is no
// active account at boot but one appears later); the boot push itself
// bypasses dedupe so a wiped watch app group always re-receives.
// Idempotent — repeat calls short-circuit on `restoredFromStorage`.
function restoreHandoffMetadata(): void {
  if (restoredFromStorage) return
  restoredFromStorage = true
  const at = appStorage.getNumber(appKeys.watchLastHandoffAt)
  const id = appStorage.getString(appKeys.watchLastHandoffAccountId)
  const sig = appStorage.getString(appKeys.watchLastHandoffSignature)
  if (typeof at === 'number' && at > 0) lastSentAt = at
  if (typeof id === 'string' && id.length > 0) lastSentAccountId = id
  if (typeof sig === 'string' && sig.length > 0) lastSentSignature = sig
}

function persistHandoffMetadata(): void {
  if (lastSentAt === null) {
    appStorage.remove(appKeys.watchLastHandoffAt)
  } else {
    appStorage.set(appKeys.watchLastHandoffAt, lastSentAt)
  }
  if (lastSentAccountId === null) {
    appStorage.remove(appKeys.watchLastHandoffAccountId)
  } else {
    appStorage.set(appKeys.watchLastHandoffAccountId, lastSentAccountId)
  }
  if (lastSentSignature === null) {
    appStorage.remove(appKeys.watchLastHandoffSignature)
  } else {
    appStorage.set(appKeys.watchLastHandoffSignature, lastSentSignature)
  }
}

function buildActiveHandoff(): HandoffPayload | null {
  const active = AccountStore.activeAccount()
  if (!active) return null
  const accessToken = secureStore.getAccessToken(active.accountId)
  if (!accessToken) return null
  const features = getFeatures<HalflightFeatures>(active.instance)
  return {
    type: 'handoff',
    instance: active.instance,
    accountId: active.accountId,
    accessToken,
    accountType: active.type,
    username: active.username,
    avatarUrl: active.avatarUrl,
    userAgent: userAgentHeader(),
    issuedAt: Date.now(),
    appVersion: Application.nativeApplicationVersion ?? '0.0.0',
    supportsTapestry: features?.tapestry === true,
    accentVariant: active.accentVariant,
  }
}

function signatureOf(p: HandoffPayload): string {
  // First 8 token chars is enough to detect rotation without putting
  // the full secret into a long-lived module variable. `avatarUrl`
  // tail is included so an avatar-only profile change still pushes
  // — the watch's SwiftUI AsyncImage refreshes off the new URL.
  const avatarTail = (p.avatarUrl ?? '').slice(-12)
  return `${p.accountId}:${p.accessToken.slice(0, 8)}:${p.instance}:${avatarTail}`
}

/**
 * Push the active account's handoff payload to the watch. No-op when
 * there is no active account, no token, the platform isn't iOS, or the
 * payload signature matches the most recent one (token + accountId +
 * instance unchanged). Caller passes a free-text `reason` purely for
 * dev-mode log triage — production has zero string cost beyond the
 * function-call.
 */
export function pushActiveHandoff(reason: string = 'manual'): void {
  if (!isSupported()) return
  const payload = buildActiveHandoff()
  if (!payload) {
    devlog.wc(`push skip reason=${reason} no-active-or-token`)
    return
  }
  const sig = signatureOf(payload)
  if (sig === lastSentSignature && !BYPASS_DEDUPE_REASONS.has(reason)) {
    devlog.wc(
      `push dedupe reason=${reason} @${payload.username}@${payload.instance} sig=${sig.slice(0, 24)}`
    )
    return
  }
  try {
    updateApplicationContext(payload as unknown as Record<string, unknown>)
    lastSentSignature = sig
    lastSentAt = payload.issuedAt
    lastSentAccountId = payload.accountId
    persistHandoffMetadata()
    emitHandoffEvent()
    devlog.wc(
      `push ok reason=${reason} @${payload.username}@${payload.instance} accountId=${payload.accountId} sig=${sig.slice(0, 24)}`
    )
  } catch (e) {
    devlog.wc(`push fail reason=${reason} err=${String(e).slice(0, 200)}`)
  }
}

/**
 * Push handoff after a token refresh for `accountId`. Two safety rails
 * vs the bare `pushActiveHandoff`:
 *   1. Skip if `accountId` is not the active account — the watch only
 *      ever knows ONE account, so pushing a non-active account's token
 *      would either leak it or silently overwrite the user's "current"
 *      pairing.
 *   2. Reset the dedupe signature so the new token always lands, even
 *      when the (accountId, instance) pair matches the last push.
 */
export function pushHandoffForAccount(accountId: string): void {
  if (!isSupported()) return
  if (AccountStore.activeId() !== accountId) return
  lastSentSignature = null
  pushActiveHandoff('token-refresh')
}

/**
 * Tell the watch to drop the named account's local credentials. Used
 * by the per-account Sign Out flow on the phone. Fire-and-forget; the
 * watch's WCSession receiver wipes its keychain + AuthState if the
 * accountId matches the one it currently holds. A revoke for a non-
 * matching accountId is a no-op on the watch (defensive).
 */
export function revokeOnWatch(accountId: string): void {
  if (!isSupported()) return
  try {
    sendMessage(
      { type: 'revoke', accountId } as unknown as Record<string, unknown>,
      undefined,
      () => {}
    )
    devlog.wc(`revoke accountId=${accountId}`)
    if (lastSentAccountId === accountId) {
      lastSentSignature = null
      lastSentAccountId = null
      lastSentAt = null
      persistHandoffMetadata()
      emitHandoffEvent()
    }
  } catch {
    // Sign-out must never block on the watch being reachable.
  }
}

/**
 * Tell the watch to wipe every credential. Used by Sign Out Of All on
 * the phone. Fire-and-forget. The watch's WCSession receiver clears
 * the keychain unconditionally and lands on EmptyAuthView.
 */
export function revokeAllOnWatch(): void {
  if (!isSupported()) return
  try {
    sendMessage(
      { type: 'revoke-all' } as unknown as Record<string, unknown>,
      undefined,
      () => {}
    )
    devlog.wc('revoke-all')
    lastSentSignature = null
    lastSentAccountId = null
    lastSentAt = null
    persistHandoffMetadata()
    emitHandoffEvent()
  } catch {
    // ditto
  }
}

/**
 * Wire the WCSession message listeners. Called once at app boot by the
 * `WatchHandoffBridge` component in `_layout.tsx`. Idempotent — repeat
 * calls are no-ops. The single message we react to today is
 * `request-resync`: when the watch's Account screen "Re-sync from
 * iPhone" affordance fires, the phone re-pushes the active handoff
 * payload (bypassing the dedupe so a fresh `issuedAt` lands on the
 * watch's "Updated Nm ago" line).
 */
export function enableWatchHandoff(): void {
  if (!isSupported()) return
  if (listenersAttached) return
  listenersAttached = true
  restoreHandoffMetadata()
  const handleResync = (reason: string) => {
    lastSentSignature = null
    pushActiveHandoff(reason)
  }
  watchEvents.addListener('message', (msg: Record<string, unknown> | undefined) => {
    if (msg?.type === 'request-resync') handleResync('watch-requested-resync')
  })
  // The watch falls back to `transferUserInfo` when WCSession is
  // unreachable at request time (sendMessage's reachability guarantee
  // only holds during an active session). Without this listener, an
  // off-wrist resync request would silently drop on the floor here
  // until the watch re-attempted on next reachability.
  watchEvents.addListener(
    'user-info',
    (payloads: Array<Record<string, unknown>> | undefined) => {
      if (!payloads) return
      for (const msg of payloads) {
        if (msg?.type === 'request-resync') {
          handleResync('watch-requested-resync-userinfo')
        }
      }
    }
  )
}

export type WatchStatus = {
  paired: boolean
  installed: boolean
  reachable: boolean
  /** ms since epoch of the most recent successful push from this process. */
  lastHandoffAt: number | null
  /** AccountId that landed on the watch most recently this process. */
  lastHandoffAccountId: string | null
}

/**
 * Synchronous handoff snapshot — just the in-process timestamp + the
 * accountId, no rnwc round-trip. The Settings → Apple Watch row reads
 * `paired` / `installed` / `reachable` via the rnwc hooks (which fire
 * on event change) and reads the rest via this getter so it stays in
 * sync with the pub-sub channel without a Suspense seam.
 */
export function getWatchHandoffSnapshot(): {
  lastHandoffAt: number | null
  lastHandoffAccountId: string | null
} {
  return { lastHandoffAt: lastSentAt, lastHandoffAccountId: lastSentAccountId }
}

/**
 * Snapshot of the WCSession's pairing/installation state plus this
 * process's most recent handoff metadata. Drives the Settings →
 * Accounts → Apple Watch row. Async because the underlying
 * `getIsPaired` / `getIsWatchAppInstalled` / `getReachability` are
 * RN-bridge round-trips. On Android (no WCSession at all) returns a
 * paired-and-installed-false stub with no native call.
 */
export async function getWatchStatus(): Promise<WatchStatus> {
  if (!isSupported()) {
    return {
      paired: false,
      installed: false,
      reachable: false,
      lastHandoffAt: null,
      lastHandoffAccountId: null,
    }
  }
  const [paired, installed, reachable] = await Promise.all([
    getIsPaired().catch(() => false),
    getIsWatchAppInstalled().catch(() => false),
    getReachability().catch(() => false),
  ])
  return {
    paired,
    installed,
    reachable,
    lastHandoffAt: lastSentAt,
    lastHandoffAccountId: lastSentAccountId,
  }
}
