// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
// populates `incomingCallsAtom` so the bottom strip can render.
//
// Encrypted DMs: `RoomEvent.Timeline` fires once with
// `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt
// (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline
// and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline
// handler — mirrors CallEmbed.ts:233-234. Dedup relies on `registry.has(key)`
// for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so
// double delivery (Timeline for cleartext + Decrypted for encrypted) is safe.
//
// The `registryRef` sync-effect below handles an asymmetry: REMOVE on the
// atom can come from outside the hook (strip buttons), and we need to drop
// timers/listeners for those keys to avoid leaks and to let a fresh ring with
// the same dedup key re-trigger.

import { useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import {
  EventType,
  MatrixClient,
  MatrixEvent,
  MatrixEventEvent,
  MatrixEventHandlerMap,
  RelationType,
  Room,
  RoomEvent,
  RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types';
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { useMatrixClient } from './useMatrixClient';
import { mDirectAtom } from '../state/mDirectList';
import { callEmbedAtom } from '../state/callEmbed';
import { incomingCallsAtom } from '../state/incomingCalls';
import {
  getIncomingCallKey,
  getNotificationEventSendTs,
  getRtcNotificationLifetime,
  isRtcNotificationExpired,
  RTC_NOTIFICATION_DEFAULT_LIFETIME,
} from '../utils/rtcNotification';
import { callForegroundService } from '../plugins/call/callForegroundService';

// Extract the room-scoped call slot id from a raw membership state event,
// covering both the legacy `m.call.member` shape (SessionMembershipData with
// `call_id` string) and the `m.rtc.member` shape (RtcMembershipData with
// `slot_id` of form `'{application}#{id}'`). Returns the bare id portion;
// caller normalizes the legacy `''` ↔ `'ROOM'` equivalence at the dedup-key
// boundary (see `getIncomingCallKey`). Returns undefined for malformed events.
const extractCallIdFromMembershipEvent = (ev: MatrixEvent): string | undefined => {
  if (ev.getType() === EventType.RTCMembership) {
    const slotId = (ev.getContent() as { slot_id?: unknown }).slot_id;
    if (typeof slotId !== 'string') return undefined;
    const hashIdx = slotId.indexOf('#');
    return hashIdx >= 0 ? slotId.slice(hashIdx + 1) : '';
  }
  return ev.getContent<SessionMembershipData>().call_id ?? '';
};

// Returns the room-scoped slot id ('' for legacy or new with implicit default,
// 'ROOM' after SDK 40.2 compat-hack via senderMembership.slotDescription.id;
// dedup-key normalization handled in getIncomingCallKey). Returns undefined
// when no membership is known. matrix-js-sdk 39+ exposes the slot id as
// `slotDescription.id` parsed from new `slot_id` or computed from legacy
// `call_id`; the runtime `CallMembership.callId` getter was removed.
const findCallIdSync = (
  mx: MatrixClient,
  room: Room,
  sender: string,
  membershipEventId: string
): string | undefined => {
  const session = mx.matrixRTC.getRoomSession(room);
  const senderMembership = session.memberships.find((m) => m.userId === sender);
  if (senderMembership) {
    return senderMembership.slotDescription.id;
  }

  // Race-recovery: ring arrived before /sync delivered the membership state
  // event into the SDK session. Fall back to direct state lookup, covering
  // both legacy `m.call.member` and `m.rtc.member` event types so a peer on
  // an MSC4354-capable homeserver doesn't silently produce an empty key here.
  const stateEvents = [
    ...room.currentState.getStateEvents(EventType.GroupCallMemberPrefix),
    ...room.currentState.getStateEvents(EventType.RTCMembership),
  ];
  const senderStateEv = stateEvents.find((ev) => ev.getSender() === sender);
  if (senderStateEv) {
    return extractCallIdFromMembershipEvent(senderStateEv);
  }

  const timelineEv = room.findEventById(membershipEventId);
  if (timelineEv) {
    return extractCallIdFromMembershipEvent(timelineEv);
  }

  const stateEv = stateEvents.find((ev) => ev.getId() === membershipEventId);
  if (stateEv) {
    return extractCallIdFromMembershipEvent(stateEv);
  }

  return undefined;
};

const resolveCallId = async (
  mx: MatrixClient,
  room: Room,
  sender: string,
  membershipEventId: string
): Promise<string | undefined> => {
  const sync = findCallIdSync(mx, room, sender, membershipEventId);
  if (sync !== undefined) return sync;

  // Race: ring arrived before /sync delivered the membership state event.
  const session = mx.matrixRTC.getRoomSession(room);
  const waited = await new Promise<string | undefined>((resolve) => {
    let timeout: ReturnType<typeof setTimeout>;
    const handler = () => {
      const found = findCallIdSync(mx, room, sender, membershipEventId);
      if (found !== undefined) {
        clearTimeout(timeout);
        session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
        resolve(found);
      }
    };
    timeout = setTimeout(() => {
      session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
      resolve(undefined);
    }, 5000);
    session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
  });
  if (waited !== undefined) return waited;

  try {
    const raw = await mx.fetchRoomEvent(room.roomId, membershipEventId);
    const fetched = new MatrixEvent(raw);
    return extractCallIdFromMembershipEvent(fetched);
  } catch {
    return undefined;
  }
};

type RegistryEntry = {
  roomId: string;
  notifEventId: string;
  // Sender-reported origin timestamp (ms since epoch). Used for
  // latest-wins-by-senderTs on same-room second rings: a late-arriving older
  // ring must not evict a newer one already seated. See processEvent below.
  senderTs: number;
  timer: ReturnType<typeof setTimeout>;
  unsubMemberships?: () => void;
};

export const useIncomingRtcNotifications = (): void => {
  const mx = useMatrixClient();
  const mDirect = useAtomValue(mDirectAtom);
  const callEmbed = useAtomValue(callEmbedAtom);
  const incoming = useAtomValue(incomingCallsAtom);
  const setIncoming = useSetAtom(incomingCallsAtom);

  const mDirectRef = useRef(mDirect);
  mDirectRef.current = mDirect;

  const registryRef = useRef<Map<string, RegistryEntry>>(new Map());

  // Notification IDs whose RTCDecline already arrived — stops a late-decrypted
  // RTCNotification from resurrecting an already-declined ring when decline is
  // cleartext (killed-state receiver path) but notification is encrypted.
  const declinedTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());

  // When local user joins any call (via header / other UI), drop any toast for that room.
  useEffect(() => {
    if (callEmbed) {
      setIncoming({ type: 'REMOVE_BY_ROOM', roomId: callEmbed.roomId });
    }
  }, [callEmbed, setIncoming]);

  // Any key dropped from the atom (external REMOVE via strip accept/decline, etc.)
  // must also drop the matching hook-local registry entry — otherwise its
  // expiry timer and memberships listener leak, and a fresh ring for the same
  // dedup key would be swallowed by `registry.has(key)` until the timer
  // finally fires.
  //
  // Also catches the external-remove paths for the Android native ring
  // registry: removals issued by strip buttons, pendingCallActionConsumer,
  // and the callEmbed join effect go through setIncoming directly without
  // touching the hook-local registry — this effect is the single place we can
  // call removeIncomingRing on the bridge for those paths. Internal removals
  // (removeByKey) call removeIncomingRing inline so they don't rely on this
  // effect seeing the registry entry.
  useEffect(() => {
    const registry = registryRef.current;
    Array.from(registry.keys()).forEach((key) => {
      if (!incoming.has(key)) {
        const entry = registry.get(key);
        if (!entry) return;
        clearTimeout(entry.timer);
        entry.unsubMemberships?.();
        callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
          /* best-effort — registry tombstones the eventId regardless */
        });
        registry.delete(key);
      }
    });
  }, [incoming]);

  useEffect(() => {
    const registry = registryRef.current;
    const declinedTimers = declinedTimersRef.current;

    const removeByKey = (key: string) => {
      const entry = registry.get(key);
      if (!entry) return;
      clearTimeout(entry.timer);
      entry.unsubMemberships?.();
      // Bottom of the internal-removal stack (removeByRoom / removeByNotifId
      // funnel through here). Bridge the native ring registry remove before
      // clearing the hook-local registry: the [incoming] sync-effect above
      // only sees entries still present at atom-change time, so if we
      // deleted here and then setIncoming, the effect would miss this removal
      // and the native registry would keep the entry. External paths (strip
      // buttons, pendingCallActionConsumer) keep registry intact across the
      // atom change and are handled by the sync-effect.
      callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
        /* best-effort — registry tombstones the eventId regardless */
      });
      registry.delete(key);
      setIncoming({ type: 'REMOVE', key });
    };

    const removeByRoom = (roomId: string) => {
      Array.from(registry.entries())
        .filter(([, entry]) => entry.roomId === roomId)
        .forEach(([key]) => removeByKey(key));
    };

    const removeByNotifId = (notifEventId: string) => {
      Array.from(registry.entries())
        .filter(([, entry]) => entry.notifEventId === notifEventId)
        .forEach(([key]) => removeByKey(key));
    };

    const subscribeMemberships = (room: Room): (() => void) => {
      const session = mx.matrixRTC.getRoomSession(room);
      const handler = (_old: CallMembership[], next: CallMembership[]) => {
        if (next.some((m) => m.userId === mx.getUserId())) {
          removeByRoom(room.roomId);
        }
      };
      session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
      return () => session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
    };

    const scheduleExpiry = (key: string, ev: MatrixEvent): ReturnType<typeof setTimeout> => {
      const lifetime = getRtcNotificationLifetime(ev);
      const expireAt = getNotificationEventSendTs(ev) + lifetime;
      const delay = Math.max(0, expireAt - Date.now());
      return setTimeout(() => removeByKey(key), delay);
    };

    const rememberDeclined = (notifEventId: string) => {
      const existing = declinedTimers.get(notifEventId);
      if (existing) clearTimeout(existing);
      const timer = setTimeout(() => {
        declinedTimers.delete(notifEventId);
      }, RTC_NOTIFICATION_DEFAULT_LIFETIME);
      declinedTimers.set(notifEventId, timer);
    };

    const processEvent = async (ev: MatrixEvent, room: Room): Promise<void> => {
      if (ev.getType() === EventType.RTCDecline) {
        const rel = ev.getRelation();
        if (rel?.event_id) {
          rememberDeclined(rel.event_id);
          removeByNotifId(rel.event_id);
          // Explicit forget for the decline-first race: if the notification
          // processEvent hasn't run yet (or hasn't reached ADD because
          // resolveCallId is mid-await), registry doesn't own the notifEventId
          // and removeByNotifId is a no-op. The Java registry may still hold
          // the FCM seed — a render on next backgrounding would resurrect
          // an already-declined ring.
          callForegroundService.removeIncomingRing(rel.event_id).catch(() => {
            /* best-effort */
          });
        }
        return;
      }

      if (ev.getType() !== EventType.RTCNotification) return;
      if (ev.getSender() === mx.getSafeUserId()) return;

      const evId = ev.getId();

      // The Java-side ring registry may already hold an entry for this
      // eventId from an FCM seed that landed before /sync delivered the event
      // to us. Every suppress-return below must remove+tombstone the entry so
      // the next MainActivity.onPause renderRegistry doesn't surface native
      // CallStyle for a ring JS decided not to ring. Paths we don't explicitly
      // remove here (missing evId / malformed rel): registry lifetime check
      // in isExpired remains the only safety net.
      const removeFromRegistry = () => {
        if (evId) {
          callForegroundService.removeIncomingRing(evId).catch(() => {
            /* best-effort */
          });
        }
      };

      const content = ev.getContent<IRTCNotificationContent>();
      // Only DM ring — group call notifications use 'notification' type and are out of scope.
      // FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove.
      if (content.notification_type !== 'ring') return;
      if (!mDirectRef.current.has(room.roomId)) {
        // `mDirectAtom` is hydrated from account-data in a React effect, so it
        // can be empty during cold/warm startup even for a legitimate DM. Keep
        // the native registry alive here: pre-registry behavior was "no JS
        // strip", not "cancel the Android CallStyle".
        return;
      }
      if (isRtcNotificationExpired(ev)) {
        removeFromRegistry();
        return;
      }

      // Already participating in the room session → suppress duplicate toast.
      const session = mx.matrixRTC.getRoomSession(room);
      if (session.memberships.some((m) => m.userId === mx.getUserId())) {
        removeFromRegistry();
        return;
      }

      const rel = ev.getRelation();
      if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return;

      const sender = ev.getSender();
      if (!sender) return;

      if (!evId) return;
      if (declinedTimers.has(evId)) {
        removeFromRegistry();
        return;
      }

      const callId = await resolveCallId(mx, room, sender, rel.event_id);
      if (callId === undefined) {
        removeFromRegistry();
        return;
      }

      // Re-check anything that can change during the await. resolveCallId can
      // yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
      // a membership join or a matching decline can land meanwhile and must be
      // observed before we commit the ADD.
      if (session.memberships.some((m) => m.userId === mx.getUserId())) {
        removeFromRegistry();
        return;
      }
      if (declinedTimers.has(evId)) {
        removeFromRegistry();
        return;
      }

      const key = getIncomingCallKey(callId, room.roomId);
      const senderTs = getNotificationEventSendTs(ev);

      // Latest-wins per-room, compared by senderTs. A late FCM retry /
      // reordered /sync / decrypt lag can deliver an older ring AFTER a
      // newer same-room ring already seated in registry. Naive last-observed
      // wins would let the stale event evict the newer one. Only evict when
      // the incoming event's senderTs is newer than the existing same-room
      // entry's; if incoming is older, drop it (return) so the newer ring
      // keeps the slot. Native registry enforces the symmetric invariant in
      // VojoFirebaseMessagingService.upsertIncomingRing.
      const sameRoomStaler = Array.from(registry.values()).filter(
        (e) => e.roomId === room.roomId && e.notifEventId !== evId
      );
      const incomingIsOlder = sameRoomStaler.some(
        (e) => e.senderTs > 0 && senderTs > 0 && senderTs < e.senderTs
      );
      if (incomingIsOlder) {
        // Stale same-room event — don't ADD, don't evict. Native side will
        // tombstone via its own check when our upsert hits the bridge; here
        // we also bridge the removeIncomingRing so any FCM seed for this
        // stale eventId goes away.
        callForegroundService.removeIncomingRing(evId).catch(() => {
          /* best-effort */
        });
        return;
      }
      Array.from(registry.entries())
        .filter(([, entry]) => entry.roomId === room.roomId && entry.notifEventId !== evId)
        .forEach(([supersededKey]) => removeByKey(supersededKey));

      // Duplicate processEvent invocation (e.g., timeline + decrypted path for
      // the same event). The earlier one owns the ring lifecycle — do NOT
      // remove here or we'd tombstone an eventId the first invocation is
      // about to ADD.
      if (registry.has(key)) return;

      const timer = scheduleExpiry(key, ev);
      const unsubMemberships = subscribeMemberships(room);

      registry.set(key, {
        roomId: room.roomId,
        notifEventId: evId,
        senderTs,
        timer,
        unsubMemberships,
      });

      // Happy-path upsert into the native ring registry. Idempotent
      // with any prior FCM seed for the same eventId — Java merges metadata
      // append-only, so we pass only the fields JS has reliable access to.
      const senderMember = room.getMember(sender);
      const callerName = senderMember?.rawDisplayName || room.name || sender || 'Vojo';
      const lifetime = getRtcNotificationLifetime(ev);
      callForegroundService
        .upsertIncomingRing({
          eventId: evId,
          roomId: room.roomId,
          callerName,
          senderTs,
          lifetime,
        })
        .catch(() => {
          /* best-effort — FCM seed likely already populated it anyway */
        });

      setIncoming({
        type: 'ADD',
        key,
        call: { notifEvent: ev, roomId: room.roomId, callId },
      });
    };

    const handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (
      ev,
      room,
      _toStartOfTimeline,
      _removed,
      data
    ) => {
      if (!data.liveEvent || !room) return;
      // Encrypted events land here as m.room.encrypted; kick decryption and let
      // the Decrypted handler pick them up once the cleartext is available.
      if (ev.isEncrypted()) {
        mx.decryptEventIfNeeded(ev).catch(() => undefined);
        return;
      }
      processEvent(ev, room);
    };

    const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
      if (err) return;
      const roomId = ev.getRoomId();
      if (!roomId) return;
      const room = mx.getRoom(roomId);
      if (!room) return;
      // No liveEvent flag on Decrypted. Backfill safety relies on
      // `isRtcNotificationExpired` (ring branch), `registry.has(key)` dedup,
      // and `declinedTimersRef` (blocks a late-decrypted notification whose
      // decline already landed cleartext-first on the timeline).
      processEvent(ev, room);
    };

    const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
      // v11+ rooms moved `redacts` into content; matrix-js-sdk mirrors the
      // older top-level field when possible but the content fallback is
      // the safety net.
      const redacted =
        ev.event.redacts ?? (ev.getContent() as { redacts?: string } | undefined)?.redacts;
      if (!redacted) return;
      // Symmetric with RTCDecline: if the redaction arrives before
      // the notification's processEvent reaches ADD (race via
      // handleTimeline / handleDecrypted), registry has no entry →
      // removeByNotifId is a no-op, but the Java ring registry may hold
      // an FCM seed that would render on next onPause.
      //   - rememberDeclined blocks any late ADD attempt for this eventId.
      //   - bridge removeIncomingRing tombstones the native-side entry.
      //   - removeByNotifId covers the common case where atom already has it.
      rememberDeclined(redacted);
      removeByNotifId(redacted);
      callForegroundService.removeIncomingRing(redacted).catch(() => {
        /* best-effort */
      });
    };

    const handleSessionEnded = (roomId: string) => {
      removeByRoom(roomId);
    };

    mx.on(RoomEvent.Timeline, handleTimeline);
    mx.on(MatrixEventEvent.Decrypted, handleDecrypted);
    mx.on(RoomEvent.Redaction, handleRedaction);
    mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);

    return () => {
      mx.removeListener(RoomEvent.Timeline, handleTimeline);
      mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
      mx.removeListener(RoomEvent.Redaction, handleRedaction);
      mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
      // Sync the Java ring registry on teardown: process-global state
      // outlives this hook, so any live entry left unremoved will be rendered
      // by the next MainActivity.onPause for a ring no JS owner exists for.
      // Reasons to hit this path: logout, client swap, provider remount,
      // hot-reload in dev. Best-effort bridge — registry tombstones each
      // eventId for the ring lifetime so a late FCM re-delivery can't
      // resurrect the zombie.
      registry.forEach((entry) => {
        clearTimeout(entry.timer);
        entry.unsubMemberships?.();
        callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
          /* best-effort */
        });
      });
      registry.clear();
      declinedTimers.forEach((timer) => clearTimeout(timer));
      declinedTimers.clear();
    };
  }, [mx, setIncoming]);
};
