import * as plugins from '../../plugins.js';
import { SimpleLocalClient, type IServiceCallRequest, type IServiceCallResult, type ISimpleLocalSnapshot } from '../../core/index.js';
import { ObihaiMapper } from './obihai.mapper.js';
import type { IObihaiConfig, IObihaiRawData } from './obihai.types.js';
import { obihaiDefaultCallStatusPath, obihaiDefaultLinePath, obihaiDefaultPassword, obihaiDefaultPort, obihaiDefaultRebootPath, obihaiDefaultStatusPath, obihaiDefaultTimeoutMs, obihaiDefaultUsername, obihaiProfile } from './obihai.types.js';

export class ObihaiApiError extends Error {}
export class ObihaiApiConnectionError extends ObihaiApiError {}
export class ObihaiApiAuthorizationError extends ObihaiApiError {}
export class ObihaiUnsupportedCommandError extends ObihaiApiError {}

export class ObihaiClient extends SimpleLocalClient<IObihaiConfig> {
  private cachedSnapshot?: ISimpleLocalSnapshot;

  constructor(configArg: IObihaiConfig) {
    super(obihaiProfile, configArg);
    this.obihaiConfig = configArg;
  }

  private readonly obihaiConfig: IObihaiConfig;

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

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

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

    if (!forceRefreshArg && this.hasManualData()) {
      this.cachedSnapshot = ObihaiMapper.toSnapshot({
        config: this.obihaiConfig,
        rawData: this.obihaiConfig.rawData ?? this.obihaiConfig.rawText,
        online: this.obihaiConfig.online ?? true,
        source: 'manual',
      });
      return this.clone(this.cachedSnapshot);
    }

    if (this.hasEndpoint()) {
      try {
        this.cachedSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.cachedSnapshot = this.buildOfflineSnapshot(this.errorMessage(errorArg));
      }
      return this.clone(this.cachedSnapshot);
    }

    this.cachedSnapshot = this.buildOfflineSnapshot('No Obihai HTTP endpoint, client, snapshotProvider, snapshot, entities, or rawData is configured.');
    return this.clone(this.cachedSnapshot);
  }

  public override async refresh(): Promise<IServiceCallResult> {
    this.cachedSnapshot = undefined;
    const snapshot = await this.getSnapshot(this.hasEndpoint());
    const success = snapshot.online && !snapshot.error;
    return { success, error: success ? undefined : snapshot.error, data: snapshot };
  }

  public async fetchSnapshot(): Promise<ISimpleLocalSnapshot> {
    const statusXml = await this.requestText(obihaiDefaultStatusPath);
    const lineXml = await this.requestText(obihaiDefaultLinePath).catch((errorArg) => {
      void errorArg;
      return '';
    });
    const callStatusHtml = await this.requestText(obihaiDefaultCallStatusPath).catch((errorArg) => {
      void errorArg;
      return '';
    });

    const rawData: IObihaiRawData = {
      deviceInfo: parseDeviceInfo(statusXml),
      services: parseServices(statusXml),
      lineServices: lineXml ? parseLineServices(lineXml) : {},
      callDirection: { 'Call Direction': parseCallDirection(callStatusHtml) || 'No Active Calls' },
      fetchedAt: new Date().toISOString(),
    };
    return ObihaiMapper.toSnapshot({ config: this.obihaiConfig, rawData, online: true, source: 'http' });
  }

  public override async execute(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
    if (this.obihaiConfig.client?.execute) {
      return this.normalizeServiceResult(await this.obihaiConfig.client.execute(requestArg));
    }
    if (this.obihaiConfig.commandExecutor) {
      return this.normalizeServiceResult(await this.obihaiConfig.commandExecutor.execute(requestArg));
    }
    if (requestArg.service === 'refresh') {
      return this.refresh();
    }
    if (!this.hasEndpoint()) {
      return {
        success: false,
        error: 'Obihai commands require a live HTTP endpoint, an injected client.execute(), or commandExecutor. Static snapshots/manual data are read-only.',
      };
    }

    try {
      if (requestArg.service !== 'press' && requestArg.service !== 'reboot') {
        throw new ObihaiUnsupportedCommandError(`Unsupported Obihai service: ${requestArg.service}`);
      }
      await this.requestText(obihaiDefaultRebootPath);
      this.cachedSnapshot = undefined;
      return { success: true, data: { action: 'reboot' } };
    } catch (errorArg) {
      return { success: false, error: this.errorMessage(errorArg) };
    }
  }

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

  private async requestText(pathArg: string): Promise<string> {
    const url = this.buildUrl(pathArg);
    const firstResponse = await this.fetchWithHeaders(url);
    if (firstResponse.status === 401) {
      const challenge = firstResponse.headers.get('www-authenticate') || '';
      const authorization = this.authorizationHeader(challenge, 'GET', new URL(url).pathname);
      if (!authorization) {
        throw new ObihaiApiAuthorizationError('Obihai endpoint requires HTTP Digest authentication but did not provide a supported challenge.');
      }
      const response = await this.fetchWithHeaders(url, { authorization });
      return this.responseText(response, url);
    }
    return this.responseText(firstResponse, url);
  }

  private async fetchWithHeaders(urlArg: string, headersArg: Record<string, string> = {}): Promise<Response> {
    try {
      return await globalThis.fetch(urlArg, {
        method: 'GET',
        headers: headersArg,
        signal: AbortSignal.timeout(this.obihaiConfig.timeoutMs || obihaiDefaultTimeoutMs),
      });
    } catch (errorArg) {
      throw new ObihaiApiConnectionError(`Connection to ${urlArg} failed: ${this.errorMessage(errorArg)}`);
    }
  }

  private async responseText(responseArg: Response, urlArg: string): Promise<string> {
    const text = await responseArg.text();
    if (responseArg.status === 401 || responseArg.status === 403) {
      throw new ObihaiApiAuthorizationError('Please check your Obihai credentials.');
    }
    if (!responseArg.ok) {
      throw new ObihaiApiConnectionError(`Obihai endpoint ${urlArg} failed with HTTP ${responseArg.status}: ${text.slice(0, 160)}`);
    }
    return text;
  }

  private authorizationHeader(challengeArg: string, methodArg: string, uriArg: string): string | undefined {
    if (/^basic\s/i.test(challengeArg)) {
      return `Basic ${Buffer.from(`${this.username()}:${this.password()}`).toString('base64')}`;
    }
    if (!/^digest\s/i.test(challengeArg)) {
      return undefined;
    }
    const challenge = parseDigestChallenge(challengeArg);
    const realm = challenge.realm;
    const nonce = challenge.nonce;
    if (!realm || !nonce) {
      return undefined;
    }
    const qop = challenge.qop?.split(',').map((valueArg) => valueArg.trim()).includes('auth') ? 'auth' : undefined;
    const nc = '00000001';
    const cnonce = plugins.crypto.randomBytes(8).toString('hex');
    const username = this.username();
    const password = this.password();
    let ha1 = md5(`${username}:${realm}:${password}`);
    if ((challenge.algorithm || '').toLowerCase() === 'md5-sess') {
      ha1 = md5(`${ha1}:${nonce}:${cnonce}`);
    }
    const ha2 = md5(`${methodArg}:${uriArg}`);
    const response = qop ? md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${nonce}:${ha2}`);
    const values: Record<string, string> = {
      username,
      realm,
      nonce,
      uri: uriArg,
      response,
      algorithm: challenge.algorithm || 'MD5',
    };
    if (challenge.opaque) {
      values.opaque = challenge.opaque;
    }
    if (qop) {
      values.qop = qop;
      values.nc = nc;
      values.cnonce = cnonce;
    }
    return `Digest ${Object.entries(values).map(([keyArg, valueArg]) => keyArg === 'qop' || keyArg === 'nc' || keyArg === 'algorithm' ? `${keyArg}=${valueArg}` : `${keyArg}="${valueArg}"`).join(', ')}`;
  }

  private buildUrl(pathArg: string): string {
    const base = this.baseUrl();
    return new URL(pathArg, base).toString();
  }

  private baseUrl(): string {
    if (this.obihaiConfig.url) {
      const parsed = new URL(this.obihaiConfig.url);
      if (!parsed.pathname.endsWith('/')) {
        parsed.pathname = `${parsed.pathname}/`;
      }
      return parsed.toString();
    }
    const host = this.obihaiConfig.host;
    if (!host) {
      throw new ObihaiApiConnectionError('Obihai HTTP URL requires config.host or config.url.');
    }
    const port = this.obihaiConfig.port || obihaiDefaultPort;
    const pathPart = this.username() === 'user' ? '/user/' : '/';
    return `http://${hostForUrl(host)}:${port}${pathPart}`;
  }

  private username(): string {
    return this.obihaiConfig.username || obihaiDefaultUsername;
  }

  private password(): string {
    return this.obihaiConfig.password || obihaiDefaultPassword;
  }

  private hasEndpoint(): boolean {
    return Boolean(this.obihaiConfig.url || this.obihaiConfig.host);
  }

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

  private buildOfflineSnapshot(errorArg: string): ISimpleLocalSnapshot {
    return ObihaiMapper.toSnapshot({ config: this.obihaiConfig, online: false, source: 'runtime', error: errorArg });
  }

  private normalizeServiceResult(valueArg: unknown): IServiceCallResult {
    if (valueArg && typeof valueArg === 'object' && 'success' in valueArg && typeof (valueArg as IServiceCallResult).success === 'boolean') {
      return valueArg as IServiceCallResult;
    }
    return { success: true, data: valueArg };
  }

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

  private clone<TValue>(valueArg: TValue): TValue {
    return JSON.parse(JSON.stringify(valueArg)) as TValue;
  }
}

const parseDeviceInfo = (xmlArg: string): Record<string, unknown> => {
  const result: Record<string, unknown> = {};
  for (const objectArg of parseObjects(xmlArg)) {
    if (objectArg.name.includes('Product Information')) {
      for (const key of ['ModelName', 'HardwareVersion', 'SoftwareVersion', 'SerialNumber', 'UpTime']) {
        result[key] = objectArg.parameters[key];
      }
    }
    if (objectArg.name.includes('WAN Status')) {
      result.MACAddress = objectArg.parameters.MACAddress;
    }
  }
  return cleanRecord(result);
};

const parseServices = (xmlArg: string): Record<string, unknown> => {
  const services: Record<string, unknown> = {};
  const modelRebootRequired = xmlArg.match(/<model\b[^>]*\breboot_req=["']([^"']+)["']/i)?.[1];
  if (modelRebootRequired) {
    services['Reboot Required'] = modelRebootRequired;
  }
  for (const objectArg of parseObjects(xmlArg)) {
    if (objectArg.name.includes('Service Status')) {
      const value = parseServiceStatus(objectArg.name, objectArg.parameters);
      if (value !== undefined) {
        services[objectArg.name] = value;
      }
    }
    if (objectArg.name.includes('Product Information')) {
      const lastReboot = parseLastReboot(String(objectArg.parameters.UpTime || ''));
      if (lastReboot) {
        services['Last Reboot'] = lastReboot;
      }
    }
  }
  return services;
};

const parseLineServices = (xmlArg: string): Record<string, unknown> => {
  const services: Record<string, unknown> = {};
  for (const objectArg of parseObjects(xmlArg)) {
    if (!objectArg.name.includes('Port Status')) {
      continue;
    }
    const subtitle = objectArg.subtitle || objectArg.name;
    if (objectArg.parameters.State !== undefined) {
      services[subtitle] = objectArg.parameters.State;
    }
    if (objectArg.parameters.LastCallerInfo !== undefined) {
      services[`${subtitle} last caller info`] = String(objectArg.parameters.LastCallerInfo).replace(/'/g, '').trim();
    }
  }
  return services;
};

const parseServiceStatus = (nameArg: string, parametersArg: Record<string, string>): string | undefined => {
  const status = parametersArg.Status;
  if (!status) {
    return undefined;
  }
  if (nameArg.includes('OBiTALK Service Status')) {
    return status.split(/\s+/)[0];
  }
  const state = status.split(/\s+/)[0];
  if (state !== 'Service') {
    return parametersArg.CallState?.split(/\s+/)[0] || state;
  }
  return undefined;
};

const parseCallDirection = (htmlArg: string): string | undefined => {
  const activeCalls = htmlArg.match(/Number of Active Calls:\s*([^<\s]+)/i)?.[1];
  if (activeCalls === '1') {
    if (/Inbound/i.test(htmlArg)) {
      return 'Inbound Call';
    }
    if (/Outbound/i.test(htmlArg)) {
      return 'Outbound Call';
    }
  }
  return undefined;
};

const parseLastReboot = (uptimeArg: string): string | undefined => {
  const match = uptimeArg.match(/([\d.]+)\s+days?\s+([0-9]{1,2}:[0-9]{2}:[0-9]{2})/i);
  if (!match) {
    return undefined;
  }
  const [hours, minutes, seconds] = match[2].split(':').map((partArg) => Number(partArg));
  const uptimeMs = Number(match[1]) * 24 * 60 * 60 * 1000 + hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000;
  return new Date(Date.now() - uptimeMs).toISOString();
};

const parseObjects = (xmlArg: string): Array<{ name: string; subtitle?: string; parameters: Record<string, string> }> => {
  const objects: Array<{ name: string; subtitle?: string; parameters: Record<string, string> }> = [];
  const objectRe = /<object\b([^>]*)>([\s\S]*?)<\/object>/gi;
  let objectMatch: RegExpExecArray | null;
  while ((objectMatch = objectRe.exec(xmlArg))) {
    const attrs = parseAttributes(objectMatch[1]);
    const parameters: Record<string, string> = {};
    const parameterRe = /<parameter\b([^>]*)>[\s\S]*?<value\b([^>]*)\/?>(?:[\s\S]*?<\/value>)?[\s\S]*?<\/parameter>/gi;
    let parameterMatch: RegExpExecArray | null;
    while ((parameterMatch = parameterRe.exec(objectMatch[2]))) {
      const parameterAttrs = parseAttributes(parameterMatch[1]);
      const valueAttrs = parseAttributes(parameterMatch[2]);
      if (parameterAttrs.name) {
        parameters[parameterAttrs.name] = decodeXml(valueAttrs.current || '');
      }
    }
    objects.push({ name: attrs.name || '', subtitle: attrs.subtitle, parameters });
  }
  return objects;
};

const parseAttributes = (valueArg: string): Record<string, string> => {
  const attrs: Record<string, string> = {};
  const attrRe = /([A-Za-z0-9_-]+)\s*=\s*(["'])(.*?)\2/g;
  let match: RegExpExecArray | null;
  while ((match = attrRe.exec(valueArg))) {
    attrs[match[1]] = decodeXml(match[3]);
  }
  return attrs;
};

const parseDigestChallenge = (challengeArg: string): Record<string, string> => {
  const values: Record<string, string> = {};
  const body = challengeArg.replace(/^digest\s+/i, '');
  const partRe = /([A-Za-z0-9_-]+)=(?:"([^"]*)"|([^,]*))(?:,\s*|$)/g;
  let match: RegExpExecArray | null;
  while ((match = partRe.exec(body))) {
    values[match[1]] = (match[2] ?? match[3] ?? '').trim();
  }
  return values;
};

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

const cleanRecord = (valueArg: Record<string, unknown>): Record<string, unknown> => Object.fromEntries(Object.entries(valueArg).filter(([, itemArg]) => itemArg !== undefined && itemArg !== ''));

const decodeXml = (valueArg: string): string => valueArg
  .replace(/&quot;/g, '"')
  .replace(/&apos;/g, "'")
  .replace(/&lt;/g, '<')
  .replace(/&gt;/g, '>')
  .replace(/&amp;/g, '&');

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