import * as plugins from '../../plugins.js';
import type {
  ISqueezeboxAlarm,
  ISqueezeboxCliResponse,
  ISqueezeboxCommandRequest,
  ISqueezeboxConfig,
  ISqueezeboxFavorite,
  ISqueezeboxJsonRpcRequest,
  ISqueezeboxJsonRpcResponse,
  ISqueezeboxPlayer,
  ISqueezeboxServerInfo,
  ISqueezeboxSnapshot,
  ISqueezeboxSyncGroup,
  ISqueezeboxTrack,
  TSqueezeboxSnapshotSource,
} from './squeezebox.types.js';
import { squeezeboxDefaultBrowseLimit, squeezeboxDefaultCliPort, squeezeboxDefaultHttpPort, squeezeboxDefaultTimeoutMs, squeezeboxDefaultVolumeStep } from './squeezebox.types.js';

export class SqueezeboxCommandError extends Error {
  constructor(public readonly command: string[], messageArg: string) {
    super(`Squeezebox command ${command.join(' ')} failed: ${messageArg}`);
    this.name = 'SqueezeboxCommandError';
  }
}

export class SqueezeboxClient {
  private nextId = 1;
  private currentSnapshot?: ISqueezeboxSnapshot;
  private restorePoint?: ISqueezeboxSnapshot;

  constructor(private readonly config: ISqueezeboxConfig) {
    this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot), 'snapshot') : undefined;
  }

  public async getSnapshot(): Promise<ISqueezeboxSnapshot> {
    if (this.currentSnapshot) {
      return this.normalizeSnapshot(this.cloneSnapshot(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
    }
    if (!this.config.host && !this.config.commandExecutor) {
      return this.normalizeSnapshot(this.snapshotFromConfig(false, 'Squeezebox refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
    }

    try {
      return await this.fetchSnapshot();
    } catch (errorArg) {
      return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
    }
  }

  public async validateConnection(): Promise<ISqueezeboxSnapshot> {
    const snapshot = await this.fetchSnapshot();
    if (!snapshot.server.uuid && !snapshot.server.id) {
      throw new Error('Lyrion Music Server did not provide a unique identifier.');
    }
    return snapshot;
  }

  public async execute(requestArg: ISqueezeboxCommandRequest): Promise<unknown> {
    if (requestArg.command === 'play') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['play']);
    }
    if (requestArg.command === 'pause') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['pause', '1']);
    }
    if (requestArg.command === 'play_pause') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['pause']);
    }
    if (requestArg.command === 'stop') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['stop']);
    }
    if (requestArg.command === 'next_track') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['playlist', 'index', '+1']);
    }
    if (requestArg.command === 'previous_track') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['playlist', 'index', '-1']);
    }
    if (requestArg.command === 'seek') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['time', String(this.requiredNumber(requestArg.position, 'Squeezebox seek requires position.'))]);
    }
    if (requestArg.command === 'set_power') {
      if (typeof requestArg.powered !== 'boolean') {
        throw new Error('Squeezebox set_power requires powered.');
      }
      return this.playerQuery(this.requiredPlayerId(requestArg), ['power', requestArg.powered ? '1' : '0']);
    }
    if (requestArg.command === 'set_volume') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', String(this.volumePercent(requestArg))]);
    }
    if (requestArg.command === 'volume_up') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', `+${this.volumeStep(requestArg.step)}`]);
    }
    if (requestArg.command === 'volume_down') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', `-${this.volumeStep(requestArg.step)}`]);
    }
    if (requestArg.command === 'mute') {
      if (typeof requestArg.muted !== 'boolean') {
        throw new Error('Squeezebox mute requires muted.');
      }
      return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'muting', requestArg.muted ? '1' : '0']);
    }
    if (requestArg.command === 'select_source') {
      return this.selectSource(this.requiredPlayerId(requestArg), this.requiredString(requestArg.source, 'Squeezebox select_source requires source.'));
    }
    if (requestArg.command === 'play_media') {
      return this.playMedia(this.requiredPlayerId(requestArg), this.requiredString(requestArg.mediaId, 'Squeezebox play_media requires mediaId.'), requestArg.enqueue);
    }
    if (requestArg.command === 'sync') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['sync', this.requiredString(requestArg.targetPlayerId, 'Squeezebox sync requires targetPlayerId.')]);
    }
    if (requestArg.command === 'unsync') {
      return this.playerQuery(this.requiredPlayerId(requestArg), ['sync', '-']);
    }
    if (requestArg.command === 'raw_query') {
      const params = requestArg.parameters || [];
      if (!params.length || params.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
        throw new Error('Squeezebox raw_query requires string, number, or boolean parameters.');
      }
      return this.playerQuery(requestArg.playerId, params.map((itemArg) => String(itemArg)));
    }
    throw new Error(`Unsupported Squeezebox command: ${requestArg.command}`);
  }

  public async query(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
    return this.playerQuery(playerIdArg, commandArg);
  }

  public async snapshot(): Promise<ISqueezeboxSnapshot> {
    this.restorePoint = await this.getSnapshot();
    return this.cloneSnapshot(this.restorePoint);
  }

  public async restore(snapshotArg = this.restorePoint): Promise<void> {
    if (!snapshotArg) {
      throw new Error('Squeezebox restore requires a prior snapshot.');
    }
    for (const player of snapshotArg.players) {
      if (typeof player.volume === 'number') {
        await this.execute({ command: 'set_volume', playerId: player.playerId, volume: player.volume });
      }
      if (typeof player.muting === 'boolean') {
        await this.execute({ command: 'mute', playerId: player.playerId, muted: player.muting });
      }
      if (typeof player.power === 'boolean') {
        await this.execute({ command: 'set_power', playerId: player.playerId, powered: player.power });
      }
      if (player.mode === 'play') {
        await this.execute({ command: 'play', playerId: player.playerId });
      } else if (player.mode === 'pause') {
        await this.execute({ command: 'pause', playerId: player.playerId });
      } else if (player.mode === 'stop') {
        await this.execute({ command: 'stop', playerId: player.playerId });
      }
    }
  }

  public async destroy(): Promise<void> {}

  private async fetchSnapshot(): Promise<ISqueezeboxSnapshot> {
    const serverStatus = await this.playerQuery(undefined, ['serverstatus', '-', '-', 'prefs:libraryname']);
    const players = await this.playersFromStatus(serverStatus);
    const [favorites, syncGroups] = await Promise.all([
      this.fetchFavorites().catch(() => []),
      this.fetchSyncGroups(players).catch(() => this.syncGroupsFromPlayers(players)),
    ]);
    const source = this.config.commandExecutor ? 'executor' : this.config.transport === 'cli' ? 'cli' : 'jsonrpc';
    return this.normalizeSnapshot({
      server: this.serverFromStatus(serverStatus),
      players,
      favorites,
      syncGroups,
      online: true,
      updatedAt: new Date().toISOString(),
      source,
      raw: { serverStatus },
    }, source);
  }

  private async playersFromStatus(serverStatusArg: Record<string, unknown>): Promise<ISqueezeboxPlayer[]> {
    const loop = arrayRecords(valueForKeys(serverStatusArg, ['players_loop', 'player_loop', 'players']));
    const basePlayers = loop.length ? loop.map((itemArg) => this.playerFromData(itemArg)) : await this.fetchPlayers();
    const refreshed = await Promise.all(basePlayers.map(async (playerArg) => ({
      ...playerArg,
      ...(await this.fetchPlayerStatus(playerArg.playerId).catch(() => undefined)),
    })));
    return refreshed.map((playerArg) => this.normalizePlayer(playerArg));
  }

  private async fetchPlayers(): Promise<ISqueezeboxPlayer[]> {
    const response = await this.playerQuery(undefined, ['players', '0', String(this.config.browseLimit || squeezeboxDefaultBrowseLimit)]);
    const loop = arrayRecords(valueForKeys(response, ['players_loop', 'player_loop', 'players']));
    return loop.map((itemArg) => this.playerFromData(itemArg));
  }

  private async fetchPlayerStatus(playerIdArg: string): Promise<Partial<ISqueezeboxPlayer>> {
    const response = await this.playerQuery(playerIdArg, ['status', '-', '1', 'tags:adKlJytxN']);
    return this.playerFromData({ ...response, playerid: playerIdArg });
  }

  private async fetchFavorites(): Promise<ISqueezeboxFavorite[]> {
    const response = await this.playerQuery(undefined, ['favorites', 'items', '0', String(this.config.browseLimit || squeezeboxDefaultBrowseLimit)]);
    const loop = arrayRecords(valueForKeys(response, ['loop_loop', 'favorites_loop', 'items_loop', 'items', 'favorites']));
    return loop.map((itemArg, indexArg) => this.favoriteFromData(itemArg, indexArg));
  }

  private async fetchSyncGroups(playersArg: ISqueezeboxPlayer[]): Promise<ISqueezeboxSyncGroup[]> {
    const response = await this.playerQuery(undefined, ['syncgroups', '?']);
    const loop = arrayRecords(valueForKeys(response, ['syncgroups_loop', 'sync_groups', 'groups']));
    const groups = loop.map((itemArg, indexArg) => this.syncGroupFromData(itemArg, indexArg));
    return groups.length ? groups : this.syncGroupsFromPlayers(playersArg);
  }

  private async selectSource(playerIdArg: string, sourceArg: string): Promise<unknown> {
    const snapshot = await this.getSnapshot();
    const favorite = (snapshot.favorites || []).find((favoriteArg) => favoriteArg.name === sourceArg || favoriteArg.id === sourceArg || favoriteArg.itemId === sourceArg || favoriteArg.url === sourceArg);
    if (favorite?.url) {
      return this.playerQuery(playerIdArg, ['playlist', 'play', favorite.url]);
    }
    if (favorite?.itemId) {
      return this.playerQuery(playerIdArg, ['favorites', 'playlist', 'play', `item_id:${favorite.itemId}`]);
    }
    if (sourceLike(sourceArg) || isUrl(sourceArg)) {
      return this.playerQuery(playerIdArg, ['playlist', 'play', sourceArg]);
    }
    throw new Error(`Unknown Squeezebox source: ${sourceArg}`);
  }

  private async playMedia(playerIdArg: string, mediaIdArg: string, enqueueArg: 'play' | 'add' | 'next' = 'play'): Promise<unknown> {
    const command = enqueueArg === 'add' ? 'add' : enqueueArg === 'next' ? 'insert' : 'play';
    return this.playerQuery(playerIdArg, ['playlist', command, mediaIdArg]);
  }

  private async playerQuery(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
    if (!commandArg.length || commandArg.some((itemArg) => !itemArg)) {
      throw new Error('Squeezebox command parameters must be non-empty strings.');
    }
    const transport = this.config.transport || 'jsonrpc';
    if (transport === 'snapshot' || this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
      throw new Error('Squeezebox command transport requires config.host or commandExecutor. Static snapshots are read-only.');
    }
    if (transport === 'cli') {
      return this.cliResponseToRecord(await this.requestCli(playerIdArg, commandArg));
    }
    return this.requestJsonRpc(playerIdArg, commandArg);
  }

  private async requestJsonRpc(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
    const host = this.config.host;
    const port = this.config.port || squeezeboxDefaultHttpPort;
    const endpoint = host ? `${this.config.https ? 'https' : 'http'}://${formatHost(host)}:${port}/jsonrpc.js` : undefined;
    const body: ISqueezeboxJsonRpcRequest = {
      id: this.nextId++,
      method: 'slim.request',
      params: [playerIdArg || '', commandArg],
    };
    if (this.config.commandExecutor) {
      return this.executorResultToRecord(await this.config.commandExecutor.execute({
        transport: 'jsonrpc',
        host,
        port,
        endpoint,
        playerId: playerIdArg,
        command: commandArg,
        body,
      }), commandArg);
    }
    if (!host || !endpoint) {
      throw new Error('Squeezebox HTTP JSON-RPC requires config.host or commandExecutor.');
    }

    const controller = new AbortController();
    const timeout = globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs || squeezeboxDefaultTimeoutMs);
    try {
      const headers: Record<string, string> = {
        accept: 'application/json',
        'content-type': 'application/json',
      };
      if (this.config.username || this.config.password) {
        headers.authorization = `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`).toString('base64')}`;
      }
      const response = await globalThis.fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
        signal: controller.signal,
      });
      const text = await response.text();
      if (!response.ok) {
        throw new Error(`Squeezebox JSON-RPC failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
      }
      if (!text.trim()) {
        throw new Error('Squeezebox JSON-RPC returned an empty response.');
      }
      return this.jsonRpcResponseToRecord(JSON.parse(text) as ISqueezeboxJsonRpcResponse, commandArg);
    } finally {
      globalThis.clearTimeout(timeout);
    }
  }

  private async requestCli(playerIdArg: string | undefined, commandArg: string[]): Promise<ISqueezeboxCliResponse> {
    const host = this.config.host;
    const port = this.config.cliPort || this.config.port || squeezeboxDefaultCliPort;
    const cliLine = this.cliLine(playerIdArg, commandArg);
    if (this.config.commandExecutor) {
      return this.executorResultToCliResponse(await this.config.commandExecutor.execute({
        transport: 'cli',
        host,
        port,
        playerId: playerIdArg,
        command: commandArg,
        cliLine,
      }), playerIdArg, commandArg);
    }
    if (!host) {
      throw new Error('Squeezebox CLI command requires config.host or commandExecutor.');
    }
    const timeoutMs = this.config.timeoutMs || squeezeboxDefaultTimeoutMs;
    return new Promise<ISqueezeboxCliResponse>((resolve, reject) => {
      let buffer = '';
      let settled = false;
      let authenticated = !this.config.username && !this.config.password;
      let commandSent = false;
      const socket = plugins.net.createConnection({ host, port });

      const finish = (errorArg?: Error, responseArg?: ISqueezeboxCliResponse) => {
        if (settled) {
          return;
        }
        settled = true;
        socket.removeAllListeners();
        socket.destroy();
        if (errorArg) {
          reject(errorArg);
          return;
        }
        resolve(responseArg as ISqueezeboxCliResponse);
      };

      const writeLine = (lineArg: string) => socket.write(`${lineArg}\n`);
      const handleLine = (lineArg: string) => {
        if (!lineArg.trim()) {
          return;
        }
        if (!authenticated) {
          authenticated = true;
          commandSent = true;
          writeLine(cliLine);
          return;
        }
        if (!commandSent) {
          return;
        }
        finish(undefined, this.parseCliLine(lineArg, playerIdArg, commandArg));
      };

      socket.setEncoding('utf8');
      socket.setTimeout(timeoutMs, () => finish(new Error(`Squeezebox CLI command timed out after ${timeoutMs}ms.`)));
      socket.on('connect', () => {
        if (this.config.username || this.config.password) {
          writeLine(this.cliLine(undefined, ['login', this.config.username || '', this.config.password || '']));
        } else {
          commandSent = true;
          writeLine(cliLine);
        }
      });
      socket.on('error', (errorArg) => finish(errorArg));
      socket.on('close', () => finish(new Error('Squeezebox CLI connection closed before a response was received.')));
      socket.on('data', (chunkArg) => {
        buffer += chunkArg;
        const lines = buffer.split(/\r?\n/);
        buffer = lines.pop() || '';
        lines.forEach(handleLine);
      });
    });
  }

  private serverFromStatus(statusArg: Record<string, unknown>): ISqueezeboxServerInfo {
    const host = this.config.host;
    const name = this.config.name || stringValue(valueForKeys(statusArg, ['libraryname', 'prefs:libraryname', 'name', 'server_name'])) || host || 'Lyrion Music Server';
    const uuid = stringValue(valueForKeys(statusArg, ['uuid', 'server_uuid']));
    return {
      id: this.config.serverId || uuid || (host ? `${host}:${this.config.port || squeezeboxDefaultHttpPort}` : name),
      uuid,
      mac: stringValue(valueForKeys(statusArg, ['mac', 'server_mac'])),
      name,
      libraryName: stringValue(valueForKeys(statusArg, ['libraryname', 'prefs:libraryname'])),
      host,
      port: this.config.port || (host ? squeezeboxDefaultHttpPort : undefined),
      version: stringValue(valueForKeys(statusArg, ['version', 'server_version'])),
      manufacturer: 'Lyrion',
      model: 'Lyrion Music Server',
      playerCount: numberValue(valueForKeys(statusArg, ['player count', 'player_count', 'players', 'playercount'])),
      otherPlayerCount: numberValue(valueForKeys(statusArg, ['other player count', 'other_player_count'])),
      rescan: booleanValue(valueForKeys(statusArg, ['rescan'])),
      needsRestart: booleanValue(valueForKeys(statusArg, ['needsrestart', 'needs_restart'])),
      stats: {
        totalAlbums: numberValue(valueForKeys(statusArg, ['info total albums', 'info_total_albums', 'albums'])),
        totalArtists: numberValue(valueForKeys(statusArg, ['info total artists', 'info_total_artists', 'artists'])),
        totalDuration: numberValue(valueForKeys(statusArg, ['info total duration', 'info_total_duration', 'duration'])),
        totalGenres: numberValue(valueForKeys(statusArg, ['info total genres', 'info_total_genres', 'genres'])),
        totalSongs: numberValue(valueForKeys(statusArg, ['info total songs', 'info_total_songs', 'songs'])),
        lastScan: stringValue(valueForKeys(statusArg, ['lastscan', 'last_scan'])) || numberValue(valueForKeys(statusArg, ['lastscan', 'last_scan'])),
      },
      raw: statusArg,
    };
  }

  private playerFromData(dataArg: Record<string, unknown>): ISqueezeboxPlayer {
    const playerId = stringValue(valueForKeys(dataArg, ['playerid', 'player_id', 'id', 'playerId'])) || 'unknown';
    const playlist = this.playlistFromData(dataArg);
    return this.normalizePlayer({
      playerId,
      uuid: stringValue(valueForKeys(dataArg, ['uuid', 'player_uuid'])),
      name: stringValue(valueForKeys(dataArg, ['name', 'player_name'])) || playerId,
      model: stringValue(valueForKeys(dataArg, ['model', 'modelname', 'player_model'])) || 'Squeezebox Player',
      modelType: stringValue(valueForKeys(dataArg, ['model_type', 'modelType', 'displaytype'])),
      manufacturer: stringValue(valueForKeys(dataArg, ['manufacturer'])) || 'Logitech',
      creator: stringValue(valueForKeys(dataArg, ['creator'])),
      firmware: stringValue(valueForKeys(dataArg, ['firmware', 'firmware_version', 'player_version'])),
      ipAddress: cleanIp(stringValue(valueForKeys(dataArg, ['ip', 'ipAddress', 'player_ip']))),
      connected: booleanValue(valueForKeys(dataArg, ['connected', 'player_connected'])),
      power: booleanValue(valueForKeys(dataArg, ['power'])),
      mode: stringValue(valueForKeys(dataArg, ['mode', 'playmode'])) as ISqueezeboxPlayer['mode'],
      volume: numberValue(valueForKeys(dataArg, ['volume', 'mixer volume'])),
      muting: booleanValue(valueForKeys(dataArg, ['muting', 'mixer muting', 'muted'])),
      repeat: this.repeatFromValue(valueForKeys(dataArg, ['repeat', 'playlist repeat'])) as ISqueezeboxPlayer['repeat'],
      shuffle: this.shuffleFromValue(valueForKeys(dataArg, ['shuffle', 'playlist shuffle'])) as ISqueezeboxPlayer['shuffle'],
      time: numberValue(valueForKeys(dataArg, ['time', 'elapsed'])),
      duration: numberValue(valueForKeys(dataArg, ['duration'])),
      title: stringValue(valueForKeys(dataArg, ['title', 'track'])),
      remoteTitle: stringValue(valueForKeys(dataArg, ['remote_title', 'remoteTitle'])),
      artist: stringValue(valueForKeys(dataArg, ['artist'])),
      album: stringValue(valueForKeys(dataArg, ['album'])),
      url: stringValue(valueForKeys(dataArg, ['url', 'current_url'])),
      imageUrl: stringValue(valueForKeys(dataArg, ['image_url', 'artwork_url', 'coverart'])),
      playlist,
      currentIndex: numberValue(valueForKeys(dataArg, ['playlist_cur_index', 'current_index', 'currentIndex'])),
      alarms: this.alarmsFromData(dataArg),
      alarmsEnabled: booleanValue(valueForKeys(dataArg, ['alarms_enabled', 'alarmsEnabled'])),
      alarmNext: stringValue(valueForKeys(dataArg, ['alarm_next', 'alarmNext'])),
      syncGroup: stringArray(valueForKeys(dataArg, ['sync_group', 'syncGroup', 'syncgroup'])).filter((idArg) => idArg !== playerId),
      source: stringValue(valueForKeys(dataArg, ['source'])),
      available: booleanValue(valueForKeys(dataArg, ['available'])) ?? booleanValue(valueForKeys(dataArg, ['connected', 'player_connected'])),
      raw: dataArg,
    });
  }

  private normalizePlayer(playerArg: ISqueezeboxPlayer | Partial<ISqueezeboxPlayer>): ISqueezeboxPlayer {
    const playerId = playerArg.playerId || 'unknown';
    const connected = playerArg.connected ?? playerArg.available ?? true;
    return {
      ...playerArg,
      playerId,
      name: playerArg.name || playerId,
      model: playerArg.model || 'Squeezebox Player',
      connected,
      available: playerArg.available ?? connected,
      power: playerArg.power ?? true,
      mode: playerArg.mode || 'unknown',
      syncGroup: playerArg.syncGroup || [],
    } as ISqueezeboxPlayer;
  }

  private favoriteFromData(dataArg: Record<string, unknown>, indexArg: number): ISqueezeboxFavorite {
    const itemId = stringValue(valueForKeys(dataArg, ['item_id', 'itemId', 'id']));
    const url = stringValue(valueForKeys(dataArg, ['url', 'playlist_url', 'play_url']));
    const name = stringValue(valueForKeys(dataArg, ['name', 'title', 'text'])) || itemId || url || `Favorite ${indexArg + 1}`;
    return {
      id: itemId || url || String(indexArg + 1),
      name,
      type: stringValue(valueForKeys(dataArg, ['type', 'isaudio'])),
      url,
      itemId,
      imageUrl: stringValue(valueForKeys(dataArg, ['image', 'image_url', 'icon'])),
      playable: booleanValue(valueForKeys(dataArg, ['playable', 'isaudio'])) ?? true,
      raw: dataArg,
    };
  }

  private syncGroupFromData(dataArg: Record<string, unknown>, indexArg: number): ISqueezeboxSyncGroup {
    const playerIds = stringArray(valueForKeys(dataArg, ['players', 'playerids', 'members', 'sync_members', 'playerIds']));
    const leader = stringValue(valueForKeys(dataArg, ['leader', 'master', 'leaderPlayerId'])) || playerIds[0];
    return {
      id: stringValue(valueForKeys(dataArg, ['id', 'sync_group_id'])) || `sync_${indexArg + 1}`,
      name: stringValue(valueForKeys(dataArg, ['name'])),
      playerIds,
      leaderPlayerId: leader,
      raw: dataArg,
    };
  }

  private syncGroupsFromPlayers(playersArg: ISqueezeboxPlayer[]): ISqueezeboxSyncGroup[] {
    const groups = new Map<string, ISqueezeboxSyncGroup>();
    for (const player of playersArg) {
      const playerIds = [player.playerId, ...(player.syncGroup || [])].filter(Boolean).sort();
      if (playerIds.length < 2) {
        continue;
      }
      const key = playerIds.join(',');
      groups.set(key, { id: `sync_${slug(key)}`, playerIds, leaderPlayerId: playerIds[0] });
    }
    return [...groups.values()];
  }

  private playlistFromData(dataArg: Record<string, unknown>): ISqueezeboxTrack[] | undefined {
    const loop = arrayRecords(valueForKeys(dataArg, ['playlist_loop', 'playlist']));
    if (!loop.length) {
      return undefined;
    }
    return loop.map((trackArg) => ({
      id: valueForKeys(trackArg, ['id', 'track_id']) as string | number | undefined,
      url: stringValue(valueForKeys(trackArg, ['url'])),
      title: stringValue(valueForKeys(trackArg, ['title', 'track'])),
      artist: stringValue(valueForKeys(trackArg, ['artist'])),
      album: stringValue(valueForKeys(trackArg, ['album'])),
      duration: numberValue(valueForKeys(trackArg, ['duration'])),
      remoteTitle: stringValue(valueForKeys(trackArg, ['remote_title'])),
      imageUrl: stringValue(valueForKeys(trackArg, ['image_url', 'coverart'])),
      raw: trackArg,
    }));
  }

  private alarmsFromData(dataArg: Record<string, unknown>): ISqueezeboxAlarm[] | undefined {
    const loop = arrayRecords(valueForKeys(dataArg, ['alarms', 'alarms_loop']));
    if (!loop.length) {
      return undefined;
    }
    return loop.map((alarmArg, indexArg) => ({
      id: stringValue(valueForKeys(alarmArg, ['id', 'alarm_id'])) || String(indexArg + 1),
      enabled: booleanValue(valueForKeys(alarmArg, ['enabled'])),
      time: stringValue(valueForKeys(alarmArg, ['time'])),
      repeat: booleanValue(valueForKeys(alarmArg, ['repeat'])),
      scheduledToday: booleanValue(valueForKeys(alarmArg, ['scheduled_today', 'scheduledToday'])),
      daysOfWeek: numberArray(valueForKeys(alarmArg, ['dow', 'daysOfWeek'])),
      volume: numberValue(valueForKeys(alarmArg, ['volume'])),
      url: stringValue(valueForKeys(alarmArg, ['url'])),
      raw: alarmArg,
    }));
  }

  private normalizeSnapshot(snapshotArg: ISqueezeboxSnapshot, sourceArg: TSqueezeboxSnapshotSource): ISqueezeboxSnapshot {
    const server = {
      ...snapshotArg.server,
      id: snapshotArg.server.id || snapshotArg.server.uuid || this.config.serverId || this.config.host || snapshotArg.server.name || 'squeezebox',
      name: snapshotArg.server.name || snapshotArg.server.libraryName || this.config.name || this.config.host || 'Lyrion Music Server',
      host: snapshotArg.server.host || this.config.host,
      port: snapshotArg.server.port || (this.config.host ? this.config.port || squeezeboxDefaultHttpPort : this.config.port),
      manufacturer: snapshotArg.server.manufacturer || 'Lyrion',
      model: snapshotArg.server.model || 'Lyrion Music Server',
      playerCount: snapshotArg.server.playerCount ?? snapshotArg.players.length,
    };
    const players = (snapshotArg.players || []).map((playerArg) => this.normalizePlayer(playerArg));
    return {
      ...snapshotArg,
      server,
      players,
      favorites: snapshotArg.favorites || [],
      syncGroups: snapshotArg.syncGroups || this.syncGroupsFromPlayers(players),
      online: snapshotArg.online,
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
      source: snapshotArg.source || sourceArg,
    };
  }

  private snapshotFromConfig(onlineArg: boolean, errorArg?: string): ISqueezeboxSnapshot {
    const host = this.config.host;
    return {
      server: {
        id: this.config.serverId || (host ? `${host}:${this.config.port || squeezeboxDefaultHttpPort}` : undefined) || this.config.name || 'squeezebox',
        name: this.config.name || host || 'Lyrion Music Server',
        host,
        port: this.config.port || (host ? squeezeboxDefaultHttpPort : undefined),
        manufacturer: 'Lyrion',
        model: 'Lyrion Music Server',
      },
      players: [],
      favorites: [],
      syncGroups: [],
      online: onlineArg,
      updatedAt: new Date().toISOString(),
      source: 'runtime',
      error: errorArg,
    };
  }

  private jsonRpcResponseToRecord(responseArg: ISqueezeboxJsonRpcResponse | Record<string, unknown>, commandArg: string[]): Record<string, unknown> {
    if ('error' in responseArg && responseArg.error) {
      const error = responseArg.error as { message?: string } | string;
      throw new SqueezeboxCommandError(commandArg, typeof error === 'string' ? error : error.message || JSON.stringify(error));
    }
    if ('result' in responseArg) {
      return recordValue(responseArg.result);
    }
    return recordValue(responseArg);
  }

  private executorResultToRecord(resultArg: unknown, commandArg: string[]): Record<string, unknown> {
    if (this.isCliResponse(resultArg)) {
      return resultArg.result;
    }
    if (this.isJsonRpcResponse(resultArg)) {
      return this.jsonRpcResponseToRecord(resultArg, commandArg);
    }
    return recordValue(resultArg);
  }

  private executorResultToCliResponse(resultArg: unknown, playerIdArg: string | undefined, commandArg: string[]): ISqueezeboxCliResponse {
    if (this.isCliResponse(resultArg)) {
      return resultArg;
    }
    if (typeof resultArg === 'string') {
      return this.parseCliLine(resultArg, playerIdArg, commandArg);
    }
    return {
      playerId: playerIdArg,
      command: commandArg,
      rawLine: '',
      tokens: [],
      result: this.executorResultToRecord(resultArg, commandArg),
    };
  }

  private parseCliLine(lineArg: string, playerIdArg: string | undefined, commandArg: string[]): ISqueezeboxCliResponse {
    const tokens = lineArg.trim().split(/\s+/).map((tokenArg) => decodeURIComponentSafe(tokenArg));
    const result: Record<string, unknown> = {};
    for (const token of tokens) {
      const separator = token.indexOf(':');
      if (separator > 0) {
        addRecordValue(result, token.slice(0, separator), token.slice(separator + 1));
      }
    }
    if (!Object.keys(result).length && tokens.length > commandArg.length) {
      result.value = tokens[tokens.length - 1];
    }
    return { playerId: playerIdArg, command: commandArg, rawLine: lineArg, tokens, result };
  }

  private cliResponseToRecord(responseArg: ISqueezeboxCliResponse): Record<string, unknown> {
    return responseArg.result;
  }

  private cliLine(playerIdArg: string | undefined, commandArg: string[]): string {
    return [playerIdArg, ...commandArg].filter((itemArg): itemArg is string => Boolean(itemArg)).map((itemArg) => encodeCliValue(itemArg)).join(' ');
  }

  private isJsonRpcResponse(valueArg: unknown): valueArg is ISqueezeboxJsonRpcResponse {
    return Boolean(valueArg && typeof valueArg === 'object' && ('result' in valueArg || 'error' in valueArg || (valueArg as { method?: unknown }).method === 'slim.request'));
  }

  private isCliResponse(valueArg: unknown): valueArg is ISqueezeboxCliResponse {
    return Boolean(valueArg && typeof valueArg === 'object' && Array.isArray((valueArg as ISqueezeboxCliResponse).command) && (valueArg as ISqueezeboxCliResponse).result);
  }

  private requiredPlayerId(requestArg: ISqueezeboxCommandRequest): string {
    return this.requiredString(requestArg.playerId, 'Squeezebox command requires playerId.');
  }

  private requiredString(valueArg: unknown, errorArg: string): string {
    if (typeof valueArg !== 'string' || !valueArg) {
      throw new Error(errorArg);
    }
    return valueArg;
  }

  private requiredNumber(valueArg: unknown, errorArg: string): number {
    if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
      throw new Error(errorArg);
    }
    return valueArg;
  }

  private volumePercent(requestArg: ISqueezeboxCommandRequest): number {
    const value = requestArg.volumeLevel ?? requestArg.volume;
    if (typeof value !== 'number' || !Number.isFinite(value)) {
      throw new Error('Squeezebox set_volume requires volumeLevel or volume.');
    }
    return Math.max(0, Math.min(100, Math.round(value <= 1 ? value * 100 : value)));
  }

  private volumeStep(valueArg: number | undefined): number {
    const step = typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : this.config.volumeStep || squeezeboxDefaultVolumeStep;
    return Math.max(1, Math.min(100, Math.round(step <= 1 ? step * 100 : step)));
  }

  private repeatFromValue(valueArg: unknown): string | undefined {
    if (valueArg === 2 || valueArg === '2' || valueArg === 'playlist') {
      return 'playlist';
    }
    if (valueArg === 1 || valueArg === '1' || valueArg === 'song') {
      return 'song';
    }
    if (valueArg !== undefined) {
      return 'none';
    }
    return undefined;
  }

  private shuffleFromValue(valueArg: unknown): string | undefined {
    if (valueArg === 2 || valueArg === '2' || valueArg === 'album') {
      return 'album';
    }
    if (valueArg === 1 || valueArg === '1' || valueArg === 'song') {
      return 'song';
    }
    if (valueArg !== undefined) {
      return 'none';
    }
    return undefined;
  }

  private cloneSnapshot(snapshotArg: ISqueezeboxSnapshot): ISqueezeboxSnapshot {
    return JSON.parse(JSON.stringify(snapshotArg)) as ISqueezeboxSnapshot;
  }
}

const valueForKeys = (recordArg: Record<string, unknown> | undefined, keysArg: string[]): unknown => {
  if (!recordArg) {
    return undefined;
  }
  const lowerEntries = Object.entries(recordArg).map(([key, value]) => [key.toLowerCase(), value] as const);
  for (const key of keysArg) {
    const direct = recordArg[key];
    if (direct !== undefined) {
      return direct;
    }
    const match = lowerEntries.find(([entryKey]) => entryKey === key.toLowerCase());
    if (match) {
      return match[1];
    }
  }
  return undefined;
};

const arrayRecords = (valueArg: unknown): Record<string, unknown>[] => {
  if (!Array.isArray(valueArg)) {
    return [];
  }
  return valueArg.filter((itemArg): itemArg is Record<string, unknown> => Boolean(itemArg && typeof itemArg === 'object' && !Array.isArray(itemArg)));
};

const recordValue = (valueArg: unknown): Record<string, unknown> => {
  if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
    return valueArg as Record<string, unknown>;
  }
  return valueArg === undefined ? {} : { value: valueArg };
};

const addRecordValue = (recordArg: Record<string, unknown>, keyArg: string, valueArg: unknown): void => {
  const existing = recordArg[keyArg];
  if (existing === undefined) {
    recordArg[keyArg] = valueArg;
  } else if (Array.isArray(existing)) {
    existing.push(valueArg);
  } else {
    recordArg[keyArg] = [existing, valueArg];
  }
};

const stringValue = (valueArg: unknown): string | undefined => {
  if (typeof valueArg === 'string' && valueArg) {
    return valueArg;
  }
  if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
    return String(valueArg);
  }
  return undefined;
};

const numberValue = (valueArg: unknown): number | undefined => {
  if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
    return valueArg;
  }
  if (typeof valueArg === 'string' && valueArg.trim()) {
    const parsed = Number(valueArg);
    return Number.isFinite(parsed) ? parsed : undefined;
  }
  return undefined;
};

const booleanValue = (valueArg: unknown): boolean | undefined => {
  if (typeof valueArg === 'boolean') {
    return valueArg;
  }
  if (typeof valueArg === 'number') {
    return valueArg !== 0;
  }
  if (typeof valueArg === 'string') {
    const lower = valueArg.toLowerCase();
    if (['1', 'true', 'yes', 'on'].includes(lower)) {
      return true;
    }
    if (['0', 'false', 'no', 'off'].includes(lower)) {
      return false;
    }
  }
  return undefined;
};

const stringArray = (valueArg: unknown): string[] => {
  if (Array.isArray(valueArg)) {
    return valueArg.map((itemArg) => stringValue(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg));
  }
  if (typeof valueArg === 'string') {
    return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean);
  }
  return [];
};

const numberArray = (valueArg: unknown): number[] | undefined => {
  const values = Array.isArray(valueArg) ? valueArg : typeof valueArg === 'string' ? valueArg.split(',') : [];
  const numbers = values.map((itemArg) => numberValue(itemArg)).filter((itemArg): itemArg is number => typeof itemArg === 'number');
  return numbers.length ? numbers : undefined;
};

const cleanIp = (valueArg: string | undefined): string | undefined => valueArg?.split(':')[0] || undefined;

const sourceLike = (valueArg: string): boolean => /^(source|wavin|spotify|loop):/i.test(valueArg);

const isUrl = (valueArg: string): boolean => {
  try {
    const url = new URL(valueArg);
    return Boolean(url.protocol && url.host);
  } catch {
    return false;
  }
};

const encodeCliValue = (valueArg: string): string => encodeURIComponent(valueArg).replace(/%3A/gi, ':');

const decodeURIComponentSafe = (valueArg: string): string => {
  try {
    return decodeURIComponent(valueArg);
  } catch {
    return valueArg;
  }
};

const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;

const slug = (valueArg: string): string => valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'squeezebox';
