import type {
  IForkedDaapdCommandExecutorResult,
  IForkedDaapdCommandRequest,
  IForkedDaapdConfig,
  IForkedDaapdLibraryItem,
  IForkedDaapdOutput,
  IForkedDaapdPlayerStatus,
  IForkedDaapdQueueStatus,
  IForkedDaapdRawCommandRequest,
  IForkedDaapdServerConfig,
  IForkedDaapdServerInfo,
  IForkedDaapdSnapshot,
  TForkedDaapdProtocol,
} from './forked_daapd.types.js';
import {
  forkedDaapdDefaultMaxPlaylists,
  forkedDaapdDefaultPort,
  forkedDaapdDefaultTimeoutMs,
  forkedDaapdDefaultUnmuteVolume,
  forkedDaapdSourceNameClear,
  forkedDaapdSourceNameDefault,
} from './forked_daapd.types.js';

const notifyEventTypes = ['player', 'outputs', 'volume', 'options', 'queue', 'database'];

const startupPlayer: IForkedDaapdPlayerStatus = {
  state: 'stop',
  repeat: 'off',
  consume: false,
  shuffle: false,
  volume: 0,
  item_id: 0,
  item_length_ms: 0,
  item_progress_ms: 0,
};

const startupQueue: IForkedDaapdQueueStatus = {
  version: 0,
  count: 0,
  items: [],
};

export class ForkedDaapdHttpError extends Error {
  constructor(public readonly status: number, messageArg: string) {
    super(messageArg);
    this.name = 'ForkedDaapdHttpError';
  }
}

export class ForkedDaapdCommandError extends Error {
  constructor(public readonly endpoint: string, messageArg: string) {
    super(`OwnTone request ${endpoint} failed: ${messageArg}`);
    this.name = 'ForkedDaapdCommandError';
  }
}

export class ForkedDaapdClient {
  private currentSnapshot?: IForkedDaapdSnapshot;

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

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

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

  public async validateConnection(): Promise<IForkedDaapdSnapshot> {
    const serverConfig = await this.getRequest<IForkedDaapdServerConfig>('config');
    if (serverConfig.websocket_port === 0) {
      throw new Error('OwnTone server websocket not enabled.');
    }
    if (!serverConfig.library_name && typeof serverConfig.websocket_port !== 'number') {
      throw new Error('The OwnTone integration requires an OwnTone server with version >= 27.0.');
    }
    return this.fetchSnapshot(serverConfig);
  }

  public async execute(requestArg: IForkedDaapdCommandRequest): Promise<unknown> {
    if (requestArg.command === 'play') {
      return this.putRequest('player/play');
    }
    if (requestArg.command === 'pause') {
      return this.putRequest('player/pause');
    }
    if (requestArg.command === 'stop') {
      return this.putRequest('player/stop');
    }
    if (requestArg.command === 'next_track') {
      return this.putRequest('player/next');
    }
    if (requestArg.command === 'previous_track') {
      return this.putRequest('player/previous');
    }
    if (requestArg.command === 'seek') {
      const positionMs = typeof requestArg.positionMs === 'number'
        ? Math.round(requestArg.positionMs)
        : typeof requestArg.position === 'number'
          ? Math.round(requestArg.position * 1000)
          : undefined;
      if (positionMs === undefined) {
        throw new Error('OwnTone seek requires position or positionMs.');
      }
      return this.putRequest('player/seek', { position_ms: positionMs });
    }
    if (requestArg.command === 'set_volume') {
      const params: Record<string, string | number | boolean | undefined> = { volume: this.volumePercent(requestArg) };
      if (requestArg.outputId) {
        params.output_id = requestArg.outputId;
      }
      return this.putRequest('player/volume', params);
    }
    if (requestArg.command === 'mute') {
      if (typeof requestArg.muted !== 'boolean') {
        throw new Error('OwnTone mute requires muted.');
      }
      const volume = requestArg.muted ? 0 : this.volumePercent({ ...requestArg, volumeLevel: requestArg.volumeLevel ?? forkedDaapdDefaultUnmuteVolume });
      const params: Record<string, string | number | boolean | undefined> = { volume };
      if (requestArg.outputId) {
        params.output_id = requestArg.outputId;
      }
      return this.putRequest('player/volume', params);
    }
    if (requestArg.command === 'shuffle') {
      if (typeof requestArg.shuffle !== 'boolean') {
        throw new Error('OwnTone shuffle requires shuffle.');
      }
      return this.putRequest('player/shuffle', { state: requestArg.shuffle });
    }
    if (requestArg.command === 'repeat') {
      if (!requestArg.repeat) {
        throw new Error('OwnTone repeat requires repeat.');
      }
      return this.putRequest('player/repeat', { state: requestArg.repeat });
    }
    if (requestArg.command === 'clear_queue') {
      return this.putRequest('queue/clear');
    }
    if (requestArg.command === 'play_media') {
      const uri = requestArg.uri || requestArg.mediaId;
      if (!uri) {
        throw new Error('OwnTone play_media requires mediaId or uri.');
      }
      return this.postRequest('queue/items/add', {
        uris: uri,
        playback: requestArg.playback || 'start',
        clear: requestArg.clear ?? true,
      });
    }
    if (requestArg.command === 'select_source') {
      return this.selectSource(requestArg);
    }
    if (requestArg.command === 'turn_on') {
      const snapshot = await this.getSnapshot();
      const outputIds = requestArg.outputIds?.length ? requestArg.outputIds : snapshot.outputs.map((outputArg) => outputArg.id);
      if (requestArg.volume !== undefined || requestArg.volumeLevel !== undefined) {
        await this.execute({ command: 'set_volume', volume: requestArg.volume, volumeLevel: requestArg.volumeLevel });
      }
      return this.execute({ command: 'set_enabled_outputs', outputIds });
    }
    if (requestArg.command === 'turn_off') {
      await this.execute({ command: 'pause' });
      return this.execute({ command: 'set_enabled_outputs', outputIds: [] });
    }
    if (requestArg.command === 'set_output') {
      if (!requestArg.outputId) {
        throw new Error('OwnTone set_output requires outputId.');
      }
      const body: Record<string, number | boolean> = {};
      if (typeof requestArg.selected === 'boolean') {
        body.selected = requestArg.selected;
      }
      if (requestArg.volume !== undefined || requestArg.volumeLevel !== undefined) {
        body.volume = this.volumePercent(requestArg);
      }
      return this.putRequest(`outputs/${encodeURIComponent(requestArg.outputId)}`, undefined, body);
    }
    if (requestArg.command === 'set_enabled_outputs') {
      return this.putRequest('outputs/set', undefined, { outputs: requestArg.outputIds || [] });
    }
    if (requestArg.command === 'raw_request') {
      if (!requestArg.endpoint) {
        throw new Error('OwnTone raw_request requires endpoint.');
      }
      return this.request(requestArg.method || 'GET', requestArg.endpoint, requestArg.params, requestArg.body, requestArg.expectJson ?? true);
    }
    throw new Error(`Unsupported OwnTone command: ${requestArg.command}`);
  }

  public async getRequest<TValue = Record<string, unknown>>(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>): Promise<TValue> {
    return (await this.request('GET', endpointArg, paramsArg, undefined, true)).data as TValue;
  }

  public async putRequest(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>, bodyArg?: unknown): Promise<unknown> {
    return this.request('PUT', endpointArg, paramsArg, bodyArg, false);
  }

  public async postRequest(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>, bodyArg?: unknown): Promise<unknown> {
    return this.request('POST', endpointArg, paramsArg, bodyArg, false);
  }

  public fullUrl(pathArg: string): string {
    if (/^https?:\/\//i.test(pathArg)) {
      return pathArg;
    }
    const host = this.config.host;
    const port = this.config.port || forkedDaapdDefaultPort;
    if (!host) {
      return pathArg;
    }
    const encodedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
    const credentials = this.config.password ? `admin:${encodeURIComponent(this.config.password)}@` : '';
    return `http://${credentials}${encodedHost}:${port}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`;
  }

  public async subscribeToNotifications(handlerArg: (snapshotArg: IForkedDaapdSnapshot, eventTypesArg: string[]) => void | Promise<void>): Promise<() => Promise<void>> {
    const snapshot = await this.getSnapshot();
    const host = snapshot.server.host || this.config.host;
    const websocketPort = snapshot.server.websocketPort;
    if (!host || !websocketPort) {
      throw new Error('OwnTone WebSocket notifications require a discovered server websocket_port and host.');
    }
    const WebSocketCtor = (globalThis as unknown as { WebSocket?: new (urlArg: string, protocolsArg?: string | string[]) => IForkedDaapdWebSocket }).WebSocket;
    if (!WebSocketCtor) {
      throw new Error('OwnTone WebSocket notifications require a global WebSocket implementation.');
    }

    const websocket = new WebSocketCtor(this.websocketUrl(host, websocketPort), 'notify');
    const onOpen = async () => {
      websocket.send(JSON.stringify({ notify: notifyEventTypes }));
      await handlerArg(await this.getSnapshot(), notifyEventTypes);
    };
    const onMessage = async (eventArg: { data?: unknown }) => {
      const payload = typeof eventArg.data === 'string' ? JSON.parse(eventArg.data) as { notify?: string[] } : undefined;
      const events = Array.isArray(payload?.notify) ? payload.notify : notifyEventTypes;
      await handlerArg(await this.getSnapshot(), events);
    };
    attachWebSocketHandler(websocket, 'open', onOpen);
    attachWebSocketHandler(websocket, 'message', (eventArg) => { void onMessage(eventArg as { data?: unknown }); });
    return async () => websocket.close();
  }

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

  private async fetchSnapshot(serverConfigArg?: IForkedDaapdServerConfig): Promise<IForkedDaapdSnapshot> {
    const serverConfig = serverConfigArg || await this.getRequest<IForkedDaapdServerConfig>('config');
    const [player, queue, outputsResponse, playlists, pipes] = await Promise.all([
      this.getRequest<IForkedDaapdPlayerStatus>('player'),
      this.getRequest<IForkedDaapdQueueStatus>('queue'),
      this.getRequest<{ outputs?: IForkedDaapdOutput[] }>('outputs'),
      this.getListItems('library/playlists').catch(() => []),
      this.getPipes().catch(() => []),
    ]);

    const snapshot: IForkedDaapdSnapshot = {
      server: this.serverFromConfig(serverConfig),
      player,
      queue,
      outputs: outputsResponse.outputs || [],
      library: { playlists, pipes },
      online: true,
      updatedAt: new Date().toISOString(),
      source: this.config.commandExecutor && !this.config.host ? 'executor' : 'http',
      maxPlaylists: this.config.maxPlaylists,
      raw: { config: serverConfig },
    };
    return this.normalizeSnapshot(snapshot, snapshot.source || 'http');
  }

  private async getListItems(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>): Promise<IForkedDaapdLibraryItem[]> {
    const response = await this.getRequest<{ items?: IForkedDaapdLibraryItem[] }>(endpointArg, paramsArg);
    return Array.isArray(response.items) ? response.items : [];
  }

  private async getPipes(): Promise<IForkedDaapdLibraryItem[]> {
    const response = await this.getRequest<Record<string, { items?: IForkedDaapdLibraryItem[] }>>('search', { type: 'tracks', expression: 'data_kind is pipe' });
    return Array.isArray(response.tracks?.items) ? response.tracks.items : [];
  }

  private async selectSource(requestArg: IForkedDaapdCommandRequest): Promise<unknown> {
    const source = requestArg.source;
    if (!source) {
      throw new Error('OwnTone select_source requires source.');
    }
    if (source === forkedDaapdSourceNameClear) {
      return this.execute({ command: 'clear_queue' });
    }
    if (source === forkedDaapdSourceNameDefault) {
      return { source };
    }
    const snapshot = await this.getSnapshot();
    const uri = this.sourceUris(snapshot)[source];
    if (!uri) {
      throw new Error(`Unknown OwnTone source: ${source}`);
    }
    return this.execute({ command: 'play_media', uri, clear: true });
  }

  private sourceUris(snapshotArg: IForkedDaapdSnapshot): Record<string, string | undefined> {
    const sources: Record<string, string | undefined> = {
      [forkedDaapdSourceNameClear]: undefined,
      [forkedDaapdSourceNameDefault]: undefined,
    };
    for (const pipe of snapshotArg.library.pipes) {
      const title = stringValue(pipe.title) || stringValue(pipe.name);
      if (title && pipe.uri) {
        sources[`${title} (pipe)`] = pipe.uri;
      }
    }
    for (const playlist of snapshotArg.library.playlists.slice(0, snapshotArg.maxPlaylists || forkedDaapdDefaultMaxPlaylists)) {
      const name = stringValue(playlist.name) || stringValue(playlist.title);
      if (name && playlist.uri) {
        sources[`${name} (playlist)`] = playlist.uri;
      }
    }
    return sources;
  }

  private async request(
    methodArg: IForkedDaapdRawCommandRequest['method'],
    endpointArg: string,
    paramsArg?: Record<string, string | number | boolean | undefined>,
    bodyArg?: unknown,
    expectJsonArg = false
  ): Promise<{ status: number; data?: unknown; response?: string }> {
    const rawRequest = this.rawRequest(methodArg, endpointArg, paramsArg, bodyArg, expectJsonArg);
    if (this.config.commandExecutor) {
      return this.executorResult(rawRequest, await this.config.commandExecutor.execute(rawRequest));
    }
    if (!rawRequest.host || !rawRequest.url) {
      throw new Error('OwnTone HTTP command requires config.host or commandExecutor. Static snapshots are read-only.');
    }

    const abortController = new AbortController();
    const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || forkedDaapdDefaultTimeoutMs);
    try {
      const headers: Record<string, string> = {};
      if (this.config.password) {
        headers.Authorization = `Basic ${Buffer.from(`admin:${this.config.password}`).toString('base64')}`;
      }
      if (bodyArg !== undefined) {
        headers['Content-Type'] = 'application/json';
      }
      const response = await globalThis.fetch(rawRequest.url, {
        method: methodArg,
        headers,
        body: bodyArg === undefined ? undefined : JSON.stringify(bodyArg),
        signal: abortController.signal,
      });
      const text = await response.text();
      if (!response.ok) {
        throw new ForkedDaapdHttpError(response.status, `OwnTone ${methodArg} ${endpointArg} failed with HTTP ${response.status}: ${text}`);
      }
      return { status: response.status, response: text, data: expectJsonArg && text ? JSON.parse(text) : undefined };
    } finally {
      globalThis.clearTimeout(timeout);
    }
  }

  private rawRequest(
    methodArg: IForkedDaapdRawCommandRequest['method'],
    endpointArg: string,
    paramsArg: Record<string, string | number | boolean | undefined> | undefined,
    bodyArg: unknown,
    expectJsonArg: boolean
  ): IForkedDaapdRawCommandRequest {
    const protocol: TForkedDaapdProtocol = 'http';
    const host = this.config.host;
    const port = this.config.port || forkedDaapdDefaultPort;
    return {
      method: methodArg,
      endpoint: cleanEndpoint(endpointArg),
      host,
      port,
      protocol,
      params: paramsArg,
      body: bodyArg,
      password: this.config.password,
      expectJson: expectJsonArg,
      url: host ? this.apiUrl(protocol, host, port, endpointArg, paramsArg) : undefined,
    };
  }

  private apiUrl(protocolArg: TForkedDaapdProtocol, hostArg: string, portArg: number, endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>): string {
    const encodedHost = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
    const url = new URL(`${protocolArg}://${encodedHost}:${portArg}/api/${cleanEndpoint(endpointArg)}`);
    for (const [key, value] of Object.entries(paramsArg || {})) {
      if (value !== undefined) {
        url.searchParams.set(key, typeof value === 'boolean' ? String(value).toLowerCase() : String(value));
      }
    }
    return url.toString();
  }

  private websocketUrl(hostArg: string, portArg: number): string {
    const encodedHost = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
    return `ws://${encodedHost}:${portArg}/`;
  }

  private executorResult(requestArg: IForkedDaapdRawCommandRequest, resultArg: unknown): { status: number; data?: unknown; response?: string } {
    if (resultArg === undefined || resultArg === true) {
      return { status: 204, data: resultArg };
    }
    if (typeof resultArg === 'string') {
      if (!requestArg.expectJson) {
        return { status: 200, response: resultArg };
      }
      try {
        return { status: 200, response: resultArg, data: JSON.parse(resultArg) as unknown };
      } catch {
        throw new ForkedDaapdCommandError(requestArg.endpoint, `executor returned invalid JSON: ${resultArg}`);
      }
    }
    if (this.isRecord(resultArg)) {
      const result = resultArg as IForkedDaapdCommandExecutorResult;
      if (result.ok === false || result.success === false || result.status && result.status >= 400) {
        throw new ForkedDaapdCommandError(requestArg.endpoint, JSON.stringify(resultArg));
      }
      if ('data' in result) {
        return { status: result.status || 200, data: result.data, response: result.response };
      }
      return { status: result.status || 200, data: requestArg.expectJson ? resultArg : undefined, response: result.response };
    }
    return { status: 200, data: resultArg };
  }

  private serverFromConfig(configArg: IForkedDaapdServerConfig): IForkedDaapdServerInfo {
    const port = this.config.port || forkedDaapdDefaultPort;
    const libraryName = stringValue(configArg.library_name) || this.config.name;
    const id = this.config.uniqueId || libraryName || (this.config.host ? `${this.config.host}:${port}` : 'owntone');
    return {
      id,
      name: libraryName || this.config.host || 'OwnTone',
      host: this.config.host,
      port,
      protocol: 'http',
      libraryName,
      websocketPort: numberValue(configArg.websocket_port),
      version: stringValue(configArg.version),
      config: configArg,
    };
  }

  private normalizeSnapshot(snapshotArg: IForkedDaapdSnapshot, sourceArg: IForkedDaapdSnapshot['source']): IForkedDaapdSnapshot {
    const server = this.normalizeServer(snapshotArg.server);
    const player = { ...startupPlayer, ...snapshotArg.player };
    const queue = {
      ...startupQueue,
      ...snapshotArg.queue,
      items: Array.isArray(snapshotArg.queue?.items) ? snapshotArg.queue.items : [],
    };
    const outputs = (snapshotArg.outputs || []).map((outputArg) => ({
      ...outputArg,
      id: String(outputArg.id),
      name: outputArg.name || String(outputArg.id),
      selected: Boolean(outputArg.selected),
      volume: numberValue(outputArg.volume) ?? 0,
    }));
    return {
      ...snapshotArg,
      server,
      player,
      queue,
      outputs,
      library: {
        playlists: snapshotArg.library?.playlists || [],
        pipes: snapshotArg.library?.pipes || [],
      },
      online: Boolean(snapshotArg.online),
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
      source: snapshotArg.source || sourceArg,
      maxPlaylists: snapshotArg.maxPlaylists ?? this.config.maxPlaylists ?? forkedDaapdDefaultMaxPlaylists,
    };
  }

  private normalizeServer(serverArg: IForkedDaapdServerInfo): IForkedDaapdServerInfo {
    const port = serverArg.port || this.config.port || forkedDaapdDefaultPort;
    const name = serverArg.name || serverArg.libraryName || this.config.name || this.config.host || 'OwnTone';
    return {
      ...serverArg,
      id: serverArg.id || this.config.uniqueId || name || (this.config.host ? `${this.config.host}:${port}` : 'owntone'),
      name,
      host: serverArg.host || this.config.host,
      port,
      protocol: 'http',
      libraryName: serverArg.libraryName || name,
    };
  }

  private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IForkedDaapdSnapshot {
    const port = this.config.port || forkedDaapdDefaultPort;
    const name = this.config.name || this.config.host || 'OwnTone';
    return {
      server: {
        id: this.config.uniqueId || (this.config.host ? `${this.config.host}:${port}` : 'owntone'),
        name,
        host: this.config.host,
        port,
        protocol: 'http',
        libraryName: name,
      },
      player: { ...startupPlayer },
      queue: { ...startupQueue, items: [] },
      outputs: [],
      library: { playlists: [], pipes: [] },
      online: onlineArg,
      updatedAt: new Date().toISOString(),
      source: 'runtime',
      maxPlaylists: this.config.maxPlaylists,
      error: errorArg,
    };
  }

  private volumePercent(requestArg: Pick<IForkedDaapdCommandRequest, 'volume' | 'volumeLevel'>): number {
    const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
    if (typeof value !== 'number' || !Number.isFinite(value)) {
      throw new Error('OwnTone volume command requires volumeLevel or volume.');
    }
    return Math.max(0, Math.min(100, Math.round(value)));
  }

  private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
    return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
  }

  private cloneValue<TValue>(valueArg: TValue): TValue {
    return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
  }
}

interface IForkedDaapdWebSocket {
  addEventListener?: (typeArg: string, listenerArg: (eventArg: unknown) => void) => void;
  onopen?: (eventArg: unknown) => void;
  onmessage?: (eventArg: unknown) => void;
  send(dataArg: string): void;
  close(): void;
}

const attachWebSocketHandler = (websocketArg: IForkedDaapdWebSocket, typeArg: 'open' | 'message', listenerArg: (eventArg: unknown) => void): void => {
  if (websocketArg.addEventListener) {
    websocketArg.addEventListener(typeArg, listenerArg);
    return;
  }
  if (typeArg === 'open') {
    websocketArg.onopen = listenerArg;
  } else {
    websocketArg.onmessage = listenerArg;
  }
};

const cleanEndpoint = (endpointArg: string): string => endpointArg.replace(/^\/?api\//, '').replace(/^\//, '');

const stringValue = (valueArg: unknown): string | undefined => {
  if (typeof valueArg === 'string' && valueArg.trim()) {
    return valueArg.trim();
  }
  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;
};
