/**
 * SyncPlayProvider — React glue around `SyncPlayManager`.
 *
 * Responsibilities:
 *  - Manager lifecycle (construct on api change, destroy on unmount)
 *  - React mirrors of manager state (`isEnabled`, `groupInfo`,
 *    `pendingPlaybackCommand`) so components re-render
 *  - Navigation handlers wired into `PlayerWrapper.localPlay` /
 *    `localSetCurrentPlaylistItem` — these are what jellyfin-web does
 *    synchronously via `playbackManager.play`; on RN they navigate
 *    to the player screen instead
 *  - AppState foreground re-join (we may miss broadcasts while
 *    suspended)
 *
 * External API surface (`useSyncPlay`) is stable; components don't
 * change when the internals do.
 */

import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import {
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { toast } from "sonner-native";
import { useAppRouter } from "@/hooks/useAppRouter";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import i18n from "@/i18n";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { Controller as SyncPlayController } from "./Controller";
import { SyncPlayManager } from "./Manager";
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";

interface SyncPlayContextValue {
  isEnabled: boolean;
  groupInfo: GroupInfoDto | null;
  canJoinGroups: boolean;
  canCreateGroups: boolean;

  joinGroup: (groupId: string) => Promise<void>;
  createGroup: (groupName?: string) => Promise<void>;
  leaveGroup: () => Promise<void>;
  getGroups: () => Promise<GroupInfoDto[]>;

  controller: SyncPlayController | null;

  setPlayerControls: (controls: PlayerControls | null) => void;
  notifyReady: () => void;
  notifyBuffering: (isBuffering: boolean) => void;
  notifyPlaybackStart: () => void;

  pendingPlaybackCommand: "Unpause" | "Pause" | null;
  /**
   * Current SyncPlay OSD overlay state. Drives the animated icon over the
   * video that mirrors jellyfin-web's `#syncPlayIcon`. `null` means hidden.
   */
  osdAction: SyncPlayOsdAction | null;
}

const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);

interface SyncPlayProviderProps {
  children: ReactNode;
}

export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
  const api = useAtomValue(apiAtom);
  const user = useAtomValue(userAtom);
  const router = useAppRouter();
  const { isConnected: isWsConnected } = useWebSocketContext();

  const [manager, setManager] = useState<SyncPlayManager | null>(null);
  const isNavigatingToPlayerRef = useRef(false);

  const [isEnabled, setIsEnabled] = useState(false);
  const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
  const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
    "Unpause" | "Pause" | null
  >(null);

  // While in a SyncPlay group, hold a keep-alive token on the global
  // WebSocket so backgrounding the app does NOT cleanly close the
  // socket. A clean close is interpreted by the Jellyfin server as
  // leaving the group and is broadcast to every other member as
  // "<user> has left the group". Keeping the socket open across a
  // short suspend lets us stay in the group while quickly switching
  // apps; if the OS eventually tears the TCP connection down anyway,
  // the app-foreground rejoin effect below will pull us back in.
  useKeepWebSocketAlive(isEnabled);

  const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
  const osdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  /**
   * Set the OSD overlay action.
   *
   * `transient` mirrors jellyfin-web's `iconVisibilityTime = 1500` for the
   * Unpause / Pause / Seek command-confirmation flashes. Persistent actions
   * (schedule-play, buffering, wait-*) stay until cleared by a state
   * transition or a subsequent call with `null`.
   */
  const showOsd = useCallback(
    (action: SyncPlayOsdAction | null, transient = false) => {
      if (osdTimeoutRef.current) {
        clearTimeout(osdTimeoutRef.current);
        osdTimeoutRef.current = null;
      }
      setOsdAction(action);
      if (transient && action !== null) {
        osdTimeoutRef.current = setTimeout(() => {
          osdTimeoutRef.current = null;
          setOsdAction((cur) => (cur === action ? null : cur));
        }, 1500);
      }
    },
    [],
  );

  // Pending play/pause tap → optimistic schedule-play overlay (unless another
  // overlay reason has already taken precedence).
  useEffect(() => {
    if (pendingPlaybackCommand) {
      setOsdAction((cur) => cur ?? "schedule-play");
    } else {
      setOsdAction((cur) => (cur === "schedule-play" ? null : cur));
    }
  }, [pendingPlaybackCommand]);

  // Clear the OSD auto-expire timeout on unmount.
  useEffect(() => {
    return () => {
      if (osdTimeoutRef.current) {
        clearTimeout(osdTimeoutRef.current);
        osdTimeoutRef.current = null;
      }
    };
  }, []);

  const canJoinGroups = useMemo(() => {
    const access = user?.Policy?.SyncPlayAccess;
    return access !== "None" && access !== undefined;
  }, [user?.Policy?.SyncPlayAccess]);

  const canCreateGroups = useMemo(
    () => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups",
    [user?.Policy?.SyncPlayAccess],
  );

  // Latch: `true` once we've fired the per-attach `playbackstart` event.
  const playbackStartFiredRef = useRef(false);

  // ---------------------------------------------------------------------------
  // Manager lifecycle
  // ---------------------------------------------------------------------------

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

    const mgr = new SyncPlayManager(api);
    mgr.init();
    setManager(mgr);

    const playerWrapper = mgr.getPlayerWrapper();

    // localPlay → navigate to direct-player with syncPlay=true
    playerWrapper.setLocalPlayHandler((options) => {
      const itemId = options.ids[0];
      if (!itemId) {
        console.warn("SyncPlay: localPlay called with no ids");
        return;
      }
      if (isNavigatingToPlayerRef.current) {
        console.debug("SyncPlay: already navigating to player");
        return;
      }
      isNavigatingToPlayerRef.current = true;

      toast(i18n.t("syncplay.joining_playback"));

      const queryParams = new URLSearchParams({
        itemId,
        playbackPosition: String(options.startPositionTicks ?? 0),
        syncPlay: "true",
      }).toString();
      router.push(`/player/direct-player?${queryParams}`);

      setTimeout(() => {
        isNavigatingToPlayerRef.current = false;
      }, 2000);
    });

    // localSetCurrentPlaylistItem → navigate to the new playlist item
    playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => {
      if (!playlistItemId) return;
      const queueCore = mgr.getQueueCore();
      const target = queueCore
        .getPlaylist()
        .find((i) => i.PlaylistItemId === playlistItemId);
      const itemId = target?.Id;
      if (!itemId) {
        console.warn(
          "SyncPlay: localSetCurrentPlaylistItem — item not in playlist",
          playlistItemId,
        );
        return;
      }
      if (isNavigatingToPlayerRef.current) return;
      isNavigatingToPlayerRef.current = true;

      const queryParams = new URLSearchParams({
        itemId,
        playbackPosition: String(queueCore.getStartPositionTicks()),
        syncPlay: "true",
      }).toString();
      router.push(`/player/direct-player?${queryParams}`);

      setTimeout(() => {
        isNavigatingToPlayerRef.current = false;
      }, 2000);
    });

    mgr.on("enabled", (...args: unknown[]) => {
      const enabled = args[0] as boolean;
      setIsEnabled(enabled);
      if (!enabled) setGroupInfo(null);
    });

    mgr.on("group-update", (...args: unknown[]) => {
      setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
    });

    mgr.on("pending-playback-change", (...args: unknown[]) => {
      setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
    });

    // group-state-change → on "Waiting", pause locally so we don't drift
    // ahead of the group while the server is reconciling buffering/seek
    // state. Position resync is *only* done from the explicit Pause /
    // Unpause / Seek SendCommands that follow (`PlaybackCore.applyCommand`
    // → `scheduleUnpause` etc.) — those commands carry the canonical
    // `PositionTicks` for the action's `When`. The old code here also
    // called `wrapper.localSeek(lastCommand.PositionTicks)`, but
    // `lastCommand` is the *previous* Pause/Unpause and can be many
    // seconds stale, so it rewound the user every time someone else
    // buffered. Don't put a seek back here.
    mgr.on("group-state-change", (...args: unknown[]) => {
      const state = args[0] as string | undefined;
      const reason = args[2] as string | undefined;
      const wrapper = mgr.getPlayerWrapper();
      if (!wrapper.isPlaybackActive()) return;
      if (state === "Waiting") {
        wrapper.localPause();
      }

      // Drive the persistent OSD overlay from (state, reason).
      // Mirrors jellyfin-web's `group-state-update` → `showIcon` mapping.
      if (state === "Waiting") {
        if (reason === "Buffer") showOsd("buffering");
        else if (reason === "Unpause") showOsd("wait-unpause");
        else if (reason === "Pause") showOsd("wait-pause");
        else if (reason === "Seek") showOsd("seek");
      } else if (state === "Playing" || state === "Paused") {
        // Stable state — clear any persistent overlay; transient flashes
        // come from the `osd` event below and self-expire.
        setOsdAction((cur) => {
          if (
            cur === "schedule-play" ||
            cur === "buffering" ||
            cur === "wait-pause" ||
            cur === "wait-unpause"
          ) {
            return null;
          }
          return cur;
        });
      }
    });

    // PlaybackCore-emitted OSD events. Transient ones auto-expire in 1.5s.
    mgr.on("osd", (...args: unknown[]) => {
      const action = args[0] as SyncPlayOsdAction;
      const transient =
        action === "unpause" || action === "pause" || action === "seek";
      showOsd(action, transient);
    });

    mgr.on("toast", (...args: unknown[]) => {
      const key = args[0] as string;
      const arg = args[1] as string | undefined;
      const message = arg
        ? i18n.t(`syncplay.toasts.${key}`, { user: arg })
        : i18n.t(`syncplay.toasts.${key}`);
      toast(message);
    });

    return () => {
      mgr.destroy();
      setManager(null);
    };
  }, [api, router]);

  // Initial join race: once `enabled` flips true, snapshot the current group.
  useEffect(() => {
    if (isEnabled && manager) {
      setGroupInfo(manager.getGroupInfo());
    }
  }, [isEnabled, manager]);

  // Wire WebSocket messages → manager
  useSyncPlayWebSocket(manager);

  // ---------------------------------------------------------------------------
  // Group management
  // ---------------------------------------------------------------------------

  const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
    if (!api) return [];
    try {
      const response = await getSyncPlayApi(api).syncPlayGetGroups();
      return (response.data as unknown as GroupInfoDto[]) ?? [];
    } catch (error) {
      console.error("SyncPlay: failed to get groups", error);
      return [];
    }
  }, [api]);

  const joinGroup = useCallback(
    async (groupId: string): Promise<void> => {
      if (!api) return;
      try {
        await getSyncPlayApi(api).syncPlayJoinGroup({
          joinGroupRequestDto: { GroupId: groupId },
        });
      } catch (error) {
        console.error("SyncPlay: failed to join group", error);
        throw error;
      }
    },
    [api],
  );

  const createGroup = useCallback(
    async (groupName?: string): Promise<void> => {
      if (!api || !user) return;
      const name = groupName || `${user.Name}'s Group`;
      try {
        await getSyncPlayApi(api).syncPlayCreateGroup({
          newGroupRequestDto: { GroupName: name },
        });
      } catch (error) {
        console.error("SyncPlay: failed to create group", error);
        throw error;
      }
    },
    [api, user],
  );

  const leaveGroup = useCallback(async (): Promise<void> => {
    if (!api) return;
    try {
      await getSyncPlayApi(api).syncPlayLeaveGroup();
    } catch (error) {
      console.error("SyncPlay: failed to leave group", error);
      throw error;
    }
  }, [api]);

  // ---------------------------------------------------------------------------
  // App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
  // ---------------------------------------------------------------------------

  const lastGroupIdRef = useRef<string | null>(null);
  useEffect(() => {
    lastGroupIdRef.current = groupInfo?.GroupId ?? null;
  }, [groupInfo?.GroupId]);

  // Track whether the WebSocket got torn down while the app was
  // backgrounded. If it survived (keep-alive worked), the server
  // still has us in the group and we must NOT call JoinGroup again —
  // doing so would trigger a redundant "X joined the group" broadcast
  // to every other member every time we briefly leave the app.
  const wsClosedWhileBackgroundedRef = useRef(false);
  const appStateRef = useRef<AppStateStatus>(AppState.currentState);
  useEffect(() => {
    if (!isWsConnected && appStateRef.current !== "active") {
      wsClosedWhileBackgroundedRef.current = true;
    }
  }, [isWsConnected]);

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

    const subscription = AppState.addEventListener("change", (nextAppState) => {
      const previousAppState = appStateRef.current;
      appStateRef.current = nextAppState;

      const becameActive =
        (previousAppState === "background" ||
          previousAppState === "inactive") &&
        nextAppState === "active";
      if (!becameActive) return;

      const groupId = lastGroupIdRef.current;
      if (!groupId) return;

      // Happy path: keep-alive held the socket open across the
      // suspend. Server still considers us a member — nothing to do.
      if (!wsClosedWhileBackgroundedRef.current) {
        console.log(
          "SyncPlay: app foregrounded with WS still alive, skipping rejoin",
        );
        return;
      }
      wsClosedWhileBackgroundedRef.current = false;

      // Small delay so the WebSocket has a moment to reconnect.
      setTimeout(() => {
        console.log(
          `SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`,
        );
        getSyncPlayApi(api)
          .syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
          .catch((error) => {
            console.error("SyncPlay: failed to rejoin group", error);
          });
      }, 1000);
    });

    return () => subscription.remove();
  }, [api]);

  // ---------------------------------------------------------------------------
  // Player attach bridges
  // ---------------------------------------------------------------------------

  const setPlayerControls = useCallback(
    (controls: PlayerControls | null) => {
      // Reset the playbackstart latch on each new attach.
      playbackStartFiredRef.current = false;
      manager?.setPlayerControls(controls);
    },
    [manager],
  );

  const notifyReady = useCallback(() => {
    manager?.notifyReady();
  }, [manager]);

  const notifyBuffering = useCallback(
    (isBuffering: boolean) => {
      manager?.notifyBuffering(isBuffering);
      if (!isBuffering && !playbackStartFiredRef.current) {
        playbackStartFiredRef.current = true;
        manager?.notifyPlaybackStart();
      }
    },
    [manager],
  );

  const notifyPlaybackStart = useCallback(() => {
    manager?.notifyPlaybackStart();
  }, [manager]);

  // ---------------------------------------------------------------------------
  // Context value
  // ---------------------------------------------------------------------------

  const contextValue: SyncPlayContextValue = useMemo(
    () => ({
      isEnabled,
      groupInfo,
      canJoinGroups,
      canCreateGroups,
      joinGroup,
      createGroup,
      leaveGroup,
      getGroups,
      controller: manager?.getController() ?? null,
      setPlayerControls,
      notifyReady,
      notifyBuffering,
      notifyPlaybackStart,
      pendingPlaybackCommand,
      osdAction,
    }),
    [
      isEnabled,
      groupInfo,
      canJoinGroups,
      canCreateGroups,
      joinGroup,
      createGroup,
      leaveGroup,
      getGroups,
      manager,
      setPlayerControls,
      notifyReady,
      notifyBuffering,
      notifyPlaybackStart,
      pendingPlaybackCommand,
      osdAction,
    ],
  );

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

export function useSyncPlay(): SyncPlayContextValue {
  const context = useContext(SyncPlayContext);
  if (!context) {
    throw new Error("useSyncPlay must be used within a SyncPlayProvider");
  }
  return context;
}
