import * as plugins from '../../plugins.js';
import { SimpleLocalClient, type ISimpleLocalSnapshot, type IServiceCallResult } from '../../core/index.js';
import { MjpegMapper } from './mjpeg.mapper.js';
import type { IMjpegConfig } from './mjpeg.types.js';
import { mjpegDefaultTimeoutMs, mjpegProfile } from './mjpeg.types.js';

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

interface IMjpegImageProbe {
  source: 'still' | 'mjpeg';
  url: string;
  contentType: string;
  bytes: number;
  jpeg: boolean;
}

export class MjpegClient extends SimpleLocalClient<IMjpegConfig> {
  private cachedSnapshot?: ISimpleLocalSnapshot;

  constructor(private readonly clientConfig: IMjpegConfig) {
    super(mjpegProfile, clientConfig);
  }

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

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

    if (this.hasEndpoint()) {
      try {
        const probe = await this.probeImage();
        this.cachedSnapshot = MjpegMapper.toSnapshot({ config: this.clientConfig, rawData: this.rawDataFromProbe(probe), 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 = MjpegMapper.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 || 'MJPEG IP Camera refresh requires a live MJPEG/still URL, injected client, or snapshotProvider.', data: snapshot };
  }

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

  private async probeImage(): Promise<IMjpegImageProbe> {
    const urls = this.cameraUrls();
    if (urls.stillImageUrl) {
      try {
        const response = await this.fetchWithAuth(urls.stillImageUrl, { accept: 'image/jpeg,image/*;q=0.9,*/*;q=0.5' });
        const bytes = Buffer.from(await response.arrayBuffer());
        this.assertOk(response, bytes.toString('utf8'));
        return { source: 'still', url: urls.stillImageUrl, contentType: response.headers.get('content-type') || 'application/octet-stream', bytes: bytes.length, jpeg: isJpeg(bytes) };
      } catch (errorArg) {
        if (!urls.mjpegUrl) {
          throw errorArg;
        }
      }
    }

    if (!urls.mjpegUrl) {
      throw new Error('MJPEG IP Camera live probing requires config.mjpegUrl, config.url, or host/path.');
    }
    const response = await this.fetchWithAuth(urls.mjpegUrl, { accept: 'multipart/x-mixed-replace,image/jpeg,image/*;q=0.9,*/*;q=0.5' });
    this.assertOk(response);
    const frame = await extractJpegFrame(response, this.clientConfig.timeoutMs || mjpegDefaultTimeoutMs);
    return { source: 'mjpeg', url: urls.mjpegUrl, contentType: response.headers.get('content-type') || 'multipart/x-mixed-replace', bytes: frame.length, jpeg: true };
  }

  private async fetchWithAuth(urlArg: string, headersArg: Record<string, string>): Promise<Response> {
    this.checkVerifySsl(urlArg);
    const headers = { ...headersArg, ...this.basicAuthHeader() };
    const initial = await this.fetchOnce(urlArg, headers);
    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('GET', urlArg, challenge);
    return this.fetchOnce(urlArg, { ...headersArg, authorization });
  }

  private async fetchOnce(urlArg: string, headersArg: Record<string, string>): Promise<Response> {
    const timeoutMs = this.clientConfig.timeoutMs || mjpegDefaultTimeoutMs;
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      return await globalThis.fetch(urlArg, { headers: headersArg, signal: controller.signal });
    } finally {
      clearTimeout(timer);
    }
  }

  private assertOk(responseArg: Response, bodyArg = ''): void {
    if (responseArg.status === 401) {
      throw new Error('MJPEG IP Camera local authentication failed.');
    }
    if (!responseArg.ok) {
      throw new Error(`MJPEG IP Camera request failed with ${responseArg.status}: ${bodyArg.slice(0, 160)}`);
    }
  }

  private basicAuthHeader(): Record<string, string> {
    if (!this.clientConfig.username || this.authentication() === 'digest') {
      return {};
    }
    return { authorization: `Basic ${Buffer.from(`${this.clientConfig.username}:${this.clientConfig.password || ''}`).toString('base64')}` };
  }

  private digestAuthorization(methodArg: string, urlArg: string, challengeArg: IMjpegDigestChallenge): 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 cameraUrls(): { mjpegUrl?: string; stillImageUrl?: string } {
    const mjpegUrl = stringValue(this.clientConfig.mjpegUrl) || stringValue(this.clientConfig.mjpeg_url) || stringValue(this.clientConfig.url) || this.urlFromHostPath();
    const stillImageUrl = stringValue(this.clientConfig.stillImageUrl) || stringValue(this.clientConfig.still_image_url);
    return { mjpegUrl, stillImageUrl };
  }

  private urlFromHostPath(): string | undefined {
    const host = stringValue(this.clientConfig.host);
    if (!host) {
      return undefined;
    }
    const path = stringValue(this.clientConfig.path) || '/';
    const protocol = this.clientConfig.useTls ? 'https' : 'http';
    const port = this.clientConfig.port;
    const portPart = port && !((protocol === 'https' && port === 443) || (protocol === 'http' && port === 80)) ? `:${port}` : '';
    return `${protocol}://${host}${portPart}${path.startsWith('/') ? path : `/${path}`}`;
  }

  private rawDataFromProbe(probeArg: IMjpegImageProbe): Record<string, unknown> {
    const url = new URL(probeArg.url);
    const urls = this.cameraUrls();
    const name = this.clientConfig.name || mjpegProfile.defaultName || mjpegProfile.displayName;
    return {
      device: {
        id: this.clientConfig.uniqueId || this.clientConfig.deviceId || `${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : 80)}`,
        name,
        manufacturer: mjpegProfile.manufacturer,
        model: mjpegProfile.model,
        host: url.hostname,
        port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80,
        protocol: url.protocol === 'https:' ? 'https' : 'http',
        configurationUrl: `${url.protocol}//${url.host}`,
      },
      entities: [{
        id: 'camera',
        name,
        platform: 'camera',
        state: 'online',
        attributes: {
          contentType: probeArg.contentType,
          imageSource: probeArg.source,
          imageBytes: probeArg.bytes,
          jpeg: probeArg.jpeg,
          mjpegUrl: urls.mjpegUrl,
          stillImageUrl: urls.stillImageUrl,
          authentication: this.authentication(),
        },
      }],
      online: true,
      fetchedAt: new Date().toISOString(),
    };
  }

  private authentication(): string {
    return stringValue(this.clientConfig.authentication)?.toLowerCase() || (this.clientConfig.username ? 'basic' : 'none');
  }

  private checkVerifySsl(urlArg: string): void {
    const verifySsl = this.clientConfig.verifySsl ?? this.clientConfig.verify_ssl;
    if (verifySsl === false && new URL(urlArg).protocol === 'https:') {
      throw new Error('MJPEG IP Camera verifySsl=false is not supported with global fetch; provide a trusted HTTPS endpoint or an injected client.');
    }
  }

  private hasEndpoint(): boolean {
    const urls = this.cameraUrls();
    return Boolean(urls.mjpegUrl || urls.stillImageUrl);
  }

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

  private rawData(): unknown {
    return this.clientConfig.rawData ?? this.clientConfig.rawText;
  }

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

const extractJpegFrame = async (responseArg: Response, timeoutMsArg: number): Promise<Buffer> => {
  const reader = responseArg.body?.getReader();
  if (!reader) {
    const bytes = Buffer.from(await responseArg.arrayBuffer());
    const frame = sliceJpeg(bytes);
    if (!frame) {
      throw new Error('MJPEG IP Camera stream did not contain a JPEG frame.');
    }
    return frame;
  }

  let data = Buffer.alloc(0);
  const deadline = Date.now() + timeoutMsArg;
  try {
    while (Date.now() < deadline) {
      const remaining = Math.max(1, deadline - Date.now());
      const result = await withTimeout(reader.read(), remaining);
      if (result.done) {
        break;
      }
      data = Buffer.concat([data, Buffer.from(result.value)]);
      const frame = sliceJpeg(data);
      if (frame) {
        return frame;
      }
      if (data.length > 2 * 1024 * 1024) {
        throw new Error('MJPEG IP Camera stream exceeded 2 MiB before a JPEG frame was found.');
      }
    }
  } finally {
    await reader.cancel().catch(() => undefined);
  }
  throw new Error(`MJPEG IP Camera stream did not return a JPEG frame within ${timeoutMsArg}ms.`);
};

const withTimeout = async <TValue>(promiseArg: Promise<TValue>, timeoutMsArg: number): Promise<TValue> => {
  let timer: NodeJS.Timeout | undefined;
  try {
    return await Promise.race([
      promiseArg,
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error('timed out')), timeoutMsArg);
      }),
    ]);
  } finally {
    if (timer) {
      clearTimeout(timer);
    }
  }
};

const sliceJpeg = (bufferArg: Buffer): Buffer | undefined => {
  const start = bufferArg.indexOf(Buffer.from([0xff, 0xd8]));
  if (start < 0) {
    return undefined;
  }
  const end = bufferArg.indexOf(Buffer.from([0xff, 0xd9]), start + 2);
  return end < 0 ? undefined : bufferArg.subarray(start, end + 2);
};

const isJpeg = (bufferArg: Buffer): boolean => Boolean(sliceJpeg(bufferArg));

const parseDigestChallenge = (headerArg: string | null): IMjpegDigestChallenge | 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 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 clone = <TValue>(valueArg: TValue): TValue => JSON.parse(JSON.stringify(valueArg)) as TValue;

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