import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import {
  PlaybackCommand,
  PlaybackDevice,
  PlaybackSnapshot,
  playbackSyncService,
  storedDeviceId,
} from '../services/playbackSync';
import { trackService } from '../services/trackService';
import { adoptVibeSession, releaseVibeDriving } from '../services/vibeSession';
import type { Track } from '../types';

/**
 * Keeps this browser in step with the listener's other devices.
 *
 * Exactly one device holds the audio. That device reports what it is playing;
 * every other device renders the same thing and, when the listener presses a
 * control, sends a command instead of playing locally. Picking a device from
 * the menu moves the audio: the new owner resumes the same track at the
 * position the old one last reported, and the old one stops when it sees the
 * session is no longer its.
 */

/** Position drifts constantly; anything faster than this is noise on the wire. */
const POSITION_REPORT_MS = 10_000;

/** Waits between attempts to register this device when the network is down. */
const REGISTER_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000];

export interface PlaybackSyncApi {
  deviceId: string | null;
  devices: PlaybackDevice[];
  isOwner: boolean;
  /** True once another device holds the audio, so controls become remote controls. */
  hasRemoteOwner: boolean;
  /**
   * Whether the device holding the audio is playing. The local `isPlaying` says
   * nothing about it — a watching device is always paused — so controls that
   * draw a play/pause state read this one while `hasRemoteOwner` is true.
   */
  remotePlaying: boolean;
  transferTo: (deviceId: string) => Promise<void>;
  sendCommand: (command: PlaybackCommand) => Promise<void>;
}

export function usePlaybackSync(): PlaybackSyncApi {
  const [deviceId, setDeviceId] = useState<string | null>(storedDeviceId());
  const [devices, setDevices] = useState<PlaybackDevice[]>([]);
  const [ownerId, setOwnerId] = useState<string | null>(null);
  const [remotePlaying, setRemotePlaying] = useState(false);

  const deviceIdRef = useRef<string | null>(deviceId);
  const ownerIdRef = useRef<string | null>(null);
  // Set while a remote snapshot or command is being written into the store, so
  // the store subscription below does not report those changes straight back.
  const applyingRemote = useRef(false);
  const lastVersion = useRef(-1);
  const lastPositionReport = useRef(0);

  deviceIdRef.current = deviceId;
  ownerIdRef.current = ownerId;

  const reportNow = useCallback(async () => {
    const id = deviceIdRef.current;
    if (!id) return;
    const store = usePlaybackStore.getState();
    const vibe = useVibeStore.getState();
    try {
      await playbackSyncService.reportState(id, {
        trackId: store.currentTrack?.id ?? null,
        // Reported by the device driving the Vibe, and reported as null by a
        // device playing anything else, so the next owner knows which it is.
        vibeSessionId: store.queueOwner === 'vibe' ? vibe.activeSessionId : null,
        queue: store.queue,
        queueIndex: store.currentIndex,
        position: store.position,
        isPlaying: store.isPlaying,
      });
    } catch {
      // 409 means another device owns the session now. Its next state event is
      // what corrects this one, so there is nothing to do here.
    }
  }, []);

  /**
   * Take the queue off the wire without ending a Vibe this device is running.
   * `setQueue` is an ownership handoff: it drops the advance handler, which is
   * the thing that asks the server for the next Vibe track. A snapshot from
   * another device used to go through it, so mirroring the desktop — or taking
   * the audio back afterwards — turned a live session into a static list of the
   * hundred tracks that happened to be synced.
   */
  const adoptQueue = useCallback((queue: Track[]) => {
    if (queue.length === 0) return;
    const store = usePlaybackStore.getState();
    const runningVibe = store.queueOwner === 'vibe' && useVibeStore.getState().activeSessionId !== null;
    if (runningVibe) store.setVibeQueue(queue);
    else store.setQueue(queue);
  }, []);

  const applySnapshot = useCallback(async (state: PlaybackSnapshot) => {
    if (state.version <= lastVersion.current) return;
    lastVersion.current = state.version;

    const store = usePlaybackStore.getState();
    const iOwnIt = state.deviceId !== null && state.deviceId === deviceIdRef.current;
    // Captured before setOwnerId, which only reaches the ref on the next render.
    const alreadyOwnedIt = ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current;
    setOwnerId(state.deviceId);

    // What the controls draw, and whether the engine should load anything: both
    // follow from where the audio is, so they are set before any of the writes
    // below and on every snapshot, including the ones the owner ignores.
    const elsewhere = state.deviceId !== null && !iOwnIt;
    setRemotePlaying(elsewhere ? state.isPlaying : false);
    store.setAudioElsewhere(elsewhere);

    // The server publishes a snapshot for anything that touches the session,
    // including another device merely registering on page load. For the device
    // already holding the audio those snapshots carry nothing new: the position
    // in them is this device's own last report, up to POSITION_REPORT_MS old.
    // Applying it would drag playback backwards, so the owner ignores them and
    // stays the authority on its own position.
    if (iOwnIt && alreadyOwnedIt) return;

    // Nobody holds the session. That is not an instruction to stop: it is what
    // the server publishes when this device's stream broke for a moment, or
    // when the sweep freed an owner it thought was gone. A device with audio
    // loaded claims the session back instead of pausing and rewinding to the
    // position it last reported, which is how a phone losing signal for ten
    // seconds used to stop playing.
    if (state.deviceId === null && store.currentTrack) {
      if (alreadyOwnedIt || store.isPlaying) void reportNow();
      return;
    }

    applyingRemote.current = true;
    try {
      if (!iOwnIt) {
        // Another device holds the audio, and with it the Vibe. Stop driving the
        // session from here, then show what it plays and make no sound.
        if (store.isPlaying) store.pause();
        if (store.vibeAdvanceHandler) releaseVibeDriving();
        adoptQueue(state.queue);
        const shown = state.queue[state.queueIndex] ?? store.currentTrack;
        if (shown && shown.id !== store.currentTrack?.id) store.setCurrentTrack(shown);
        store.setPosition(state.position);
        return;
      }

      // We just took the session over: rebuild the queue, land on the right
      // track, and resume from where the previous device actually was.
      adoptQueue(state.queue);
      let track: Track | null = state.queue[state.queueIndex] ?? null;
      if (!track && state.trackId && state.trackId !== store.currentTrack?.id) {
        track = await trackService.getTrack(state.trackId).catch(() => null);
      }
      if (track && track.id !== store.currentTrack?.id) store.playTrack(track);
      store.setPosition(state.position);
      if (state.isPlaying) store.play();
      else store.pause();
    } finally {
      applyingRemote.current = false;
    }

    // Only a device that just took the audio reaches this: every other case
    // returned above. Vibe control travels with the audio, so pick up the
    // session the previous owner was driving — or let go of the one this device
    // still holds, because whatever is playing now is not it.
    if (state.vibeSessionId) {
      // Adoption rewrites the unplayed queue from the durable plan, and the
      // others are showing this device's queue, so tell them.
      if (await adoptVibeSession(state.vibeSessionId).catch(() => false)) void reportNow();
    } else if (useVibeStore.getState().activeSessionId) {
      releaseVibeDriving();
      useVibeStore.getState().reset();
    }
  }, [adoptQueue, reportNow]);

  const applyCommand = useCallback(async (command: PlaybackCommand) => {
    const store = usePlaybackStore.getState();
    applyingRemote.current = true;
    try {
      switch (command.type) {
        case 'play': store.play(); break;
        case 'pause': store.pause(); break;
        case 'next': store.next(); break;
        case 'prev': store.prev(); break;
        case 'seek': store.setPosition(command.position); break;
        case 'play_track': {
          const queued = store.queue.find((t) => t.id === command.trackId);
          const track = queued ?? await trackService.getTrack(command.trackId).catch(() => null);
          if (track) store.playTrack(track);
          break;
        }
      }
    } finally {
      applyingRemote.current = false;
    }
    // The command changed what this device plays, so the others need to know.
    void reportNow();
  }, [reportNow]);

  // Register, then hold the stream open for as long as the app is mounted.
  useEffect(() => {
    let closeStream: (() => void) | null = null;
    let cancelled = false;
    let retry: ReturnType<typeof setTimeout> | undefined;

    // Registration is one request, and a phone woken with no network fails it.
    // Giving up there left that tab with no device id and no stream until the
    // listener reloaded the page, so keep asking instead.
    const attempt = async (attemptCount: number) => {
      const device = await playbackSyncService.register().catch(() => null);
      if (cancelled) return;
      if (!device) {
        const wait = REGISTER_BACKOFF_MS[Math.min(attemptCount, REGISTER_BACKOFF_MS.length - 1)];
        retry = setTimeout(() => void attempt(attemptCount + 1), wait);
        return;
      }
      setDeviceId(device.id);
      deviceIdRef.current = device.id;
      closeStream = playbackSyncService.openStream(device.id, (event) => {
        if (event.type === 'state') {
          setDevices(event.devices);
          void applySnapshot(event.state);
        } else {
          void applyCommand(event.command);
        }
      });
    };
    void attempt(0);

    return () => {
      cancelled = true;
      clearTimeout(retry);
      closeStream?.();
      // Nothing is watching the session any more, so nothing knows where the
      // audio is. Leaving the flag set would keep the engine silent for good.
      usePlaybackStore.getState().setAudioElsewhere(false);
    };
  }, [applySnapshot, applyCommand]);

  // Local playback is reported upward — but only from the device that owns the
  // audio, and only when the change did not come from the network in the first
  // place. Starting playback on an unowned session claims it.
  useEffect(() => {
    let previous = usePlaybackStore.getState();
    return usePlaybackStore.subscribe((state) => {
      const prev = previous;
      previous = state;
      const id = deviceIdRef.current;
      if (!id || applyingRemote.current) return;

      const trackChanged = state.currentTrack?.id !== prev.currentTrack?.id;
      const playingChanged = state.isPlaying !== prev.isPlaying;
      const startedPlaying = state.isPlaying && !prev.isPlaying;

      if (ownerIdRef.current !== id) {
        // A device that does not own the session only takes it by starting
        // playback here. Everything else it does stays local.
        if (startedPlaying || (trackChanged && state.isPlaying)) {
          void playbackSyncService.transfer(id).then(() => reportNow());
        }
        return;
      }

      if (trackChanged || playingChanged) {
        void reportNow();
        lastPositionReport.current = Date.now();
        return;
      }
      if (Date.now() - lastPositionReport.current >= POSITION_REPORT_MS) {
        lastPositionReport.current = Date.now();
        void reportNow();
      }
    });
  }, [reportNow]);

  // Leaving the page hands the session back rather than stranding it on a tab
  // that is gone. The stream close does this too; this covers the browsers that
  // keep a closing connection alive long enough to matter.
  useEffect(() => {
    const release = () => {
      const id = deviceIdRef.current;
      if (id && ownerIdRef.current === id) void playbackSyncService.release(id);
    };
    window.addEventListener('pagehide', release);
    return () => window.removeEventListener('pagehide', release);
  }, []);

  const transferTo = useCallback(async (target: string) => {
    // The target resumes from the last position on record, so flush the real
    // one first — otherwise handing the audio over rewinds it by up to
    // POSITION_REPORT_MS.
    if (ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current) {
      await reportNow();
    }
    await playbackSyncService.transfer(target);
  }, [reportNow]);

  const sendCommand = useCallback(async (command: PlaybackCommand) => {
    await playbackSyncService.sendCommand(command);
  }, []);

  return {
    deviceId,
    devices,
    isOwner: ownerId !== null && ownerId === deviceId,
    hasRemoteOwner: ownerId !== null && ownerId !== deviceId,
    remotePlaying,
    transferTo,
    sendCommand,
  };
}
