// DM call auto-hangup.
//
// Scope despite the name: fires on any DM callEmbed, including when we're the
// B-side (answered a ring). Logic is symmetric: "leave if peer left or never
// arrived". Rename pending.
//
// Covers four cases:
//   1. Peer declines — RTCDecline timeline event → hangup immediately.
//   2. Peer never joins — no-answer timer fires (lifetime + grace).
//   3. Peer joins then leaves — memberships go empty → hangup after grace.
//   4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
//
// KNOWN GAP: grace constants are empirically chosen, may need tuning with
// real-world /sync + LiveKit reconnect metrics.
//
// Encrypted DMs: RTCDecline arrives as m.room.encrypted first and Timeline
// does not re-emit post-decrypt (matrix-js-sdk 38.2). We mirror the
// CallEmbed.ts:233-234 pattern — listen to both Timeline (kick decryption)
// and MatrixEventEvent.Decrypted. `performHangup` is guarded by `disposed`,
// so double delivery for cleartext rooms is a no-op.

import { useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import {
  EventType,
  MatrixEvent,
  MatrixEventEvent,
  MatrixEventHandlerMap,
  RoomEvent,
  RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types';
import { useMatrixClient } from './useMatrixClient';
import { callEmbedAtom } from '../state/callEmbed';
import { mDirectAtom } from '../state/mDirectList';
import { RTC_NOTIFICATION_DEFAULT_LIFETIME } from '../utils/rtcNotification';

// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync
// latency between B joining and A seeing the membership state event.
const NO_ANSWER_GRACE_MS = 10_000;

// Wait after the peer's last membership drops before tearing down. Matrix RTC
// memberships can flap on LiveKit reconnect / short network hiccups; without a
// grace we'd kill a live call on every blip.
const PEER_LEAVE_GRACE_MS = 8_000;

const findLatestOwnRingNotifEvent = (
  selfId: string,
  events: MatrixEvent[]
): MatrixEvent | undefined => {
  for (let i = events.length - 1; i >= 0; i -= 1) {
    const ev = events[i];
    if (ev.getSender() === selfId && ev.getType() === EventType.RTCNotification) {
      const content = ev.getContent<IRTCNotificationContent>();
      if (content.notification_type === 'ring') return ev;
    }
  }

  return undefined;
};

export const useCallerAutoHangup = (): void => {
  const mx = useMatrixClient();
  const callEmbed = useAtomValue(callEmbedAtom);
  const setCallEmbed = useSetAtom(callEmbedAtom);
  const mDirect = useAtomValue(mDirectAtom);

  useEffect(() => {
    if (!callEmbed) return undefined;
    const { roomId } = callEmbed;
    if (!mDirect.has(roomId)) return undefined;

    const selfId = mx.getUserId();
    if (!selfId) return undefined;
    const selfDeviceId = mx.getDeviceId();

    const isSelf = (m: CallMembership): boolean =>
      m.userId === selfId && (!selfDeviceId || m.deviceId === selfDeviceId);

    const session = mx.matrixRTC.getRoomSession(callEmbed.room);

    let disposed = false;
    let peerSeen = session.memberships.some((m) => !isSelf(m));
    let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined;
    let ownRingNotifEvent = findLatestOwnRingNotifEvent(
      selfId,
      callEmbed.room.getLiveTimeline()?.getEvents() ?? []
    );
    let pendingDeclineForNotifEventId: string | undefined;

    const performHangup = () => {
      if (disposed) return;
      disposed = true;
      if (callEmbed.joined) {
        callEmbed.hangup().catch(() => setCallEmbed(undefined));
      } else {
        setCallEmbed(undefined);
      }
    };

    const clearPeerLeaveTimer = () => {
      if (peerLeaveTimer) {
        clearTimeout(peerLeaveTimer);
        peerLeaveTimer = undefined;
      }
    };

    const noAnswerTimer: ReturnType<typeof setTimeout> | undefined = peerSeen
      ? undefined
      : setTimeout(performHangup, RTC_NOTIFICATION_DEFAULT_LIFETIME + NO_ANSWER_GRACE_MS);

    const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => {
      const peerPresent = next.some((m) => !isSelf(m));
      if (peerPresent) {
        clearPeerLeaveTimer();
        if (!peerSeen) {
          peerSeen = true;
          if (noAnswerTimer) clearTimeout(noAnswerTimer);
        }
        return;
      }
      if (peerSeen && !peerLeaveTimer) {
        peerLeaveTimer = setTimeout(performHangup, PEER_LEAVE_GRACE_MS);
      }
    };

    const maybeTrackOwnRing = (ev: MatrixEvent): void => {
      if (ev.getRoomId() !== roomId) return;
      if (ev.getType() !== EventType.RTCNotification) return;
      if (ev.getSender() !== selfId) return;
      const content = ev.getContent<IRTCNotificationContent>();
      if (content.notification_type !== 'ring') return;
      ownRingNotifEvent = ev;
      if (pendingDeclineForNotifEventId === ev.getId()) {
        performHangup();
      }
    };

    const maybeHangupOnDecline = (ev: MatrixEvent): void => {
      // onDecrypted fires for every decrypted event client-wide; gate on room
      // first to avoid walking type/sender on unrelated rooms.
      if (ev.getRoomId() !== roomId) return;
      if (ev.getType() !== EventType.RTCDecline) return;
      if (ev.getSender() === selfId) return;
      const declinedNotifEventId = ev.getRelation()?.event_id;
      if (!declinedNotifEventId) return;

      // Same-room retries can leave multiple historical ring events behind.
      // We only honor a decline that targets the ring this embed most recently
      // sent; older declines must not tear down the new attempt.
      if (!ownRingNotifEvent) {
        pendingDeclineForNotifEventId = declinedNotifEventId;
        return;
      }
      if (declinedNotifEventId !== ownRingNotifEvent.getId()) {
        if (ownRingNotifEvent.getAssociatedStatus() !== null) {
          pendingDeclineForNotifEventId = declinedNotifEventId;
        }
        return;
      }
      performHangup();
    };

    const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => {
      if (!data.liveEvent || !room) return;
      if (room.roomId !== roomId) return;
      // Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted
      // handler see it once cleartext is available.
      if (ev.isEncrypted()) {
        mx.decryptEventIfNeeded(ev).catch(() => undefined);
        return;
      }
      maybeTrackOwnRing(ev);
      maybeHangupOnDecline(ev);
    };

    const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
      if (err) return;
      maybeTrackOwnRing(ev);
      maybeHangupOnDecline(ev);
    };

    const onLocalEchoUpdated: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (ev, room) => {
      if (!room || room.roomId !== roomId) return;
      if (ev !== ownRingNotifEvent) return;
      if (pendingDeclineForNotifEventId !== ev.getId()) return;
      performHangup();
    };

    session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
    mx.on(RoomEvent.Timeline, onTimeline);
    mx.on(MatrixEventEvent.Decrypted, onDecrypted);
    mx.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);

    return () => {
      disposed = true;
      if (noAnswerTimer) clearTimeout(noAnswerTimer);
      clearPeerLeaveTimer();
      session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
      mx.removeListener(RoomEvent.Timeline, onTimeline);
      mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
      mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
    };
  }, [mx, callEmbed, mDirect, setCallEmbed]);
};
