import {
  type ConversationSummary,
  isConversationMuted,
  listConversations,
} from '@chat-app/shared/chat';
import {
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { playNotificationTone } from '../lib/notificationSound';
import { notify } from '../lib/osNotify';
import { supabase } from '../lib/supabase';
import { updateTrayUnread } from '../lib/trayBadge';
import { getIsWindowFocused, subscribeWindowFocus } from '../lib/windowFocus';
import { useAuth } from './AuthContext';

const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
const EPOCH = new Date(0).toISOString();

type LastReadMap = Record<string, string>;

function loadLastReadMap(): LastReadMap {
  try {
    const raw = window.localStorage.getItem(LAST_READ_STORAGE_KEY);
    if (!raw) return {};
    const parsed = JSON.parse(raw) as unknown;
    return parsed && typeof parsed === 'object' ? (parsed as LastReadMap) : {};
  } catch {
    return {};
  }
}

function saveLastReadMap(map: LastReadMap): void {
  try {
    window.localStorage.setItem(LAST_READ_STORAGE_KEY, JSON.stringify(map));
  } catch {
    /* quota / private mode */
  }
}

interface ConversationsContextValue {
  conversations: ConversationSummary[];
  loading: boolean;
  error: string | null;
  unread: Record<string, number>;
  totalUnread: number;
  refresh: () => Promise<void>;
  markRead: (conversationId: string) => void;
  setActiveConversation: (conversationId: string | null) => void;
}

const ConversationsContext = createContext<ConversationsContextValue | null>(null);

export function ConversationsProvider({ children }: { children: ReactNode }) {
  const { session, profile } = useAuth();
  const myId = session?.user.id;

  const [conversations, setConversations] = useState<ConversationSummary[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [unread, setUnread] = useState<Record<string, number>>({});

  const lastReadRef = useRef<LastReadMap>(loadLastReadMap());
  const activeConvIdRef = useRef<string | null>(null);
  const presenceRef = useRef(profile?.presenceState ?? 'offline');
  // Ref-mirrored window-focus state so the realtime message handler can
  // read it synchronously without triggering a re-render per focus flip.
  // "App focused" = our window is the foreground window in the OS,
  // which is what we actually care about when deciding whether an
  // arriving message was seen by the user or should ping.
  const isWindowFocusedRef = useRef<boolean>(getIsWindowFocused());
  const conversationsRef = useRef<ConversationSummary[]>([]);
  conversationsRef.current = conversations;

  useEffect(() => {
    presenceRef.current = profile?.presenceState ?? 'offline';
  }, [profile?.presenceState]);

  useEffect(() => {
    return subscribeWindowFocus((focused) => {
      isWindowFocusedRef.current = focused;
      // Regaining focus on the active conversation: the user is back
      // and looking at those messages, so flip the unread counter to
      // zero and persist the last-read stamp. Without this, opening
      // the app after being away leaves a stale badge until the user
      // re-clicks the conversation.
      if (focused) {
        const active = activeConvIdRef.current;
        if (active) {
          const now = new Date().toISOString();
          lastReadRef.current[active] = now;
          saveLastReadMap(lastReadRef.current);
          setUnread((prev) => (prev[active] ? { ...prev, [active]: 0 } : prev));
        }
      }
    });
  }, []);

  const computeUnreadForConv = useCallback(
    async (convId: string): Promise<number> => {
      if (!myId) return 0;
      const since = lastReadRef.current[convId] ?? EPOCH;
      const { count, error: cErr } = await supabase
        .from('messages')
        .select('id', { count: 'exact', head: true })
        .eq('conversation_id', convId)
        .gt('created_at', since)
        .neq('sender_id', myId);
      if (cErr) return 0;
      return count ?? 0;
    },
    [myId],
  );

  const refresh = useCallback(async () => {
    if (!myId) {
      setConversations([]);
      setUnread({});
      setLoading(false);
      return;
    }
    try {
      // Only flag loading on initial fetch so background re-syncs (visibility
      // change, online event) don't blank the list each time.
      setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
      const convs = await listConversations(supabase);
      setConversations(convs);
      const entries = await Promise.all(
        convs.map(async (c) => [c.id, await computeUnreadForConv(c.id)] as const),
      );
      const next: Record<string, number> = {};
      for (const [id, count] of entries) {
        // Active conversation is always read.
        next[id] = id === activeConvIdRef.current ? 0 : count;
      }
      setUnread(next);
      setError(null);
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : 'failed to load conversations');
    } finally {
      setLoading(false);
    }
  }, [myId, computeUnreadForConv]);

  const markRead = useCallback((convId: string) => {
    const now = new Date().toISOString();
    lastReadRef.current[convId] = now;
    saveLastReadMap(lastReadRef.current);
    setUnread((prev) => (prev[convId] ? { ...prev, [convId]: 0 } : prev));
  }, []);

  const setActiveConversation = useCallback(
    (convId: string | null) => {
      activeConvIdRef.current = convId;
      if (convId) markRead(convId);
    },
    [markRead],
  );

  useEffect(() => {
    if (!myId) {
      setConversations([]);
      setUnread({});
      setLoading(false);
      return;
    }
    void refresh();

    const channel = supabase
      .channel('conv-ctx:' + myId)
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'conversation_members' },
        () => {
          void refresh();
        },
      )
      .on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
        void refresh();
      })
      .on(
        'postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'profiles' },
        () => {
          // Peer updated their profile (e.g. uploaded avatar / changed name).
          // Re-pull conversations so members[].profile picks up the new data.
          void refresh();
        },
      )
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages' },
        (payload: { new: Record<string, unknown> }) => {
          const row = payload.new as unknown as { conversation_id: string; sender_id: string };
          const fromSelf = row.sender_id === myId;
          const active = row.conversation_id === activeConvIdRef.current;
          const appFocused = isWindowFocusedRef.current;
          // Only suppress sound + unread bump when the user is *actually*
          // looking at the conversation — i.e. the app window is in the
          // foreground AND the active conversation matches. Having the
          // app parked on monitor 2 while the user is in-game still
          // counts as "they didn't see it."
          const seenByUser = active && appFocused;

          if (!fromSelf) {
            if (seenByUser) {
              markRead(row.conversation_id);
            } else {
              setUnread((prev) => ({
                ...prev,
                [row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
              }));
              // Notification sound + OS notification — respect DND and
              // per-conversation mute. Body stays empty because message
              // content is E2E-encrypted and only decryptable in the
              // conversation view (not at this hook level).
              const convForMute = conversationsRef.current.find(
                (c) => c.id === row.conversation_id,
              );
              const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
              if (presenceRef.current !== 'dnd' && !muted) {
                playNotificationTone();
                const conv = conversationsRef.current.find(
                  (c) => c.id === row.conversation_id,
                );
                const sender = conv?.members.find(
                  (m) => m.userId === row.sender_id,
                )?.profile;
                const senderName = sender?.displayName ?? '…';
                const title =
                  conv?.type === 'group'
                    ? (conv.name ?? 'Neue Nachricht') + ' · ' + senderName
                    : senderName;
                void notify({ title, body: 'Neue Nachricht' });
              }
            }
          }

          // Re-pull conversations to update lastMessageAt ordering.
          void refresh();
        },
      )
      .subscribe();

    // Windows WebView2 aggressively throttles background WebSockets and
    // sometimes drops events entirely while the window is minimised. Force a
    // refresh + realtime reconnect on visibility/focus regain so we never
    // leave stale conversation lists on a Windows client after the user
    // returns to the app.
    let lastAwakeRefresh = 0;
    const AWAKE_THROTTLE_MS = 30_000;
    const onAwake = () => {
      if (document.visibilityState !== 'visible') return;
      const now = Date.now();
      if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
      lastAwakeRefresh = now;
      void refresh();
      try {
        // If the socket got wedged during background throttle, a no-op
        // unsubscribe+resubscribe brings it back. `subscribe()` on an already
        // joined channel is a no-op so this is safe.
        channel.subscribe();
      } catch {
        /* ignore — already live */
      }
    };
    document.addEventListener('visibilitychange', onAwake);
    window.addEventListener('online', onAwake);

    return () => {
      document.removeEventListener('visibilitychange', onAwake);
      window.removeEventListener('online', onAwake);
      void supabase.removeChannel(channel);
    };
  }, [myId, refresh, markRead]);

  const totalUnread = useMemo(() => {
    let s = 0;
    for (const v of Object.values(unread)) s += v;
    return s;
  }, [unread]);

  // Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
  // only; no-op in browser-preview.
  useEffect(() => {
    void updateTrayUnread(totalUnread);
  }, [totalUnread]);

  const value = useMemo<ConversationsContextValue>(
    () => ({
      conversations,
      loading,
      error,
      unread,
      totalUnread,
      refresh,
      markRead,
      setActiveConversation,
    }),
    [
      conversations,
      loading,
      error,
      unread,
      totalUnread,
      refresh,
      markRead,
      setActiveConversation,
    ],
  );

  return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>;
}

export function useConversationsContext(): ConversationsContextValue {
  const ctx = useContext(ConversationsContext);
  if (!ctx) throw new Error('useConversationsContext must be used inside <ConversationsProvider>');
  return ctx;
}
