import type {
  IKodiActivePlayer,
  IKodiApplicationProperties,
  IKodiConfig,
  IKodiDeviceInfo,
  IKodiJsonRpcRequest,
  IKodiJsonRpcResponse,
  IKodiMediaItem,
  IKodiPlayerProperties,
  IKodiSnapshot,
  IKodiTime,
  TKodiInputCommand,
  TKodiJsonRpcParams,
  TKodiMediaType,
} from './kodi.types.js';

const defaultPort = 8080;
const defaultWsPort = 9090;
const defaultTimeoutMs = 5000;

const playerProperties = ['time', 'totaltime', 'speed', 'live', 'percentage', 'playlistid', 'position', 'repeat', 'shuffled'];
const itemProperties = ['title', 'file', 'uniqueid', 'thumbnail', 'artist', 'albumartist', 'showtitle', 'album', 'season', 'episode', 'streamdetails'];

export class KodiJsonRpcError extends Error {
  constructor(public readonly method: string, public readonly code: number | undefined, messageArg: string, public readonly data?: unknown) {
    super(`Kodi JSON-RPC ${method} failed${typeof code === 'number' ? ` (${code})` : ''}: ${messageArg}`);
    this.name = 'KodiJsonRpcError';
  }
}

export class KodiClient {
  private requestId = 1;

  constructor(private readonly config: IKodiConfig) {}

  public async getSnapshot(): Promise<IKodiSnapshot> {
    if (this.config.snapshot) {
      return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
    }
    if (!this.config.host) {
      return this.normalizeSnapshot({
        deviceInfo: this.deviceInfoFromConfig(),
        players: [],
        online: false,
        updatedAt: new Date().toISOString(),
      });
    }

    const [application, players] = await Promise.all([
      this.getApplicationProperties().catch(() => undefined),
      this.getActivePlayers(),
    ]);
    const player = players[0];
    const [playerPropertiesResult, item] = player ? await Promise.all([
      this.getPlayerProperties(player.playerid).catch(() => undefined),
      this.getPlayerItem(player.playerid).catch(() => undefined),
    ]) : [undefined, undefined];

    return this.normalizeSnapshot({
      deviceInfo: {
        ...this.deviceInfoFromConfig(),
        name: this.config.name || this.config.deviceInfo?.name || application?.name || this.config.host,
        version: this.versionString(application),
      },
      application,
      players,
      player,
      playerProperties: playerPropertiesResult,
      item,
      online: true,
      updatedAt: new Date().toISOString(),
    });
  }

  public async ping(): Promise<boolean> {
    const result = await this.callMethod<string>('JSONRPC.Ping');
    return result === 'pong';
  }

  public async getApplicationProperties(): Promise<IKodiApplicationProperties> {
    return this.callMethod<IKodiApplicationProperties>('Application.GetProperties', {
      properties: ['name', 'version', 'volume', 'muted'],
    });
  }

  public async getActivePlayers(): Promise<IKodiActivePlayer[]> {
    return this.callMethod<IKodiActivePlayer[]>('Player.GetActivePlayers');
  }

  public async getPlayerProperties(playerIdArg: number): Promise<IKodiPlayerProperties> {
    return this.callMethod<IKodiPlayerProperties>('Player.GetProperties', {
      playerid: playerIdArg,
      properties: playerProperties,
    });
  }

  public async getPlayerItem(playerIdArg: number): Promise<IKodiMediaItem | undefined> {
    const result = await this.callMethod<{ item?: IKodiMediaItem }>('Player.GetItem', {
      playerid: playerIdArg,
      properties: itemProperties,
    });
    return result.item;
  }

  public async setVolumeLevel(volumeLevelArg: number): Promise<number> {
    return this.callMethod<number>('Application.SetVolume', {
      volume: Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100))),
    });
  }

  public async stepVolume(directionArg: 'increment' | 'decrement'): Promise<number> {
    return this.callMethod<number>('Application.SetVolume', { volume: directionArg });
  }

  public async setMuted(mutedArg: boolean): Promise<boolean> {
    return this.callMethod<boolean>('Application.SetMute', { mute: mutedArg });
  }

  public async playPause(): Promise<unknown> {
    return this.playState('toggle');
  }

  public async play(): Promise<unknown> {
    return this.playState(true);
  }

  public async pause(): Promise<unknown> {
    return this.playState(false);
  }

  public async stop(): Promise<string> {
    return this.callMethod<string>('Player.Stop', { playerid: await this.activePlayerId() });
  }

  public async nextTrack(): Promise<string> {
    return this.callMethod<string>('Player.GoTo', { playerid: await this.activePlayerId(), to: 'next' });
  }

  public async previousTrack(): Promise<string> {
    return this.callMethod<string>('Player.GoTo', { playerid: await this.activePlayerId(), to: 'previous' });
  }

  public async seek(positionSecondsArg: number): Promise<unknown> {
    return this.callMethod('Player.Seek', {
      playerid: await this.activePlayerId(),
      value: { time: this.secondsToTime(positionSecondsArg) },
    });
  }

  public async playMedia(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Promise<string> {
    return this.callMethod<string>('Player.Open', { item: this.mediaItem(mediaTypeArg, mediaIdArg) });
  }

  public async clearPlaylist(playlistIdArg = 0): Promise<string> {
    return this.callMethod<string>('Playlist.Clear', { playlistid: playlistIdArg });
  }

  public async addToPlaylist(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number, playlistIdArg = 0): Promise<string> {
    return this.callMethod<string>('Playlist.Add', {
      playlistid: playlistIdArg,
      item: this.playlistItem(mediaTypeArg, mediaIdArg),
    });
  }

  public async showNotification(titleArg: string, messageArg: string, iconArg = 'info', displayTimeMsArg = 10000): Promise<string> {
    return this.callMethod<string>('GUI.ShowNotification', {
      title: titleArg,
      message: messageArg,
      image: iconArg,
      displaytime: Math.max(1500, Math.round(displayTimeMsArg)),
    });
  }

  public async input(commandArg: TKodiInputCommand): Promise<string | unknown> {
    const method = this.inputMethod(commandArg);
    return this.callMethod(method);
  }

  public async quit(): Promise<string> {
    return this.callMethod<string>('Application.Quit');
  }

  public async callMethod<T = unknown>(methodArg: string, paramsArg?: TKodiJsonRpcParams): Promise<T> {
    if (!this.config.host) {
      throw new Error('Kodi host is required when snapshot data is not provided.');
    }

    const request: IKodiJsonRpcRequest = {
      jsonrpc: '2.0',
      method: methodArg,
      id: this.requestId++,
    };
    if (paramsArg !== undefined) {
      request.params = paramsArg;
    }

    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
    try {
      const response = await globalThis.fetch(this.jsonRpcUrl(), {
        method: 'POST',
        headers: this.headers(),
        body: JSON.stringify(request),
        signal: abortController.signal,
      });
      const text = await response.text();
      if (!response.ok) {
        throw new Error(`Kodi request ${methodArg} failed with HTTP ${response.status}: ${text}`);
      }
      const payload = JSON.parse(text) as IKodiJsonRpcResponse<T>;
      if (payload.error) {
        throw new KodiJsonRpcError(methodArg, payload.error.code, payload.error.message || 'Unknown JSON-RPC error', payload.error.data);
      }
      return payload.result as T;
    } finally {
      clearTimeout(timeout);
    }
  }

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

  private async playState(playArg: boolean | 'toggle'): Promise<unknown> {
    return this.callMethod('Player.PlayPause', {
      playerid: await this.activePlayerId(),
      play: playArg,
    });
  }

  private async activePlayerId(): Promise<number> {
    const snapshot = await this.getSnapshot();
    const playerId = snapshot.player?.playerid ?? snapshot.players[0]?.playerid;
    if (typeof playerId !== 'number') {
      throw new Error('Kodi has no active player.');
    }
    return playerId;
  }

  private mediaItem(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Record<string, unknown> {
    const type = mediaTypeArg.toLowerCase();
    if (type === 'playlist') {
      return { playlistid: Number(mediaIdArg) };
    }
    if (type === 'channel') {
      return { channelid: Number(mediaIdArg) };
    }
    if (type === 'directory') {
      return { path: String(mediaIdArg), recursive: true };
    }
    if (type === 'movie') {
      return { movieid: Number(mediaIdArg) };
    }
    if (type === 'episode') {
      return { episodeid: Number(mediaIdArg) };
    }
    if (type === 'season') {
      return { seasonid: Number(mediaIdArg) };
    }
    if (type === 'tvshow') {
      return { tvshowid: Number(mediaIdArg) };
    }
    if (type === 'album') {
      return { albumid: Number(mediaIdArg) };
    }
    if (type === 'artist') {
      return { artistid: Number(mediaIdArg) };
    }
    if (type === 'song' || type === 'track') {
      return { songid: Number(mediaIdArg) };
    }
    return { file: String(mediaIdArg) };
  }

  private playlistItem(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Record<string, unknown> {
    const type = mediaTypeArg.toLowerCase();
    if (type === 'album') {
      return { albumid: Number(mediaIdArg) };
    }
    if (type === 'artist') {
      return { artistid: Number(mediaIdArg) };
    }
    if (type === 'song' || type === 'track') {
      return { songid: Number(mediaIdArg) };
    }
    return this.mediaItem(type, mediaIdArg);
  }

  private secondsToTime(secondsArg: number): IKodiTime {
    const safeSeconds = Math.max(0, Math.floor(secondsArg));
    return {
      hours: Math.floor(safeSeconds / 3600),
      minutes: Math.floor((safeSeconds % 3600) / 60),
      seconds: safeSeconds % 60,
      milliseconds: Math.max(0, Math.round((secondsArg - Math.floor(secondsArg)) * 1000)),
    };
  }

  private inputMethod(commandArg: string): string {
    const normalized = commandArg.replace(/[_\s-]+/g, '').toLowerCase();
    const methods: Record<string, string> = {
      up: 'Input.Up',
      down: 'Input.Down',
      left: 'Input.Left',
      right: 'Input.Right',
      select: 'Input.Select',
      ok: 'Input.Select',
      enter: 'Input.Select',
      back: 'Input.Back',
      home: 'Input.Home',
      info: 'Input.Info',
      contextmenu: 'Input.ContextMenu',
    };
    return methods[normalized] || (commandArg.includes('.') ? commandArg : `Input.${commandArg}`);
  }

  private normalizeSnapshot(snapshotArg: IKodiSnapshot): IKodiSnapshot {
    const deviceInfo = {
      ...this.deviceInfoFromConfig(),
      ...snapshotArg.deviceInfo,
    };
    if (!deviceInfo.name) {
      deviceInfo.name = snapshotArg.application?.name || this.config.name || this.config.host || 'Kodi';
    }
    if (!deviceInfo.version) {
      deviceInfo.version = this.versionString(snapshotArg.application);
    }
    return {
      ...snapshotArg,
      deviceInfo,
      players: snapshotArg.players || [],
      player: snapshotArg.player || snapshotArg.players?.[0],
      online: snapshotArg.online,
    };
  }

  private deviceInfoFromConfig(): IKodiDeviceInfo {
    return {
      ...this.config.deviceInfo,
      id: this.config.deviceInfo?.id || this.config.uniqueId,
      uuid: this.config.deviceInfo?.uuid || this.config.uniqueId,
      name: this.config.deviceInfo?.name || this.config.name,
      host: this.config.deviceInfo?.host || this.config.host,
      port: this.config.deviceInfo?.port || this.config.port || defaultPort,
      wsPort: this.config.deviceInfo?.wsPort || this.config.wsPort || defaultWsPort,
      manufacturer: this.config.deviceInfo?.manufacturer || 'Kodi',
    };
  }

  private versionString(applicationArg: IKodiApplicationProperties | undefined): string | undefined {
    const version = applicationArg?.version;
    if (!version || typeof version.major !== 'number') {
      return undefined;
    }
    return `${version.major}.${typeof version.minor === 'number' ? version.minor : 0}${version.revision ? `-${version.revision}` : ''}`;
  }

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

  private jsonRpcUrl(): string {
    const protocol = this.config.ssl ? 'https' : 'http';
    return `${protocol}://${this.config.host}:${this.config.port || defaultPort}/jsonrpc`;
  }

  private headers(): Record<string, string> {
    const headers: Record<string, string> = { 'content-type': 'application/json' };
    if (this.config.username !== undefined && this.config.password !== undefined) {
      headers.authorization = `Basic ${globalThis.btoa(`${this.config.username}:${this.config.password}`)}`;
    }
    return headers;
  }
}
