import type {
  IDirectvClientLike,
  IDirectvConfig,
  IDirectvDeviceInfo,
  IDirectvDeviceUpdate,
  IDirectvGetLocationsResponse,
  IDirectvGetVersionResponse,
  IDirectvLocation,
  IDirectvLocationState,
  IDirectvModeResponse,
  IDirectvProgram,
  IDirectvProgramResponse,
  IDirectvRawRequest,
  IDirectvSnapshot,
  TDirectvRemoteKey,
  TDirectvSnapshotSource,
} from './directv.types.js';
import {
  directvDefaultDevice,
  directvDefaultName,
  directvDefaultPort,
  directvDefaultTimeoutMs,
  directvRemoteKeys,
} from './directv.types.js';

const validRemoteKeys = new Set<string>(directvRemoteKeys);

export class DirectvError extends Error {
  constructor(messageArg: string) {
    super(messageArg);
    this.name = 'DirectvError';
  }
}

export class DirectvAccessRestrictedError extends DirectvError {
  constructor(messageArg = 'DirecTV access is restricted. Enable external device access on the receiver.') {
    super(messageArg);
    this.name = 'DirectvAccessRestrictedError';
  }
}

export class DirectvTransportError extends DirectvError {
  constructor(messageArg: string) {
    super(messageArg);
    this.name = 'DirectvTransportError';
  }
}

export class DirectvClient {
  private currentSnapshot?: IDirectvSnapshot;

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

  public async getSnapshot(): Promise<IDirectvSnapshot> {
    if (this.config.snapshot) {
      this.currentSnapshot = this.normalizeSnapshot(this.cloneValue(this.config.snapshot), 'snapshot');
      return this.cloneValue(this.currentSnapshot);
    }

    if (this.config.client?.getSnapshot || this.config.client?.snapshot) {
      this.currentSnapshot = this.normalizeSnapshot(await this.snapshotFromClient(this.config.client), 'client');
      return this.cloneValue(this.currentSnapshot);
    }

    if (this.config.host || this.config.commandExecutor) {
      try {
        this.currentSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
      }
      return this.cloneValue(this.currentSnapshot);
    }

    this.currentSnapshot = this.offlineSnapshot('DirecTV refresh requires a host, snapshot, injected client, or command executor.');
    return this.cloneValue(this.currentSnapshot);
  }

  public async refresh(): Promise<IDirectvSnapshot> {
    return this.getSnapshot();
  }

  public async validateConnection(): Promise<IDirectvSnapshot> {
    const snapshot = await this.fetchSnapshot();
    if (!snapshot.online) {
      throw new DirectvTransportError(snapshot.error || 'DirecTV receiver did not return live state.');
    }
    return snapshot;
  }

  public async update(): Promise<IDirectvDeviceUpdate> {
    const info = await this.requestJson<IDirectvGetVersionResponse>('info/getVersion');
    if (!this.isRecord(info)) {
      throw new DirectvTransportError('DirecTV info/getVersion returned an invalid response.');
    }

    const locationsResponse = await this.requestJson<IDirectvGetLocationsResponse>('info/getLocations');
    if (!this.isRecord(locationsResponse) || !Array.isArray(locationsResponse.locations)) {
      throw new DirectvTransportError('DirecTV info/getLocations returned an invalid response.');
    }

    const deviceInfo = this.deviceInfoFromResponse(info);
    const locations = locationsResponse.locations.map((locationArg) => this.locationFromResponse(locationArg));
    return {
      info: deviceInfo,
      locations: locations.length ? locations : this.configLocations(deviceInfo),
    };
  }

  public async state(clientArg = directvDefaultDevice): Promise<IDirectvLocationState> {
    const updatedAt = new Date().toISOString();
    let available = true;
    let authorized = true;
    let standby = true;
    let error: string | undefined;

    try {
      const mode = await this.requestJson<IDirectvModeResponse>('info/mode', { clientAddr: clientArg });
      const modeValue = this.numberValue(mode.mode, 1);
      standby = modeValue === 1;
    } catch (errorArg) {
      if (errorArg instanceof DirectvAccessRestrictedError) {
        return {
          address: clientArg,
          available: false,
          authorized: false,
          standby: true,
          status: 'unauthorized',
          updatedAt,
          error: errorArg.message,
        };
      }
      available = false;
      standby = true;
      error = this.errorMessage(errorArg);
    }

    let program: IDirectvProgram | undefined;
    if (!standby && available) {
      try {
        program = await this.tuned(clientArg);
      } catch (errorArg) {
        if (errorArg instanceof DirectvAccessRestrictedError) {
          authorized = false;
          error = errorArg.message;
        } else {
          available = false;
          error = this.errorMessage(errorArg);
        }
      }
    }

    const previousState = this.currentSnapshot?.states.find((stateArg) => stateArg.address === clientArg);
    const paused = Boolean(program?.recorded && previousState?.program?.position === program.position);
    const positionUpdatedAt = program && !paused ? updatedAt : previousState?.positionUpdatedAt;
    return {
      address: clientArg,
      available,
      authorized,
      standby,
      status: !authorized ? 'unauthorized' : !available ? 'unavailable' : standby ? 'standby' : 'active',
      program,
      paused,
      positionUpdatedAt,
      updatedAt,
      error,
    };
  }

  public async status(clientArg = directvDefaultDevice): Promise<string> {
    try {
      const mode = await this.requestJson<IDirectvModeResponse>('info/mode', { clientAddr: clientArg });
      return this.numberValue(mode.mode, 1) === 1 ? 'standby' : 'active';
    } catch (errorArg) {
      return errorArg instanceof DirectvAccessRestrictedError ? 'unauthorized' : 'unavailable';
    }
  }

  public async tuned(clientArg = directvDefaultDevice): Promise<IDirectvProgram> {
    const response = await this.requestJson<IDirectvProgramResponse>('tv/getTuned', { clientAddr: clientArg });
    if (!this.isRecord(response)) {
      throw new DirectvTransportError('DirecTV tv/getTuned returned an invalid response.');
    }
    return this.programFromResponse(response);
  }

  public async sendRemoteKey(keyArg: TDirectvRemoteKey | string, clientArg = directvDefaultDevice): Promise<unknown> {
    const key = keyArg.toLowerCase();
    if (!validRemoteKeys.has(key)) {
      throw new DirectvError(`Remote key is invalid: ${keyArg}`);
    }

    if (this.config.client?.sendRemoteKey) {
      return this.config.client.sendRemoteKey(key, clientArg);
    }
    if (this.config.client?.remote) {
      return this.config.client.remote(key, clientArg);
    }

    return this.requestJson('remote/processKey', {
      key,
      hold: 'keyPress',
      clientAddr: clientArg,
    });
  }

  public async remote(keyArg: TDirectvRemoteKey | string, clientArg = directvDefaultDevice): Promise<unknown> {
    return this.sendRemoteKey(keyArg, clientArg);
  }

  public async tune(channelArg: string, clientArg = directvDefaultDevice): Promise<unknown> {
    const [major, minor] = this.parseChannelNumber(channelArg);
    if (this.config.client?.tune) {
      return this.config.client.tune(channelArg, clientArg);
    }
    return this.requestJson('tv/tune', {
      major,
      minor,
      clientAddr: clientArg,
    });
  }

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

  private async fetchSnapshot(): Promise<IDirectvSnapshot> {
    const device = await this.update();
    const updatedAt = new Date().toISOString();
    const states = await Promise.all(device.locations.map((locationArg) => this.state(locationArg.address)));
    const source = this.config.commandExecutor ? 'executor' : 'http';
    return this.normalizeSnapshot({
      device: device.info,
      locations: device.locations,
      states,
      online: true,
      updatedAt,
      source,
    }, source);
  }

  private async snapshotFromClient(clientArg: IDirectvClientLike): Promise<IDirectvSnapshot> {
    const result = clientArg.getSnapshot ? await clientArg.getSnapshot() : clientArg.snapshot ? await clientArg.snapshot() : undefined;
    if (!result) {
      throw new DirectvError('DirecTV client must expose getSnapshot() or snapshot().');
    }
    return result;
  }

  private async requestJson<TValue>(endpointArg: string, paramsArg: Record<string, string | number | boolean | undefined> = {}): Promise<TValue> {
    const request = this.rawRequest(endpointArg, paramsArg);
    if (this.config.commandExecutor) {
      return this.executorResult<TValue>(await this.config.commandExecutor.execute(request));
    }
    if (!this.config.host) {
      throw new DirectvTransportError('DirecTV local HTTP request requires config.host or commandExecutor.');
    }

    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || directvDefaultTimeoutMs);
    try {
      const response = await globalThis.fetch(request.url, {
        method: request.method,
        signal: abortController.signal,
        headers: {
          Accept: 'application/json, text/plain, */*',
          'User-Agent': 'smarthome.exchange-directv/0.1.0',
        },
      });
      const text = await response.text();
      if (response.status === 403) {
        throw new DirectvAccessRestrictedError();
      }
      if (!response.ok) {
        throw new DirectvTransportError(`DirecTV request ${endpointArg} failed with HTTP ${response.status}: ${text}`);
      }
      return this.parseResponseText<TValue>(text, response.headers.get('content-type') || '');
    } catch (errorArg) {
      if (errorArg instanceof DirectvError) {
        throw errorArg;
      }
      if (errorArg instanceof Error && errorArg.name === 'AbortError') {
        throw new DirectvTransportError(`DirecTV request ${endpointArg} timed out after ${this.config.timeoutMs || directvDefaultTimeoutMs}ms.`);
      }
      throw new DirectvTransportError(`DirecTV request ${endpointArg} failed: ${this.errorMessage(errorArg)}`);
    } finally {
      clearTimeout(timeout);
    }
  }

  private async executorResult<TValue>(valueArg: unknown): Promise<TValue> {
    if (valueArg instanceof Response) {
      const text = await valueArg.text();
      if (valueArg.status === 403) {
        throw new DirectvAccessRestrictedError();
      }
      if (!valueArg.ok) {
        throw new DirectvTransportError(`DirecTV executor request failed with HTTP ${valueArg.status}: ${text}`);
      }
      return this.parseResponseText<TValue>(text, valueArg.headers.get('content-type') || '');
    }
    if (typeof valueArg === 'string') {
      return this.parseResponseText<TValue>(valueArg, '');
    }
    return valueArg as TValue;
  }

  private parseResponseText<TValue>(textArg: string, contentTypeArg: string): TValue {
    const text = textArg.trim();
    if (!text) {
      return {} as TValue;
    }
    if (contentTypeArg.includes('application/json') || text.startsWith('{') || text.startsWith('[')) {
      return JSON.parse(text) as TValue;
    }
    return text as TValue;
  }

  private rawRequest(endpointArg: string, paramsArg: Record<string, string | number | boolean | undefined>): IDirectvRawRequest {
    const port = this.config.port || directvDefaultPort;
    const endpoint = endpointArg.replace(/^\/+/, '');
    const base = this.config.host ? `http://${this.formatHost(this.config.host)}:${port}/` : 'http://directv.local/';
    const url = new URL(endpoint, base);
    for (const [key, value] of Object.entries(paramsArg)) {
      if (value !== undefined) {
        url.searchParams.set(key, String(value));
      }
    }
    return {
      endpoint,
      params: { ...paramsArg },
      url: url.toString(),
      host: this.config.host,
      port,
      method: 'GET',
    };
  }

  private normalizeSnapshot(snapshotArg: IDirectvSnapshot, sourceArg: TDirectvSnapshotSource): IDirectvSnapshot {
    const device = {
      ...this.deviceInfoFromConfig(),
      ...snapshotArg.device,
    };
    const locations = snapshotArg.locations?.length ? snapshotArg.locations : this.configLocations(device);
    const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
    const online = snapshotArg.online ?? snapshotArg.states?.some((stateArg) => stateArg.available) ?? false;
    const states = locations.map((locationArg) => this.normalizeState(locationArg, snapshotArg.states?.find((stateArg) => stateArg.address === locationArg.address), online, updatedAt));
    return {
      ...snapshotArg,
      device,
      locations,
      states,
      online,
      updatedAt,
      source: snapshotArg.source || sourceArg,
      error: snapshotArg.error || states.find((stateArg) => stateArg.error)?.error,
    };
  }

  private normalizeState(locationArg: IDirectvLocation, stateArg: IDirectvLocationState | undefined, onlineArg: boolean, updatedAtArg: string): IDirectvLocationState {
    if (stateArg) {
      return {
        ...stateArg,
        address: stateArg.address || locationArg.address,
        available: stateArg.available ?? onlineArg,
        authorized: stateArg.authorized ?? true,
        standby: stateArg.standby ?? !onlineArg,
        status: stateArg.status || (!onlineArg ? 'unavailable' : stateArg.standby ? 'standby' : 'active'),
        updatedAt: stateArg.updatedAt || updatedAtArg,
      };
    }
    return {
      address: locationArg.address,
      available: onlineArg,
      authorized: true,
      standby: !onlineArg,
      status: onlineArg ? 'active' : 'unavailable',
      updatedAt: updatedAtArg,
    };
  }

  private offlineSnapshot(errorArg: string): IDirectvSnapshot {
    const device = this.deviceInfoFromConfig();
    const updatedAt = new Date().toISOString();
    const locations = this.configLocations(device);
    return this.normalizeSnapshot({
      device,
      locations,
      states: locations.map((locationArg) => ({
        address: locationArg.address,
        available: false,
        authorized: true,
        standby: true,
        status: 'unavailable',
        updatedAt,
        error: errorArg,
      })),
      online: false,
      updatedAt,
      source: 'runtime',
      error: errorArg,
    }, 'runtime');
  }

  private deviceInfoFromResponse(dataArg: IDirectvGetVersionResponse): IDirectvDeviceInfo {
    return {
      ...this.deviceInfoFromConfig(),
      brand: 'DirecTV',
      receiverId: this.normalizeReceiverId(this.stringValue(dataArg.receiverId) || this.config.receiverId || this.config.uniqueId || this.config.host || 'manual-directv'),
      version: this.stringValue(dataArg.stbSoftwareVersion) || 'Unknown',
    };
  }

  private deviceInfoFromConfig(): IDirectvDeviceInfo {
    return {
      brand: 'DirecTV',
      receiverId: this.normalizeReceiverId(this.config.receiverId || this.config.uniqueId || this.config.host || 'manual-directv'),
      version: 'Unknown',
      host: this.config.host,
      port: this.config.port || directvDefaultPort,
      name: this.config.name || directvDefaultName,
    };
  }

  private locationFromResponse(dataArg: unknown): IDirectvLocation {
    const data = this.isRecord(dataArg) ? dataArg : {};
    const address = this.stringValue(data.clientAddr) || directvDefaultDevice;
    return {
      client: address !== directvDefaultDevice,
      name: this.stringValue(data.locationName) || (address === directvDefaultDevice ? directvDefaultName : `DirecTV Client ${address}`),
      address,
    };
  }

  private configLocations(deviceArg: IDirectvDeviceInfo): IDirectvLocation[] {
    if (this.config.locations?.length) {
      return this.config.locations.map((locationArg) => ({
        client: locationArg.address !== directvDefaultDevice,
        name: locationArg.name || (locationArg.address === directvDefaultDevice ? directvDefaultName : `DirecTV Client ${locationArg.address}`),
        address: locationArg.address || directvDefaultDevice,
      }));
    }
    return [{ client: false, name: deviceArg.name || directvDefaultName, address: directvDefaultDevice }];
  }

  private programFromResponse(dataArg: IDirectvProgramResponse): IDirectvProgram {
    const major = this.numberValue(dataArg.major, 0);
    const minor = this.numberValue(dataArg.minor, 65535);
    const uniqueId = this.optionalNumber(dataArg.uniqueId);
    const music = this.isRecord(dataArg.music) ? dataArg.music : {};
    const episodeTitle = this.stringValue(dataArg.episodeTitle);
    const musicTitle = this.stringValue(music.title);
    const startTime = this.optionalNumber(dataArg.startTime);
    return {
      channel: this.combineChannelNumber(major, minor),
      channelName: this.stringValue(dataArg.callsign),
      programId: this.optionalNumber(dataArg.programId),
      programType: episodeTitle ? 'tvshow' : musicTitle ? 'music' : 'movie',
      duration: this.numberValue(dataArg.duration, 0),
      title: this.stringValue(dataArg.title),
      episodeTitle,
      musicTitle,
      musicAlbum: this.stringValue(music.cd),
      musicArtist: this.stringValue(music.by),
      ondemand: this.booleanValue(dataArg.isVod, false),
      partial: this.booleanValue(dataArg.isPartial, false),
      payperview: this.booleanValue(dataArg.isPpv, false),
      position: this.numberValue(dataArg.offset, 0),
      purchased: this.booleanValue(dataArg.isPurchased, false),
      rating: this.stringValue(dataArg.rating),
      recorded: uniqueId !== undefined,
      recording: this.booleanValue(dataArg.isRecording, false),
      startTime: startTime !== undefined ? new Date(startTime * 1000).toISOString() : undefined,
      uniqueId,
      viewed: this.booleanValue(dataArg.isViewed, false),
    };
  }

  private parseChannelNumber(channelArg: string): [string, string] {
    const [major, minor] = channelArg.split('-', 2);
    if (!major) {
      throw new DirectvError('DirecTV channel number is required.');
    }
    return [major, minor || '65535'];
  }

  private combineChannelNumber(majorArg: number, minorArg: number): string {
    return minorArg === 65535 ? String(majorArg) : `${majorArg}-${minorArg}`;
  }

  private normalizeReceiverId(valueArg: string): string {
    return valueArg.replace(/^RID-/i, '').replace(/\s+/g, '') || 'manual-directv';
  }

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

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

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

  private optionalNumber(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 booleanValue(valueArg: unknown, fallbackArg: boolean): boolean {
    if (typeof valueArg === 'boolean') {
      return valueArg;
    }
    if (typeof valueArg === 'number') {
      return valueArg !== 0;
    }
    if (typeof valueArg === 'string') {
      if (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) return true;
      if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) return false;
    }
    return fallbackArg;
  }

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

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

  private errorMessage(errorArg: unknown): string {
    return errorArg instanceof Error ? errorArg.message : String(errorArg);
  }
}
