import * as plugins from '../../plugins.js';
import type {
  IMpdCommandRequest,
  IMpdCommandResponse,
  IMpdConfig,
  IMpdCurrentSong,
  IMpdOutput,
  IMpdResponseLine,
  IMpdServerInfo,
  IMpdSnapshot,
  IMpdStats,
  IMpdStatus,
  IMpdStoredPlaylist,
  TMpdCommand,
} from './mpd.types.js';
import { mpdDefaultPort } from './mpd.types.js';

const defaultTimeoutMs = 5000;

export class MpdCommandError extends Error {
  constructor(public readonly command: string, messageArg: string) {
    super(`MPD command ${command} failed: ${messageArg}`);
    this.name = 'MpdCommandError';
  }
}

export class MpdClient {
  private currentSnapshot?: IMpdSnapshot;
  private restorePoint?: IMpdSnapshot;

  constructor(private readonly config: IMpdConfig) {
    this.currentSnapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
  }

  public async getSnapshot(): Promise<IMpdSnapshot> {
    if (this.isSnapshotMode()) {
      return this.normalizeSnapshot(this.cloneSnapshot(this.requireSnapshot()), this.currentSnapshot?.source || 'manual');
    }
    if (!this.config.host) {
      return this.normalizeSnapshot({
        server: this.serverInfo(),
        status: { state: 'stop' },
        outputs: [],
        commands: [],
        playlists: [],
        online: false,
        updatedAt: new Date().toISOString(),
        source: 'runtime',
      }, 'runtime');
    }

    const status = await this.getStatus();
    const [currentSong, outputs, commands, playlists, stats] = await Promise.all([
      this.getCurrentSong().catch(() => undefined),
      this.getOutputs().catch(() => []),
      this.getCommands().catch(() => []),
      this.getPlaylists().catch(() => []),
      this.getStats().catch(() => undefined),
    ]);

    return this.normalizeSnapshot({
      server: {
        ...this.serverInfo(),
        commands,
      },
      status,
      currentSong,
      outputs,
      commands,
      playlists,
      stats,
      online: true,
      updatedAt: new Date().toISOString(),
      source: 'tcp',
    }, 'tcp');
  }

  public async ping(): Promise<boolean> {
    await this.command('ping');
    return true;
  }

  public async getStatus(): Promise<IMpdStatus> {
    if (this.isSnapshotMode()) {
      return { ...this.requireSnapshot().status };
    }
    return this.recordFromResponse(await this.command('status')) as IMpdStatus;
  }

  public async getCurrentSong(): Promise<IMpdCurrentSong | undefined> {
    if (this.isSnapshotMode()) {
      return this.cloneValue(this.requireSnapshot().currentSong);
    }
    const song = this.recordFromResponse(await this.command('currentsong')) as IMpdCurrentSong;
    return Object.keys(song).length ? song : undefined;
  }

  public async getOutputs(): Promise<IMpdOutput[]> {
    if (this.isSnapshotMode()) {
      return this.cloneValue(this.requireSnapshot().outputs) || [];
    }
    return this.outputsFromResponse(await this.command('outputs'));
  }

  public async getCommands(): Promise<TMpdCommand[]> {
    if (this.isSnapshotMode()) {
      return [...this.requireSnapshot().commands];
    }
    return this.commandValues(await this.command('commands'));
  }

  public async getPlaylists(): Promise<IMpdStoredPlaylist[]> {
    if (this.isSnapshotMode()) {
      return this.cloneValue(this.requireSnapshot().playlists) || [];
    }
    return this.playlistsFromResponse(await this.command('listplaylists'));
  }

  public async getStats(): Promise<IMpdStats> {
    if (this.isSnapshotMode()) {
      return { ...(this.requireSnapshot().stats || {}) };
    }
    return this.recordFromResponse(await this.command('stats')) as IMpdStats;
  }

  public async play(): Promise<void> {
    if (this.isSnapshotMode()) {
      this.requireSnapshot().status.state = 'play';
      return;
    }
    await this.command('play');
  }

  public async pause(pausedArg = true): Promise<void> {
    if (this.isSnapshotMode()) {
      this.requireSnapshot().status.state = pausedArg ? 'pause' : 'play';
      return;
    }
    await this.command('pause', [pausedArg ? 1 : 0]);
  }

  public async stop(): Promise<void> {
    if (this.isSnapshotMode()) {
      this.requireSnapshot().status.state = 'stop';
      return;
    }
    await this.command('stop');
  }

  public async next(): Promise<void> {
    if (this.isSnapshotMode()) {
      return;
    }
    await this.command('next');
  }

  public async previous(): Promise<void> {
    if (this.isSnapshotMode()) {
      return;
    }
    await this.command('previous');
  }

  public async setVolumeLevel(volumeArg: number): Promise<void> {
    const volume = Math.max(0, Math.min(100, Math.round(volumeArg <= 1 ? volumeArg * 100 : volumeArg)));
    if (this.isSnapshotMode()) {
      this.requireSnapshot().status.volume = volume;
      return;
    }
    await this.command('setvol', [volume]);
  }

  public async selectSource(sourceArg: string): Promise<void> {
    if (this.isSnapshotMode()) {
      const snapshot = this.requireSnapshot();
      if (snapshot.playlists.length && !snapshot.playlists.some((itemArg) => itemArg.playlist === sourceArg)) {
        throw new Error(`MPD playlist not found: ${sourceArg}`);
      }
      snapshot.status.lastloadedplaylist = sourceArg;
      snapshot.status.state = 'play';
      return;
    }
    await this.command('clear');
    await this.command('load', [sourceArg]);
    await this.command('play');
  }

  public async playMedia(uriArg: string): Promise<void> {
    if (this.isSnapshotMode()) {
      const snapshot = this.requireSnapshot();
      snapshot.currentSong = { file: uriArg, title: uriArg.split('/').pop() || uriArg };
      snapshot.status.state = 'play';
      return;
    }
    await this.command('clear');
    await this.command('add', [uriArg]);
    await this.command('play');
  }

  public async setOutput(outputIdArg: string | number, enabledArg: boolean): Promise<void> {
    if (this.isSnapshotMode()) {
      const output = this.requireOutput(outputIdArg);
      output.outputenabled = enabledArg;
      return;
    }
    await this.command(enabledArg ? 'enableoutput' : 'disableoutput', [outputIdArg]);
  }

  public async toggleOutput(outputIdArg: string | number): Promise<void> {
    if (this.isSnapshotMode()) {
      const output = this.requireOutput(outputIdArg);
      output.outputenabled = !this.outputEnabled(output);
      return;
    }
    await this.command('toggleoutput', [outputIdArg]);
  }

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

  public async restore(snapshotArg = this.restorePoint): Promise<void> {
    if (!snapshotArg) {
      throw new Error('MPD restore requires a prior snapshot.');
    }
    if (this.isSnapshotMode()) {
      this.currentSnapshot = this.cloneSnapshot(snapshotArg);
      return;
    }
    const volume = this.numberValue(snapshotArg.status.volume);
    if (volume !== undefined && volume >= 0) {
      await this.setVolumeLevel(volume);
    }
    for (const output of snapshotArg.outputs) {
      await this.setOutput(output.outputid, this.outputEnabled(output));
    }
    if (snapshotArg.status.state === 'play') {
      await this.play();
    } else if (snapshotArg.status.state === 'pause') {
      await this.pause(true);
    } else if (snapshotArg.status.state === 'stop') {
      await this.stop();
    }
  }

  public async command(commandArg: TMpdCommand, argsArg: Array<string | number | boolean> = []): Promise<IMpdCommandResponse> {
    if (this.isSnapshotMode()) {
      return this.snapshotCommand(commandArg, argsArg);
    }
    return this.requestTcp({ command: commandArg, args: argsArg });
  }

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

  private async requestTcp(requestArg: IMpdCommandRequest): Promise<IMpdCommandResponse> {
    const host = this.config.host;
    if (!host) {
      throw new Error('MPD TCP command requires config.host.');
    }
    const port = this.config.port || mpdDefaultPort;
    const timeoutMs = this.config.timeoutMs || defaultTimeoutMs;

    return new Promise<IMpdCommandResponse>((resolve, reject) => {
      let buffer = '';
      let settled = false;
      let protocolVersion: string | undefined;
      let authenticated = !this.config.password;
      let commandSent = false;
      const rawLines: string[] = [];
      const socket = plugins.net.createConnection({ host, port });

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

      const sendCommand = (commandArg: string, argsArg: Array<string | number | boolean> = []) => {
        socket.write(`${this.commandLine(commandArg, argsArg)}\n`);
      };

      const handleLine = (lineArg: string) => {
        if (!protocolVersion) {
          const match = /^OK MPD\s+(.+)$/.exec(lineArg);
          if (!match) {
            finish(new Error(`MPD greeting was not received: ${lineArg}`));
            return;
          }
          protocolVersion = match[1];
          if (!authenticated && this.config.password) {
            sendCommand('password', [this.config.password]);
            return;
          }
          commandSent = true;
          sendCommand(requestArg.command, requestArg.args || []);
          return;
        }

        if (!authenticated) {
          if (lineArg === 'OK') {
            authenticated = true;
            commandSent = true;
            sendCommand(requestArg.command, requestArg.args || []);
            return;
          }
          if (lineArg.startsWith('ACK ')) {
            finish(new MpdCommandError('password', lineArg));
            return;
          }
          return;
        }

        if (!commandSent) {
          return;
        }
        if (lineArg === 'OK') {
          finish(undefined, {
            command: requestArg.command,
            rawLines,
            lines: this.parseLines(rawLines),
            protocolVersion,
          });
          return;
        }
        if (lineArg.startsWith('ACK ')) {
          finish(new MpdCommandError(requestArg.command, lineArg));
          return;
        }
        rawLines.push(lineArg);
      };

      socket.setEncoding('utf8');
      socket.setTimeout(timeoutMs, () => finish(new Error(`MPD TCP command timed out after ${timeoutMs}ms.`)));
      socket.on('error', (errorArg) => finish(errorArg));
      socket.on('close', () => finish(new Error('MPD TCP connection closed before command completed.')));
      socket.on('data', (chunkArg) => {
        buffer += chunkArg;
        const lines = buffer.split(/\r?\n/);
        buffer = lines.pop() || '';
        for (const line of lines) {
          handleLine(line);
        }
      });
    });
  }

  private snapshotCommand(commandArg: TMpdCommand, argsArg: Array<string | number | boolean>): IMpdCommandResponse {
    const snapshot = this.requireSnapshot();
    if (commandArg === 'ping') {
      return this.response(commandArg, []);
    }
    if (commandArg === 'status') {
      return this.response(commandArg, this.linesFromRecord(snapshot.status));
    }
    if (commandArg === 'currentsong') {
      return this.response(commandArg, this.linesFromRecord(snapshot.currentSong || {}));
    }
    if (commandArg === 'outputs') {
      return this.response(commandArg, snapshot.outputs.flatMap((outputArg) => this.linesFromOutput(outputArg)));
    }
    if (commandArg === 'commands') {
      return this.response(commandArg, snapshot.commands.map((command) => `command: ${command}`));
    }
    if (commandArg === 'listplaylists') {
      return this.response(commandArg, snapshot.playlists.flatMap((playlistArg) => playlistArg.lastModified ? [`playlist: ${playlistArg.playlist}`, `Last-Modified: ${playlistArg.lastModified}`] : [`playlist: ${playlistArg.playlist}`]));
    }
    if (commandArg === 'stats') {
      return this.response(commandArg, this.linesFromRecord(snapshot.stats || {}));
    }
    if (commandArg === 'play') {
      snapshot.status.state = 'play';
      return this.response(commandArg, []);
    }
    if (commandArg === 'pause') {
      snapshot.status.state = argsArg[0] === 0 || argsArg[0] === '0' || argsArg[0] === false ? 'play' : 'pause';
      return this.response(commandArg, []);
    }
    if (commandArg === 'stop') {
      snapshot.status.state = 'stop';
      return this.response(commandArg, []);
    }
    if (commandArg === 'setvol') {
      snapshot.status.volume = this.numberValue(argsArg[0]) ?? snapshot.status.volume;
      return this.response(commandArg, []);
    }
    if (commandArg === 'enableoutput' || commandArg === 'disableoutput' || commandArg === 'toggleoutput') {
      if (argsArg[0] === undefined) {
        throw new Error(`MPD ${commandArg} requires an output id.`);
      }
      if (commandArg === 'toggleoutput') {
        this.toggleOutput(argsArg[0] as string | number);
      } else {
        this.setOutput(argsArg[0] as string | number, commandArg === 'enableoutput');
      }
      return this.response(commandArg, []);
    }
    return this.response(commandArg, []);
  }

  private response(commandArg: TMpdCommand, rawLinesArg: string[]): IMpdCommandResponse {
    return {
      command: commandArg,
      rawLines: rawLinesArg,
      lines: this.parseLines(rawLinesArg),
      protocolVersion: this.requireSnapshot().server.protocolVersion,
    };
  }

  private parseLines(rawLinesArg: string[]): IMpdResponseLine[] {
    return rawLinesArg.map((lineArg) => {
      const separator = lineArg.indexOf(': ');
      return separator >= 0 ? { key: lineArg.slice(0, separator), value: lineArg.slice(separator + 2) } : undefined;
    }).filter((lineArg): lineArg is IMpdResponseLine => Boolean(lineArg));
  }

  private recordFromResponse(responseArg: IMpdCommandResponse): Record<string, unknown> {
    const record: Record<string, unknown> = {};
    for (const line of responseArg.lines) {
      const key = line.key;
      const existing = record[key];
      if (existing === undefined) {
        record[key] = line.value;
      } else if (Array.isArray(existing)) {
        existing.push(line.value);
      } else {
        record[key] = [existing, line.value];
      }
    }
    return record;
  }

  private outputsFromResponse(responseArg: IMpdCommandResponse): IMpdOutput[] {
    const outputs: IMpdOutput[] = [];
    let current: Partial<IMpdOutput> | undefined;
    for (const line of responseArg.lines) {
      if (line.key === 'outputid') {
        if (current?.outputid !== undefined && current.outputname) {
          outputs.push(current as IMpdOutput);
        }
        current = { outputid: this.numberValue(line.value) ?? line.value, outputname: line.value };
        continue;
      }
      if (!current) {
        continue;
      }
      if (line.key === 'outputname') {
        current.outputname = line.value;
      } else if (line.key === 'outputenabled') {
        current.outputenabled = line.value === '1';
      } else if (line.key === 'attribute') {
        const [name, ...valueParts] = line.value.split('=');
        current.attributes = current.attributes || {};
        current.attributes[name] = valueParts.join('=');
      } else {
        current[line.key] = line.value;
      }
    }
    if (current?.outputid !== undefined && current.outputname) {
      outputs.push(current as IMpdOutput);
    }
    return outputs;
  }

  private playlistsFromResponse(responseArg: IMpdCommandResponse): IMpdStoredPlaylist[] {
    const playlists: IMpdStoredPlaylist[] = [];
    let current: IMpdStoredPlaylist | undefined;
    for (const line of responseArg.lines) {
      if (line.key === 'playlist') {
        if (current) {
          playlists.push(current);
        }
        current = { playlist: line.value };
      } else if (current && line.key.toLowerCase() === 'last-modified') {
        current.lastModified = line.value;
      } else if (current) {
        current[line.key] = line.value;
      }
    }
    if (current) {
      playlists.push(current);
    }
    return playlists;
  }

  private commandValues(responseArg: IMpdCommandResponse): TMpdCommand[] {
    return responseArg.lines.filter((lineArg) => lineArg.key === 'command').map((lineArg) => lineArg.value);
  }

  private commandLine(commandArg: string, argsArg: Array<string | number | boolean>): string {
    return [commandArg, ...argsArg.map((arg) => this.argument(arg))].join(' ');
  }

  private argument(valueArg: string | number | boolean): string {
    if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
      return String(valueArg);
    }
    if (typeof valueArg === 'boolean') {
      return valueArg ? '1' : '0';
    }
    const value = String(valueArg);
    if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
      return value;
    }
    return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
  }

  private normalizeSnapshot(snapshotArg: IMpdSnapshot, sourceArg: IMpdSnapshot['source']): IMpdSnapshot {
    const server = {
      ...this.serverInfo(),
      ...snapshotArg.server,
    };
    if (!server.name) {
      server.name = this.config.name || server.host || 'Music Player Daemon';
    }
    return {
      ...snapshotArg,
      server,
      status: snapshotArg.status || { state: 'stop' },
      outputs: snapshotArg.outputs || [],
      commands: snapshotArg.commands || server.commands || [],
      playlists: snapshotArg.playlists || [],
      online: snapshotArg.online,
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
      source: snapshotArg.source || sourceArg,
    };
  }

  private serverInfo(): IMpdServerInfo {
    return {
      id: this.config.serverId || (this.config.host ? `${this.config.host}:${this.config.port || mpdDefaultPort}` : undefined),
      host: this.config.host,
      port: this.config.port || mpdDefaultPort,
      name: this.config.name,
    };
  }

  private isSnapshotMode(): boolean {
    return Boolean(this.currentSnapshot) || this.config.transport === 'snapshot';
  }

  private requireSnapshot(): IMpdSnapshot {
    if (!this.currentSnapshot) {
      this.currentSnapshot = this.normalizeSnapshot({
        server: this.serverInfo(),
        status: { state: 'stop' },
        outputs: [],
        commands: [],
        playlists: [],
        online: true,
        source: 'manual',
      }, 'manual');
    }
    return this.currentSnapshot;
  }

  private requireOutput(outputIdArg: string | number): IMpdOutput {
    const output = this.requireSnapshot().outputs.find((outputArg) => String(outputArg.outputid) === String(outputIdArg));
    if (!output) {
      throw new Error(`MPD output not found: ${outputIdArg}`);
    }
    return output;
  }

  private outputEnabled(outputArg: IMpdOutput): boolean {
    return outputArg.outputenabled === true || outputArg.outputenabled === '1' || outputArg.outputenabled === 1;
  }

  private 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;
  }

  private linesFromRecord(recordArg: Record<string, unknown>): string[] {
    const lines: string[] = [];
    for (const [key, value] of Object.entries(recordArg)) {
      if (value === undefined) {
        continue;
      }
      if (Array.isArray(value)) {
        lines.push(...value.map((itemArg) => `${key}: ${itemArg}`));
      } else {
        lines.push(`${key}: ${value}`);
      }
    }
    return lines;
  }

  private linesFromOutput(outputArg: IMpdOutput): string[] {
    const lines = [
      `outputid: ${outputArg.outputid}`,
      `outputname: ${outputArg.outputname}`,
    ];
    if (outputArg.plugin) {
      lines.push(`plugin: ${outputArg.plugin}`);
    }
    lines.push(`outputenabled: ${this.outputEnabled(outputArg) ? 1 : 0}`);
    for (const [key, value] of Object.entries(outputArg.attributes || {})) {
      lines.push(`attribute: ${key}=${value}`);
    }
    return lines;
  }

  private cloneSnapshot(snapshotArg: IMpdSnapshot): IMpdSnapshot {
    return this.cloneValue(snapshotArg) as IMpdSnapshot;
  }

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