import {
  type ReactNode,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  type CommentEvent,
  type LikeEvent,
  type PlaylistEvent,
  type UserEvent,
  type VoteEvent,
  WSContext,
  type WSContextValue,
} from "./WSContext.ts";
import { WS_URL } from "../config/api.ts";
import type {
  Dump,
  IncomingWSMessage,
  Notification,
  OnlineUser,
  OutgoingWSMessage,
} from "../model.ts";
import {
  deserializeComment,
  deserializeDump,
  deserializeNotification,
  deserializePlaylist,
  deserializePublicUser,
} from "../model.ts";
import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";

interface WSProviderProps {
  children: ReactNode;
}

const MAX_BACKOFF = 30_000;
const ACK_TIMEOUT = 5_000;
const CONNECT_TIMEOUT = 2_500;

interface PendingVote {
  timeout: ReturnType<typeof setTimeout>;
  rollback: () => void;
}

interface PendingLike {
  timeout: ReturnType<typeof setTimeout>;
  rollback: () => void;
}

// Minimal runtime check: verify the `type` field is a known string so we can
// safely cast to the discriminated union and let TypeScript narrow from there.
function parseWSMessage(data: string): IncomingWSMessage | null {
  try {
    const msg = JSON.parse(data);
    if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
      return null;
    }
    return msg as IncomingWSMessage;
  } catch {
    return null;
  }
}

export function WSProvider({ children }: WSProviderProps) {
  const { token, user, logout } = useAuth();
  const userId = user?.id ?? null;
  const [wsStatus, setWSStatus] = useState<
    "connecting" | "connected" | "disconnected"
  >("connecting");
  const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);

  // Reset status to "connecting" during render when token changes, rather than
  // inside the effect (which would cause a cascading re-render).
  const [prevToken, setPrevToken] = useState(token);
  if (prevToken !== token) {
    setPrevToken(token);
    setWSStatus("connecting");
    setWSErrorMessage(null);
  }

  const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
  const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
  const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
  const [likeCounts, setLikeCounts] = useState<Record<string, number>>({});
  const [myLikes, setMyLikes] = useState<Set<string>>(new Set());
  const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
  const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
  const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
  const [lastLikeEvent, setLastLikeEvent] = useState<LikeEvent | null>(null);
  const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
  const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
    PlaylistEvent | null
  >(null);
  const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
    new Set(),
  );
  const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
    null,
  );
  const [lastUserEvent, setLastUserEvent] = useState<UserEvent | null>(null);
  const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
  const [lastNotification, setLastNotification] = useState<Notification | null>(
    null,
  );

  // Refs to avoid stale closures in event handlers
  const voteCountsRef = useRef(voteCounts);
  const myVotesRef = useRef(myVotes);
  const likeCountsRef = useRef(likeCounts);
  const myLikesRef = useRef(myLikes);
  const userIdRef = useRef(userId);
  // Stable ref for logout so the effect doesn't reconnect when the function
  // reference changes on re-renders.
  const onForceLogoutRef = useRef(logout);
  useLayoutEffect(() => {
    voteCountsRef.current = voteCounts;
    myVotesRef.current = myVotes;
    likeCountsRef.current = likeCounts;
    myLikesRef.current = myLikes;
    userIdRef.current = userId;
    onForceLogoutRef.current = logout;
  });

  const socketRef = useRef<WebSocket | null>(null);
  // Tracks pending optimistic votes: dumpId → pending rollback handler
  const pendingRef = useRef<Map<string, PendingVote>>(
    new Map(),
  );
  // Tracks pending optimistic comment likes: commentId → pending rollback handler
  const pendingLikesRef = useRef<Map<string, PendingLike>>(
    new Map(),
  );

  const clearPendingVote = useCallback((dumpId: string) => {
    const pending = pendingRef.current.get(dumpId);
    if (!pending) return;
    clearTimeout(pending.timeout);
    pendingRef.current.delete(dumpId);
  }, []);

  const clearAllPendingVotes = useCallback(() => {
    for (const pending of pendingRef.current.values()) {
      clearTimeout(pending.timeout);
    }
    pendingRef.current.clear();
  }, []);

  const schedulePendingVote = useCallback((
    dumpId: string,
    rollback: () => void,
  ) => {
    clearPendingVote(dumpId);
    const timeout = setTimeout(() => {
      pendingRef.current.delete(dumpId);
      rollback();
    }, ACK_TIMEOUT);
    pendingRef.current.set(dumpId, { timeout, rollback });
  }, [clearPendingVote]);

  const clearPendingLike = useCallback((commentId: string) => {
    const pending = pendingLikesRef.current.get(commentId);
    if (!pending) return;
    clearTimeout(pending.timeout);
    pendingLikesRef.current.delete(commentId);
  }, []);

  const clearAllPendingLikes = useCallback(() => {
    for (const pending of pendingLikesRef.current.values()) {
      clearTimeout(pending.timeout);
    }
    pendingLikesRef.current.clear();
  }, []);

  const schedulePendingLike = useCallback((
    commentId: string,
    rollback: () => void,
  ) => {
    clearPendingLike(commentId);
    const timeout = setTimeout(() => {
      pendingLikesRef.current.delete(commentId);
      rollback();
    }, ACK_TIMEOUT);
    pendingLikesRef.current.set(commentId, { timeout, rollback });
  }, [clearPendingLike]);

  useEffect(() => {
    let closed = false;
    let backoff = 500;
    let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
    let connectTimeout: ReturnType<typeof setTimeout> | null = null;
    let everConnected = false;

    function connect() {
      if (closed) return;

      const url = `${WS_URL}/ws${
        token ? `?token=${encodeURIComponent(token)}` : ""
      }`;
      const ws = new WebSocket(url);
      socketRef.current = ws;

      connectTimeout = setTimeout(() => {
        if (ws.readyState !== WebSocket.CONNECTING) return;
        setWSStatus("disconnected");
        setWSErrorMessage(
          t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
        );
        ws.close();
      }, CONNECT_TIMEOUT);

      ws.onopen = () => {
        if (connectTimeout) {
          clearTimeout(connectTimeout);
          connectTimeout = null;
        }
        everConnected = true;
        setWSStatus("connected");
        setWSErrorMessage(null);
      };

      ws.onmessage = (event) => {
        const msg = parseWSMessage(event.data);
        if (!msg) return;

        switch (msg.type) {
          case "ping":
            ws.send(
              JSON.stringify({ type: "pong" } satisfies OutgoingWSMessage),
            );
            break;

          case "welcome":
            backoff = 500; // reset backoff on successful connect
            setOnlineUsers(msg.users);
            setMyVotes(new Set(msg.myVotes));
            setMyLikes(new Set(msg.myCommentLikes));
            setUnreadNotificationCount(msg.unreadNotificationCount);
            // welcome provides authoritative server state — cancel any
            // in-flight revert timers, they are now superseded.
            clearAllPendingVotes();
            clearAllPendingLikes();
            break;

          case "presence_update":
            setOnlineUsers(msg.users);
            break;

          case "votes_update": {
            const { dumpId, voteCount, voterId, action } = msg;
            setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
            setLastVoteEvent({ dumpId, voterId, action });
            // Keep myVotes in sync across tabs: if this vote event belongs to
            // the current user (from another tab), update myVotes accordingly.
            if (voterId === userIdRef.current) {
              clearPendingVote(dumpId);
              setMyVotes((prev) => {
                const next = new Set(prev);
                if (action === "cast") next.add(dumpId);
                else next.delete(dumpId);
                return next;
              });
            }
            break;
          }

          case "comment_likes_update": {
            const { commentId, likeCount, likerId, action } = msg;
            setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
            setLastLikeEvent({ commentId, likerId, action });
            // Keep myLikes in sync across tabs: if this like event belongs to
            // the current user (from another tab), update myLikes accordingly.
            if (likerId === userIdRef.current) {
              clearPendingLike(commentId);
              setMyLikes((prev) => {
                const next = new Set(prev);
                if (action === "cast") next.add(commentId);
                else next.delete(commentId);
                return next;
              });
            }
            break;
          }

          case "dump_created": {
            const dump = deserializeDump(msg.dump);
            setRecentDumps((prev) => [dump, ...prev]);
            break;
          }

          case "dump_updated": {
            const dump = deserializeDump(msg.dump);
            setLastDumpEvent(dump);
            // Un-delete if this dump was previously removed from the feed
            // (e.g. it was made private, and is now public again).
            setDeletedDumpIds((prev) => {
              if (!prev.has(dump.id)) return prev;
              const next = new Set(prev);
              next.delete(dump.id);
              return next;
            });
            // Add to live feed if not already present (private→public).
            setRecentDumps((prev) =>
              prev.some((d) => d.id === dump.id) ? prev : [dump, ...prev]
            );
            break;
          }

          case "dump_deleted": {
            const { dumpId } = msg;
            setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
            setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
            break;
          }

          case "vote_ack": {
            const { dumpId, action, voteCount } = msg;
            clearPendingVote(dumpId);
            // Reconcile with authoritative count
            setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
            // Confirm vote state
            setMyVotes((prev) => {
              const next = new Set(prev);
              if (action === "cast") next.add(dumpId);
              else next.delete(dumpId);
              return next;
            });
            break;
          }

          case "comment_like_ack": {
            const { commentId, action, likeCount } = msg;
            clearPendingLike(commentId);
            // Reconcile with authoritative count
            setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
            // Confirm like state
            setMyLikes((prev) => {
              const next = new Set(prev);
              if (action === "cast") next.add(commentId);
              else next.delete(commentId);
              return next;
            });
            break;
          }

          case "playlist_created":
          case "playlist_updated": {
            const playlist = deserializePlaylist(msg.playlist);
            setLastPlaylistEvent({
              type: msg.type === "playlist_created" ? "created" : "updated",
              playlistId: playlist.id,
              playlist,
            });
            break;
          }

          case "playlist_deleted": {
            const { playlistId, userId } = msg;
            setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
            setLastPlaylistEvent({ type: "deleted", playlistId, userId });
            break;
          }

          case "playlist_dumps_updated": {
            const { playlistId, dumpIds } = msg;
            setLastPlaylistEvent({
              type: "dumps_updated",
              playlistId,
              dumpIds,
            });
            break;
          }

          case "user_updated": {
            const user = deserializePublicUser(msg.user);
            setLastUserEvent({ user });
            break;
          }

          case "comment_created": {
            const comment = deserializeComment(msg.comment);
            setLastCommentEvent({
              type: "created",
              dumpId: comment.dumpId,
              comment,
            });
            break;
          }

          case "comment_deleted": {
            const { commentId, dumpId } = msg;
            setLastCommentEvent({ type: "deleted", dumpId, commentId });
            break;
          }

          case "comment_updated": {
            const comment = deserializeComment(msg.comment);
            setLastCommentEvent({
              type: "updated",
              dumpId: comment.dumpId,
              comment,
            });
            break;
          }

          case "notification_created": {
            const notification = deserializeNotification(msg.notification);
            setLastNotification(notification);
            setUnreadNotificationCount((prev) => prev + 1);
            break;
          }

          case "force_logout":
            onForceLogoutRef.current();
            break;

          case "error":
            // Vote errors currently don't identify which dump/action failed, so
            // fall back to the per-dump timeout rollback instead of guessing.
            break;
        }
      };

      ws.onclose = () => {
        if (connectTimeout) {
          clearTimeout(connectTimeout);
          connectTimeout = null;
        }
        if (closed) return;
        setWSStatus("disconnected");
        setWSErrorMessage(
          everConnected
            ? t`Live updates are temporarily disconnected. Trying to reconnect…`
            : t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
        );
        reconnectTimer = setTimeout(() => {
          backoff = Math.min(backoff * 2, MAX_BACKOFF);
          connect();
        }, backoff);
      };

      ws.onerror = () => {
        // onclose will fire after onerror
      };
    }

    connect();

    const pending = pendingRef.current;
    const pendingLikes = pendingLikesRef.current;
    return () => {
      closed = true;
      if (reconnectTimer) clearTimeout(reconnectTimer);
      if (connectTimeout) clearTimeout(connectTimeout);
      socketRef.current?.close();
      socketRef.current = null;
      for (const pendingVote of pending.values()) {
        clearTimeout(pendingVote.timeout);
      }
      pending.clear();
      for (const pendingLike of pendingLikes.values()) {
        clearTimeout(pendingLike.timeout);
      }
      pendingLikes.clear();
    };
  }, [
    clearAllPendingVotes,
    clearPendingVote,
    clearAllPendingLikes,
    clearPendingLike,
    token,
  ]);

  const castVote = useCallback((dumpId: string) => {
    // Optimistic update
    const prevCount = voteCountsRef.current[dumpId] ?? 0;
    const prevVoted = myVotesRef.current.has(dumpId);
    if (prevVoted) return; // already voted

    setMyVotes((prev) => {
      const n = new Set(prev);
      n.add(dumpId);
      return n;
    });
    setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));

    // Schedule revert if no authoritative confirmation arrives.
    schedulePendingVote(dumpId, () => {
      setMyVotes((prev) => {
        const n = new Set(prev);
        n.delete(dumpId);
        return n;
      });
      setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
    });

    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(
        JSON.stringify(
          { type: "vote_cast", dumpId } satisfies OutgoingWSMessage,
        ),
      );
    }
    // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
  }, [schedulePendingVote]);

  const removeVote = useCallback((dumpId: string) => {
    // Optimistic update
    const prevCount = voteCountsRef.current[dumpId] ?? 0;
    const prevVoted = myVotesRef.current.has(dumpId);
    if (!prevVoted) return; // not voted

    setMyVotes((prev) => {
      const n = new Set(prev);
      n.delete(dumpId);
      return n;
    });
    setVoteCounts((prev) => ({
      ...prev,
      [dumpId]: Math.max(0, prevCount - 1),
    }));

    // Schedule revert if no authoritative confirmation arrives.
    schedulePendingVote(dumpId, () => {
      setMyVotes((prev) => {
        const n = new Set(prev);
        n.add(dumpId);
        return n;
      });
      setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
    });

    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(
        JSON.stringify(
          { type: "vote_remove", dumpId } satisfies OutgoingWSMessage,
        ),
      );
    }
    // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
  }, [schedulePendingVote]);

  const castCommentLike = useCallback((commentId: string) => {
    // Optimistic update
    const prevCount = likeCountsRef.current[commentId] ?? 0;
    const prevLiked = myLikesRef.current.has(commentId);
    if (prevLiked) return; // already liked

    setMyLikes((prev) => {
      const n = new Set(prev);
      n.add(commentId);
      return n;
    });
    setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));

    // Schedule revert if no authoritative confirmation arrives.
    schedulePendingLike(commentId, () => {
      setMyLikes((prev) => {
        const n = new Set(prev);
        n.delete(commentId);
        return n;
      });
      setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
    });

    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(
        JSON.stringify(
          { type: "comment_like_cast", commentId } satisfies OutgoingWSMessage,
        ),
      );
    }
    // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
  }, [schedulePendingLike]);

  const removeCommentLike = useCallback((commentId: string) => {
    // Optimistic update
    const prevCount = likeCountsRef.current[commentId] ?? 0;
    const prevLiked = myLikesRef.current.has(commentId);
    if (!prevLiked) return; // not liked

    setMyLikes((prev) => {
      const n = new Set(prev);
      n.delete(commentId);
      return n;
    });
    setLikeCounts((prev) => ({
      ...prev,
      [commentId]: Math.max(0, prevCount - 1),
    }));

    // Schedule revert if no authoritative confirmation arrives.
    schedulePendingLike(commentId, () => {
      setMyLikes((prev) => {
        const n = new Set(prev);
        n.add(commentId);
        return n;
      });
      setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
    });

    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(
        JSON.stringify(
          {
            type: "comment_like_remove",
            commentId,
          } satisfies OutgoingWSMessage,
        ),
      );
    }
    // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
  }, [schedulePendingLike]);

  const injectDump = useCallback((dump: Dump) => {
    setRecentDumps((prev) => {
      if (prev.some((d) => d.id === dump.id)) return prev;
      return [dump, ...prev];
    });
  }, []);

  const clearUnreadNotifications = useCallback(() => {
    setUnreadNotificationCount(0);
  }, []);

  const value: WSContextValue = useMemo(() => ({
    wsStatus,
    wsErrorMessage,
    onlineUsers,
    voteCounts,
    myVotes,
    likeCounts,
    myLikes,
    recentDumps,
    deletedDumpIds,
    lastVoteEvent,
    lastLikeEvent,
    lastDumpEvent,
    lastPlaylistEvent,
    deletedPlaylistIds,
    lastCommentEvent,
    lastUserEvent,
    unreadNotificationCount,
    lastNotification,
    castVote,
    removeVote,
    castCommentLike,
    removeCommentLike,
    injectDump,
    clearUnreadNotifications,
  }), [
    wsStatus,
    wsErrorMessage,
    onlineUsers,
    voteCounts,
    myVotes,
    likeCounts,
    myLikes,
    recentDumps,
    deletedDumpIds,
    lastVoteEvent,
    lastLikeEvent,
    lastDumpEvent,
    lastPlaylistEvent,
    deletedPlaylistIds,
    lastCommentEvent,
    lastUserEvent,
    unreadNotificationCount,
    lastNotification,
    castVote,
    removeVote,
    castCommentLike,
    removeCommentLike,
    injectDump,
    clearUnreadNotifications,
  ]);

  return (
    <WSContext.Provider value={value}>
      {children}
    </WSContext.Provider>
  );
}
