import * as plugins from '../../plugins.js';
import type { ICastConfig, ICastDeviceInfo, ICastMediaLoadOptions, ICastMediaStatus, ICastReceiverStatus, ICastSnapshot, ICastVolume } from './cast.types.js';

const castPort = 8009;
const httpInfoPort = 8008;
const defaultMediaReceiverAppId = 'CC1AD845';
const senderId = 'sender-0';
const receiverId = 'receiver-0';
const connectionNamespace = 'urn:x-cast:com.google.cast.tp.connection';
const heartbeatNamespace = 'urn:x-cast:com.google.cast.tp.heartbeat';
const receiverNamespace = 'urn:x-cast:com.google.cast.receiver';
const mediaNamespace = 'urn:x-cast:com.google.cast.media';

interface ICastV2Message {
  protocolVersion?: number;
  sourceId?: string;
  destinationId?: string;
  namespace?: string;
  payloadType?: number;
  payloadUtf8?: string;
}

interface ICastJsonPayload extends Record<string, unknown> {
  type?: string;
  requestId?: number;
}

interface IPendingRequest {
  expectedTypes: string[];
  resolve(payloadArg: ICastJsonPayload): void;
  reject(errorArg: Error): void;
  timer: ReturnType<typeof setTimeout>;
}

export class CastClient {
  private channel?: CastV2Channel;

  constructor(private readonly config: ICastConfig) {}

  public async getSnapshot(): Promise<ICastSnapshot> {
    const deviceInfo = await this.getDeviceInfo();
    const receiverStatus = await this.getReceiverStatus();
    const mediaStatus = await this.getMediaStatus(receiverStatus);
    return { deviceInfo, receiverStatus, mediaStatus };
  }

  public async getDeviceInfo(): Promise<ICastDeviceInfo> {
    if (this.config.deviceInfo) {
      return this.config.deviceInfo;
    }
    const payload = await this.requestJson<unknown>('/setup/eureka_info?options=detail');
    return this.mapEurekaInfo(payload);
  }

  public async getReceiverStatus(): Promise<ICastReceiverStatus> {
    if (this.config.receiverStatus) {
      return this.config.receiverStatus;
    }
    return (await this.getChannel()).getReceiverStatus();
  }

  public async getMediaStatus(receiverStatusArg?: ICastReceiverStatus): Promise<ICastMediaStatus | undefined> {
    if (this.config.mediaStatus) {
      return this.config.mediaStatus;
    }
    const receiverStatus = receiverStatusArg || await this.getReceiverStatus();
    const app = receiverStatus.applications?.find((appArg) => appArg.transportId);
    if (!app?.transportId) {
      return undefined;
    }
    return (await this.getChannel()).getMediaStatus(app.transportId);
  }

  public async turnOn(): Promise<void> {
    await this.launchApp(this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId);
  }

  public async turnOff(): Promise<void> {
    const receiverStatus = await this.getReceiverStatus();
    const app = receiverStatus.applications?.[0];
    if (app?.sessionId) {
      await (await this.getChannel()).stopApp(app.sessionId);
    }
  }

  public async launchApp(appIdArg: string): Promise<ICastReceiverStatus> {
    return (await this.getChannel()).launchApp(appIdArg);
  }

  public async loadMedia(optionsArg: ICastMediaLoadOptions): Promise<ICastMediaStatus | undefined> {
    const channel = await this.getChannel();
    const appId = optionsArg.appId || this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId;
    const receiverStatus = await channel.launchApp(appId);
    const app = receiverStatus.applications?.find((appArg) => appArg.appId === appId) || receiverStatus.applications?.[0];
    if (!app?.transportId) {
      throw new Error('Cast receiver did not return an application transport id.');
    }
    return channel.loadMedia(app.transportId, app.sessionId, optionsArg);
  }

  public async play(): Promise<void> {
    await this.sendMediaCommand('PLAY');
  }

  public async pause(): Promise<void> {
    await this.sendMediaCommand('PAUSE');
  }

  public async stop(): Promise<void> {
    await this.sendMediaCommand('STOP');
  }

  public async seek(positionArg: number): Promise<void> {
    await this.sendMediaCommand('SEEK', { currentTime: positionArg });
  }

  public async setVolumeLevel(levelArg: number): Promise<void> {
    await (await this.getChannel()).setVolume({ level: Math.max(0, Math.min(1, levelArg)) });
  }

  public async setMuted(mutedArg: boolean): Promise<void> {
    await (await this.getChannel()).setVolume({ muted: mutedArg });
  }

  public async stepVolume(deltaArg: number): Promise<void> {
    const status = await this.getReceiverStatus();
    const currentLevel = typeof status.volume?.level === 'number' ? status.volume.level : 0.5;
    await this.setVolumeLevel(currentLevel + deltaArg);
  }

  public async destroy(): Promise<void> {
    this.channel?.destroy();
    this.channel = undefined;
  }

  private async sendMediaCommand(typeArg: string, dataArg: Record<string, unknown> = {}): Promise<void> {
    const receiverStatus = await this.getReceiverStatus();
    const app = receiverStatus.applications?.find((appArg) => appArg.transportId);
    if (!app?.transportId) {
      throw new Error('Cast media command requires an active receiver application.');
    }
    const mediaStatus = await this.getMediaStatus(receiverStatus);
    if (typeof mediaStatus?.mediaSessionId !== 'number') {
      throw new Error('Cast media command requires an active media session.');
    }
    await (await this.getChannel()).sendMediaCommand(app.transportId, typeArg, mediaStatus.mediaSessionId, dataArg);
  }

  private async getChannel(): Promise<CastV2Channel> {
    if (this.channel) {
      return this.channel;
    }
    if (!this.config.host) {
      throw new Error('Cast host is required when fixture data is not provided.');
    }
    const channel = new CastV2Channel(this.config.host, this.config.port || castPort);
    await channel.connect();
    this.channel = channel;
    return channel;
  }

  private async requestJson<TResult>(pathArg: string): Promise<TResult> {
    if (!this.config.host) {
      throw new Error('Cast host is required when fixture data is not provided.');
    }
    const response = await globalThis.fetch(`${this.httpBaseUrl()}${pathArg}`);
    const text = await response.text();
    if (!response.ok) {
      throw new Error(`Cast request ${pathArg} failed with HTTP ${response.status}: ${text}`);
    }
    return JSON.parse(text) as TResult;
  }

  private mapEurekaInfo(valueArg: unknown): ICastDeviceInfo {
    const root = this.asRecord(valueArg);
    const deviceInfo = this.asRecord(root.device_info);
    const uuid = this.stripUuid(this.stringValue(deviceInfo.ssdp_udn) || this.stringValue(root.ssdp_udn) || this.config.uuid);
    return {
      uuid,
      friendlyName: this.stringValue(root.name) || this.stringValue(deviceInfo.name) || this.config.host,
      modelName: this.stringValue(deviceInfo.model_name) || this.stringValue(root.model_name),
      manufacturer: this.stringValue(deviceInfo.manufacturer) || 'Google',
      castType: this.stringValue(deviceInfo.cast_type) || this.stringValue(root.cast_type),
      host: this.config.host,
      port: this.config.port || castPort,
      buildVersion: this.stringValue(root.build_version),
      firmwareVersion: this.stringValue(root.cast_build_revision),
    };
  }

  private httpBaseUrl(): string {
    return `http://${this.config.host}:${this.config.httpPort || httpInfoPort}`;
  }

  private asRecord(valueArg: unknown): Record<string, unknown> {
    return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : {};
  }

  private stringValue(valueArg: unknown): string | undefined {
    return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
  }

  private stripUuid(valueArg?: string): string | undefined {
    return valueArg?.replace(/^uuid:/i, '').replace(/-/g, '').toLowerCase();
  }
}

class CastV2Channel {
  private socket?: plugins.tls.TLSSocket;
  private buffer = Buffer.alloc(0);
  private nextRequestId = 1;
  private readonly pendingRequests = new Map<number, IPendingRequest>();

  constructor(private readonly host: string, private readonly port: number) {}

  public async connect(): Promise<void> {
    if (this.socket && !this.socket.destroyed) {
      return;
    }
    this.socket = await new Promise<plugins.tls.TLSSocket>((resolve, reject) => {
      const socket = plugins.tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false, servername: this.host });
      socket.once('secureConnect', () => resolve(socket));
      socket.once('error', reject);
    });
    this.socket.on('data', (chunkArg) => this.handleData(chunkArg));
    this.socket.on('error', (errorArg) => this.rejectPending(errorArg));
    this.socket.on('close', () => this.rejectPending(new Error('Cast socket closed.')));
    await this.connectNamespace(receiverId);
  }

  public async getReceiverStatus(): Promise<ICastReceiverStatus> {
    const payload = await this.sendReceiverRequest('GET_STATUS', {}, ['RECEIVER_STATUS']);
    return (payload.status || {}) as ICastReceiverStatus;
  }

  public async launchApp(appIdArg: string): Promise<ICastReceiverStatus> {
    const payload = await this.sendReceiverRequest('LAUNCH', { appId: appIdArg }, ['RECEIVER_STATUS']);
    return (payload.status || {}) as ICastReceiverStatus;
  }

  public async stopApp(sessionIdArg: string): Promise<void> {
    await this.sendReceiverRequest('STOP', { sessionId: sessionIdArg }, ['RECEIVER_STATUS']);
  }

  public async setVolume(volumeArg: ICastVolume): Promise<void> {
    await this.sendReceiverRequest('SET_VOLUME', { volume: volumeArg }, ['RECEIVER_STATUS']);
  }

  public async getMediaStatus(transportIdArg: string): Promise<ICastMediaStatus | undefined> {
    await this.connectNamespace(transportIdArg);
    const payload = await this.sendRequest(transportIdArg, mediaNamespace, { type: 'GET_STATUS' }, ['MEDIA_STATUS']);
    return this.firstMediaStatus(payload);
  }

  public async loadMedia(transportIdArg: string, sessionIdArg: string | undefined, optionsArg: ICastMediaLoadOptions): Promise<ICastMediaStatus | undefined> {
    await this.connectNamespace(transportIdArg);
    const metadata: Record<string, unknown> = {};
    if (optionsArg.title) {
      metadata.title = optionsArg.title;
    }
    if (optionsArg.subtitle) {
      metadata.subtitle = optionsArg.subtitle;
    }
    if (optionsArg.artist) {
      metadata.artist = optionsArg.artist;
    }
    if (optionsArg.albumName) {
      metadata.albumName = optionsArg.albumName;
    }
    if (optionsArg.imageUrl) {
      metadata.images = [{ url: optionsArg.imageUrl }];
    }
    const media: Record<string, unknown> = {
      contentId: optionsArg.contentId,
      contentType: optionsArg.contentType,
      streamType: optionsArg.streamType || 'BUFFERED',
    };
    if (Object.keys(metadata).length) {
      media.metadata = metadata;
    }
    const payload = await this.sendRequest(transportIdArg, mediaNamespace, {
      type: 'LOAD',
      sessionId: sessionIdArg,
      media,
      autoplay: true,
      currentTime: 0,
    }, ['MEDIA_STATUS']);
    return this.firstMediaStatus(payload);
  }

  public async sendMediaCommand(transportIdArg: string, typeArg: string, mediaSessionIdArg: number, dataArg: Record<string, unknown>): Promise<void> {
    await this.connectNamespace(transportIdArg);
    await this.sendRequest(transportIdArg, mediaNamespace, { ...dataArg, type: typeArg, mediaSessionId: mediaSessionIdArg }, ['MEDIA_STATUS']);
  }

  public destroy(): void {
    this.rejectPending(new Error('Cast socket destroyed.'));
    this.socket?.destroy();
    this.socket = undefined;
  }

  private async sendReceiverRequest(typeArg: string, dataArg: Record<string, unknown>, expectedTypesArg: string[]): Promise<ICastJsonPayload> {
    return this.sendRequest(receiverId, receiverNamespace, { ...dataArg, type: typeArg }, expectedTypesArg);
  }

  private async connectNamespace(destinationIdArg: string): Promise<void> {
    await this.sendMessage(destinationIdArg, connectionNamespace, { type: 'CONNECT' });
  }

  private async sendRequest(destinationIdArg: string, namespaceArg: string, payloadArg: Record<string, unknown>, expectedTypesArg: string[]): Promise<ICastJsonPayload> {
    const requestId = this.nextRequestId++;
    const payload = { ...payloadArg, requestId };
    const promise = new Promise<ICastJsonPayload>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(requestId);
        reject(new Error(`Cast request ${String(payloadArg.type)} timed out.`));
      }, 7000);
      this.pendingRequests.set(requestId, { expectedTypes: expectedTypesArg, resolve, reject, timer });
    });
    await this.sendMessage(destinationIdArg, namespaceArg, payload);
    return promise;
  }

  private async sendMessage(destinationIdArg: string, namespaceArg: string, payloadArg: Record<string, unknown>): Promise<void> {
    if (!this.socket || this.socket.destroyed) {
      throw new Error('Cast socket is not connected.');
    }
    const message = this.encodeMessage({
      protocolVersion: 0,
      sourceId: senderId,
      destinationId: destinationIdArg,
      namespace: namespaceArg,
      payloadType: 0,
      payloadUtf8: JSON.stringify(payloadArg),
    });
    const frame = Buffer.alloc(4 + message.length);
    frame.writeUInt32BE(message.length, 0);
    message.copy(frame, 4);
    await new Promise<void>((resolve, reject) => {
      this.socket?.write(frame, (errorArg) => errorArg ? reject(errorArg) : resolve());
    });
  }

  private handleData(chunkArg: Buffer | string): void {
    const chunk = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg);
    this.buffer = Buffer.concat([this.buffer, chunk]);
    while (this.buffer.length >= 4) {
      const length = this.buffer.readUInt32BE(0);
      if (this.buffer.length < 4 + length) {
        return;
      }
      const frame = this.buffer.subarray(4, 4 + length);
      this.buffer = this.buffer.subarray(4 + length);
      this.handleMessage(this.decodeMessage(frame));
    }
  }

  private handleMessage(messageArg: ICastV2Message): void {
    if (!messageArg.payloadUtf8) {
      return;
    }
    const payload = JSON.parse(messageArg.payloadUtf8) as ICastJsonPayload;
    if (messageArg.namespace === heartbeatNamespace && payload.type === 'PING') {
      void this.sendMessage(messageArg.sourceId || receiverId, heartbeatNamespace, { type: 'PONG' });
      return;
    }
    const requestId = typeof payload.requestId === 'number' ? payload.requestId : undefined;
    if (requestId === undefined) {
      return;
    }
    const pending = this.pendingRequests.get(requestId);
    if (!pending) {
      return;
    }
    if (payload.type === 'INVALID_REQUEST' || payload.type === 'ERROR') {
      clearTimeout(pending.timer);
      this.pendingRequests.delete(requestId);
      pending.reject(new Error(`Cast request failed: ${messageArg.payloadUtf8}`));
      return;
    }
    if (!pending.expectedTypes.length || pending.expectedTypes.includes(payload.type || '')) {
      clearTimeout(pending.timer);
      this.pendingRequests.delete(requestId);
      pending.resolve(payload);
    }
  }

  private firstMediaStatus(payloadArg: ICastJsonPayload): ICastMediaStatus | undefined {
    const status = payloadArg.status;
    return Array.isArray(status) ? status[0] as ICastMediaStatus | undefined : undefined;
  }

  private rejectPending(errorArg: Error): void {
    for (const [requestId, pending] of this.pendingRequests) {
      clearTimeout(pending.timer);
      this.pendingRequests.delete(requestId);
      pending.reject(errorArg);
    }
  }

  private encodeMessage(messageArg: ICastV2Message): Buffer {
    const chunks = [
      this.encodeVarintField(1, messageArg.protocolVersion || 0),
      this.encodeStringField(2, messageArg.sourceId || senderId),
      this.encodeStringField(3, messageArg.destinationId || receiverId),
      this.encodeStringField(4, messageArg.namespace || receiverNamespace),
      this.encodeVarintField(5, messageArg.payloadType || 0),
    ];
    if (messageArg.payloadUtf8 !== undefined) {
      chunks.push(this.encodeStringField(6, messageArg.payloadUtf8));
    }
    return Buffer.concat(chunks);
  }

  private decodeMessage(bufferArg: Buffer): ICastV2Message {
    const message: ICastV2Message = {};
    let offset = 0;
    while (offset < bufferArg.length) {
      const key = this.decodeVarint(bufferArg, offset);
      offset = key.offset;
      const fieldNumber = key.value >> 3;
      const wireType = key.value & 7;
      if (wireType === 0) {
        const value = this.decodeVarint(bufferArg, offset);
        offset = value.offset;
        if (fieldNumber === 1) {
          message.protocolVersion = value.value;
        } else if (fieldNumber === 5) {
          message.payloadType = value.value;
        }
        continue;
      }
      if (wireType === 2) {
        const length = this.decodeVarint(bufferArg, offset);
        offset = length.offset;
        const value = bufferArg.subarray(offset, offset + length.value).toString('utf8');
        offset += length.value;
        if (fieldNumber === 2) {
          message.sourceId = value;
        } else if (fieldNumber === 3) {
          message.destinationId = value;
        } else if (fieldNumber === 4) {
          message.namespace = value;
        } else if (fieldNumber === 6) {
          message.payloadUtf8 = value;
        }
        continue;
      }
      throw new Error(`Unsupported Cast protobuf wire type: ${wireType}`);
    }
    return message;
  }

  private encodeVarintField(fieldNumberArg: number, valueArg: number): Buffer {
    return Buffer.concat([this.encodeVarint((fieldNumberArg << 3) | 0), this.encodeVarint(valueArg)]);
  }

  private encodeStringField(fieldNumberArg: number, valueArg: string): Buffer {
    const value = Buffer.from(valueArg, 'utf8');
    return Buffer.concat([this.encodeVarint((fieldNumberArg << 3) | 2), this.encodeVarint(value.length), value]);
  }

  private encodeVarint(valueArg: number): Buffer {
    const bytes: number[] = [];
    let value = valueArg >>> 0;
    while (value > 127) {
      bytes.push((value & 0x7f) | 0x80);
      value >>>= 7;
    }
    bytes.push(value);
    return Buffer.from(bytes);
  }

  private decodeVarint(bufferArg: Buffer, offsetArg: number): { value: number; offset: number } {
    let result = 0;
    let shift = 0;
    let offset = offsetArg;
    while (offset < bufferArg.length) {
      const byte = bufferArg[offset++];
      result |= (byte & 0x7f) << shift;
      if ((byte & 0x80) === 0) {
        return { value: result >>> 0, offset };
      }
      shift += 7;
    }
    throw new Error('Invalid Cast protobuf varint.');
  }
}
