import { ElgatoMapper } from './elgato.mapper.js';
import type {
  IElgatoBatteryInfo,
  IElgatoClientLike,
  IElgatoCommandRequest,
  IElgatoConfig,
  IElgatoInfo,
  IElgatoRawData,
  IElgatoRefreshResult,
  IElgatoSettings,
  IElgatoSnapshot,
  IElgatoState,
} from './elgato.types.js';
import { elgatoDefaultPort, elgatoDefaultTimeoutMs } from './elgato.types.js';

export class ElgatoApiError extends Error {}
export class ElgatoApiConnectionError extends ElgatoApiError {}
export class ElgatoApiAuthorizationError extends ElgatoApiError {}
export class ElgatoApiValidationError extends ElgatoApiError {}

export class ElgatoClient {
  private currentSnapshot?: IElgatoSnapshot;

  constructor(private readonly config: IElgatoConfig) {}

  public async getSnapshot(forceRefreshArg = false): Promise<IElgatoSnapshot> {
    if (!forceRefreshArg && this.currentSnapshot) {
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (!forceRefreshArg && this.config.snapshot) {
      this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (this.config.client) {
      try {
        this.currentSnapshot = await this.snapshotFromClient(this.config.client);
      } catch (errorArg) {
        this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
      }
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (!forceRefreshArg && this.hasManualData()) {
      this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
      return this.cloneSnapshot(this.currentSnapshot);
    }

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

    if (this.config.snapshot) {
      this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (this.hasManualData()) {
      this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
      return this.cloneSnapshot(this.currentSnapshot);
    }

    this.currentSnapshot = this.offlineSnapshot('No Elgato HTTP endpoint, client, snapshot, or manual data is configured.');
    return this.cloneSnapshot(this.currentSnapshot);
  }

  public async refresh(): Promise<IElgatoRefreshResult> {
    try {
      this.currentSnapshot = undefined;
      const hasLiveTransport = Boolean(this.config.host || this.config.url || this.config.client);
      const snapshot = await this.getSnapshot(hasLiveTransport);
      const success = snapshot.online && !snapshot.error;
      return { success, snapshot, error: success ? undefined : snapshot.error, data: { source: snapshot.source } };
    } catch (errorArg) {
      const error = this.errorMessage(errorArg);
      const snapshot = this.offlineSnapshot(error);
      this.currentSnapshot = snapshot;
      return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
    }
  }

  public async validateConnection(): Promise<IElgatoInfo> {
    return this.info();
  }

  public async fetchSnapshot(): Promise<IElgatoSnapshot> {
    const [info, settings, state] = await Promise.all([
      this.info(),
      this.settings(),
      this.state(),
    ]);
    const battery = settings.battery ? await this.battery().catch(() => undefined) : undefined;
    return ElgatoMapper.toSnapshot({
      config: this.config,
      rawData: { info, settings, state, battery, fetchedAt: new Date().toISOString() },
      online: true,
      source: 'http',
    });
  }

  public async info(): Promise<IElgatoInfo> {
    const data = await this.requestJson<IElgatoInfo>('accessory-info');
    if (!data || typeof data !== 'object') {
      throw new ElgatoApiConnectionError('Elgato accessory-info response was not an object.');
    }
    return data;
  }

  public async settings(): Promise<IElgatoSettings> {
    const data = await this.requestJson<IElgatoSettings>('lights/settings');
    if (!data || typeof data !== 'object') {
      throw new ElgatoApiConnectionError('Elgato lights/settings response was not an object.');
    }
    return data;
  }

  public async state(): Promise<IElgatoState> {
    const data = await this.requestJson<Record<string, unknown>>('lights');
    const lights = Array.isArray(data.lights) ? data.lights : undefined;
    const state = lights?.[0];
    if (!state || typeof state !== 'object') {
      throw new ElgatoApiConnectionError('Elgato lights response did not contain lights[0].');
    }
    return state as IElgatoState;
  }

  public async battery(): Promise<IElgatoBatteryInfo> {
    const data = await this.requestJson<IElgatoBatteryInfo>('battery-info');
    if (!data || typeof data !== 'object') {
      throw new ElgatoApiConnectionError('Elgato battery-info response was not an object.');
    }
    return data;
  }

  public async setLight(commandArg: Partial<IElgatoCommandRequest>): Promise<{ ok: true; light: Record<string, number> }> {
    const light: Record<string, number> = {};
    if (commandArg.on !== undefined) {
      light.on = commandArg.on ? 1 : 0;
    }

    const brightness = commandArg.brightness ?? (commandArg.brightness255 !== undefined ? Math.round((this.clamp(commandArg.brightness255, 0, 255) / 255) * 100) : undefined);
    if (brightness !== undefined) {
      if (brightness < 0 || brightness > 100) {
        throw new ElgatoApiValidationError('Brightness must be between 0 and 100.');
      }
      light.brightness = Math.round(brightness);
    }

    const hue = commandArg.hue;
    const saturation = commandArg.saturation;
    const temperature = commandArg.temperature ?? ElgatoMapper.kelvinToMired(commandArg.colorTemperatureKelvin);
    if (temperature !== undefined && (hue !== undefined || saturation !== undefined)) {
      throw new ElgatoApiValidationError('Cannot set color temperature together with hue or saturation.');
    }
    if (hue !== undefined) {
      if (hue < 0 || hue > 360) {
        throw new ElgatoApiValidationError('Hue must be between 0 and 360.');
      }
      light.hue = hue;
    }
    if (saturation !== undefined) {
      if (saturation < 0 || saturation > 100) {
        throw new ElgatoApiValidationError('Saturation must be between 0 and 100.');
      }
      light.saturation = saturation;
    }
    if (temperature !== undefined) {
      if (temperature < 143 || temperature > 344) {
        throw new ElgatoApiValidationError('Color temperature mired value must be between 143 and 344.');
      }
      light.temperature = Math.round(temperature);
    }
    if (!Object.keys(light).length) {
      throw new ElgatoApiValidationError('No light parameters were provided.');
    }

    await this.requestJson('lights', { method: 'PUT', data: { numberOfLights: 1, lights: [light] } });
    this.currentSnapshot = undefined;
    return { ok: true, light };
  }

  public async identify(): Promise<{ ok: true }> {
    await this.requestJson('identify', { method: 'POST' });
    return { ok: true };
  }

  public async restart(): Promise<{ ok: true }> {
    await this.requestJson('restart', { method: 'POST' });
    return { ok: true };
  }

  public async batteryBypass(onArg: boolean): Promise<{ ok: true; enabled: boolean }> {
    await this.requestJson('/elgato/lights/settings', { method: 'PUT', data: { battery: { bypass: onArg ? 1 : 0 } } });
    this.currentSnapshot = undefined;
    return { ok: true, enabled: onArg };
  }

  public async energySaving(onArg: boolean): Promise<{ ok: true; enabled: boolean }> {
    const settings = await this.settings();
    const energySaving = { ...(settings.battery?.energySaving || {}), enable: onArg ? 1 : 0 };
    await this.requestJson('/elgato/lights/settings', { method: 'PUT', data: { battery: { energySaving } } });
    this.currentSnapshot = undefined;
    return { ok: true, enabled: onArg };
  }

  public async execute(commandArg: IElgatoCommandRequest): Promise<unknown> {
    if (this.config.commandExecutor) {
      return this.config.commandExecutor.execute(commandArg);
    }
    if (this.config.client && !this.config.host && !this.config.url) {
      return this.executeWithClient(this.config.client, commandArg);
    }
    if (commandArg.action === 'refresh') {
      return this.refresh();
    }
    if (!this.config.host && !this.config.url) {
      throw new ElgatoApiConnectionError('Elgato commands require config.host/config.url, an injected client, or commandExecutor. Static snapshots/manual data are read-only.');
    }
    if (commandArg.action === 'set_light') {
      return this.setLight(commandArg);
    }
    if (commandArg.action === 'identify') {
      return this.identify();
    }
    if (commandArg.action === 'restart') {
      return this.restart();
    }
    if (commandArg.action === 'battery_bypass') {
      if (commandArg.enabled === undefined) {
        throw new ElgatoApiValidationError('battery_bypass requires an enabled value.');
      }
      return this.batteryBypass(commandArg.enabled);
    }
    if (commandArg.action === 'energy_saving') {
      if (commandArg.enabled === undefined) {
        throw new ElgatoApiValidationError('energy_saving requires an enabled value.');
      }
      return this.energySaving(commandArg.enabled);
    }
    throw new ElgatoApiError(`Unsupported Elgato command: ${commandArg.action}`);
  }

  public async destroy(): Promise<void> {
    await this.config.client?.destroy?.();
  }

  private async executeWithClient(clientArg: IElgatoClientLike, commandArg: IElgatoCommandRequest): Promise<unknown> {
    if (clientArg.execute) {
      return clientArg.execute(commandArg);
    }
    if (commandArg.action === 'refresh') {
      return this.refresh();
    }
    if (commandArg.action === 'set_light' && clientArg.light) {
      return clientArg.light(commandArg);
    }
    if (commandArg.action === 'identify' && clientArg.identify) {
      return clientArg.identify();
    }
    if (commandArg.action === 'restart' && clientArg.restart) {
      return clientArg.restart();
    }
    if (commandArg.action === 'battery_bypass' && clientArg.batteryBypass && commandArg.enabled !== undefined) {
      return clientArg.batteryBypass(commandArg.enabled);
    }
    if (commandArg.action === 'energy_saving' && clientArg.energySaving && commandArg.enabled !== undefined) {
      return clientArg.energySaving(commandArg.enabled);
    }
    throw new ElgatoApiConnectionError('Elgato command is not available on the injected client and no HTTP endpoint or commandExecutor is configured.');
  }

  private async snapshotFromClient(clientArg: IElgatoClientLike): Promise<IElgatoSnapshot> {
    if (clientArg.getSnapshot) {
      const result = await clientArg.getSnapshot();
      if (isElgatoSnapshot(result)) {
        return ElgatoMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
      }
      return ElgatoMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
    }
    if (clientArg.getRawData) {
      return ElgatoMapper.toSnapshot({ config: this.config, rawData: await clientArg.getRawData(), online: true, source: 'client' });
    }

    const [info, settings, state] = await Promise.all([
      clientArg.info ? clientArg.info() : undefined,
      clientArg.settings ? clientArg.settings() : undefined,
      clientArg.state ? clientArg.state() : undefined,
    ]);
    const battery = clientArg.battery ? await clientArg.battery().catch(() => undefined) : undefined;
    if (!info && !settings && !state && !battery) {
      throw new ElgatoApiConnectionError('Elgato client must expose getSnapshot(), getRawData(), or at least one raw API getter.');
    }
    return ElgatoMapper.toSnapshot({ config: this.config, rawData: { info, settings, state, battery }, online: true, source: 'client' });
  }

  private async requestJson<TValue = Record<string, unknown>>(pathArg: string, optionsArg: IElgatoRequestOptions = {}): Promise<TValue> {
    const response = await this.request(pathArg, { ...optionsArg, headers: { accept: 'application/json', ...optionsArg.headers } });
    const text = await response.text();
    if (!text.trim()) {
      return {} as TValue;
    }
    try {
      return JSON.parse(text) as TValue;
    } catch (errorArg) {
      throw new ElgatoApiConnectionError(`Unable to parse Elgato JSON from ${pathArg}: ${this.errorMessage(errorArg)}`);
    }
  }

  private async request(pathArg: string, optionsArg: IElgatoRequestOptions = {}): Promise<Response> {
    if (!this.config.host && !this.config.url) {
      throw new ElgatoApiConnectionError('Elgato HTTP requests require config.host or config.url.');
    }
    const url = this.url(pathArg);
    const headers = new Headers({
      'user-agent': 'SmartHomeExchangeElgato',
      ...optionsArg.headers,
    });
    let body: BodyInit | undefined;
    if (optionsArg.data !== undefined) {
      headers.set('content-type', 'application/json');
      body = JSON.stringify(optionsArg.data);
    }
    const response = await this.fetchWithTimeout(url, {
      method: optionsArg.method || 'GET',
      headers,
      body,
    }, this.config.timeoutMs || elgatoDefaultTimeoutMs);
    return this.checkedResponse(response, pathArg);
  }

  private async checkedResponse(responseArg: Response, pathArg: string): Promise<Response> {
    if (!responseArg.ok) {
      const text = await responseArg.text().catch(() => '');
      if (responseArg.status === 401 || responseArg.status === 403) {
        throw new ElgatoApiAuthorizationError('Elgato local API authorization failed.');
      }
      throw new ElgatoApiConnectionError(`Elgato endpoint ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
    }
    return responseArg;
  }

  private async fetchWithTimeout(urlArg: string, initArg: RequestInit, timeoutMsArg: number): Promise<Response> {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
    try {
      return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
    } catch (errorArg) {
      throw new ElgatoApiConnectionError(`Connection to ${urlArg} failed: ${this.errorMessage(errorArg)}`);
    } finally {
      clearTimeout(timeout);
    }
  }

  private url(pathArg: string): string {
    if (/^https?:\/\//i.test(pathArg)) {
      return pathArg;
    }
    const normalizedPath = pathArg.startsWith('/elgato/') ? pathArg : `/elgato/${pathArg.replace(/^\/+/, '')}`;
    return `${this.baseUrl()}${normalizedPath}`;
  }

  private baseUrl(): string {
    return endpointBaseUrl(this.config);
  }

  private hasManualData(): boolean {
    return Boolean(this.config.rawData || this.config.info || this.config.settings || this.config.state || this.config.battery);
  }

  private offlineSnapshot(errorArg: string): IElgatoSnapshot {
    return ElgatoMapper.toSnapshot({
      config: this.config,
      online: false,
      source: 'runtime',
      error: errorArg,
    });
  }

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

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

  private clamp(valueArg: number, minArg: number, maxArg: number): number {
    return Math.min(maxArg, Math.max(minArg, valueArg));
  }
}

interface IElgatoRequestOptions {
  method?: 'GET' | 'POST' | 'PUT';
  headers?: HeadersInit;
  data?: unknown;
}

const endpointBaseUrl = (configArg: Pick<IElgatoConfig, 'host' | 'url' | 'port'>): string => {
  const configured = configArg.url || configArg.host || 'localhost';
  if (/^https?:\/\//i.test(configured)) {
    const url = new URL(configured);
    const port = configArg.port || (url.port ? Number(url.port) : undefined);
    const defaultPort = url.protocol === 'https:' ? 443 : 80;
    return `${url.protocol}//${hostForUrl(url.hostname)}${port && port !== defaultPort ? `:${port}` : ''}`;
  }
  const port = configArg.port || elgatoDefaultPort;
  return `http://${hostForUrl(configured)}${port !== 80 ? `:${port}` : ''}`;
};

const hostForUrl = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;

const isElgatoSnapshot = (valueArg: unknown): valueArg is IElgatoSnapshot => {
  return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'light' in valueArg && 'capabilities' in valueArg);
};
