import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import type { Socket } from 'node:net';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { HomeAssistantMusicAssistantIntegration, MusicAssistantClient, MusicAssistantConfigFlow, MusicAssistantIntegration, MusicAssistantMapper, createMusicAssistantDiscoveryDescriptor, musicAssistantProfile, type IMusicAssistantSnapshot, type TMusicAssistantRawData } from '../../ts/integrations/music_assistant/index.js';

const rawData: TMusicAssistantRawData = {
  device: {
    id: 'music_assistant-device-1',
    name: "Music Assistant Device",
    manufacturer: "Music Assistant",
    model: "Music Assistant local integration",
    serialNumber: 'music_assistant-serial-1',
  },
  entities: [
    { id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "music_assistant" } },
  ],
  online: true,
  updatedAt: '2026-01-01T00:00:00.000Z',
  source: 'manual',
};

tap.test('matches manual Music Assistant candidates and creates config flow output', async () => {
  const descriptor = createMusicAssistantDiscoveryDescriptor();
  const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'music_assistant-manual-match');
  const result = await matcher!.matches({ source: 'manual', id: 'music_assistant-device-1', name: "Music Assistant Device", metadata: { rawData } }, {});

  expect(result.matched).toBeTrue();
  expect(result.candidate?.integrationDomain).toEqual("music_assistant");

  const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
  expect(validation.matched).toBeTrue();

  const done = await (await new MusicAssistantConfigFlow().start(result.candidate!, {})).submit!({});
  expect(done.kind).toEqual('done');
  expect(done.config?.uniqueId).toEqual('music_assistant-device-1');
  expect(done.config?.rawData).toEqual(rawData);
});

tap.test('maps Music Assistant raw snapshots to runtime devices and entities', async () => {
  const client = new MusicAssistantClient({ name: "Music Assistant Runtime", rawData });
  const snapshot = await client.getSnapshot();
  const mappedSnapshot = MusicAssistantMapper.toSnapshotFromRaw({ name: "Music Assistant Runtime" }, rawData);
  const devices = MusicAssistantMapper.toDevices(mappedSnapshot);
  const entities = MusicAssistantMapper.toEntities(mappedSnapshot);

  expect(snapshot.online).toBeTrue();
  expect(mappedSnapshot.source).toEqual('manual');
  expect(devices[0].integrationDomain).toEqual("music_assistant");
  expect(devices[0].manufacturer).toEqual("Music Assistant");
  expect(entities.some((entityArg) => entityArg.integrationDomain === "music_assistant" && entityArg.platform === "button")).toBeTrue();
});

tap.test('reads Music Assistant /info and WebSocket commands over the local API', async () => {
  const commands: string[] = [];
  const info = {
    server_id: 'mass-1',
    server_version: '2.8.0',
    schema_version: 28,
    min_supported_schema_version: 24,
    base_url: 'http://127.0.0.1:8095',
    homeassistant_addon: false,
    onboard_done: true,
    name: 'Test MASS',
    status: 'running',
  };
  const player = {
    player_id: 'kitchen',
    provider: 'builtin',
    type: 'player',
    name: 'Kitchen Speaker',
    available: true,
    playback_state: 'playing',
    powered: true,
    volume_level: 55,
    volume_muted: false,
    active_source: 'kitchen',
    current_media: { uri: 'library://track/1', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', duration: 180, elapsed_time: 12 },
    device_info: { model: 'Speaker', manufacturer: 'Acme Audio', identifiers: { ip_address: '127.0.0.1' } },
    options: [{ key: 'bass', name: 'Bass', type: 'integer', value: 2, min_value: -10, max_value: 10, step: 1 }],
  };
  const queue = {
    queue_id: 'kitchen',
    active: true,
    display_name: 'Kitchen Speaker',
    available: true,
    items: 1,
    shuffle_enabled: false,
    repeat_mode: 'off',
    current_index: 0,
    elapsed_time: 12,
    state: 'playing',
    current_item: { name: 'Test Song', duration: 180, uri: 'library://track/1', media_item: { name: 'Test Song', uri: 'library://track/1' } },
  };
  const searchResult = { artists: [], albums: [], tracks: [{ media_type: 'track', uri: 'library://track/1', name: 'Test Song', version: '', image: null }], playlists: [], radio: [], audiobooks: [], podcasts: [] };
  const sockets = new Set<Socket>();
  const server = createServer((requestArg, responseArg) => {
    responseArg.setHeader('content-type', 'application/json');
    if (requestArg.url === '/info') {
      responseArg.end(JSON.stringify(info));
      return;
    }
    responseArg.statusCode = 404;
    responseArg.end(JSON.stringify({ error: 'not found' }));
  });
  server.on('upgrade', (requestArg, socketArg) => {
    const socket = socketArg as Socket;
    sockets.add(socket);
    socket.on('close', () => sockets.delete(socket));
    acceptWebSocket(socket, String(requestArg.headers['sec-websocket-key'] || ''));
    socket.write(webSocketTextFrame(JSON.stringify(info)));
    socket.on('data', (chunkArg) => {
      for (const message of readWebSocketTextFrames(Buffer.from(chunkArg))) {
        const request = JSON.parse(message) as { message_id: string; command: string; args?: Record<string, unknown> };
        commands.push(request.command);
        const result = request.command === 'auth'
          ? true
          : request.command === 'players/all'
            ? [player]
            : request.command === 'player_queues/all'
              ? [queue]
              : request.command === 'music/search'
                ? searchResult
                : null;
        socket.write(webSocketTextFrame(JSON.stringify({ message_id: request.message_id, result })));
      }
    });
  });

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  try {
    const address = server.address();
    const port = typeof address === 'object' && address ? address.port : 0;
    const url = `http://127.0.0.1:${port}`;
    const client = new MusicAssistantClient({ url, token: 'local-token', timeoutMs: 1000 });
    const snapshot = await client.getSnapshot(true);
    const runtime = await new MusicAssistantIntegration().setup({ url, token: 'local-token', timeoutMs: 1000 }, {});
    const entities = await runtime.entities();
    const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: {}, data: { player_id: 'kitchen' } });
    const search = await runtime.callService!({ domain: 'music_assistant', service: 'search', target: {}, data: { name: 'Test Song', media_type: 'track', limit: 1 } });

    expect(snapshot.error).toEqual(undefined);
    expect(snapshot.online).toBeTrue();
    expect(snapshot.source).toEqual('http');
    expect(snapshot.device.id).toEqual('mass-1');
    expect(snapshot.entities.find((entityArg) => entityArg.id === 'player_kitchen')?.state).toEqual('playing');
    expect(snapshot.entities.find((entityArg) => entityArg.id === 'player_kitchen_option_bass')?.platform).toEqual('number');
    expect(entities.some((entityArg) => entityArg.id === 'media_player.test_mass_player_kitchen' && entityArg.state === 'playing')).toBeTrue();
    expect(play.success).toBeTrue();
    expect(search.success).toBeTrue();
    expect(commands.includes('auth')).toBeTrue();
    expect(commands.includes('players/all')).toBeTrue();
    expect(commands.includes('player_queues/all')).toBeTrue();
    expect(commands.includes('players/cmd/play')).toBeTrue();
    expect(commands.includes('music/search')).toBeTrue();
    await runtime.destroy();
  } finally {
    for (const socket of sockets) {
      socket.destroy();
    }
    await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
  }
});

tap.test('exposes Music Assistant runtime, HA alias, and unsupported control without executor', async () => {
  const integration = new MusicAssistantIntegration();
  const alias = new HomeAssistantMusicAssistantIntegration();
  expect(alias instanceof MusicAssistantIntegration).toBeTrue();
  expect(alias.domain).toEqual("music_assistant");
  expect(integration.status).toEqual("control-runtime");
  expect(musicAssistantProfile.metadata.configFlow).toEqual(true);
  expect(musicAssistantProfile.metadata.requirements).toEqual([
    "music-assistant-client==1.3.5",
  ]);
  expect(String((musicAssistantProfile.metadata.localApi as Record<string, unknown>).status)).toContain('/info');

  const runtime = await integration.setup({ name: "Music Assistant Runtime", rawData }, {});
  const statusResult = await runtime.callService!({ domain: "music_assistant", service: 'status', target: {} });
  const refresh = await runtime.callService!({ domain: "music_assistant", service: 'refresh', target: {} });
  const snapshot = statusResult.data as IMusicAssistantSnapshot;

  expect(statusResult.success).toBeTrue();
  expect(refresh.success).toBeTrue();
  expect(snapshot.online).toBeTrue();
  expect((await runtime.devices())[0].name).toEqual("Music Assistant Device");

  const command = await runtime.callService!({ domain: "music_assistant", service: musicAssistantProfile.controlServices?.[0] || 'turn_on', target: {} });
  expect(command.success).toBeFalse();
  expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
  await runtime.destroy();
});

const acceptWebSocket = (socketArg: Socket, keyArg: string): void => {
  const accept = createHash('sha1').update(`${keyArg}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64');
  socketArg.write([
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    `Sec-WebSocket-Accept: ${accept}`,
    '',
    '',
  ].join('\r\n'));
};

const webSocketTextFrame = (textArg: string): Buffer => {
  const payload = Buffer.from(textArg, 'utf8');
  if (payload.length < 126) {
    return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);
  }
  const header = Buffer.alloc(4);
  header[0] = 0x81;
  header[1] = 126;
  header.writeUInt16BE(payload.length, 2);
  return Buffer.concat([header, payload]);
};

const readWebSocketTextFrames = (bufferArg: Buffer): string[] => {
  const messages: string[] = [];
  let offset = 0;
  while (offset + 2 <= bufferArg.length) {
    const opcode = bufferArg[offset] & 0x0f;
    const masked = (bufferArg[offset + 1] & 0x80) !== 0;
    let length = bufferArg[offset + 1] & 0x7f;
    offset += 2;
    if (length === 126) {
      length = bufferArg.readUInt16BE(offset);
      offset += 2;
    } else if (length === 127) {
      length = Number(bufferArg.readBigUInt64BE(offset));
      offset += 8;
    }
    const mask = masked ? bufferArg.subarray(offset, offset + 4) : undefined;
    offset += masked ? 4 : 0;
    const payload = Buffer.from(bufferArg.subarray(offset, offset + length));
    offset += length;
    if (opcode !== 1) {
      continue;
    }
    if (mask) {
      for (let index = 0; index < payload.length; index++) {
        payload[index] ^= mask[index % 4];
      }
    }
    messages.push(payload.toString('utf8'));
  }
  return messages;
};

export default tap.start();
