import * as plugins from '../../plugins.js';
import type {
  IHikvisionBinarySensor,
  IHikvisionCameraChannel,
  IHikvisionClientCommand,
  IHikvisionConfig,
  IHikvisionDeviceInfo,
  IHikvisionEvent,
  IHikvisionSnapshot,
  IHikvisionSnapshotImage,
  THikvisionAuthScheme,
  THikvisionCurrentEventStateEntry,
  THikvisionProtocol,
} from './hikvision.types.js';
import {
  hikvisionBinarySensorDescriptions,
  hikvisionDefaultPort,
  hikvisionDefaultRtspPort,
  hikvisionDefaultTimeoutMs,
  hikvisionRawEventTypeMap,
} from './hikvision.types.js';

const descriptionBySensorType = new Map(hikvisionBinarySensorDescriptions.map((descriptionArg) => [descriptionArg.sensorType.toLowerCase(), descriptionArg]));
const descriptionByKey = new Map(hikvisionBinarySensorDescriptions.map((descriptionArg) => [descriptionArg.key, descriptionArg]));

export class HikvisionHttpError extends Error {
  constructor(public readonly status: number, messageArg: string) {
    super(messageArg);
    this.name = 'HikvisionHttpError';
  }
}

export class HikvisionClient {
  private snapshot?: IHikvisionSnapshot;

  constructor(private readonly config: IHikvisionConfig) {}

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

    if (!forceRefreshArg && this.config.snapshot) {
      this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
      return this.snapshot;
    }

    if (this.config.host && this.config.liveProbe !== false) {
      try {
        this.snapshot = await this.fetchLiveSnapshot();
        return this.snapshot;
      } catch (errorArg) {
        this.snapshot = this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg));
        return this.snapshot;
      }
    }

    this.snapshot = this.snapshotFromConfig(this.config.connected ?? false);
    return this.snapshot;
  }

  public async validateConnection(): Promise<void> {
    await this.requestText('/ISAPI/System/deviceInfo');
  }

  public async execute(commandArg: IHikvisionClientCommand): Promise<unknown> {
    if (commandArg.type === 'refresh') {
      return this.getSnapshot(true);
    }
    if (commandArg.type === 'stream_source') {
      const snapshot = await this.getSnapshot();
      const camera = this.findCamera(snapshot, commandArg.cameraId || commandArg.streamingChannelId || commandArg.channel);
      return {
        cameraId: camera.id,
        channel: camera.channel,
        streamingChannelId: camera.streamingChannelId,
        streamSource: camera.rtspUrl,
        rtspUrl: camera.rtspUrl,
        snapshotUrl: camera.snapshotUrl,
        verified: false,
      };
    }
    if (commandArg.type === 'snapshot_image') {
      if (commandArg.filename) {
        throw new Error('Hikvision snapshot file writes are not implemented; request data as base64 without data.filename.');
      }
      const image = await this.getSnapshotImage(commandArg.streamingChannelId || commandArg.cameraId || commandArg.channel);
      return {
        contentType: image.contentType,
        byteLength: image.data.byteLength,
        dataBase64: Buffer.from(image.data).toString('base64'),
      };
    }
    if (commandArg.type === 'event_triggers_metadata') {
      const snapshot = await this.getSnapshot();
      return {
        events: snapshot.events,
        binarySensors: snapshot.binarySensors,
      };
    }
    if (commandArg.type === 'subscribe_events') {
      throw new Error('Hikvision live event streaming is not implemented in this TypeScript port; use snapshot/manual event data or poll entities.');
    }
    throw new Error(`Unsupported Hikvision command: ${commandArg.type}`);
  }

  public async getSnapshotImage(cameraIdArg?: string | number): Promise<IHikvisionSnapshotImage> {
    if (!this.config.host) {
      throw new Error('Hikvision host is required to fetch a camera snapshot image.');
    }
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, cameraIdArg);
    const streamingChannelId = camera.streamingChannelId || this.streamingChannelId(camera.id, camera.channel);
    const response = await this.request(`/ISAPI/Streaming/channels/${streamingChannelId}/picture`, { method: 'GET' });
    return {
      contentType: response.headers.get('content-type') || 'image/jpeg',
      data: new Uint8Array(await response.arrayBuffer()),
    };
  }

  public streamSource(cameraArg: IHikvisionCameraChannel): string | undefined {
    return cameraArg.rtspUrl;
  }

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

  private async fetchLiveSnapshot(): Promise<IHikvisionSnapshot> {
    const updatedAt = new Date().toISOString();
    const deviceInfoXml = await this.requestText('/ISAPI/System/deviceInfo');
    const deviceInfo = this.deviceInfoFromXml(deviceInfoXml, true);
    const [streamingChannels, eventTriggers] = await Promise.all([
      this.fetchStreamingChannels().catch(() => []),
      this.config.fetchEventTriggers === false ? Promise.resolve([]) : this.fetchEventTriggers().catch(() => []),
    ]);
    const cameras = streamingChannels.length ? streamingChannels : this.camerasFromConfig(true, deviceInfo);
    return this.normalizeSnapshot({
      deviceInfo,
      cameras,
      events: [...(this.config.events || []), ...eventTriggers],
      binarySensors: this.config.binarySensors || [],
      connected: true,
      updatedAt,
      metadata: {
        ...this.config.metadata,
        liveProbeImplemented: true,
        eventTriggerProbeImplemented: this.config.fetchEventTriggers !== false,
      },
    });
  }

  private async fetchStreamingChannels(): Promise<IHikvisionCameraChannel[]> {
    const xml = await this.requestText('/ISAPI/Streaming/channels');
    const cameras = this.parseStreamingChannels(xml);
    return cameras.length ? cameras : [];
  }

  private async fetchEventTriggers(): Promise<IHikvisionEvent[]> {
    const xml = await this.requestText('/ISAPI/Event/triggers');
    return this.parseEventTriggers(xml);
  }

  private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IHikvisionSnapshot {
    const deviceInfo = this.deviceInfo(connectedArg);
    const source = this.config.snapshot;
    return this.normalizeSnapshot({
      deviceInfo,
      cameras: this.config.cameras || this.config.channels || source?.cameras || this.camerasFromConfig(connectedArg, deviceInfo),
      binarySensors: this.config.binarySensors || source?.binarySensors || [],
      events: [...(source?.events || []), ...(this.config.events || [])],
      connected: connectedArg,
      updatedAt: new Date().toISOString(),
      metadata: {
        ...source?.metadata,
        ...this.config.metadata,
        lastLiveError: lastErrorArg,
      },
    });
  }

  private normalizeSnapshot(snapshotArg: IHikvisionSnapshot): IHikvisionSnapshot {
    const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
    const deviceInfo = {
      ...this.deviceInfo(connected),
      ...snapshotArg.deviceInfo,
      online: connected,
    };
    const cameras = this.uniqueCameras((snapshotArg.cameras || []).map((cameraArg, indexArg) => this.normalizeCamera(cameraArg, indexArg, connected, deviceInfo)));
    const events = this.uniqueEvents([...(snapshotArg.events || []), ...(this.config.events || [])].map((eventArg) => this.normalizeEvent(eventArg)));
    const binarySensors = this.uniqueBinarySensors([
      ...this.binarySensorsFromEvents(events, connected),
      ...(snapshotArg.binarySensors || []),
      ...(this.config.binarySensors || []),
      ...this.binarySensorsFromCurrentEventStates(connected),
    ].map((sensorArg) => this.normalizeBinarySensor(sensorArg, connected)));
    return {
      ...snapshotArg,
      deviceInfo,
      cameras,
      events,
      binarySensors,
      connected,
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
      metadata: {
        ...snapshotArg.metadata,
        nativePort: true,
        liveEventsImplemented: false,
      },
    };
  }

  private normalizeCamera(cameraArg: IHikvisionCameraChannel, indexArg: number, connectedArg: boolean, deviceInfoArg: IHikvisionDeviceInfo): IHikvisionCameraChannel {
    const channel = cameraArg.channel ?? this.logicalChannel(cameraArg.id, indexArg);
    const id = cameraArg.id || String(channel || indexArg + 1);
    const streamingChannelId = cameraArg.streamingChannelId || this.streamingChannelId(id, channel);
    return {
      ...cameraArg,
      id,
      name: cameraArg.name || (channel ? `${deviceInfoArg.name || 'Hikvision'} channel ${channel}` : `${deviceInfoArg.name || 'Hikvision'} camera`),
      channel,
      streamingChannelId,
      enabled: cameraArg.enabled !== false,
      snapshotUrl: cameraArg.snapshotUrl || this.cameraUrl(`/ISAPI/Streaming/channels/${streamingChannelId}/picture`),
      rtspUrl: cameraArg.rtspUrl || this.rtspUrl(streamingChannelId),
      available: connectedArg && cameraArg.available !== false && cameraArg.enabled !== false,
    };
  }

  private normalizeEvent(eventArg: IHikvisionEvent): IHikvisionEvent {
    const sensorType = this.friendlySensorType(eventArg.sensorType || eventArg.eventType || eventArg.rawType || eventArg.name) || eventArg.sensorType;
    const description = sensorType ? descriptionBySensorType.get(sensorType.toLowerCase()) : undefined;
    const key = eventArg.key || description?.key || this.slug(sensorType || eventArg.id);
    return {
      ...eventArg,
      sensorType,
      key,
      name: eventArg.name || description?.name || sensorType || eventArg.id,
      deviceClass: eventArg.deviceClass || description?.deviceClass,
      entityCategory: eventArg.entityCategory || description?.entityCategory,
      state: eventArg.state || (eventArg.isOn ? 'on' : 'off'),
    };
  }

  private normalizeBinarySensor(sensorArg: IHikvisionBinarySensor, connectedArg: boolean): IHikvisionBinarySensor {
    const sensorType = this.friendlySensorType(sensorArg.sensorType) || sensorArg.sensorType;
    const description = descriptionBySensorType.get(sensorType.toLowerCase()) || descriptionByKey.get(sensorArg.key);
    return {
      ...sensorArg,
      key: sensorArg.key || description?.key || this.slug(sensorType),
      sensorType,
      name: sensorArg.name || description?.name || sensorType,
      deviceClass: sensorArg.deviceClass || description?.deviceClass,
      entityCategory: sensorArg.entityCategory || description?.entityCategory,
      available: sensorArg.available ?? connectedArg,
    };
  }

  private camerasFromConfig(connectedArg: boolean, deviceInfoArg: IHikvisionDeviceInfo): IHikvisionCameraChannel[] {
    const cameras = this.config.cameras || this.config.channels;
    if (cameras?.length) {
      return cameras;
    }
    if (!this.config.host && !this.config.name && !this.config.deviceInfo) {
      return [];
    }
    return [{
      id: '1',
      name: `${deviceInfoArg.name || this.config.name || 'Hikvision'} Camera`,
      channel: 1,
      streamingChannelId: '101',
      enabled: true,
      available: connectedArg,
    }];
  }

  private binarySensorsFromEvents(eventsArg: IHikvisionEvent[], connectedArg: boolean): IHikvisionBinarySensor[] {
    const sensors: IHikvisionBinarySensor[] = [];
    for (const eventArg of eventsArg) {
        const sensorType = this.friendlySensorType(eventArg.sensorType || eventArg.eventType || eventArg.rawType || eventArg.name);
        if (!sensorType) {
          continue;
        }
        const description = descriptionBySensorType.get(sensorType.toLowerCase());
        if (!description) {
          continue;
        }
        sensors.push({
          key: description.key,
          sensorType,
          name: description.name,
          channel: eventArg.channel ?? 1,
          isOn: eventArg.isOn ?? eventArg.state === 'on',
          deviceClass: description.deviceClass,
          entityCategory: description.entityCategory,
          eventId: eventArg.id,
          triggerId: eventArg.triggerId,
          available: connectedArg,
          attributes: {
            rawType: eventArg.rawType,
            eventType: eventArg.eventType,
          },
        });
    }
    return sensors;
  }

  private binarySensorsFromCurrentEventStates(connectedArg: boolean): IHikvisionBinarySensor[] {
    const states = this.config.currentEventStates;
    if (!states) {
      return [];
    }
    const sensors: IHikvisionBinarySensor[] = [];
    for (const [rawSensorType, entries] of Object.entries(states)) {
      const sensorType = this.friendlySensorType(rawSensorType);
      const description = sensorType ? descriptionBySensorType.get(sensorType.toLowerCase()) : undefined;
      if (!sensorType || !description) {
        continue;
      }
      for (const entry of entries.length ? entries : [1]) {
        const parsed = this.currentEventStateEntry(entry);
        sensors.push({
          key: description.key,
          sensorType,
          name: description.name,
          channel: parsed.channel ?? 1,
          isOn: parsed.isOn ?? false,
          deviceClass: description.deviceClass,
          entityCategory: description.entityCategory,
          lastTripTime: parsed.lastTripTime,
          available: connectedArg,
          attributes: parsed.attributes,
        });
      }
    }
    return sensors;
  }

  private currentEventStateEntry(entryArg: THikvisionCurrentEventStateEntry): { channel?: number | string; isOn?: boolean; lastTripTime?: string | number | null; attributes?: Record<string, unknown> } {
    if (typeof entryArg === 'number' || typeof entryArg === 'string') {
      return { channel: entryArg };
    }
    if (Array.isArray(entryArg)) {
      return {
        isOn: this.booleanValue(entryArg[0]),
        channel: entryArg[1],
        lastTripTime: typeof entryArg[3] === 'string' || typeof entryArg[3] === 'number' || entryArg[3] === null ? entryArg[3] : undefined,
      };
    }
    const channel = entryArg.channel ?? entryArg.channelId ?? entryArg.channel_id;
    return {
      channel,
      isOn: entryArg.isOn ?? entryArg.on ?? this.booleanValue(entryArg.state),
      lastTripTime: entryArg.lastTripTime ?? entryArg.last_trip_time,
      attributes: entryArg.attributes,
    };
  }

  private parseStreamingChannels(xmlArg: string): IHikvisionCameraChannel[] {
    const blocks = [...this.blocks(xmlArg, 'StreamingChannel'), ...this.blocks(xmlArg, 'VideoInputChannel')];
    const byChannel = new Map<string, IHikvisionCameraChannel>();
    blocks.forEach((blockArg, indexArg) => {
      const rawId = this.firstText(blockArg, 'id') || this.attribute(blockArg, 'id') || String(indexArg + 1);
      const channel = this.logicalChannel(rawId, indexArg);
      const streamingChannelId = this.streamingChannelId(rawId, channel);
      const key = String(channel ?? rawId);
      const camera: IHikvisionCameraChannel = {
        id: key,
        channel,
        streamingChannelId,
        name: this.firstText(blockArg, 'channelName') || this.firstText(blockArg, 'name') || this.firstText(blockArg, 'videoInputChannelName') || `Channel ${key}`,
        enabled: this.booleanValue(this.firstText(blockArg, 'enabled')) ?? true,
        attributes: {
          rawStreamingChannelId: rawId,
          transportProtocol: this.firstText(blockArg, 'TransportProtocol'),
          videoCodecType: this.firstText(blockArg, 'videoCodecType'),
        },
      };
      const existing = byChannel.get(key);
      if (!existing || streamingChannelId.endsWith('01') || !existing.streamingChannelId?.endsWith('01')) {
        byChannel.set(key, camera);
      }
    });
    return [...byChannel.values()];
  }

  private parseEventTriggers(xmlArg: string): IHikvisionEvent[] {
    const events: IHikvisionEvent[] = [];
    const blocks = this.blocks(xmlArg, 'EventTrigger');
    blocks.forEach((blockArg, indexArg) => {
      const rawType = this.firstText(blockArg, 'eventType') || this.firstText(blockArg, 'eventDescription') || this.firstText(blockArg, 'notificationMethod');
      if (!rawType || this.normalizedEventKey(rawType) === 'videoloss') {
        return;
      }
      const sensorType = this.friendlySensorType(rawType);
      const description = sensorType ? descriptionBySensorType.get(sensorType.toLowerCase()) : undefined;
      if (!sensorType || !description) {
        return;
      }
      const triggerId = this.firstText(blockArg, 'id') || this.attribute(blockArg, 'id') || `${description.key}_${indexArg + 1}`;
      const channels = this.channelsFromTriggerBlock(blockArg);
      for (const channel of channels.length ? channels : [1]) {
        events.push({
          id: `${description.key}_${channel}_${this.slug(triggerId)}`,
          key: description.key,
          sensorType,
          name: `${description.name} channel ${channel}`,
          channel,
          state: 'off',
          isOn: false,
          deviceClass: description.deviceClass,
          entityCategory: description.entityCategory,
          eventType: rawType,
          rawType,
          triggerId,
          payload: {
            triggerDescription: this.firstText(blockArg, 'eventDescription'),
            notificationMethods: this.allTexts(blockArg, 'notificationMethod'),
          },
        });
      }
    });
    return events;
  }

  private channelsFromTriggerBlock(blockArg: string): Array<number | string> {
    const values = [
      ...this.allTexts(blockArg, 'videoInputChannelID'),
      ...this.allTexts(blockArg, 'dynVideoInputChannelID'),
      ...this.allTexts(blockArg, 'channelID'),
      ...this.allTexts(blockArg, 'inputIOPortID'),
    ];
    return [...new Set(values.map((valueArg) => this.numberValue(valueArg) ?? valueArg).filter((valueArg) => valueArg !== ''))];
  }

  private deviceInfoFromXml(xmlArg: string, connectedArg: boolean): IHikvisionDeviceInfo {
    return this.deviceInfo(connectedArg, {
      id: this.firstText(xmlArg, 'deviceID') || this.firstText(xmlArg, 'deviceId') || this.firstText(xmlArg, 'serialNumber'),
      name: this.firstText(xmlArg, 'deviceName'),
      model: this.firstText(xmlArg, 'model'),
      serialNumber: this.firstText(xmlArg, 'serialNumber'),
      macAddress: normalizeMac(this.firstText(xmlArg, 'macAddress') || this.firstText(xmlArg, 'MACAddress')),
      firmwareVersion: this.firstText(xmlArg, 'firmwareVersion'),
      deviceType: this.firstText(xmlArg, 'deviceType'),
      attributes: {
        encoderVersion: this.firstText(xmlArg, 'encoderVersion'),
        firmwareReleasedDate: this.firstText(xmlArg, 'firmwareReleasedDate'),
      },
    });
  }

  private deviceInfo(connectedArg: boolean, liveArg: Partial<IHikvisionDeviceInfo> = {}): IHikvisionDeviceInfo {
    const protocol = this.protocol();
    const port = this.config.port || (protocol === 'https' ? 443 : hikvisionDefaultPort);
    const serialNumber = liveArg.serialNumber || this.config.deviceInfo?.serialNumber || this.config.uniqueId;
    const macAddress = normalizeMac(liveArg.macAddress || this.config.deviceInfo?.macAddress || serialNumber);
    return {
      ...this.config.deviceInfo,
      ...liveArg,
      id: liveArg.id || this.config.deviceInfo?.id || this.config.uniqueId || serialNumber || macAddress || this.config.host || 'manual-hikvision',
      name: this.config.name || liveArg.name || this.config.deviceInfo?.name || this.config.host || 'Hikvision device',
      manufacturer: liveArg.manufacturer || this.config.deviceInfo?.manufacturer || 'Hikvision',
      model: liveArg.model || this.config.model || this.config.deviceInfo?.model,
      serialNumber,
      macAddress: macAddress || undefined,
      firmwareVersion: liveArg.firmwareVersion || this.config.deviceInfo?.firmwareVersion,
      deviceType: liveArg.deviceType || this.config.deviceType || this.config.deviceInfo?.deviceType || this.deviceTypeFromModel(liveArg.model || this.config.model),
      host: this.config.deviceInfo?.host || this.config.host,
      port: this.config.deviceInfo?.port || port,
      protocol: this.config.deviceInfo?.protocol || protocol,
      rtspPort: this.config.deviceInfo?.rtspPort || this.config.rtspPort || hikvisionDefaultRtspPort,
      online: connectedArg,
    };
  }

  private async requestText(pathArg: string): Promise<string> {
    return (await this.request(pathArg, { method: 'GET' })).text();
  }

  private async request(pathArg: string, initArg: RequestInit): Promise<Response> {
    if (!this.config.host && !/^https?:\/\//i.test(pathArg)) {
      throw new Error('Hikvision request requires config.host.');
    }
    const url = this.pathUrl(pathArg);
    const headers = new Headers(initArg.headers);
    if (this.config.username && this.authScheme() === 'basic') {
      headers.set('authorization', this.basicAuthorization());
    }
    const response = await this.fetchWithTimeout(url, { ...initArg, headers });
    if (response.status === 401 && this.config.username && this.authScheme() !== 'basic') {
      const challenge = response.headers.get('www-authenticate') || '';
      const retryHeaders = new Headers(initArg.headers);
      if (/digest/i.test(challenge)) {
        const retryUrl = new URL(url);
        retryHeaders.set('authorization', this.digestAuthorization(challenge, initArg.method || 'GET', retryUrl.pathname + retryUrl.search));
        return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, headers: retryHeaders }), pathArg);
      }
      if (/basic/i.test(challenge) || this.authScheme() === 'auto') {
        retryHeaders.set('authorization', this.basicAuthorization());
        return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, headers: retryHeaders }), pathArg);
      }
    }
    return this.checkedResponse(response, pathArg);
  }

  private async checkedResponse(responseArg: Response, pathArg: string): Promise<Response> {
    if (!responseArg.ok) {
      const text = await responseArg.text().catch(() => '');
      throw new HikvisionHttpError(responseArg.status, `Hikvision request ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${this.compactXml(text)}` : ''}`);
    }
    return responseArg;
  }

  private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || hikvisionDefaultTimeoutMs);
    try {
      return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
    } finally {
      clearTimeout(timeout);
    }
  }

  private findCamera(snapshotArg: IHikvisionSnapshot, cameraIdArg: string | number | undefined): IHikvisionCameraChannel {
    const cameraId = cameraIdArg === undefined ? '' : String(cameraIdArg);
    const camera = snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.channel) === cameraId || cameraArg.streamingChannelId === cameraId) || snapshotArg.cameras[0];
    if (!camera) {
      throw new Error('Hikvision camera snapshot requires a configured or discovered camera.');
    }
    return camera;
  }

  private uniqueCameras(camerasArg: IHikvisionCameraChannel[]): IHikvisionCameraChannel[] {
    const map = new Map<string, IHikvisionCameraChannel>();
    for (const camera of camerasArg) {
      const key = camera.id || String(camera.channel || camera.streamingChannelId);
      map.set(key, { ...map.get(key), ...camera });
    }
    return [...map.values()];
  }

  private uniqueEvents(eventsArg: IHikvisionEvent[]): IHikvisionEvent[] {
    const map = new Map<string, IHikvisionEvent>();
    for (const event of eventsArg) {
      map.set(event.id, { ...map.get(event.id), ...event });
    }
    return [...map.values()];
  }

  private uniqueBinarySensors(sensorsArg: IHikvisionBinarySensor[]): IHikvisionBinarySensor[] {
    const map = new Map<string, IHikvisionBinarySensor>();
    for (const sensor of sensorsArg) {
      const key = `${sensor.key}:${sensor.channel}`;
      map.set(key, { ...map.get(key), ...sensor });
    }
    return [...map.values()];
  }

  private cameraUrl(pathArg: string): string | undefined {
    return this.config.host ? `${this.baseUrl()}${pathArg}` : undefined;
  }

  private rtspUrl(streamingChannelIdArg: string): string | undefined {
    if (!this.config.host) {
      return undefined;
    }
    const auth = this.config.username ? `${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password || '')}@` : '';
    return `rtsp://${auth}${this.config.host}:${this.config.rtspPort || hikvisionDefaultRtspPort}/Streaming/Channels/${streamingChannelIdArg}`;
  }

  private baseUrl(): string {
    const protocol = this.protocol();
    const port = this.config.port || (protocol === 'https' ? 443 : hikvisionDefaultPort);
    return `${protocol}://${this.config.host}:${port}`;
  }

  private pathUrl(pathArg: string): string {
    if (/^https?:\/\//i.test(pathArg)) {
      return pathArg;
    }
    return `${this.baseUrl()}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`;
  }

  private protocol(): THikvisionProtocol {
    return this.config.protocol || (this.config.ssl ? 'https' : 'http');
  }

  private authScheme(): THikvisionAuthScheme {
    return this.config.authScheme || 'auto';
  }

  private digestAuthorization(challengeArg: string, methodArg: string, uriArg: string): string {
    const challenge = parseDigestChallenge(challengeArg);
    if (!challenge.realm || !challenge.nonce) {
      throw new Error('Hikvision digest authentication challenge is missing realm or nonce.');
    }
    const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
    if (algorithm !== 'MD5' && algorithm !== 'MD5-SESS') {
      throw new Error(`Hikvision digest authentication algorithm is unsupported: ${algorithm}`);
    }
    const qop = splitCsv(challenge.qop).includes('auth') ? 'auth' : undefined;
    const cnonce = plugins.crypto.randomBytes(8).toString('hex');
    const nc = '00000001';
    const username = this.config.username || '';
    const password = this.config.password || '';
    const ha1Raw = md5(`${username}:${challenge.realm}:${password}`);
    const ha1 = algorithm === 'MD5-SESS' ? md5(`${ha1Raw}:${challenge.nonce}:${cnonce}`) : ha1Raw;
    const ha2 = md5(`${methodArg.toUpperCase()}:${uriArg}`);
    const response = qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`);
    const parts: Record<string, string> = { username, realm: challenge.realm, nonce: challenge.nonce, uri: uriArg, response, algorithm };
    if (challenge.opaque) {
      parts.opaque = challenge.opaque;
    }
    if (qop) {
      parts.qop = qop;
      parts.nc = nc;
      parts.cnonce = cnonce;
    }
    return `Digest ${Object.entries(parts).map(([keyArg, valueArg]) => keyArg === 'qop' || keyArg === 'nc' || keyArg === 'algorithm' ? `${keyArg}=${valueArg}` : `${keyArg}="${valueArg.replace(/"/g, '\\"')}"`).join(', ')}`;
  }

  private basicAuthorization(): string {
    return `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`, 'utf8').toString('base64')}`;
  }

  private friendlySensorType(valueArg: unknown): string | undefined {
    const value = stringValue(valueArg);
    if (!value) {
      return undefined;
    }
    const lower = value.toLowerCase();
    const exact = descriptionBySensorType.get(lower)?.sensorType;
    if (exact) {
      return exact;
    }
    return hikvisionRawEventTypeMap[lower] || hikvisionRawEventTypeMap[this.normalizedEventKey(value)];
  }

  private normalizedEventKey(valueArg: string): string {
    return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '');
  }

  private logicalChannel(idArg: string | number | undefined, indexArg: number): number | undefined {
    const value = this.numberValue(idArg);
    if (value === undefined) {
      return indexArg + 1;
    }
    if (value >= 100) {
      return Math.floor(value / 100);
    }
    return value;
  }

  private streamingChannelId(idArg: string | number | undefined, channelArg: number | undefined): string {
    const raw = stringValue(idArg);
    const numeric = this.numberValue(raw);
    if (numeric !== undefined && numeric >= 100) {
      return String(Math.trunc(numeric));
    }
    return channelArg !== undefined ? `${Math.trunc(channelArg)}01` : raw || '101';
  }

  private deviceTypeFromModel(modelArg: string | undefined): string | undefined {
    if (!modelArg) {
      return undefined;
    }
    return /nvr|dvr/i.test(modelArg) ? 'NVR' : 'Camera';
  }

  private blocks(xmlArg: string, localNameArg: string): string[] {
    const blocks: string[] = [];
    const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>[\\s\\S]*?<\\/([\\w.-]+:)?${localNameArg}>`, 'gi');
    for (const match of xmlArg.matchAll(pattern)) {
      blocks.push(match[0]);
    }
    return blocks;
  }

  private firstText(xmlArg: string, localNameArg: string): string | undefined {
    const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>([\\s\\S]*?)<\\/([\\w.-]+:)?${localNameArg}>`, 'i');
    const match = xmlArg.match(pattern);
    if (!match) {
      return undefined;
    }
    return this.decodeXml(match[2].replace(/<[^>]+>/g, '').trim()) || undefined;
  }

  private allTexts(xmlArg: string, localNameArg: string): string[] {
    const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>([\\s\\S]*?)<\\/([\\w.-]+:)?${localNameArg}>`, 'gi');
    const values: string[] = [];
    for (const match of xmlArg.matchAll(pattern)) {
      const value = this.decodeXml(match[2].replace(/<[^>]+>/g, '').trim());
      if (value) {
        values.push(value);
      }
    }
    return values;
  }

  private attribute(xmlArg: string, attributeArg: string): string | undefined {
    const match = xmlArg.match(new RegExp(`${attributeArg}=["']([^"']+)["']`, 'i'));
    return match ? this.decodeXml(match[1]) : undefined;
  }

  private compactXml(xmlArg: string): string {
    return xmlArg.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
  }

  private decodeXml(valueArg: string): string {
    return valueArg
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&apos;/g, "'")
      .replace(/&amp;/g, '&');
  }

  private booleanValue(valueArg: unknown): boolean | undefined {
    if (typeof valueArg === 'boolean') {
      return valueArg;
    }
    if (typeof valueArg === 'number') {
      return valueArg === 1 ? true : valueArg === 0 ? false : undefined;
    }
    if (typeof valueArg === 'string') {
      const lower = valueArg.toLowerCase();
      if (['true', 'yes', 'on', '1', 'active', 'triggered'].includes(lower)) {
        return true;
      }
      if (['false', 'no', 'off', '0', 'inactive', 'idle'].includes(lower)) {
        return false;
      }
    }
    return undefined;
  }

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

  private slug(valueArg: string): string {
    return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'hikvision';
  }

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

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

const parseDigestChallenge = (valueArg: string): Record<string, string> => {
  const result: Record<string, string> = {};
  const challenge = valueArg.replace(/^\s*Digest\s+/i, '');
  const matcher = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|([^,\s]+))/g;
  for (const match of challenge.matchAll(matcher)) {
    result[match[1].toLowerCase()] = match[2] ?? match[3] ?? '';
  }
  return result;
};

const splitCsv = (valueArg: string | undefined): string[] => {
  return (valueArg || '').split(',').map((entryArg) => entryArg.trim()).filter(Boolean);
};

const normalizeMac = (valueArg: string | undefined): string | undefined => {
  const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
  return cleaned.length === 12 ? cleaned : undefined;
};

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