import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetAtom } from 'jotai';
import {
  ClientEvent,
  MatrixClient,
  MatrixEvent,
  NotificationCountType,
  Room,
  RoomEvent,
} from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useClientConfig } from './useClientConfig';
import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor';
import {
  PUSH_APP_IDS,
  PUSH_ENABLED_KEY,
  PUSH_STATE_CHANGE_EVENT,
  clearPusherIds,
  ensureRtcRingPushRule,
  isPushEnabled,
  loadPusherIds,
  registerFcmPusher,
  registerWebPusher,
  removeRtcRingPushRule,
  savePusherIds,
  setPushEnabled,
  unregisterPusher,
  urlBase64ToUint8Array,
} from '../utils/push';
import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils';
import { pendingCallActionAtom } from '../state/pendingCallAction';
import { useRoomNavigate } from './useRoomNavigate';
import { polling, type RoomMetadataMap, type UserAvatarsMap } from '../plugins/polling';
import { getAccountData, getMDirects } from '../utils/room';
import { AccountDataEvent } from '../../types/matrix/accountData';

const noop = (): void => undefined;

const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => {
  // m.direct lists DMs per peer-user; flattened into a Set the Java side can
  // consult per room when picking the DM-vs-group notification channel.
  // Falls back to an empty Set when account data is not yet hydrated — in
  // that brief post-login window every room is treated as a group (quieter
  // channel). The next m.direct sync re-dumps via our ClientEvent.AccountData
  // listener with correct isDirect values, so the under-alerting is bounded
  // to seconds.
  const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct);
  const dmRooms = mDirectEvent ? getMDirects(mDirectEvent) : new Set<string>();

  return mx.getRooms().reduce<RoomMetadataMap>((acc, room) => {
    const { name } = room;
    if (typeof name !== 'string' || name.length === 0) return acc;
    // hasEncryptionStateEvent reads the m.room.encryption state event from
    // the live timeline — synchronous and cheap. Used by the Java side to
    // gate the inline reply action: encrypted rooms get a read-only
    // notification because the Java path has no key material to encrypt
    // outgoing replies with.
    //
    // Room avatar: prefer the explicit m.room.avatar mxc; for a DM with no
    // configured room avatar, fall through to the "other member" fallback
    // (mirrors getDirectRoomAvatarUrl). Lets the Java MessagingStyle
    // notification show the conversation avatar as largeIcon, the same
    // shape WhatsApp / Element use.
    const roomMxc = room.getMxcAvatarUrl();
    const fallbackMxc = dmRooms.has(room.roomId)
      ? room.getAvatarFallbackMember()?.getMxcAvatarUrl()
      : undefined;
    const avatarMxc = roomMxc ?? fallbackMxc ?? undefined;
    acc[room.roomId] = {
      name,
      isDirect: dmRooms.has(room.roomId),
      isEncrypted: room.hasEncryptionStateEvent(),
      ...(avatarMxc ? { avatarMxc } : {}),
    };
    return acc;
  }, {});
};

const MAX_BRIDGED_USER_AVATARS = 500;

const buildUserAvatarsSnapshot = (mx: MatrixClient): UserAvatarsMap => {
  // user_id → mxc avatar URL, gathered from every room the user is in. The
  // Java FCM/WorkManager renderers can't fetch profiles themselves (would
  // need synchronous HTTP in the receive path), so this snapshot is the
  // sole source of per-sender avatar data on the native side. Bounded at
  // MAX_BRIDGED_USER_AVATARS to cap the serialised JSON; truncation drops
  // the latest iteration entries (essentially arbitrary which rooms get
  // their members covered, but the self user is always included first to
  // protect the MessagingStyle self-anchor icon).
  const out: UserAvatarsMap = {};
  const myUserId = mx.getUserId();
  if (myUserId) {
    const myMxc = mx.getUser(myUserId)?.avatarUrl;
    if (myMxc) out[myUserId] = myMxc;
  }
  // Sort by roomId for STABLE iteration across dumps. mx.getRooms()
  // returns rooms in matrix-js-sdk's internal Map order which is
  // insertion-time and can flip on a /sync that re-orders state. Without
  // a stable sort, the 500-entry cap could include peer X on dump A,
  // exclude X on dump B (different rooms iterated first), and the
  // notification for room R would show X's avatar one push and lose it
  // the next — visible flicker.
  const compareRoomId = (a: Room, b: Room): number => {
    if (a.roomId < b.roomId) return -1;
    if (a.roomId > b.roomId) return 1;
    return 0;
  };
  // `.some` is the eslint-friendly way to express "iterate with early
  // exit" — returning true from the callback aborts the walk. Each room
  // appends its joined-member avatars into `out` until we hit the cap;
  // we abort the whole walk when the cap is reached. Members are added
  // imperatively into `out` (idiomatic for a bounded accumulator that
  // skips already-seen keys); the cap check is a simple length read.
  [...mx.getRooms()].sort(compareRoomId).some((room) => {
    room.getJoinedMembers().some((member) => {
      if (Object.keys(out).length >= MAX_BRIDGED_USER_AVATARS) return true;
      if (out[member.userId]) return false;
      const mxc = member.getMxcAvatarUrl();
      if (mxc) out[member.userId] = mxc;
      return false;
    });
    return Object.keys(out).length >= MAX_BRIDGED_USER_AVATARS;
  });
  return out;
};

const dumpRoomNamesToNative = async (mx: MatrixClient): Promise<void> => {
  // Bridge both the room metadata snapshot AND the user avatars map. They
  // travel together because every trigger that invalidates one (visibility
  // resume, m.direct change, m.room.encryption flip) typically invalidates
  // the other — a fresh DM appears in m.direct alongside the new peer in
  // user avatars; a freshly-joined member affects both. Two sequential
  // bridge calls instead of bundling because the native plugin keeps the
  // two payloads in separate SharedPreferences keys for parsing simplicity.
  await polling.saveRoomNames(buildRoomMetadataSnapshot(mx));
  await polling.saveUserAvatars(buildUserAvatarsSnapshot(mx));
};

export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied';

/**
 * Reactive mirror of isPushEnabled(). Source of truth lives in localStorage
 * (see PUSH_ENABLED_KEY); this hook just subscribes to same-tab changes via
 * our custom event and cross-tab changes via the `storage` event.
 */
export function usePushEnabled(): boolean {
  const [enabled, setEnabled] = useState<boolean>(() => isPushEnabled());

  useEffect(() => {
    const sync = () => setEnabled(isPushEnabled());
    const onStorage = (ev: StorageEvent) => {
      if (ev.key === PUSH_ENABLED_KEY) sync();
    };
    window.addEventListener(PUSH_STATE_CHANGE_EVENT, sync);
    window.addEventListener('storage', onStorage);
    return () => {
      window.removeEventListener(PUSH_STATE_CHANGE_EVENT, sync);
      window.removeEventListener('storage', onStorage);
    };
  }, []);

  return enabled;
}

const webStatus = (): PushStatus => {
  if (typeof window === 'undefined') return 'unavailable';
  if (!('PushManager' in window) || !('serviceWorker' in navigator)) return 'unavailable';
  if (!('Notification' in window)) return 'unavailable';
  if (Notification.permission === 'granted') return 'granted';
  if (Notification.permission === 'denied') return 'denied';
  return 'prompt';
};

export function usePushNotificationStatus(): PushStatus {
  const [status, setStatus] = useState<PushStatus>(() =>
    isNativePlatform() ? 'prompt' : webStatus()
  );

  useEffect(() => {
    let cancelled = false;

    const refresh = async (): Promise<void> => {
      if (!isNativePlatform()) {
        if (!cancelled) setStatus(webStatus());
        return;
      }
      try {
        const { PushNotifications } = await import('@capacitor/push-notifications');
        const result = await PushNotifications.checkPermissions();
        if (cancelled) return;
        if (result.receive === 'granted') setStatus('granted');
        else if (result.receive === 'denied') setStatus('denied');
        else setStatus('prompt');
      } catch {
        if (!cancelled) setStatus('unavailable');
      }
    };

    refresh();

    // Re-check when:
    // - user returns from OS settings (visibilitychange / focus)
    // - register/disable flow explicitly signals a permission change
    // - web Permissions API reports a change (best-effort, no native equivalent)
    const onVisibility = () => {
      if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
    };
    const onFocus = () => refresh();
    const onPermChange = () => refresh();

    document.addEventListener('visibilitychange', onVisibility);
    window.addEventListener('focus', onFocus);
    window.addEventListener('vojo:pushPermissionChange', onPermChange);

    let permStatus: PermissionStatus | undefined;
    if (!isNativePlatform() && 'permissions' in navigator) {
      navigator.permissions
        // notifications is not in the canonical PermissionName union in some TS libs
        .query({ name: 'notifications' as PermissionName })
        .then((p) => {
          if (cancelled) return;
          permStatus = p;
          p.addEventListener('change', onPermChange);
        })
        .catch(() => undefined);
    }

    return () => {
      cancelled = true;
      document.removeEventListener('visibilitychange', onVisibility);
      window.removeEventListener('focus', onFocus);
      window.removeEventListener('vojo:pushPermissionChange', onPermChange);
      permStatus?.removeEventListener('change', onPermChange);
    };
  }, []);

  return status;
}

async function getWebSubscription(vapidKey: string): Promise<PushSubscription> {
  const registration = await navigator.serviceWorker.ready;
  const existing = await registration.pushManager.getSubscription();
  if (existing) return existing;
  return registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidKey),
  });
}

export function useRegisterPushNotifications(): () => Promise<void> {
  const mx = useMatrixClient();
  const clientConfig = useClientConfig();

  return useCallback(async () => {
    const cfg = clientConfig.push;
    if (!cfg?.gatewayUrl) {
      throw new Error('Push gateway is not configured');
    }

    if (isNativePlatform()) {
      const { PushNotifications } = await import('@capacitor/push-notifications');
      const perm = await PushNotifications.requestPermissions();
      // Notify status listeners of the new permission state regardless of grant/deny
      // so the UI swaps from 'prompt' → 'granted'/'denied' without waiting for a remount.
      window.dispatchEvent(new CustomEvent('vojo:pushPermissionChange'));
      if (perm.receive !== 'granted') throw new Error('permission_denied');

      const appId = cfg.fcmAppId ?? PUSH_APP_IDS.fcm;
      const ids = await new Promise<{ pushkey: string; appId: string }>((resolve, reject) => {
        const timeout = setTimeout(() => reject(new Error('fcm_token_timeout')), 15000);

        let regHandle: { remove: () => Promise<void> } | undefined;
        let errHandle: { remove: () => Promise<void> } | undefined;
        const cleanup = () => {
          clearTimeout(timeout);
          regHandle?.remove();
          errHandle?.remove();
        };

        PushNotifications.addListener('registration', async (token) => {
          try {
            const result = await registerFcmPusher(mx, token.value, cfg.gatewayUrl, appId);
            cleanup();
            resolve(result);
          } catch (err) {
            cleanup();
            reject(err);
          }
        }).then((h) => {
          regHandle = h;
        });

        PushNotifications.addListener('registrationError', (e) => {
          cleanup();
          reject(new Error(e.error || 'fcm_registration_error'));
        }).then((h) => {
          errHandle = h;
        });

        PushNotifications.register().catch((err) => {
          cleanup();
          reject(err);
        });
      });

      savePusherIds(ids);
      setPushEnabled(true);
      await ensureRtcRingPushRule(mx);
      return;
    }

    if (!cfg.vapidPublicKey) throw new Error('VAPID key is not configured');
    if (webStatus() === 'unavailable') throw new Error('Push is not supported');

    const permission = await Notification.requestPermission();
    window.dispatchEvent(new CustomEvent('vojo:pushPermissionChange'));
    if (permission !== 'granted') throw new Error('permission_denied');

    const subscription = await getWebSubscription(cfg.vapidPublicKey);
    const appId = cfg.webAppId ?? PUSH_APP_IDS.web;
    const ids = await registerWebPusher(mx, subscription, cfg.gatewayUrl, appId);

    savePusherIds(ids);
    setPushEnabled(true);
    await ensureRtcRingPushRule(mx);
  }, [mx, clientConfig]);
}

export function useDisablePushNotifications(): () => Promise<void> {
  const mx = useMatrixClient();

  return useCallback(async () => {
    const ids = loadPusherIds();
    // 1. Deactivate on server first — avoids dead pushers + token leakage
    if (ids) {
      try {
        await unregisterPusher(mx, ids.pushkey, ids.appId);
      } catch {
        /* ignore */
      }
    }

    // 2. Invalidate the device-side token/subscription — a local safety net
    //    so pushes stop even if the server-side setPusher(kind: null) failed.
    //    Do NOT remove Capacitor JS listeners — they're owned by
    //    usePushNotificationsLifecycle and guarded by isPushEnabled() checks,
    //    so they become no-ops when disabled. Removing them would break
    //    re-enable in the same session until full app restart.
    if (isNativePlatform()) {
      try {
        const { PushNotifications } = await import('@capacitor/push-notifications');
        await PushNotifications.unregister();
      } catch {
        /* ignore */
      }
    } else if ('serviceWorker' in navigator) {
      try {
        const reg = await navigator.serviceWorker.ready;
        const sub = await reg.pushManager.getSubscription();
        if (sub) await sub.unsubscribe();
      } catch {
        /* ignore */
      }
    }

    // 3. Drop the account-scoped RTC ring push rule. It's added symmetrically in
    //    register flows; leaving it behind would mean other logged-in clients
    //    (Element, a second Vojo session) still apply ring tweaks after the
    //    user explicitly turned push off on this device.
    await removeRtcRingPushRule(mx);

    // 4. Tear down the WorkManager polling fallback. Belt-and-suspenders with
    //    the lifecycle effect's cleanup — without this, polling could keep
    //    firing against a stale access_token between disable and the next
    //    re-render that observes the new pushEnabled state.
    await polling.cancel();
    await polling.clearSession();

    clearPusherIds();
    setPushEnabled(false);
  }, [mx]);
}

/**
 * Runs the app-level side effects for push notifications:
 * - web: auto re-register on startup if previously enabled (subscription may rotate)
 * - native: handle FCM token delivery + rotation without recursing into register()
 * - navigate to the tapped notification's room (web + native)
 */
export function usePushNotificationsLifecycle(): void {
  const register = useRegisterPushNotifications();
  const mx = useMatrixClient();
  const clientConfig = useClientConfig();
  const navigate = useNavigate();
  const { navigateRoom } = useRoomNavigate();
  const setPendingCallAction = useSetAtom(pendingCallActionAtom);
  const pushEnabled = usePushEnabled();

  useEffect(() => {
    if (isNativePlatform()) return;
    if (!isPushEnabled()) return;
    register().catch(noop);
  }, [register]);

  // Push rule for RTC ring is account-scoped and survives between sessions, but
  // existing pushers that predate this feature won't have it — re-assert on
  // every client startup (idempotent) so users with already-enabled push don't
  // need to toggle push off/on to start receiving call pushes.
  useEffect(() => {
    if (!isPushEnabled()) return;
    ensureRtcRingPushRule(mx).catch(noop);
  }, [mx]);

  useEffect(() => {
    const onNavigate = (ev: Event) => {
      const detail = (ev as CustomEvent).detail as
        | { roomId?: string; isInvite?: boolean }
        | undefined;
      // Push-tap navigations are "switch to this", not "stack on top of where
      // I was". Without replace, N notifications from the same chat plus tab
      // hops accumulate as N+ entries in our app back-stack (see
      // useAndroidBackButton) — user presses back many times to exit one chat.
      if (detail?.isInvite) {
        // Invites live inline in the Direct list — the row sits at the top
        // until the user accepts/declines. Always land on the bare /direct/
        // panel rather than /direct/{roomId}/ for an invite-state room: on
        // mobile MobileFriendlyPageNav hides the panel for any non-root
        // direct path, dropping the user into Room.tsx which has no
        // membership gate and would render an empty stripped-state timeline
        // with no Accept/Decline UI.
        navigate(getDirectPath(), { replace: true });
        return;
      }
      if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
    };
    const onSubChange = () => {
      if (isPushEnabled()) register().catch(noop);
    };

    window.addEventListener('vojo:pushNavigate', onNavigate);
    window.addEventListener('vojo:pushSubscriptionChange', onSubChange);
    return () => {
      window.removeEventListener('vojo:pushNavigate', onNavigate);
      window.removeEventListener('vojo:pushSubscriptionChange', onSubChange);
    };
  }, [navigate, navigateRoom, register]);

  // Receipt-driven dismiss: when a server-side read receipt drops a room's
  // unread count to zero, cancel the room's MessagingStyle notification
  // (and clear its native cache) so the shade matches reality. Mirrors
  // element-web's onRoomReceipt (src/Notifier.ts:486-500). Works only when
  // JS is alive — for killed-process / FCM-blocked scenarios this branch
  // is silent; acceptable because there's no client-visible read signal a
  // dead JS context could observe from server-side anyway.
  useEffect(() => {
    if (!isAndroidPlatform()) return undefined;

    const handleReceipt = (ev: MatrixEvent, room: Room) => {
      // Mirror useBindRoomToUnreadAtom: only act when the receipt is from
      // the current user. SDK fires this for every participant; we don't
      // want a peer reading on their device to dismiss our notification.
      const myUserId = mx.getUserId();
      if (!myUserId) return;
      const content = ev.getContent() as Record<
        string,
        Record<string, Record<string, { ts?: number }>>
      >;
      const isMyReceipt = Object.keys(content ?? {}).some((eventId) =>
        Object.keys(content[eventId] ?? {}).some(
          (rType) => myUserId in (content[eventId]?.[rType] ?? {})
        )
      );
      if (!isMyReceipt) return;

      // `room.getUnreadNotificationCount()` is post-receipt; element-web
      // gates dismissal on it == 0. Per-thread unread does not split: Total
      // sums across main + threads, which is the right "everything read"
      // signal for the shade dismiss.
      const total = room.getUnreadNotificationCount(NotificationCountType.Total);
      if (total === 0) {
        polling.dismissRoom(room.roomId).catch(noop);
      }
    };

    mx.on(RoomEvent.Receipt, handleReceipt);
    return () => {
      mx.removeListener(RoomEvent.Receipt, handleReceipt);
    };
  }, [mx]);

  // Re-dump the room metadata snapshot whenever m.direct changes — without
  // this, freshly-created DMs would land on the louder group-message
  // channel until the next visibilitychange re-bridge. The dump is small
  // (room id + name + isDirect + isEncrypted) and account-data updates are
  // rare.
  //
  // The same effect also re-dumps on m.room.encryption state events so the
  // inline-reply action is dropped within seconds of a room being switched
  // to E2EE. Without this re-dump, a cleartext-by-prefs notification could
  // post the reply action onto a freshly-encrypted room and the receiver
  // would send a cleartext reply (Synapse does not enforce the
  // "encrypted-only" rule, so the leak is real).
  useEffect(() => {
    if (!isAndroidPlatform()) return undefined;
    if (!pushEnabled) return undefined;

    const handleAccountData = (event: MatrixEvent) => {
      if (event.getType() !== AccountDataEvent.Direct) return;
      dumpRoomNamesToNative(mx).catch(noop);
    };
    const handleTimeline = (event: MatrixEvent) => {
      if (event.getType() === 'm.room.encryption') {
        dumpRoomNamesToNative(mx).catch(noop);
      }
    };

    mx.on(ClientEvent.AccountData, handleAccountData);
    mx.on(RoomEvent.Timeline, handleTimeline);
    return () => {
      mx.removeListener(ClientEvent.AccountData, handleAccountData);
      mx.removeListener(RoomEvent.Timeline, handleTimeline);
    };
  }, [mx, pushEnabled]);

  // WorkManager-based polling fallback for users where FCM is blocked.
  // Runs in parallel with FCM — the renderer dedupes by event_id.hashCode()
  // notification id, so a double-delivery (FCM in seconds + polling at the
  // next 15-min cycle) collapses to one entry in the shade. Web has no
  // equivalent; the Service Worker already handles all push wakeups.
  useEffect(() => {
    if (!isAndroidPlatform()) return undefined;
    if (!pushEnabled) return undefined;

    const token = mx.getAccessToken();
    const homeserverUrl = mx.baseUrl;
    if (!token || !homeserverUrl) return undefined;
    const userId = mx.getUserId() ?? undefined;

    let cancelled = false;
    (async () => {
      try {
        await polling.saveSession({ accessToken: token, homeserverUrl, userId });
        if (cancelled) return;
        await polling.schedule(15);
        if (cancelled) return;
        await dumpRoomNamesToNative(mx);
      } catch (err) {
        // eslint-disable-next-line no-console
        console.warn('[polling] lifecycle setup failed:', err);
      }
    })();

    const onVisibility = () => {
      if (cancelled) return;
      if (typeof document === 'undefined') return;
      if (document.visibilityState !== 'visible') return;
      // Re-save credentials so a 401 clear inside the Worker (see
      // VojoPollWorker.doWork) recovers as soon as the user comes back to
      // the app — not only after a full remount. Then refresh the
      // room-name snapshot. Both calls are idempotent overwrites.
      (async () => {
        try {
          const currentToken = mx.getAccessToken();
          if (cancelled) return;
          if (currentToken) {
            await polling.saveSession({
              accessToken: currentToken,
              homeserverUrl: mx.baseUrl,
              userId: mx.getUserId() ?? undefined,
            });
            if (cancelled) return;
          }
          await dumpRoomNamesToNative(mx);
        } catch (err) {
          // eslint-disable-next-line no-console
          console.warn('[polling] visibility re-bridge failed:', err);
        }
      })();
    };
    document.addEventListener('visibilitychange', onVisibility);

    return () => {
      cancelled = true;
      document.removeEventListener('visibilitychange', onVisibility);
      // Only stop the scheduled Worker here — DO NOT clearSession. The
      // destructive wipe of access_token / watermark / NotificationDedup /
      // room_names belongs only on real disable / logout (handled by
      // useDisablePushNotifications, logoutClient, clearLocalSessionAndReload,
      // and SessionLoggedOut). A bare effect cleanup runs on any mx-instance
      // swap or unmount; clearing native state from there would silently
      // erase the LRU and re-render events the next poll cycle.
      polling.cancel().catch(noop);
    };
  }, [pushEnabled, mx]);

  useEffect(() => {
    if (!isNativePlatform()) return undefined;

    let cancelled = false;
    const cleanups: Array<() => void> = [];
    const cfg = clientConfig.push;

    const pnPromise = import('@capacitor/push-notifications');

    // Attach the action listener on its own promise chain — no other awaits
    // before it — so cold-start Decline/Answer events (fired by the native
    // PushNotifications plugin via handleOnNewIntent the moment MainActivity
    // boots) have a listener waiting. Sequential awaits behind channel
    // creation / register() added ~100-300ms during which the event could be
    // dropped entirely on a killed-process launch.
    pnPromise
      .then(({ PushNotifications }) => {
        if (cancelled) return null;
        return PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
          const data = action.notification.data as {
            room_id?: string;
            call_action?: 'answer' | 'decline';
            notif_event_id?: string;
            // Sygnal flattens nested fields with `_` separator; the Android
            // FCM service forwards every data entry verbatim into the launch
            // intent (VojoFirebaseMessagingService.java foreach), so these
            // are reliably present for invite pushes.
            type?: string;
            content_membership?: string;
          };

          // Invite-state rooms must land on the bare /direct/ panel — the
          // inline DirectInviteRow lives there with Accept/Decline. Native
          // tap on an invite must NOT fall through to the generic room-id
          // branch below (would route to /direct/{roomId}/, which has no
          // membership gate path of its own; see DirectRouteRoomProvider for
          // the second-line defence).
          if (data.type === 'm.room.member' && data.content_membership === 'invite') {
            navigate(getDirectPath(), { replace: true });
            return;
          }

          // Native CallStyle Answer → open the room and queue a JS-side
          // switch/start via
          // pendingCallActionAtom. The consumer hook picks it up once the
          // CallEmbedProvider tree is mounted. DM rooms live in the Direct tab
          // route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
          // it doesn't have in its left-rail, hence the DM path here.
          if (data.call_action === 'answer' && data.room_id) {
            navigate(getDirectRoomPath(data.room_id), { replace: true });
            setPendingCallAction({
              kind: 'answer',
              roomId: data.room_id,
              notifEventId: data.notif_event_id,
            });
            return;
          }

          // Decline: queue the action and let the consumer (inside
          // CallEmbedProvider) fire sendRtcDecline and then minimize the app.
          // Doing the minimize here would race the setAtom — setAtom schedules
          // a render-tick, minimize is synchronous, so we'd close the WebView
          // before the consumer could pick up the atom and send the decline.
          if (data.call_action === 'decline' && data.room_id && data.notif_event_id) {
            setPendingCallAction({
              kind: 'decline',
              roomId: data.room_id,
              notifEventId: data.notif_event_id,
            });
            return;
          }

          // FSI launch and body-tap on a call notification hit this path (no
          // call_action extra, but notif_event_id is present — only call
          // pushes carry it). DMs are currently the only call surface, so
          // route to the Direct tab instead of the Home-tab fallback.
          // Caveat: if group-call push is ever added, those events will also
          // carry notif_event_id and this branch will route them to the DM
          // tab incorrectly — revisit together with the group-call pipeline.
          if (data.room_id && data.notif_event_id) {
            navigate(getDirectRoomPath(data.room_id), { replace: true });
            return;
          }

          if (data.room_id) navigateRoom(data.room_id, undefined, { replace: true });
        });
      })
      .then((h) => {
        if (!h) return;
        cleanups.push(() => {
          h.remove().catch(noop);
        });
      })
      .catch(noop);

    // Channel + registration listener + token-kickoff on a parallel chain —
    // order doesn't matter for these, they just need to happen eventually.
    (async () => {
      const { PushNotifications } = await pnPromise;
      if (cancelled) return;

      // All notification channels (vojo_messages_dm_v1, vojo_messages_group_v1,
      // vojo_calls_v2) are created lazily from native code on first use —
      // VojoFirebaseMessagingService.ensureMessageChannels / ensureCallChannel.
      // Creating them here from JS would race with Java; whichever call wins
      // freezes the channel config for the lifetime of the channel (immutable
      // after creation on API 26+) and the Capacitor API can't express the
      // long repeating vibration pattern the call channel needs.

      // Persistent listener: update pusher on the server with the (possibly rotated) token.
      // MUST NOT call the full register() flow here — that would call
      // PushNotifications.register() again and re-fire this listener → infinite loop.
      const regHandle = await PushNotifications.addListener('registration', (token) => {
        if (!isPushEnabled()) return;
        if (!cfg?.gatewayUrl) return;
        const appId = cfg.fcmAppId ?? PUSH_APP_IDS.fcm;
        registerFcmPusher(mx, token.value, cfg.gatewayUrl, appId)
          .then((ids) => savePusherIds(ids))
          .catch(noop);
      });
      if (cancelled) {
        regHandle.remove().catch(noop);
      } else {
        cleanups.push(() => {
          regHandle.remove().catch(noop);
        });
      }

      // Display is handled entirely by the native VojoFirebaseMessagingService
      // (which fires for both backgrounded and killed process states via FCM's
      // direct delivery). We intentionally do NOT subscribe to
      // `pushNotificationReceived` here: doing so would create a second incoming
      // call delivery path next to the native service / timeline listener pair.
      // Foreground incoming-call UX is owned by the in-app strip via
      // useIncomingRtcNotifications; background/killed UX is owned by native
      // CallStyle. Keep this invariant unless the whole dedup model changes.

      // Kick off token delivery on startup if push was previously enabled and
      // permission is still granted. The listener above picks up the token.
      if (isPushEnabled()) {
        try {
          const perm = await PushNotifications.checkPermissions();
          if (perm.receive === 'granted') {
            await PushNotifications.register();
          }
        } catch {
          /* ignore */
        }
      }
    })().catch(noop);

    return () => {
      cancelled = true;
      cleanups.forEach((c) => c());
    };
  }, [navigate, navigateRoom, mx, clientConfig, setPendingCallAction]);
}

export { isPushEnabled, getPushPlatform } from '../utils/push';
