import * as plugins from '../../plugins.js';
import { SimpleLocalClient, type ISimpleLocalSnapshot, type IServiceCallResult } from '../../core/index.js';
import { IotawattMapper } from './iotawatt.mapper.js';
import type { IIotawattConfig, IIotawattQuerySeriesResponse, IIotawattRawApiData, IIotawattSensorSnapshot, IIotawattSeriesDefinition, IIotawattStatusResponse, TIotawattRawData } from './iotawatt.types.js';
import { iotawattDefaultPort, iotawattDefaultTimeoutMs, iotawattDefaultTimespanSeconds, iotawattProfile } from './iotawatt.types.js';

interface IIotawattDigestChallenge {
  realm: string;
  nonce: string;
  qop?: string;
  opaque?: string;
  algorithm?: string;
}

export class IotawattClient extends SimpleLocalClient<IIotawattConfig> {
  private cachedSnapshot?: ISimpleLocalSnapshot;

  constructor(private readonly clientConfig: IIotawattConfig) {
    super(iotawattProfile, clientConfig);
  }

  public async getSnapshot(forceRefreshArg = false): Promise<ISimpleLocalSnapshot> {
    if (!forceRefreshArg && this.cachedSnapshot) {
      return clone(this.cachedSnapshot);
    }

    if (!forceRefreshArg && this.clientConfig.snapshot) {
      this.cachedSnapshot = IotawattMapper.toSnapshot({ config: this.clientConfig, source: 'snapshot', online: this.clientConfig.snapshot.online });
      return clone(this.cachedSnapshot);
    }

    if (this.hasEndpoint()) {
      try {
        const rawData = await this.fetchRawData();
        this.cachedSnapshot = IotawattMapper.toSnapshot({ config: this.clientConfig, rawData, online: true, source: 'http' });
      } catch (errorArg) {
        this.cachedSnapshot = this.createOfflineSnapshot(errorMessage(errorArg));
      }
      return clone(this.cachedSnapshot);
    }

    if (this.clientConfig.client || this.clientConfig.snapshotProvider) {
      this.cachedSnapshot = await super.getSnapshot(forceRefreshArg);
      return clone(this.cachedSnapshot);
    }

    if (this.hasManualData()) {
      this.cachedSnapshot = IotawattMapper.toSnapshot({ config: this.clientConfig, rawData: this.rawData(), online: this.clientConfig.online ?? true, source: 'manual' });
      return clone(this.cachedSnapshot);
    }

    this.cachedSnapshot = await super.getSnapshot(forceRefreshArg);
    return clone(this.cachedSnapshot);
  }

  public async refresh(): Promise<IServiceCallResult> {
    this.cachedSnapshot = undefined;
    const liveCapable = this.hasEndpoint() || Boolean(this.clientConfig.client || this.clientConfig.snapshotProvider);
    const snapshot = await this.getSnapshot(liveCapable);
    const success = liveCapable && snapshot.online && !snapshot.error && snapshot.source !== 'runtime';
    return { success, error: success ? undefined : snapshot.error || 'IoTaWatt refresh requires a live host/url, injected client, or snapshotProvider.', data: snapshot };
  }

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

  private async fetchRawData(): Promise<IIotawattRawApiData> {
    const wifiStatus = await this.requestJson<IIotawattStatusResponse>('/status?wifi=yes');
    const ioStatus = await this.requestJson<IIotawattStatusResponse>('/status?inputs=yes&outputs=yes');
    const querySeries = await this.requestJson<IIotawattQuerySeriesResponse>('/query?show=series');
    const sensors = this.createSensors(ioStatus, querySeries.series || [], wifiStatus.wifi?.mac || '');

    await this.updateCurrentSensors(sensors);
    await this.updateIntegratedTotalSensors(sensors);
    if (this.clientConfig.includeNonTotalSensors) {
      await this.updateIntegratedIntervalSensors(sensors);
    }

    return {
      status: { ...ioStatus, wifi: wifiStatus.wifi },
      series: querySeries.series || [],
      sensors,
      fetchedAt: new Date().toISOString(),
    };
  }

  private createSensors(statusArg: IIotawattStatusResponse, seriesArg: IIotawattSeriesDefinition[], macAddressArg: string): Record<string, IIotawattSensorSnapshot> {
    const sensors: Record<string, IIotawattSensorSnapshot> = {};
    const inputs = Array.isArray(statusArg.inputs) ? statusArg.inputs : [];
    const outputs = Array.isArray(statusArg.outputs) ? statusArg.outputs : [];
    const mac = macAddressArg.replace(/:/g, '').toUpperCase();

    inputs.forEach((inputArg, indexArg) => {
      const channel = stringOrNumber(inputArg.channel) ?? indexArg;
      const series = seriesArg[indexArg] || {};
      const baseName = stringValue(series.name) || `Input ${channel}`;
      const unit = stringValue(series.unit) || 'Watts';
      this.createSensorSet(sensors, `input_${channel}`, channel, baseName, 'Input', unit, mac);
    });

    outputs.forEach((outputArg) => {
      const baseName = stringValue(outputArg.name) || 'Output';
      const unit = stringValue(outputArg.units) || 'Watts';
      this.createSensorSet(sensors, `output_${baseName}`, 'N/A', baseName, 'Output', unit, mac);
    });

    return sensors;
  }

  private createSensorSet(sensorsArg: Record<string, IIotawattSensorSnapshot>, keyArg: string, channelArg: string | number, baseNameArg: string, typeArg: 'Input' | 'Output', unitArg: string, macAddressArg: string): void {
    this.createSensor(sensorsArg, keyArg, channelArg, baseNameArg, typeArg, unitArg, macAddressArg);
    if (unitArg === 'Watts') {
      this.createSensor(sensorsArg, `${keyArg}_total_energy`, channelArg, baseNameArg, typeArg, 'WattHours', macAddressArg, '.wh', true);
      if (this.clientConfig.includeNonTotalSensors) {
        this.createSensor(sensorsArg, `${keyArg}_energy`, channelArg, baseNameArg, typeArg, 'WattHours', macAddressArg, '.wh', false);
      }
    }
  }

  private createSensor(sensorsArg: Record<string, IIotawattSensorSnapshot>, keyArg: string, channelArg: string | number, baseNameArg: string, typeArg: 'Input' | 'Output', unitArg: string, macAddressArg: string, suffixArg?: string, fromStartArg = false): void {
    const sourceName = `${baseNameArg}${suffixArg || ''}`;
    sensorsArg[keyArg] = {
      key: keyArg,
      channel: channelArg,
      baseName: baseNameArg,
      sourceName,
      name: `${sourceName}${suffixArg === '.wh' && !fromStartArg ? '_last' : ''}`,
      type: typeArg,
      unit: unitArg,
      value: null,
      fromStart: fromStartArg,
      sensorId: `${macAddressArg}_${typeArg}_${sourceName}`,
    };
  }

  private async updateCurrentSensors(sensorsArg: Record<string, IIotawattSensorSnapshot>): Promise<void> {
    const entries = Object.entries(sensorsArg).filter(([, sensorArg]) => sensorArg.unit !== 'WattHours');
    if (!entries.length) {
      return;
    }
    const names = entries.map(([, sensorArg]) => `${sensorArg.sourceName}.${sensorArg.unit.toLowerCase()}`);
    const timespan = this.clientConfig.timespanSeconds || iotawattDefaultTimespanSeconds;
    const values = await this.requestJson<unknown[][]>(`/query?select=[${names.join(',')}]&begin=s-${timespan}s&end=s&group=${timespan}s`);
    const row = Array.isArray(values[0]) ? values[0] : [];
    entries.forEach(([keyArg], indexArg) => {
      sensorsArg[keyArg].value = row[indexArg] as number | string | boolean | null | undefined ?? null;
    });
  }

  private async updateIntegratedTotalSensors(sensorsArg: Record<string, IIotawattSensorSnapshot>): Promise<void> {
    const entries = Object.entries(sensorsArg).filter(([, sensorArg]) => sensorArg.unit === 'WattHours' && sensorArg.fromStart);
    if (!entries.length) {
      return;
    }
    const values = await this.requestJson<unknown[][]>(`/query?select=[time.iso,${entries.map(([, sensorArg]) => sensorArg.sourceName).join(',')}]&begin=${this.clientConfig.integratedInterval || 'd'}&end=s&group=all`);
    const row = Array.isArray(values[0]) ? values[0] : [];
    entries.forEach(([keyArg], indexArg) => {
      sensorsArg[keyArg].begin = typeof row[0] === 'string' ? row[0] : undefined;
      sensorsArg[keyArg].value = row[indexArg + 1] as number | string | boolean | null | undefined ?? null;
    });
  }

  private async updateIntegratedIntervalSensors(sensorsArg: Record<string, IIotawattSensorSnapshot>): Promise<void> {
    const entries = Object.entries(sensorsArg).filter(([, sensorArg]) => sensorArg.unit === 'WattHours' && !sensorArg.fromStart);
    if (!entries.length) {
      return;
    }
    const now = roundedUtcDate();
    const timespan = this.clientConfig.timespanSeconds || iotawattDefaultTimespanSeconds;
    const begin = new Date(now.getTime() - timespan * 1000);
    const values = await this.requestJson<unknown[][]>(`/query?select=[time.iso,${entries.map(([, sensorArg]) => `${sensorArg.sourceName}.d3`).join(',')}]&begin=${isoNoMilliseconds(begin)}Z&end=${isoNoMilliseconds(now)}Z&group=all`);
    const row = Array.isArray(values[0]) ? values[0] : [];
    entries.forEach(([keyArg], indexArg) => {
      sensorsArg[keyArg].begin = typeof row[0] === 'string' ? row[0] : undefined;
      sensorsArg[keyArg].value = row[indexArg + 1] as number | string | boolean | null | undefined ?? null;
    });
  }

  private async requestJson<TValue>(pathArg: string): Promise<TValue> {
    const text = await this.requestText(pathArg);
    try {
      return JSON.parse(text) as TValue;
    } catch (errorArg) {
      throw new Error(`IoTaWatt JSON parse failed for ${pathArg}: ${errorMessage(errorArg)}`);
    }
  }

  private async requestText(pathArg: string): Promise<string> {
    const response = await this.fetchWithAuth(this.url(pathArg), 'GET');
    const text = await response.text();
    if (response.status === 401) {
      throw new Error('IoTaWatt local authentication failed.');
    }
    if (!response.ok) {
      throw new Error(`IoTaWatt request ${pathArg} failed with ${response.status}: ${text.slice(0, 160)}`);
    }
    return text;
  }

  private async fetchWithAuth(urlArg: string, methodArg: string): Promise<Response> {
    const initial = await this.fetchOnce(urlArg, methodArg);
    if (initial.status !== 401 || !this.clientConfig.username) {
      return initial;
    }

    const challenge = parseDigestChallenge(initial.headers.get('www-authenticate'));
    if (!challenge) {
      return initial;
    }
    const authorization = this.digestAuthorization(methodArg, urlArg, challenge);
    return this.fetchOnce(urlArg, methodArg, { authorization });
  }

  private async fetchOnce(urlArg: string, methodArg: string, headersArg: Record<string, string> = {}): Promise<Response> {
    const timeoutMs = this.clientConfig.timeoutMs || iotawattDefaultTimeoutMs;
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      return await globalThis.fetch(urlArg, { method: methodArg, headers: { accept: 'application/json, text/json;q=0.9, */*;q=0.8', ...headersArg }, signal: controller.signal });
    } finally {
      clearTimeout(timer);
    }
  }

  private digestAuthorization(methodArg: string, urlArg: string, challengeArg: IIotawattDigestChallenge): string {
    const username = this.clientConfig.username || '';
    const password = this.clientConfig.password || '';
    const url = new URL(urlArg);
    const uri = `${url.pathname}${url.search}`;
    const qop = challengeArg.qop?.split(',').map((itemArg) => itemArg.trim()).includes('auth') ? 'auth' : undefined;
    const nc = '00000001';
    const cnonce = plugins.crypto.randomBytes(8).toString('hex');
    const ha1 = md5(`${username}:${challengeArg.realm}:${password}`);
    const ha2 = md5(`${methodArg}:${uri}`);
    const response = qop
      ? md5(`${ha1}:${challengeArg.nonce}:${nc}:${cnonce}:${qop}:${ha2}`)
      : md5(`${ha1}:${challengeArg.nonce}:${ha2}`);
    const parts = [
      `username=${quoteDigest(username)}`,
      `realm=${quoteDigest(challengeArg.realm)}`,
      `nonce=${quoteDigest(challengeArg.nonce)}`,
      `uri=${quoteDigest(uri)}`,
      `response=${quoteDigest(response)}`,
      `algorithm=${challengeArg.algorithm || 'MD5'}`,
    ];
    if (challengeArg.opaque) {
      parts.push(`opaque=${quoteDigest(challengeArg.opaque)}`);
    }
    if (qop) {
      parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce=${quoteDigest(cnonce)}`);
    }
    return `Digest ${parts.join(', ')}`;
  }

  private url(pathArg: string): string {
    const endpoint = this.endpoint();
    if (!endpoint) {
      throw new Error('IoTaWatt local API requires config.host or config.url.');
    }
    const path = pathArg.startsWith('/') ? pathArg : `/${pathArg}`;
    return new URL(path, `${endpoint.origin}/`).toString();
  }

  private endpoint(): URL | undefined {
    const configuredUrl = stringValue(this.clientConfig.url);
    if (configuredUrl) {
      return normalizeBaseUrl(configuredUrl, this.clientConfig.port);
    }
    const host = stringValue(this.clientConfig.host);
    if (!host) {
      return undefined;
    }
    return normalizeBaseUrl(host, this.clientConfig.port || iotawattDefaultPort);
  }

  private hasEndpoint(): boolean {
    return Boolean(this.endpoint());
  }

  private hasManualData(): boolean {
    return this.rawData() !== undefined || Boolean(this.clientConfig.entities?.length || this.clientConfig.snapshot);
  }

  private rawData(): TIotawattRawData | undefined {
    return this.clientConfig.rawData as TIotawattRawData | undefined ?? this.clientConfig.rawText;
  }

  private createOfflineSnapshot(errorArg: string): ISimpleLocalSnapshot {
    return IotawattMapper.toSnapshot({ config: this.clientConfig, online: false, source: 'runtime', error: errorArg });
  }
}

const parseDigestChallenge = (headerArg: string | null): IIotawattDigestChallenge | undefined => {
  if (!headerArg || !headerArg.toLowerCase().startsWith('digest ')) {
    return undefined;
  }
  const params: Record<string, string> = {};
  const pattern = /(\w+)=("(?:[^"\\]|\\.)*"|[^,]+)/g;
  let match: RegExpExecArray | null;
  while ((match = pattern.exec(headerArg.slice(7))) !== null) {
    params[match[1]] = unquoteDigest(match[2].trim());
  }
  if (!params.realm || !params.nonce) {
    return undefined;
  }
  return { realm: params.realm, nonce: params.nonce, qop: params.qop, opaque: params.opaque, algorithm: params.algorithm };
};

const normalizeBaseUrl = (valueArg: string, portArg?: number): URL => {
  const withProtocol = valueArg.includes('://') ? valueArg : `http://${valueArg}`;
  const url = new URL(withProtocol);
  url.pathname = '';
  url.search = '';
  url.hash = '';
  if (portArg) {
    url.port = String(portArg);
  }
  return url;
};

const md5 = (valueArg: string): string => plugins.crypto.createHash('md5').update(valueArg).digest('hex');

const quoteDigest = (valueArg: string): string => `"${valueArg.replace(/(["\\])/g, '\\$1')}"`;

const unquoteDigest = (valueArg: string): string => valueArg.startsWith('"') && valueArg.endsWith('"') ? valueArg.slice(1, -1).replace(/\\(["\\])/g, '$1') : valueArg;

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

const stringOrNumber = (valueArg: unknown): string | number | undefined => {
  if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
    return valueArg;
  }
  return stringValue(valueArg);
};

const roundedUtcDate = (): Date => {
  const now = new Date();
  now.setUTCSeconds(now.getUTCSeconds() - (now.getUTCSeconds() % 5), 0);
  return now;
};

const isoNoMilliseconds = (valueArg: Date): string => valueArg.toISOString().replace(/\.\d{3}Z$/, '');

const clone = <TValue>(valueArg: TValue): TValue => JSON.parse(JSON.stringify(valueArg)) as TValue;

const errorMessage = (errorArg: unknown): string => errorArg instanceof Error ? errorArg.message : String(errorArg);
