import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
  ClientEvent,
  ClientEventHandlerMap,
  MatrixEvent,
  Room,
  RoomEvent,
  RoomEventHandlerMap,
  SyncState,
  Thread,
  ThreadEvent,
} from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist';
import { manualDndAtom } from '../../state/manualDnd';
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
import { isWithinTimeWindow } from '../../utils/timeWindow';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import {
  getDirectRoomPath,
  getHomeRoomPath,
  getInboxInvitesPath,
  getOriginBaseUrl,
  withOriginBaseUrl,
} from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
  getMemberName,
  getNotificationType,
  getUnreadInfo,
  isNotificationEvent,
} from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
  MAX_MUTE_TIMEOUT_MS,
  MuteTimerEntry,
  loadMuteTimers,
  unmuteRoom,
} from '../../features/room-nav/RoomNavItem';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { invokeTauri } from '../../hooks/useTauri';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import {
  getThreadNotificationMode,
  shouldNotifyThreadReply,
  THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';


const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');

// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;

function SystemEmojiFeature() {
  const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');

  if (twitterEmoji) {
    document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
  } else {
    document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
  }

  return null;
}

function PageZoomFeature() {
  const [pageZoom] = useSetting(settingsAtom, 'pageZoom');

  if (pageZoom === 100) {
    document.documentElement.style.removeProperty('font-size');
  } else {
    document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`);
  }

  return null;
}

function FaviconUpdater() {
  const roomToUnread = useAtomValue(roomToUnreadAtom);

  useEffect(() => {
    let totalNotif = 0;
    let totalHighlight = 0;
    roomToUnread.forEach((unread) => {
      // roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates
      // (leaves have `from === null`, aggregates a Set). Sum only leaves —
      // otherwise a space-nested room is counted once as the leaf and again in
      // every ancestor space, inflating the tab title / favicon count.
      if (unread.from !== null) return;
      totalNotif += unread.total;
      totalHighlight += unread.highlight;
    });

    if (totalNotif > 0) {
      setFavicon(totalHighlight > 0 ? LogoHighlightSVG : LogoUnreadSVG);
    } else {
      setFavicon(LogoSVG);
    }

    if (totalHighlight > 0) {
      document.title = `(${totalHighlight}) Lotus Chat`;
    } else if (totalNotif > 0) {
      document.title = `· Lotus Chat`;
    } else {
      document.title = 'Lotus Chat';
    }
  }, [roomToUnread]);

  return null;
}

function InviteNotifications() {
  const audioRef = useRef<HTMLAudioElement>(null);
  const invites = useAtomValue(allInvitesAtom);
  const mx = useMatrixClient();
  // Notify only for invites that ARRIVE while the app runs — never for invites
  // already present at load. allInvitesAtom (a string[] of invited room ids)
  // populates asynchronously post-mount from the initial/cached sync, so any count/
  // seed baseline mis-fires on reload. Stay "unarmed" until the initial sync settles
  // (keeping the id baseline synced to whatever loads), then notify only for room
  // ids that first appear AFTER arming.
  const armedRef = useRef(false);
  const knownInviteIdsRef = useRef<Set<string>>(new Set());

  const navigate = useNavigate();
  const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
  const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
  const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
  const focusAssistActive = useAtomValue(focusAssistActiveAtom);
  const manualDnd = useAtomValue(manualDndAtom);
  const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
  const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
  const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
  const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
  const setToast = useSetAtom(toastQueueAtom);

  const soundSrc =
    inviteSoundId !== 'none' ? (NOTIFICATION_SOUND_MAP[inviteSoundId] ?? InviteSound) : null;

  useEffect(() => {
    const el = audioRef.current;
    if (!el) return;
    const source = el.querySelector('source');
    if (source && soundSrc) {
      source.src = soundSrc;
      el.load();
    }
  }, [soundSrc]);

  const notify = useCallback(
    (count: number) => {
      if (document.hasFocus()) {
        setToast({
          id: `invite-${Date.now()}`,
          displayName: 'Invitation',
          body: `You have ${count} new invitation request.`,
          roomName: 'Invites',
          roomId: '',
          hashPath: getInboxInvitesPath(),
        });
        return;
      }

      const invitesPath = getInboxInvitesPath();
      showOsNotification(
        'Invitation',
        {
          icon: LogoSVG,
          badge: LogoSVG,
          body: `You have ${count} new invitation request.`,
          silent: true,
          tag: 'lotus-invites',
          data: { path: invitesPath },
        },
        () => {
          if (!window.closed) navigate(invitesPath);
        },
      );
    },
    [navigate, setToast],
  );

  const playSound = useCallback(() => {
    const audioElement = audioRef.current;
    audioElement?.play();
  }, []);

  // Arm once the client's initial sync has settled (+ a short grace so the async
  // atom population lands first). Until armed, the effect below only tracks the
  // baseline of already-present invites without notifying.
  useEffect(() => {
    let timer: ReturnType<typeof setTimeout> | undefined;
    const arm = () => {
      timer = setTimeout(() => {
        armedRef.current = true;
      }, INVITE_NOTIFY_ARM_DELAY_MS);
    };
    if (mx.getSyncState() === SyncState.Syncing) {
      arm();
      return () => {
        if (timer) clearTimeout(timer);
      };
    }
    const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state) => {
      if (state === SyncState.Syncing) {
        mx.off(ClientEvent.Sync, onSync);
        arm();
      }
    };
    mx.on(ClientEvent.Sync, onSync);
    return () => {
      mx.off(ClientEvent.Sync, onSync);
      if (timer) clearTimeout(timer);
    };
  }, [mx]);

  useEffect(() => {
    const currentIds = new Set(invites);
    if (!armedRef.current) {
      // Not yet armed: keep the baseline synced to whatever the initial sync loads.
      knownInviteIdsRef.current = currentIds;
      return;
    }
    const newCount = invites.filter((id) => !knownInviteIdsRef.current.has(id)).length;
    knownInviteIdsRef.current = currentIds;
    if (newCount <= 0) return;

    const quietActive =
      focusAssistActive ||
      manualDnd ||
      isSnoozeActive(snoozeUntil) ||
      (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd));
    if (quietActive) return;

    if (showNotifications && notificationPermission('granted')) {
      notify(newCount);
    }
    if (notificationSound && inviteSoundId !== 'none') {
      playSound();
    }
  }, [
    invites,
    showNotifications,
    notificationSound,
    notify,
    playSound,
    quietHoursEnabled,
    quietHoursStart,
    quietHoursEnd,
    focusAssistActive,
    manualDnd,
    snoozeUntil,
    inviteSoundId,
  ]);

  return (
    <audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
      <source src={soundSrc ?? InviteSound} type="audio/ogg" />
    </audio>
  );
}

function PresenceUpdater() {
  usePresenceUpdater();
  return null;
}

// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
// lost and the room stays muted forever. On boot: unmute anything already
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
function MuteTimerRestore() {
  const mx = useMatrixClient();

  useEffect(() => {
    const timers = loadMuteTimers();
    if (timers.length === 0) return undefined;

    const now = Date.now();
    const pastDue: MuteTimerEntry[] = [];
    const future: MuteTimerEntry[] = [];
    timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));

    pastDue.forEach((entry) => {
      unmuteRoom(mx, entry.roomId);
    });

    const handles = future.map((entry) =>
      setTimeout(
        () => {
          unmuteRoom(mx, entry.roomId);
        },
        Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
      ),
    );

    return () => {
      handles.forEach(clearTimeout);
    };
  }, [mx]);

  return null;
}

// Fires the custom-status auto-clear even when Settings→Profile is closed. The
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
// the status never cleared. This always-mounted watcher polls the persisted
// expiry key and clears (preserving the user's chosen presence) when due.
function StatusExpiryMonitor() {
  const mx = useMatrixClient();
  const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
  const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
  // Read latest settings via refs so the poll interval isn't torn down/restarted
  // (resetting its countdown) whenever the presence setting changes.
  const presenceStatusRef = useRef(presenceStatus);
  presenceStatusRef.current = presenceStatus;
  const hidePresenceRef = useRef(hidePresence);
  hidePresenceRef.current = hidePresence;

  useEffect(() => {
    const userId = mx.getUserId();
    if (!userId) return undefined;
    const expiryKey = STATUS_EXPIRY_KEY(userId);
    const msgKey = STATUS_MSG_KEY(userId);

    const check = () => {
      const stored = localStorage.getItem(expiryKey);
      if (!stored) return;
      const ts = parseInt(stored, 10);
      if (!ts || Date.now() < ts) return;
      localStorage.removeItem(msgKey);
      localStorage.removeItem(expiryKey);
      mx.setPresence({
        presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
        status_msg: '',
      }).catch(() => undefined);
    };

    check();
    const interval = setInterval(check, 30_000);
    const onVisible = () => {
      if (document.visibilityState === 'visible') check();
    };
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      clearInterval(interval);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [mx]);

  return null;
}

function MessageNotifications() {
  const audioRef = useRef<HTMLAudioElement>(null);
  // Notify dedupe, keyed `${roomId}|${threadId ?? 'main'}` -> last notified
  // eventId, so the main timeline and each thread dedupe independently.
  const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
  // Per-thread dedupe (thread-detection gate): threadId -> last notified eventId.
  const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
  const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
  const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
  const focusAssistActive = useAtomValue(focusAssistActiveAtom);
  const manualDnd = useAtomValue(manualDndAtom);
  const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
  const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
  const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
  const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
  const setToast = useSetAtom(toastQueueAtom);
  const mDirects = useAtomValue(mDirectAtom);

  const soundSrc =
    messageSoundId !== 'none'
      ? (NOTIFICATION_SOUND_MAP[messageSoundId] ?? NotificationSound)
      : null;

  useEffect(() => {
    const el = audioRef.current;
    if (!el) return;
    const source = el.querySelector('source');
    if (source && soundSrc) {
      source.src = soundSrc;
      el.load();
    }
  }, [soundSrc]);

  const navigate = useNavigate();
  const notificationSelected = useInboxNotificationsSelected();
  const selectedRoomId = useSelectedRoom();
  const threadPrefs = useAtomValue(threadNotificationsAtom);
  const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? ''));

  const notify = useCallback(
    ({
      roomName,
      roomAvatar,
      username,
      roomId,
      eventId,
      body,
      encrypted,
      threadId,
    }: {
      roomName: string;
      roomAvatar?: string;
      username: string;
      roomId: string;
      eventId: string;
      body?: string;
      encrypted?: boolean;
      threadId?: string;
    }) => {
      const roomPath = mDirects.has(roomId)
        ? getDirectRoomPath(roomId, eventId)
        : getHomeRoomPath(roomId, eventId);

      if (document.hasFocus()) {
        setToast({
          id: `${roomId}-${eventId}-${Date.now()}`,
          avatarUrl: roomAvatar,
          displayName: username,
          body: (body ?? '').slice(0, 80),
          roomName,
          roomId,
          hashPath: roomPath,
        });
        return;
      }

      // N109: the OS notification subsystem fetches icon/badge OUTSIDE the page,
      // so the SW can't inject auth headers and authenticated-media URLs 401.
      // Use the static app logo (as invite notifications already do).
      // N106: never put decrypted E2EE plaintext into the OS notification (it
      // persists in the notification center / lock screen / is readable by other
      // apps). For encrypted rooms show only the sender; the in-page toast above
      // still shows the preview while the user is actively looking at the screen.
      showOsNotification(
        roomName,
        {
          icon: LogoSVG,
          badge: LogoSVG,
          body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
          silent: true,
          // Coalesce repeated notifications for the same room (replaces the old
          // manual notifRef.close() dedup, which a SW notification can't hold).
          // For thread replies widen the tag to room:thread so each thread
          // coalesces independently instead of clobbering the room's bucket.
          tag: threadId ? `${roomId}:${threadId}` : roomId,
          data: { path: roomPath },
        },
        () => {
          window.focus();
          navigate(roomPath);
        },
      );
    },
    [navigate, setToast, mDirects],
  );

  // N105: when a service-worker-owned notification is clicked, the SW focuses
  // this tab and forwards the target path here so we can route to it (works even
  // when the click happened while the tab was in the background / reopened).
  useEffect(() => {
    if (!('serviceWorker' in navigator)) return undefined;
    const onMessage = (event: MessageEvent) => {
      const data = event.data ?? {};
      if (data.type === 'notificationClick' && typeof data.path === 'string') {
        // On desktop, raise the native window — a service-worker/WebView2
        // `client.focus()` only focuses the document, not the OS window. No-op
        // outside Tauri.
        invokeTauri('focus_main_window');
        navigate(data.path);
      }
    };
    navigator.serviceWorker.addEventListener('message', onMessage);
    return () => navigator.serviceWorker.removeEventListener('message', onMessage);
  }, [navigate]);

  const playSound = useCallback(() => {
    const audioElement = audioRef.current;
    audioElement?.play();
  }, []);

  // Shared delivery tail for both the main timeline and per-thread paths:
  // room-level unread dedup → avatar resolution → OS/toast notify → sound, all
  // behind the quiet-hours / focus-assist gate. `threadId` (when set) widens the
  // OS coalescing tag so each thread notifies independently; the click path
  // stays the room path (RoomTimeline deep-links thread events into the panel).
  const deliverNotification = useCallback(
    (room: Room, mEvent: MatrixEvent, threadId?: string) => {
      const sender = mEvent.getSender();
      const eventId = mEvent.getId();
      if (!sender || !eventId) return;

      // Dedupe on the event id (per room AND per path): the same event can
      // re-fire (decryption, edit, thread repopulation). The main timeline and
      // each thread get their own slot — a shared per-room slot let a thread
      // reply overwrite the main slot, so a re-fired main message then mismatched
      // and double-notified. This replaces the old unread-COUNT dedupe, which
      // suppressed a genuinely-new message whenever its post-read count matched
      // the previously-notified count (the "read a DM, next message never
      // notifies/sounds" one-at-a-time cadence).
      const dedupeKey = `${room.roomId}|${threadId ?? 'main'}`;
      if (lastNotifiedEventRef.current.get(dedupeKey) === eventId) return;

      // Main-timeline path respects push rules: don't notify when the room has no
      // notification count (e.g. a non-mention in a Mentions-only room). The
      // thread path is already gated by shouldNotifyThreadReply, so it must NOT
      // re-gate on the room count — otherwise an explicit per-thread "All replies"
      // override in a Mentions-only room is silently dropped.
      if (!threadId && getUnreadInfo(room, undefined, mx).total === 0) return;

      lastNotifiedEventRef.current.set(dedupeKey, eventId);

      const quietActive =
        focusAssistActive ||
        manualDnd ||
        isSnoozeActive(snoozeUntil) ||
        (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd));
      if (quietActive) return;

      if (showNotifications && notificationPermission('granted')) {
        const avatarMxc =
          room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
        notify({
          roomName: room.name ?? 'Unknown',
          roomAvatar: avatarMxc
            ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
            : undefined,
          username: getMemberName(room, sender),
          roomId: room.roomId,
          eventId,
          body: (mEvent.getContent().body as string | undefined) ?? '',
          encrypted: room.hasEncryptionStateEvent(),
          threadId,
        });
      }

      if (notificationSound && messageSoundId !== 'none') {
        playSound();
      }
    },
    [
      mx,
      notify,
      playSound,
      showNotifications,
      notificationSound,
      useAuthentication,
      quietHoursEnabled,
      quietHoursStart,
      quietHoursEnd,
      focusAssistActive,
      manualDnd,
      snoozeUntil,
      messageSoundId,
    ],
  );

  useEffect(() => {
    const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
      mEvent,
      room,
      toStartOfTimeline,
      removed,
      data,
    ) => {
      if (mx.getSyncState() !== 'SYNCING') return;
      if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
      if (
        !room ||
        !data.liveEvent ||
        room.isSpaceRoom() ||
        !isNotificationEvent(mEvent) ||
        getNotificationType(mx, room.roomId) === NotificationType.Mute
      ) {
        return;
      }

      const sender = mEvent.getSender();
      const eventId = mEvent.getId();
      if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
      // Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply
      // handler below (per-thread gating), so ignore them here — a reply notifies once.
      if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;

      deliverNotification(room, mEvent);
    };
    mx.on(RoomEvent.Timeline, handleTimelineEvent);
    return () => {
      mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
    };
  }, [mx, notificationSelected, selectedRoomId, deliverNotification]);

  const handleNewReply = useCallback(
    // useRoomsListener prepends the emitting Room; the thread's own room lookup
    // below is kept as the authority (identical object in practice).
    (_room: Room, thread: Thread, mEvent: MatrixEvent) => {
      if (mx.getSyncState() !== 'SYNCING') return;
      const room = mx.getRoom(thread.roomId);
      if (!room || room.isSpaceRoom()) return;
      if (!isNotificationEvent(mEvent) || mEvent.isSending()) return;
      const sender = mEvent.getSender();
      if (!sender || sender === mx.getUserId()) return;
      // Suppress when the user is actively looking at this thread (or the inbox).
      if (
        document.hasFocus() &&
        (notificationSelected || (selectedRoomId === thread.roomId && activeThreadId === thread.id))
      ) {
        return;
      }

      // Per-thread dedupe: a NewReply can re-fire for the same event as the
      // thread (re)populates; notify at most once per (thread, event).
      const eventId = mEvent.getId();
      if (eventId) {
        if (lastNotifiedThreadRef.current.get(thread.id) === eventId) return;
        lastNotifiedThreadRef.current.set(thread.id, eventId);
      }

      const content = threadPrefs;
      const mode = getThreadNotificationMode(content, room.roomId, thread.id);
      const actions = mx.getPushActionsForEvent(mEvent);
      // `hasCurrentUserParticipated` is derived from the server thread bundle,
      // which lags a reply we just sent — so also treat any of our own events
      // already in the thread timeline as participation (T5: avoid under-notify).
      const myUserId = mx.getUserId();
      const participated =
        thread.hasCurrentUserParticipated ||
        thread.timeline.some((e) => e.getSender() === myUserId);
      const roomNotifType = getNotificationType(mx, room.roomId);
      const decision = shouldNotifyThreadReply({
        mode,
        defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
        participated,
        highlight: !!actions?.tweaks?.highlight,
        notify: !!actions?.notify,
        roomMuted: roomNotifType === NotificationType.Mute,
        // T6: honor a room-level "Mentions & Keywords only" setting for Default
        // threads instead of over-notifying every participated reply.
        roomMentionsOnly: roomNotifType === NotificationType.MentionsAndKeywords,
      });
      if (decision === 'none') return;

      // E2EE caveat: NewReply can fire before decryption, so MentionsOnly may
      // under-notify in encrypted rooms (same class as the main timeline path).
      // Plaintext body suppression for encrypted rooms is handled inside notify().
      deliverNotification(room, mEvent, thread.id);
    },
    [mx, notificationSelected, selectedRoomId, activeThreadId, threadPrefs, deliverNotification],
  );
  useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);

  return (
    <audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
      <source src={soundSrc ?? NotificationSound} type="audio/ogg" />
    </audio>
  );
}

type ClientNonUIFeaturesProps = {
  children: ReactNode;
};

function DeepLinkNavigator() {
  useDeepLinkNavigate();
  return null;
}

function ReminderMonitor() {
  const mx = useMatrixClient();
  const { reminders, removeReminder } = useReminders();
  const setToast = useSetAtom(toastQueueAtom);
  const mDirects = useAtomValue(mDirectAtom);
  const firedRef = useRef<Set<string>>(new Set());
  const removingRef = useRef<Set<string>>(new Set());
  // Read the latest reminders / DM map via refs so the poll interval below is
  // created once — not torn down and restarted (which resets its 30s countdown
  // and can indefinitely defer a near-due reminder) on every reminder sync (N115).
  const remindersRef = useRef(reminders);
  remindersRef.current = reminders;
  const mDirectsRef = useRef(mDirects);
  mDirectsRef.current = mDirects;

  useEffect(() => {
    const check = () => {
      const now = Date.now();
      remindersRef.current.forEach((r) => {
        if (r.timestamp > now) return;
        const key = `${r.eventId}-${r.timestamp}`;
        // Show the toast exactly once.
        if (!firedRef.current.has(key)) {
          firedRef.current.add(key);
          const room = mx.getRoom(r.roomId);
          const hashPath = mDirectsRef.current.has(r.roomId)
            ? getDirectRoomPath(r.roomId, r.eventId)
            : getHomeRoomPath(r.roomId, r.eventId);
          setToast({
            id: `reminder-${key}`,
            displayName: 'Reminder',
            body: r.message,
            roomName: room?.name ?? 'Unknown Room',
            roomId: r.roomId,
            hashPath,
          });
        }
        // Persist the removal, retrying on a later tick if it fails — without
        // re-showing the toast (N114). The server echo drops it from
        // `reminders` once the write lands.
        if (!removingRef.current.has(key)) {
          removingRef.current.add(key);
          removeReminder(r.eventId, r.timestamp).catch(() => {
            removingRef.current.delete(key);
          });
        }
      });
    };

    check();
    const interval = setInterval(check, 30_000);
    const onVisible = () => {
      if (document.visibilityState === 'visible') check();
    };
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      clearInterval(interval);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [mx, setToast, removeReminder]);

  return null;
}

// MSC1763: opt-in local enforcement of room retention. When enabled, permanently
// redacts the user's OWN messages once a room's retention window passes. Own-only
// (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight
// redactions and retries on the next tick. Default-off, so nothing auto-deletes
// unless the user turns it on.
function RetentionSweeper() {
  const mx = useMatrixClient();
  const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally');
  const enabledRef = useRef(enforceRetentionLocally);
  enabledRef.current = enforceRetentionLocally;
  const redactingRef = useRef<Set<string>>(new Set());

  useEffect(() => {
    const check = () => {
      if (!enabledRef.current) return;
      const myId = mx.getUserId();
      if (!myId) return;
      const now = Date.now();
      mx.getRooms().forEach((room) => {
        const maxLifetime = getRoomRetentionMs(room);
        if (!maxLifetime) return;
        room
          .getLiveTimeline()
          .getEvents()
          .forEach((ev) => {
            const evId = ev.getId();
            if (!evId || ev.getSender() !== myId) return;
            if (ev.isState() || ev.isRedacted() || ev.isSending()) return;
            const t = ev.getType();
            // Only actual messages — never our membership/topic/reactions.
            if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return;
            if (!isExpired(ev.getTs(), maxLifetime, now)) return;
            if (redactingRef.current.has(evId)) return;
            redactingRef.current.add(evId);
            mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => {
              redactingRef.current.delete(evId);
            });
          });
      });
    };

    check();
    const interval = setInterval(check, 30_000);
    const onVisible = () => {
      if (document.visibilityState === 'visible') check();
    };
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      clearInterval(interval);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [mx]);

  return null;
}

const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';

function TauriUpdateFeature() {
  const { isTauri, status, check, install } = useTauriUpdater();
  const setToast = useSetAtom(toastQueueAtom);
  const firedRef = useRef<string | null>(null);

  useEffect(() => {
    if (!isTauri) return;

    const runCheck = () => {
      const last = Number(localStorage.getItem(TAURI_UPDATE_LAST_CHECK_KEY) ?? '0');
      if (Date.now() - last < TAURI_UPDATE_CHECK_INTERVAL) return;
      localStorage.setItem(TAURI_UPDATE_LAST_CHECK_KEY, String(Date.now()));
      check();
    };

    runCheck();
    const interval = setInterval(runCheck, TAURI_UPDATE_CHECK_INTERVAL);
    return () => clearInterval(interval);
  }, [isTauri, check]);

  useEffect(() => {
    if (status.state !== 'available') return;
    if (firedRef.current === status.version) return;
    firedRef.current = status.version;
    setToast({
      id: `tauri-update-${status.version}`,
      displayName: '⬆ Update Available',
      body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
      roomName: 'System',
      roomId: '',
      onClick: install,
      sticky: true,
    });
  }, [status, setToast, install]);

  return null;
}

function LotusDenoiseFeature() {
  const setToast = useSetAtom(toastQueueAtom);

  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      if (event.data?.type === 'lotus-denoise-status') {
        const { active, error } = event.data;
        if (!active) {
          setToast({
            id: `denoise-fail-${Date.now()}`,
            displayName: 'Audio Quality',
            body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`,
            roomName: 'System',
            roomId: '',
          });
        }
      }
    };

    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, [setToast]);

  return null;
}

// Registers the global `?` shortcut (ignored while typing) and renders the
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
function KeyboardShortcutsFeature() {
  useKeyboardShortcutsTrigger();
  return <KeyboardShortcutsDialog />;
}

function MsgDraftHydrator(): null {
  useHydrateMsgDrafts();
  return null;
}

export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
  return (
    <>
      <MsgDraftHydrator />
      <SystemEmojiFeature />
      <PageZoomFeature />
      <FaviconUpdater />
      <PresenceUpdater />
      <MuteTimerRestore />
      <StatusExpiryMonitor />
      <InviteNotifications />
      <MessageNotifications />
      <ReminderMonitor />
      <RetentionSweeper />
      <TauriUpdateFeature />
      <TauriDesktopFeatures />
      <LotusDenoiseFeature />
      <DeepLinkNavigator />
      <KeyboardShortcutsFeature />
      {children}
    </>
  );
}
