import { FastifyInstance, FastifyRequest } from 'fastify';
import {
  NotSessionOwnerError,
  PlaybackCommand,
  PlaybackCommandType,
  PlaybackStatePatch,
  PlaybackSyncService,
} from '../services/playback-sync.service.js';

const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const COMMAND_TYPES: PlaybackCommandType[] = ['play', 'pause', 'next', 'prev', 'seek', 'play_track'];
/** Well inside the 90s staleness window, and enough to keep proxies from closing an idle stream. */
const HEARTBEAT_MS = 25_000;

type Body = Record<string, unknown>;

function isObject(value: unknown): value is Body {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function userIdFrom(request: FastifyRequest): string {
  const header = request.headers['x-user-id'];
  return typeof header === 'string' && UUID_RE.test(header) ? header : DEFAULT_USER_ID;
}

function parsePatch(body: unknown): PlaybackStatePatch | { error: string } {
  const input = isObject(body) ? body : {};
  const patch: PlaybackStatePatch = {};
  if (input.trackId !== undefined) {
    if (input.trackId !== null && !(typeof input.trackId === 'string' && UUID_RE.test(input.trackId))) {
      return { error: 'trackId must be a UUID or null' };
    }
    patch.trackId = input.trackId as string | null;
  }
  if (input.vibeSessionId !== undefined) {
    if (input.vibeSessionId !== null && !(typeof input.vibeSessionId === 'string' && UUID_RE.test(input.vibeSessionId))) {
      return { error: 'vibeSessionId must be a UUID or null' };
    }
    patch.vibeSessionId = input.vibeSessionId as string | null;
  }
  if (input.queue !== undefined) {
    if (!Array.isArray(input.queue)) return { error: 'queue must be an array' };
    patch.queue = input.queue;
  }
  if (input.queueIndex !== undefined) {
    if (typeof input.queueIndex !== 'number' || !Number.isInteger(input.queueIndex)) {
      return { error: 'queueIndex must be an integer' };
    }
    patch.queueIndex = input.queueIndex;
  }
  if (input.position !== undefined) {
    if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
      return { error: 'position must be a non-negative number of seconds' };
    }
    patch.position = input.position;
  }
  if (input.isPlaying !== undefined) {
    if (typeof input.isPlaying !== 'boolean') return { error: 'isPlaying must be a boolean' };
    patch.isPlaying = input.isPlaying;
  }
  return patch;
}

function parseCommand(body: unknown): PlaybackCommand | { error: string } {
  const input = isObject(body) ? body : {};
  const type = input.type;
  if (typeof type !== 'string' || !COMMAND_TYPES.includes(type as PlaybackCommandType)) {
    return { error: `type must be one of ${COMMAND_TYPES.join(', ')}` };
  }
  const command: PlaybackCommand = { type: type as PlaybackCommandType };
  if (type === 'seek') {
    if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
      return { error: 'seek requires a non-negative position in seconds' };
    }
    command.position = input.position;
  }
  if (type === 'play_track') {
    if (typeof input.trackId !== 'string' || !UUID_RE.test(input.trackId)) {
      return { error: 'play_track requires a trackId' };
    }
    command.trackId = input.trackId;
  }
  return command;
}

export default async function playbackRoutes(
  fastify: FastifyInstance,
  options: { playbackSync: PlaybackSyncService }
) {
  const { playbackSync } = options;

  fastify.post('/playback/devices', async (request, reply) => {
    const input = isObject(request.body) ? request.body : {};
    const name = typeof input.name === 'string' ? input.name : '';
    const deviceId = typeof input.deviceId === 'string' && UUID_RE.test(input.deviceId) ? input.deviceId : null;
    const device = await playbackSync.registerDevice(userIdFrom(request), name, deviceId);
    return reply.code(200).send(device);
  });

  fastify.get('/playback/devices', async (request, reply) => {
    return reply.code(200).send({ devices: await playbackSync.listDevices(userIdFrom(request)) });
  });

  fastify.get('/playback/state', async (request, reply) => {
    const userId = userIdFrom(request);
    const [state, devices] = await Promise.all([
      playbackSync.getState(userId),
      playbackSync.listDevices(userId),
    ]);
    return reply.code(200).send({ state, devices });
  });

  fastify.post('/playback/state', async (request, reply) => {
    const input = isObject(request.body) ? request.body : {};
    const deviceId = input.deviceId;
    if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
      return reply.code(400).send({ error: 'deviceId must be a UUID' });
    }
    const patch = parsePatch(input);
    if ('error' in patch) return reply.code(400).send({ error: patch.error });
    try {
      const state = await playbackSync.reportState(userIdFrom(request), deviceId, patch);
      return reply.code(200).send({ state });
    } catch (err) {
      if (err instanceof NotSessionOwnerError) {
        // 409, not 403: the device is allowed here, it is simply no longer the
        // one holding the audio, and its own state report is the stale thing.
        return reply.code(409).send({ error: err.message });
      }
      throw err;
    }
  });

  fastify.post('/playback/command', async (request, reply) => {
    const command = parseCommand(request.body);
    if ('error' in command) return reply.code(400).send({ error: command.error });
    const result = await playbackSync.sendCommand(userIdFrom(request), command);
    if (!result.deliveredTo) return reply.code(409).send({ error: 'no device is holding playback' });
    return reply.code(202).send(result);
  });

  fastify.post('/playback/transfer', async (request, reply) => {
    const input = isObject(request.body) ? request.body : {};
    const deviceId = input.deviceId;
    if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
      return reply.code(400).send({ error: 'deviceId must be a UUID' });
    }
    try {
      return reply.code(200).send({ state: await playbackSync.transfer(userIdFrom(request), deviceId) });
    } catch {
      return reply.code(404).send({ error: 'unknown device' });
    }
  });

  fastify.post('/playback/release', async (request, reply) => {
    const input = isObject(request.body) ? request.body : {};
    const deviceId = input.deviceId;
    if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
      return reply.code(400).send({ error: 'deviceId must be a UUID' });
    }
    await playbackSync.releaseIfOwner(userIdFrom(request), deviceId);
    return reply.code(204).send();
  });

  /**
   * The push channel. Every device holds one of these open: it receives the
   * session snapshot on connect, every later change, and the commands aimed at
   * it. The periodic comment line doubles as the device's liveness heartbeat,
   * so an open stream is what "this device is online" means.
   */
  fastify.get('/playback/stream', async (request, reply) => {
    const userId = userIdFrom(request);
    const query = request.query as { deviceId?: string };
    const deviceId = typeof query.deviceId === 'string' && UUID_RE.test(query.deviceId) ? query.deviceId : null;
    if (!deviceId) return reply.code(400).send({ error: 'deviceId must be a UUID' });

    // The response is written straight to the socket and lives for as long as
    // the tab does. Without this Fastify still believes it owes a reply and
    // waits on a handler that resolves with nothing.
    reply.hijack();
    const releaseStream = playbackSync.claimStream(userId, deviceId);

    reply.raw.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
      // nginx buffers event streams into uselessness without this.
      'X-Accel-Buffering': 'no',
    });

    const write = (payload: unknown) => {
      reply.raw.write(`data: ${JSON.stringify(payload)}\n\n`);
    };

    const unsubscribe = playbackSync.subscribe(userId, (event) => {
      if (event.type === 'command' && event.deviceId !== deviceId) return;
      write(event);
    });

    // The reply is hijacked, so a throw from here reaches no error handler that
    // could answer it. Close the stream instead and let the client reopen.
    try {
      const [state, devices] = await Promise.all([
        playbackSync.getState(userId),
        playbackSync.listDevices(userId),
      ]);
      write({ type: 'state', state, devices });
    } catch (err) {
      request.log.error(err);
      unsubscribe();
      releaseStream();
      reply.raw.end();
      return;
    }

    const heartbeat = setInterval(() => {
      reply.raw.write(': ping\n\n');
      playbackSync.touchDevice(userId, deviceId).catch(() => {});
    }, HEARTBEAT_MS);

    request.raw.on('close', () => {
      clearInterval(heartbeat);
      unsubscribe();
      releaseStream();
      // Ownership deliberately survives a closed stream. A phone changing cell,
      // locking its screen or dozing drops this connection for a few seconds
      // while its audio keeps playing; releasing here published an unowned
      // session, which the phone then read as "something else took over" and
      // paused itself. A device that is really gone loses the session two other
      // ways: `pagehide` releases it outright, and the stale-device sweep frees
      // an owner whose heartbeat has been silent past DEVICE_STALE_MS.
    });
  });
}
