import { EventEmitter } from 'node:events';
import type { Pool } from 'pg';

/**
 * Cross-device playback: one authoritative session per listener, any number of
 * devices watching it, exactly one of them holding the audio.
 *
 * Two channels do the work. `state` carries the snapshot every device renders,
 * so a phone that just opened shows what the desktop is playing. `command`
 * carries an instruction aimed at the owning device only, because the audio
 * element lives there and nowhere else. Handoff is a state change like any
 * other: the new owner starts at the position the old one reported, and the old
 * one stops when it sees it no longer owns the session.
 *
 * ponytail: the fan-out is an in-process EventEmitter, which is correct for the
 * single backend container this deploys as. A second instance would need Redis
 * pub/sub here — the rest of the design already assumes nothing else.
 */

/** A device is offline once it stops sending its stream heartbeat. */
export const DEVICE_STALE_MS = 90_000;
/** Queue snapshots are capped: this is a handoff payload, not a playlist store. */
export const MAX_SYNCED_QUEUE = 100;

export type PlaybackCommandType = 'play' | 'pause' | 'next' | 'prev' | 'seek' | 'play_track';

export interface PlaybackCommand {
  type: PlaybackCommandType;
  /** Seconds into the current track. Only meaningful for `seek`. */
  position?: number;
  /** Only meaningful for `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 durable Vibe session the owning device is playing, when it is playing
   * one. Whichever device holds the audio drives the session, so this is how the
   * next owner learns there is one to adopt.
   */
  vibeSessionId: string | null;
  queue: unknown[];
  queueIndex: number;
  position: number;
  isPlaying: boolean;
  version: number;
  updatedAt: string;
}

export interface PlaybackStatePatch {
  trackId?: string | null;
  vibeSessionId?: string | null;
  queue?: unknown[];
  queueIndex?: number;
  position?: number;
  isPlaying?: boolean;
}

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

export class NotSessionOwnerError extends Error {
  constructor() {
    super('device does not own playback');
    this.name = 'NotSessionOwnerError';
  }
}

type StateRow = {
  device_id: string | null;
  track_id: string | null;
  vibe_session_id: string | null;
  queue: unknown[];
  queue_index: number;
  position_ms: number;
  is_playing: boolean;
  version: string;
  updated_at: Date;
};

function toSnapshot(row: StateRow): PlaybackSnapshot {
  return {
    deviceId: row.device_id,
    trackId: row.track_id,
    vibeSessionId: row.vibe_session_id ?? null,
    queue: Array.isArray(row.queue) ? row.queue : [],
    queueIndex: row.queue_index,
    position: row.position_ms / 1000,
    isPlaying: row.is_playing,
    version: Number(row.version),
    updatedAt: row.updated_at.toISOString(),
  };
}

function clampPositionMs(position: number | undefined, fallback: number): number {
  if (typeof position !== 'number' || !Number.isFinite(position) || position < 0) return fallback;
  return Math.min(Math.round(position * 1000), 24 * 60 * 60 * 1000);
}

export class PlaybackSyncService {
  private readonly emitter = new EventEmitter();
  /**
   * Open streams per device, keyed `userId:deviceId`. A device only exists as
   * far as this session is concerned while it is holding one: that is what
   * decides whether a command can be delivered, and whether a browser asking to
   * reuse a stored device id would be colliding with a tab that already has it.
   * Counted rather than a flag, so a reconnect racing its own close cannot leave
   * a device permanently marked busy.
   */
  private readonly streams = new Map<string, number>();

  constructor(private readonly pgPool: Pool) {
    // One session with many idle tabs is the normal case, and Node warns at ten
    // listeners on the assumption they are a leak. They are not.
    this.emitter.setMaxListeners(0);
  }

  subscribe(userId: string, listener: (event: PlaybackEvent) => void): () => void {
    this.emitter.on(userId, listener);
    return () => this.emitter.off(userId, listener);
  }

  /** Mark a device's stream open until the returned function is called. */
  claimStream(userId: string, deviceId: string): () => void {
    const key = `${userId}:${deviceId}`;
    this.streams.set(key, (this.streams.get(key) ?? 0) + 1);
    let released = false;
    return () => {
      if (released) return;
      released = true;
      const open = (this.streams.get(key) ?? 1) - 1;
      if (open > 0) this.streams.set(key, open);
      else this.streams.delete(key);
    };
  }

  hasLiveStream(userId: string, deviceId: string): boolean {
    return (this.streams.get(`${userId}:${deviceId}`) ?? 0) > 0;
  }

  private async publish(userId: string): Promise<void> {
    const [state, devices] = await Promise.all([this.getState(userId), this.listDevices(userId)]);
    this.emitter.emit(userId, { type: 'state', state, devices } satisfies PlaybackEvent);
  }

  /**
   * Register a device, or refresh one the browser already knows about. The
   * caller supplies the id it stored locally so a reload keeps its identity and
   * the device list does not grow one row per page load.
   *
   * A second tab of the same browser asks with the same stored id, and two tabs
   * sharing one device id are one device that runs every command twice and plays
   * two copies of the audio. An id whose stream is already open therefore does
   * not get reused: the caller is given a device of its own instead.
   */
  async registerDevice(userId: string, name: string, deviceId?: string | null): Promise<PlaybackDevice> {
    const cleanName = (name || 'Unknown device').trim().slice(0, 120) || 'Unknown device';
    if (deviceId && !this.hasLiveStream(userId, deviceId)) {
      const updated = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
        `UPDATE playback_devices SET name = $3, last_seen_at = NOW()
          WHERE id = $1 AND user_id = $2
          RETURNING id, name, last_seen_at`,
        [deviceId, userId, cleanName]
      );
      if (updated.rows[0]) {
        const owner = await this.ownerId(userId);
        await this.publish(userId);
        return {
          id: updated.rows[0].id,
          name: updated.rows[0].name,
          lastSeenAt: updated.rows[0].last_seen_at.toISOString(),
          online: true,
          isOwner: owner === updated.rows[0].id,
        };
      }
    }
    const inserted = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
      `INSERT INTO playback_devices (user_id, name) VALUES ($1, $2)
       RETURNING id, name, last_seen_at`,
      [userId, cleanName]
    );
    const row = inserted.rows[0];
    await this.publish(userId);
    return {
      id: row.id,
      name: row.name,
      lastSeenAt: row.last_seen_at.toISOString(),
      online: true,
      isOwner: false,
    };
  }

  async touchDevice(userId: string, deviceId: string): Promise<void> {
    await this.pgPool.query(
      `UPDATE playback_devices SET last_seen_at = NOW() WHERE id = $1 AND user_id = $2`,
      [deviceId, userId]
    );
  }

  async listDevices(userId: string): Promise<PlaybackDevice[]> {
    const owner = await this.ownerId(userId);
    const res = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date; online: boolean }>(
      `SELECT id, name, last_seen_at,
              last_seen_at > NOW() - ($2::int * INTERVAL '1 millisecond') AS online
         FROM playback_devices
        WHERE user_id = $1
        ORDER BY last_seen_at DESC`,
      [userId, DEVICE_STALE_MS]
    );
    return res.rows.map((row) => ({
      id: row.id,
      name: row.name,
      lastSeenAt: row.last_seen_at.toISOString(),
      online: row.online,
      isOwner: row.id === owner,
    }));
  }

  private async ownerId(userId: string): Promise<string | null> {
    const res = await this.pgPool.query<{ device_id: string | null }>(
      'SELECT device_id FROM playback_state WHERE user_id = $1', [userId]
    );
    return res.rows[0]?.device_id ?? null;
  }

  async getState(userId: string): Promise<PlaybackSnapshot> {
    const res = await this.pgPool.query<StateRow>(
      `INSERT INTO playback_state (user_id) VALUES ($1)
       ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
       RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
      [userId]
    );
    return toSnapshot(res.rows[0]);
  }

  /**
   * Record what the owning device is doing. A device that does not own the
   * session is rejected rather than ignored, so a stale tab resuming from sleep
   * learns it lost the audio instead of silently fighting the current owner.
   */
  async reportState(userId: string, deviceId: string, patch: PlaybackStatePatch): Promise<PlaybackSnapshot> {
    const current = await this.getState(userId);
    if (current.deviceId !== null && current.deviceId !== deviceId) throw new NotSessionOwnerError();

    const queue = patch.queue === undefined
      ? undefined
      : patch.queue.slice(0, MAX_SYNCED_QUEUE);

    // A track deleted between the device reading it and reporting it would
    // otherwise fail the foreign key and take the whole report down with it.
    // The session is worth more than the pointer: keep the rest, drop the id.
    const write = async (trackId: string | null) => this.pgPool.query<StateRow>(
      `UPDATE playback_state
          SET device_id   = $2,
              track_id    = COALESCE($3, CASE WHEN $4 THEN NULL ELSE track_id END),
              queue       = COALESCE($5::jsonb, queue),
              queue_index = COALESCE($6, queue_index),
              position_ms = COALESCE($7, position_ms),
              is_playing  = COALESCE($8, is_playing),
              -- Only the device driving the Vibe reports one, and it reports the
              -- absence of one just as explicitly: ending a Vibe and playing an
              -- album has to clear this, or the next owner would adopt a session
              -- nothing is playing any more.
              vibe_session_id = COALESCE($9, CASE WHEN $10 THEN NULL ELSE vibe_session_id END),
              version     = version + 1,
              updated_at  = NOW()
        WHERE user_id = $1
        RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
      [
        userId,
        deviceId,
        trackId,
        patch.trackId === null,
        queue === undefined ? null : JSON.stringify(queue),
        patch.queueIndex ?? null,
        patch.position === undefined ? null : clampPositionMs(patch.position, 0),
        patch.isPlaying ?? null,
        patch.vibeSessionId ?? null,
        patch.vibeSessionId === null,
      ]
    );

    let res: Awaited<ReturnType<typeof write>>;
    try {
      res = await write(patch.trackId ?? null);
    } catch (err) {
      if ((err as { code?: string }).code !== '23503') throw err;
      res = await write(null);
    }
    await this.touchDevice(userId, deviceId);
    await this.publish(userId);
    return toSnapshot(res.rows[0]);
  }

  /**
   * Aim a command at whichever device holds the audio. Any device may send one,
   * including the owner itself — that is what makes a phone a remote for the
   * desktop without either side knowing which is which.
   *
   * Ownership outlives a closed stream, so the owner is not necessarily
   * listening: a tab that was killed keeps the session until the sweep frees it.
   * Emitting into that gap answered the presser with a success it did not get,
   * so a command is only accepted while the owner has a stream to receive it on.
   */
  async sendCommand(userId: string, command: PlaybackCommand): Promise<{ deliveredTo: string | null }> {
    const owner = await this.ownerId(userId);
    if (!owner || !this.hasLiveStream(userId, owner)) return { deliveredTo: null };
    this.emitter.emit(userId, { type: 'command', deviceId: owner, command } satisfies PlaybackEvent);
    return { deliveredTo: owner };
  }

  /**
   * Move the audio to `deviceId`. The snapshot is untouched apart from the
   * owner, so the new device resumes the same track at the same position, and
   * the previous owner stops as soon as the state event reaches it.
   */
  async transfer(userId: string, deviceId: string): Promise<PlaybackSnapshot> {
    await this.getState(userId);
    const device = await this.pgPool.query(
      'SELECT id FROM playback_devices WHERE id = $1 AND user_id = $2', [deviceId, userId]
    );
    if (device.rows.length === 0) throw new Error('unknown device');

    const res = await this.pgPool.query<StateRow>(
      `UPDATE playback_state
          SET device_id = $2, version = version + 1, updated_at = NOW()
        WHERE user_id = $1
        RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
      [userId, deviceId]
    );
    await this.publish(userId);
    return toSnapshot(res.rows[0]);
  }

  /**
   * Release ownership when the owning device goes away, leaving the snapshot
   * intact so another device can pick the session up where it stopped.
   */
  async releaseIfOwner(userId: string, deviceId: string): Promise<void> {
    const res = await this.pgPool.query(
      `UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
              version = version + 1, updated_at = NOW()
        WHERE user_id = $1 AND device_id = $2`,
      [userId, deviceId]
    );
    if (res.rowCount) await this.publish(userId);
  }

  /** Drop devices that have not been seen for a day, and free a stale owner. */
  async reapStaleDevices(): Promise<number> {
    const stale = await this.pgPool.query<{ user_id: string }>(
      `UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
              version = version + 1, updated_at = NOW()
        WHERE device_id IN (
          SELECT id FROM playback_devices
           WHERE last_seen_at < NOW() - ($1::int * INTERVAL '1 millisecond')
        )
        RETURNING user_id`,
      [DEVICE_STALE_MS]
    );
    const removed = await this.pgPool.query(
      `DELETE FROM playback_devices WHERE last_seen_at < NOW() - INTERVAL '1 day'`
    );
    for (const row of stale.rows) await this.publish(row.user_id);
    return removed.rowCount ?? 0;
  }
}
