import { describe, expect, it } from 'vitest';
import type { Pool } from 'pg';
import {
  NotSessionOwnerError,
  PlaybackEvent,
  PlaybackSyncService,
} from './playback-sync.service.js';

const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const DESKTOP = '11111111-1111-4111-8111-111111111111';
const PHONE = '22222222-2222-4222-8222-222222222222';
const SESSION = '33333333-3333-4333-8333-333333333333';

/**
 * A pool stubbed down to the one row this service reasons about. The queries
 * themselves are plain SQL against a single table, so what is worth testing is
 * the ownership and fan-out logic sitting on top of them.
 */
function poolWith(state: {
  deviceId?: string | null;
  trackId?: string | null;
  vibeSessionId?: string | null;
  isPlaying?: boolean;
  positionMs?: number;
}) {
  const row = {
    device_id: state.deviceId ?? null,
    track_id: state.trackId ?? null,
    vibe_session_id: state.vibeSessionId ?? null,
    queue: [],
    queue_index: -1,
    position_ms: state.positionMs ?? 0,
    is_playing: state.isPlaying ?? false,
    version: '4',
    updated_at: new Date('2026-08-08T12:00:00Z'),
  };
  const queries: string[] = [];
  const pool = {
    async query(rawSql: string, params?: unknown[]) {
      queries.push(rawSql);
      // The service formats its SQL in columns, so match on collapsed text.
      const sql = rawSql.replace(/\s+/g, ' ').trim();
      if (sql.includes('FROM playback_devices') && sql.includes('ORDER BY')) {
        return { rows: [{ id: DESKTOP, name: 'Linux · Firefox', last_seen_at: row.updated_at, online: true }], rowCount: 1 };
      }
      if (sql.startsWith('UPDATE playback_devices SET name')) {
        return { rows: [{ id: params?.[0], name: params?.[2], last_seen_at: row.updated_at }], rowCount: 1 };
      }
      if (sql.startsWith('INSERT INTO playback_devices')) {
        return { rows: [{ id: PHONE, name: params?.[1], last_seen_at: row.updated_at }], rowCount: 1 };
      }
      if (sql.startsWith('SELECT device_id FROM playback_state')) {
        return { rows: [{ device_id: row.device_id }], rowCount: 1 };
      }
      if (sql.includes('SELECT id FROM playback_devices')) {
        return { rows: [{ id: params?.[0] }], rowCount: 1 };
      }
      if (sql.includes('UPDATE playback_state') && sql.includes('device_id = $2')) {
        row.device_id = String(params?.[1]);
        return { rows: [row], rowCount: 1 };
      }
      if (sql.includes('UPDATE playback_state')) {
        return { rows: [row], rowCount: 1 };
      }
      return { rows: [row], rowCount: 1 };
    },
  } as unknown as Pool;
  return { pool, queries, row };
}

describe('cross-device playback', () => {
  it('refuses a state report from a device that no longer holds the audio', async () => {
    const { pool } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);

    await expect(service.reportState(USER_ID, PHONE, { isPlaying: true }))
      .rejects.toBeInstanceOf(NotSessionOwnerError);
  });

  it('accepts the first device to report on an unowned session', async () => {
    const { pool } = poolWith({ deviceId: null });
    const service = new PlaybackSyncService(pool);

    const state = await service.reportState(USER_ID, PHONE, { isPlaying: true });
    expect(state.deviceId).toBe(PHONE);
  });

  it('publishes the Vibe session the owning device is driving', async () => {
    // Vibe control follows the audio, and this is how the next owner hears about
    // the session it is taking over.
    const { pool } = poolWith({ deviceId: DESKTOP, vibeSessionId: SESSION });
    const service = new PlaybackSyncService(pool);

    expect((await service.getState(USER_ID)).vibeSessionId).toBe(SESSION);
  });

  it('keeps a device its stored id across a reload', async () => {
    const { pool } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);

    const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
    expect(device.id).toBe(DESKTOP);
  });

  it('gives a second tab a device of its own', async () => {
    // Both tabs of a browser ask with the same stored id, and one id shared by
    // two pages is one device that runs every command twice.
    const { pool } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);
    service.claimStream(USER_ID, DESKTOP);

    const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
    expect(device.id).toBe(PHONE);
  });

  it('delivers a command to the owning device only', async () => {
    const { pool } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);
    const seen: PlaybackEvent[] = [];
    service.subscribe(USER_ID, (event) => seen.push(event));
    service.claimStream(USER_ID, DESKTOP);

    const result = await service.sendCommand(USER_ID, { type: 'pause' });

    expect(result.deliveredTo).toBe(DESKTOP);
    expect(seen).toContainEqual({ type: 'command', deviceId: DESKTOP, command: { type: 'pause' } });
  });

  it('reports no delivery when the owning device has no stream to receive on', async () => {
    // Ownership outlives a closed stream, so a killed tab still holds the
    // session. Answering 202 there told the presser a lie.
    const { pool } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);

    expect(await service.sendCommand(USER_ID, { type: 'pause' })).toEqual({ deliveredTo: null });
  });

  it('frees a device id once its stream closes, and not before', async () => {
    const { pool } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);

    const release = service.claimStream(USER_ID, DESKTOP);
    const alsoOpen = service.claimStream(USER_ID, DESKTOP);
    release();
    expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(true);
    alsoOpen();
    expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
    // A close arriving twice must not free an id a later stream is holding.
    release();
    expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
  });

  it('reports no delivery when nothing holds the audio', async () => {
    const { pool } = poolWith({ deviceId: null });
    const service = new PlaybackSyncService(pool);

    expect(await service.sendCommand(USER_ID, { type: 'play' })).toEqual({ deliveredTo: null });
  });

  it('hands the session to another device without losing the position', async () => {
    const { pool } = poolWith({ deviceId: DESKTOP, trackId: 'track-1', positionMs: 45_000, isPlaying: true });
    const service = new PlaybackSyncService(pool);
    const seen: PlaybackEvent[] = [];
    service.subscribe(USER_ID, (event) => seen.push(event));

    const state = await service.transfer(USER_ID, PHONE);

    expect(state.deviceId).toBe(PHONE);
    expect(state.position).toBe(45);
    expect(state.isPlaying).toBe(true);
    expect(seen.some((event) => event.type === 'state')).toBe(true);
  });

  it('frees ownership when the owning device goes away', async () => {
    const { pool, queries } = poolWith({ deviceId: DESKTOP });
    const service = new PlaybackSyncService(pool);

    await service.releaseIfOwner(USER_ID, DESKTOP);

    expect(queries.some((sql) => sql.includes('SET device_id = NULL'))).toBe(true);
  });
});
