import api from './api';
import type { Track } from '../types';

/**
 * Client half of cross-device playback. One browser is one device, identified
 * by a stored id so a reload keeps its place in the device list instead of
 * adding a new row every time.
 */

const DEVICE_ID_KEY = 'muzick.deviceId';

/** Waits before reopening a push stream the server closed with an error. */
const STREAM_REOPEN_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000];

export type PlaybackCommand =
  | { type: 'play' | 'pause' | 'next' | 'prev' }
  | { type: 'seek'; position: number }
  | { type: 'play_track'; trackId: string };

export interface PlaybackDevice {
  id: string;
  name: string;
  lastSeenAt: string;
  online: boolean;
  isOwner: boolean;
}

export interface PlaybackSnapshot {
  deviceId: string | null;
  trackId: string | null;
  /** The Vibe session the owning device is driving, if it is driving one. */
  vibeSessionId: string | null;
  queue: Track[];
  queueIndex: number;
  position: number;
  isPlaying: boolean;
  version: number;
  updatedAt: string;
}

export type PlaybackSyncEvent =
  | { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
  | { type: 'command'; deviceId: string; command: PlaybackCommand };

/**
 * The id this tab registered under, or the one the browser last used.
 *
 * Two levels, because a device is really a tab and not a browser: the audio
 * element, the queue and the command handling all live in one page. Two tabs
 * sharing a single id are one device that runs every command twice, so each tab
 * keeps its own id in `sessionStorage` — which survives its reloads and nothing
 * else. The `localStorage` copy is only a seed for a tab that has no id yet, and
 * the server refuses to hand it back while another tab's stream is holding it.
 */
export function storedDeviceId(): string | null {
  try {
    return sessionStorage.getItem(DEVICE_ID_KEY) ?? localStorage.getItem(DEVICE_ID_KEY);
  } catch {
    return null;
  }
}

function rememberDeviceId(id: string): void {
  try {
    sessionStorage.setItem(DEVICE_ID_KEY, id);
    localStorage.setItem(DEVICE_ID_KEY, id);
  } catch {
    // Private browsing: the device still works, it just re-registers next load.
  }
}

/**
 * A name the listener can tell apart in a device menu. The user agent is the
 * only thing a browser will say about its host, so this reads platform and
 * browser out of it rather than showing the raw string.
 */
export function describeThisDevice(): string {
  const ua = navigator.userAgent;
  const platform = /iPhone|iPad|iPod/.test(ua) ? 'iPhone'
    : /Android/.test(ua) ? 'Android'
    : /Macintosh/.test(ua) ? 'Mac'
    : /Windows/.test(ua) ? 'Windows'
    : /Linux/.test(ua) ? 'Linux'
    : 'Device';
  const browser = /Firefox\//.test(ua) ? 'Firefox'
    : /Edg\//.test(ua) ? 'Edge'
    : /Chrome\//.test(ua) ? 'Chrome'
    : /Safari\//.test(ua) ? 'Safari'
    : 'Browser';
  return `${platform} · ${browser}`;
}

export const playbackSyncService = {
  async register(): Promise<PlaybackDevice> {
    const res = await api.post<PlaybackDevice>('/playback/devices', {
      deviceId: storedDeviceId(),
      name: describeThisDevice(),
    });
    rememberDeviceId(res.data.id);
    return res.data;
  },

  async getState(): Promise<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }> {
    const res = await api.get<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }>('/playback/state');
    return res.data;
  },

  /** Report what this device is playing. Rejected with 409 once it is not the owner. */
  async reportState(deviceId: string, patch: {
    trackId?: string | null;
    vibeSessionId?: string | null;
    queue?: Track[];
    queueIndex?: number;
    position?: number;
    isPlaying?: boolean;
  }): Promise<void> {
    await api.post('/playback/state', { deviceId, ...patch });
  },

  async sendCommand(command: PlaybackCommand): Promise<void> {
    await api.post('/playback/command', command);
  },

  async transfer(deviceId: string): Promise<void> {
    await api.post('/playback/transfer', { deviceId });
  },

  async release(deviceId: string): Promise<void> {
    await api.post('/playback/release', { deviceId });
  },

  /**
   * Open the push channel and keep it open. The server resends the full
   * snapshot on every connect, so a reconnection needs no resume bookkeeping.
   *
   * EventSource retries a dropped connection by itself, but only that case: an
   * error status from the server — a backend restart, a proxy 502, a captive
   * portal answering for it — closes the stream for good. A phone that hit one
   * of those stayed silent until the page was reloaded, so reopen it here, and
   * check the moment the network or the tab comes back rather than waiting out
   * a backoff the listener is watching.
   */
  openStream(deviceId: string, onEvent: (event: PlaybackSyncEvent) => void): () => void {
    const base = (import.meta.env.VITE_API_URL as string | undefined) || '/api';
    const url = `${base}/playback/stream?deviceId=${encodeURIComponent(deviceId)}`;
    let source: EventSource | null = null;
    let timer: ReturnType<typeof setTimeout> | undefined;
    let attempt = 0;
    let closed = false;

    const open = () => {
      if (closed) return;
      clearTimeout(timer);
      source = new EventSource(url);
      source.onopen = () => { attempt = 0; };
      source.onmessage = (message) => {
        try {
          onEvent(JSON.parse(message.data) as PlaybackSyncEvent);
        } catch {
          // A malformed frame is not worth tearing the stream down for.
        }
      };
      source.onerror = () => {
        // CONNECTING means EventSource is already retrying on its own terms.
        if (closed || source?.readyState !== EventSource.CLOSED) return;
        source.close();
        source = null;
        const wait = STREAM_REOPEN_MS[Math.min(attempt, STREAM_REOPEN_MS.length - 1)];
        attempt += 1;
        timer = setTimeout(open, wait);
      };
    };

    const reopenIfDead = () => {
      if (closed || document.visibilityState === 'hidden') return;
      if (!source || source.readyState === EventSource.CLOSED) open();
    };

    // React never unmounts on a page leaving, so the stream would stay open
    // until the server noticed the socket die. Closing it here frees the device
    // id straight away, which is what lets a reload register as the same device
    // rather than being told it is a second tab. A page that comes back from the
    // history cache reopens through `reopenIfDead`.
    const closeForNow = () => {
      clearTimeout(timer);
      source?.close();
      source = null;
    };

    open();
    window.addEventListener('online', reopenIfDead);
    window.addEventListener('pagehide', closeForNow);
    document.addEventListener('visibilitychange', reopenIfDead);

    return () => {
      closed = true;
      clearTimeout(timer);
      window.removeEventListener('online', reopenIfDead);
      window.removeEventListener('pagehide', closeForNow);
      document.removeEventListener('visibilitychange', reopenIfDead);
      source?.close();
    };
  },
};
