import * as plugins from '../../plugins.js';
import type {
  IAmcrestBinarySensor,
  IAmcrestCamera,
  IAmcrestClientCommand,
  IAmcrestCommandResponse,
  IAmcrestConfig,
  IAmcrestDeviceInfo,
  IAmcrestEvent,
  IAmcrestHttpCommand,
  IAmcrestSensor,
  IAmcrestSnapshot,
  IAmcrestSnapshotImage,
  IAmcrestSwitch,
  TAmcrestAuthScheme,
  TAmcrestColorBw,
  TAmcrestProtocol,
  TAmcrestPtzMovement,
  TAmcrestResolution,
  TAmcrestStreamSource,
} from './amcrest.types.js';
import {
  amcrestBinarySensorDescriptions,
  amcrestColorModes,
  amcrestDefaultPort,
  amcrestDefaultRtspPort,
  amcrestDefaultSnapshotTimeoutMs,
  amcrestDefaultTimeoutMs,
  amcrestResolutionSubtype,
  amcrestSensorDescriptions,
  amcrestSubtypeStream,
  amcrestSwitchDescriptions,
} from './amcrest.types.js';

const ptzCodes: Record<TAmcrestPtzMovement, string> = {
  zoom_out: 'ZoomWide',
  zoom_in: 'ZoomTele',
  right: 'Right',
  left: 'Left',
  up: 'Up',
  down: 'Down',
  right_down: 'RightDown',
  right_up: 'RightUp',
  left_down: 'LeftDown',
  left_up: 'LeftUp',
};

const ptzMoveOneArg2 = new Set(['Right', 'Left', 'Up', 'Down']);
const ptzMoveBothArgs = new Set(['RightDown', 'RightUp', 'LeftDown', 'LeftUp']);

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

export class AmcrestClient {
  private snapshot?: IAmcrestSnapshot;

  constructor(private readonly config: IAmcrestConfig) {}

  public async getSnapshot(forceRefreshArg = false): Promise<IAmcrestSnapshot> {
    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.hasLiveTarget()) {
      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('/cgi-bin/magicBox.cgi?action=getDeviceType');
  }

  public async execute(commandArg: IAmcrestClientCommand): 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);
      return {
        cameraId: camera.id,
        channel: camera.channel,
        resolution: commandArg.resolution || camera.resolution,
        streamSource: commandArg.streamSource || camera.streamSource,
        streamSourceUrl: this.streamSourceUrl(camera, commandArg.streamSource),
        snapshotUrl: camera.snapshotUrl,
        mjpegUrl: camera.mjpegUrl,
        rtspUrl: camera.rtspUrl,
        verified: false,
      };
    }
    if (commandArg.type === 'snapshot_image') {
      if (commandArg.filename) {
        throw new Error('Amcrest snapshot file writes are not implemented; request data as base64 without data.filename.');
      }
      const image = await this.getSnapshotImage(commandArg.channel);
      return {
        contentType: image.contentType,
        dataBase64: Buffer.from(image.data).toString('base64'),
      };
    }
    if (commandArg.type === 'set_privacy_mode') {
      const enabled = this.requireEnabled(commandArg);
      const response = await this.setBooleanConfig('privacy_mode', enabled, commandArg.channel);
      this.patchCachedSwitch('privacy_mode', enabled);
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'set_video') {
      const enabled = this.requireEnabled(commandArg);
      if (!enabled && this.snapshot?.cameras.some((cameraArg) => cameraArg.isRecording)) {
        await this.setRecordMode(false, commandArg.channel);
      }
      const response = await this.setBooleanConfig('video', enabled, commandArg.channel);
      this.patchCachedCamera({ isStreaming: enabled });
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'set_recording') {
      const enabled = this.requireEnabled(commandArg);
      if (enabled && this.snapshot?.cameras.some((cameraArg) => cameraArg.isStreaming === false)) {
        await this.setBooleanConfig('video', true, commandArg.channel);
      }
      const response = await this.setRecordMode(enabled, commandArg.channel);
      this.patchCachedCamera({ isRecording: enabled });
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'set_audio') {
      const enabled = this.requireEnabled(commandArg);
      const response = await this.setBooleanConfig('audio', enabled, commandArg.channel);
      this.patchCachedCamera({ audioEnabled: enabled });
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'set_motion_detection') {
      const enabled = this.requireEnabled(commandArg);
      const response = await this.setBooleanConfig('motion_detection', enabled, commandArg.channel);
      this.patchCachedCamera({ motionDetectionEnabled: enabled });
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'set_motion_recording') {
      const enabled = this.requireEnabled(commandArg);
      const response = await this.setBooleanConfig('motion_recording', enabled, commandArg.channel);
      this.patchCachedCamera({ motionRecordingEnabled: enabled });
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'set_color_bw') {
      if (!commandArg.colorBw) {
        throw new Error('Amcrest set_color_bw requires a supported color_bw value.');
      }
      const response = await this.setColorBw(commandArg.colorBw, commandArg.channel);
      this.patchCachedCamera({ colorBw: commandArg.colorBw });
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'goto_preset') {
      if (typeof commandArg.preset !== 'number' || !Number.isFinite(commandArg.preset) || commandArg.preset < 1) {
        throw new Error('Amcrest goto_preset requires a positive preset number.');
      }
      const response = await this.gotoPreset(commandArg.preset, commandArg.channel);
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    if (commandArg.type === 'ptz_control') {
      if (!commandArg.movement) {
        throw new Error('Amcrest ptz_control requires a movement value.');
      }
      const responses = await this.ptzControl(commandArg.movement, commandArg.travelTime, commandArg.channel);
      return { ok: true, command: commandArg.type, responses };
    }
    if (commandArg.type === 'start_tour' || commandArg.type === 'stop_tour') {
      const response = await this.tour(commandArg.type === 'start_tour', commandArg.channel);
      return { ok: true, command: commandArg.type, responses: [response] };
    }
    throw new Error(`Unsupported Amcrest command: ${commandArg.type}`);
  }

  public async getSnapshotImage(channelArg?: number): Promise<IAmcrestSnapshotImage> {
    const response = await this.request(this.snapshotHttpCommand(channelArg).path, {}, this.config.snapshotTimeoutMs || amcrestDefaultSnapshotTimeoutMs);
    return {
      contentType: response.headers.get('content-type') || 'image/jpeg',
      data: new Uint8Array(await response.arrayBuffer()),
    };
  }

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

  private async fetchLiveSnapshot(): Promise<IAmcrestSnapshot> {
    const channel = this.channel();
    const subtype = this.subtype();
    const streamFormat = this.streamFormat(subtype);
    const deviceTypeText = await this.requestText('/cgi-bin/magicBox.cgi?action=getDeviceType');
    const [vendorText, serialText, encodeText, recordText, motionText, privacyText, motionEventText, audioMutationEventText, audioIntensityEventText, crosslineEventText, presetText, storageText, colorText] = await Promise.all([
      this.requestText('/cgi-bin/magicBox.cgi?action=getVendor').catch(() => undefined),
      this.requestText('/cgi-bin/magicBox.cgi?action=getSerialNo').catch(() => undefined),
      this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=Encode').catch(() => undefined),
      this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=RecordMode').catch(() => undefined),
      this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=MotionDetect').catch(() => undefined),
      this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=LeLensMask').catch(() => undefined),
      this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=VideoMotion').catch(() => undefined),
      this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=AudioMutation').catch(() => undefined),
      this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=AudioIntensity').catch(() => undefined),
      this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=CrossLineDetection').catch(() => undefined),
      this.requestText(`/cgi-bin/ptz.cgi?action=getPresets&channel=${channel}`).catch(() => undefined),
      this.requestText('/cgi-bin/storage.cgi?action=getDeviceAllInfo').catch(() => undefined),
      this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=VideoInMode').catch(() => undefined),
    ]);
    const encodeValues = parseKeyValues(encodeText);
    const recordValues = parseKeyValues(recordText);
    const motionValues = parseKeyValues(motionText);
    const privacyValues = parseKeyValues(privacyText);
    const colorValues = parseKeyValues(colorText);
    const model = firstPlainValue(deviceTypeText) || this.config.model;
    const serialNumber = firstPlainValue(serialText) || this.config.uniqueId;
    const manufacturer = firstPlainValue(vendorText) || this.config.manufacturer || 'Amcrest';
    const connected = true;
    const deviceInfo = this.deviceInfo(connected, { manufacturer, model, serialNumber });
    const recordMode = valueBySuffix(recordValues, [`RecordMode[${channel}].Mode`, 'RecordMode.Mode', 'Mode']);
    const storage = this.storageFromText(storageText);
    const camera = this.camera(deviceInfo, connected, {
      isStreaming: booleanValue(valueBySuffix(encodeValues, [`Encode[${channel}].${streamFormat}[0].VideoEnable`, `${streamFormat}[0].VideoEnable`, 'VideoEnable'])) ?? true,
      isRecording: recordMode === 'Manual' || recordMode === '1',
      motionDetectionEnabled: booleanValue(valueBySuffix(motionValues, [`MotionDetect[${channel}].Enable`, 'MotionDetect.Enable', 'Enable'])),
      audioEnabled: booleanValue(valueBySuffix(encodeValues, [`Encode[${channel}].${streamFormat}[0].AudioEnable`, `${streamFormat}[0].AudioEnable`, 'AudioEnable'])),
      motionRecordingEnabled: booleanValue(valueBySuffix(motionValues, [`MotionDetect[${channel}].EventHandler.RecordEnable`, 'EventHandler.RecordEnable', 'RecordEnable'])),
      colorBw: this.colorModeFromValue(valueBySuffix(colorValues, [`VideoInMode[${channel}].Config[0]`, 'VideoInMode.Config[0]', 'Config[0]'])),
    });
    const currentSettings = {
      ...this.config.currentSettings,
      privacy_mode: booleanValue(valueBySuffix(privacyValues, [`LeLensMask[${channel}].Enable`, 'LeLensMask.Enable', 'Enable'])) ?? false,
    };
    const eventStates = {
      VideoMotion: this.eventTextIsOn(motionEventText),
      AudioMutation: this.eventTextIsOn(audioMutationEventText),
      AudioIntensity: this.eventTextIsOn(audioIntensityEventText),
      CrossLineDetection: this.eventTextIsOn(crosslineEventText),
    };
    const sensors = this.config.sensors || this.sensorsFromLive(this.presetCount(presetText), storage, connected);
    const binarySensors = this.config.binarySensors || this.binarySensorsFromEvents(eventStates, connected);
    const switches = this.config.switches || this.switchesFromSettings(currentSettings, connected);

    return this.normalizeSnapshot({
      deviceInfo,
      cameras: this.config.cameras || [camera],
      sensors,
      binarySensors,
      switches,
      events: this.config.events || this.eventsFromEventStates(eventStates),
      currentSettings,
      connected,
      updatedAt: new Date().toISOString(),
    });
  }

  private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IAmcrestSnapshot {
    const deviceInfo = this.deviceInfo(connectedArg);
    const currentSettings = this.config.currentSettings || this.config.snapshot?.currentSettings || {};
    return this.normalizeSnapshot({
      deviceInfo,
      cameras: this.config.cameras || this.config.snapshot?.cameras || [this.camera(deviceInfo, connectedArg)],
      sensors: this.config.sensors || this.config.snapshot?.sensors || this.sensorsFromConfig(connectedArg),
      binarySensors: this.config.binarySensors || this.config.snapshot?.binarySensors || this.binarySensorsFromEvents({}, connectedArg),
      switches: this.config.switches || this.config.snapshot?.switches || this.switchesFromSettings(currentSettings, connectedArg),
      events: this.config.events || this.config.snapshot?.events || [],
      currentSettings,
      connected: connectedArg,
      updatedAt: new Date().toISOString(),
      metadata: {
        ...this.config.snapshot?.metadata,
        lastLiveError: lastErrorArg,
      },
    });
  }

  private normalizeSnapshot(snapshotArg: IAmcrestSnapshot): IAmcrestSnapshot {
    const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
    const deviceInfo = {
      ...this.deviceInfo(connected),
      ...snapshotArg.deviceInfo,
      online: connected,
    };
    return {
      ...snapshotArg,
      deviceInfo,
      cameras: (snapshotArg.cameras || []).map((cameraArg) => this.normalizeCamera(cameraArg, deviceInfo, connected)),
      sensors: snapshotArg.sensors || [],
      binarySensors: snapshotArg.binarySensors || [],
      switches: snapshotArg.switches || [],
      events: snapshotArg.events || [],
      currentSettings: snapshotArg.currentSettings || {},
      connected,
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
    };
  }

  private normalizeCamera(cameraArg: IAmcrestCamera, deviceInfoArg: IAmcrestDeviceInfo, connectedArg: boolean): IAmcrestCamera {
    const subtype = cameraArg.subtype ?? amcrestResolutionSubtype[cameraArg.resolution || this.resolution()];
    const baseCamera = this.camera(deviceInfoArg, connectedArg, cameraArg);
    return {
      ...baseCamera,
      ...cameraArg,
      subtype,
      snapshotUrl: cameraArg.snapshotUrl || baseCamera.snapshotUrl,
      mjpegUrl: cameraArg.mjpegUrl || baseCamera.mjpegUrl,
      rtspUrl: cameraArg.rtspUrl || baseCamera.rtspUrl,
      available: connectedArg && cameraArg.available !== false,
    };
  }

  private camera(deviceInfoArg: IAmcrestDeviceInfo, connectedArg: boolean, overridesArg: Partial<IAmcrestCamera> = {}): IAmcrestCamera {
    const channel = overridesArg.channel ?? this.channel();
    const resolution = overridesArg.resolution ?? this.resolution();
    const subtype = overridesArg.subtype ?? amcrestResolutionSubtype[resolution];
    const name = overridesArg.name || `${deviceInfoArg.name || 'Amcrest'} Camera`;
    return {
      id: overridesArg.id || String(channel),
      name,
      channel,
      resolution,
      subtype,
      streamSource: overridesArg.streamSource || this.config.streamSource || 'snapshot',
      snapshotUrl: overridesArg.snapshotUrl || this.snapshotUrl(channel),
      mjpegUrl: overridesArg.mjpegUrl || this.mjpegUrl(channel, subtype),
      rtspUrl: overridesArg.rtspUrl || this.rtspUrl(channel, subtype),
      supportsPtz: overridesArg.supportsPtz ?? this.config.supportsPtz ?? true,
      isStreaming: overridesArg.isStreaming ?? true,
      isRecording: overridesArg.isRecording ?? false,
      motionDetectionEnabled: overridesArg.motionDetectionEnabled,
      audioEnabled: overridesArg.audioEnabled,
      motionRecordingEnabled: overridesArg.motionRecordingEnabled,
      colorBw: overridesArg.colorBw,
      attributes: overridesArg.attributes,
      available: connectedArg && overridesArg.available !== false,
    };
  }

  private deviceInfo(connectedArg: boolean, liveArg: Partial<IAmcrestDeviceInfo> = {}): IAmcrestDeviceInfo {
    const endpoint = this.endpoint();
    const serialNumber = liveArg.serialNumber || this.config.deviceInfo?.serialNumber || this.config.uniqueId;
    return {
      ...this.config.deviceInfo,
      ...liveArg,
      id: this.config.deviceInfo?.id || this.config.uniqueId || serialNumber || endpoint.host || 'manual-amcrest',
      name: this.config.deviceInfo?.name || this.config.name || endpoint.host || 'Amcrest Camera',
      manufacturer: liveArg.manufacturer || this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Amcrest',
      model: liveArg.model || this.config.deviceInfo?.model || this.config.model,
      serialNumber,
      host: this.config.deviceInfo?.host || endpoint.host,
      port: this.config.deviceInfo?.port || endpoint.port,
      protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
      rtspPort: this.config.deviceInfo?.rtspPort || this.config.rtspPort || amcrestDefaultRtspPort,
      url: this.config.deviceInfo?.url || this.baseUrl(),
      online: connectedArg,
    };
  }

  private sensorsFromLive(presetCountArg: number | undefined, storageArg: { usedPercent?: number; total?: string; used?: string } | undefined, connectedArg: boolean): IAmcrestSensor[] {
    const enabled = new Set(this.config.enabledSensors || []);
    const sensors: IAmcrestSensor[] = [];
    if (enabled.has('ptz_preset') || presetCountArg !== undefined) {
      sensors.push({ key: 'ptz_preset', name: 'PTZ Preset', value: presetCountArg ?? 'unknown', entityCategory: 'diagnostic', available: connectedArg });
    }
    if (enabled.has('sdcard') || storageArg?.usedPercent !== undefined) {
      sensors.push({
        key: 'sdcard',
        name: 'SD Used',
        value: storageArg?.usedPercent ?? 'unknown',
        unit: '%',
        entityCategory: 'diagnostic',
        available: connectedArg,
        attributes: {
          Total: storageArg?.total,
          Used: storageArg?.used,
        },
      });
    }
    return sensors;
  }

  private sensorsFromConfig(connectedArg: boolean): IAmcrestSensor[] {
    const enabled = new Set(this.config.enabledSensors || []);
    return amcrestSensorDescriptions
      .filter((descriptionArg) => enabled.has(descriptionArg.key))
      .map((descriptionArg) => ({
        key: descriptionArg.key,
        name: descriptionArg.name,
        value: 'unknown',
        unit: descriptionArg.unit,
        deviceClass: descriptionArg.deviceClass,
        entityCategory: descriptionArg.entityCategory,
        available: connectedArg,
      }));
  }

  private binarySensorsFromEvents(eventStatesArg: Record<string, boolean | undefined>, connectedArg: boolean): IAmcrestBinarySensor[] {
    const defaultKeys = ['online', 'motion_detected'];
    const enabled = new Set(this.config.enabledBinarySensors || this.config.snapshot?.binarySensors?.map((sensorArg) => sensorArg.key) || defaultKeys);
    return amcrestBinarySensorDescriptions
      .filter((descriptionArg) => enabled.has(descriptionArg.key))
      .map((descriptionArg) => {
        const isOnline = descriptionArg.key === 'online';
        const eventOn = descriptionArg.eventCodes?.some((codeArg) => eventStatesArg[codeArg] === true) ?? false;
        return {
          key: descriptionArg.key,
          name: descriptionArg.name,
          isOn: isOnline ? connectedArg : eventOn,
          deviceClass: descriptionArg.deviceClass,
          eventCodes: descriptionArg.eventCodes,
          shouldPoll: descriptionArg.shouldPoll,
          available: isOnline || connectedArg,
        };
      });
  }

  private switchesFromSettings(settingsArg: Record<string, unknown>, connectedArg: boolean): IAmcrestSwitch[] {
    const enabled = new Set(this.config.enabledSwitches || this.config.snapshot?.switches?.map((switchArg) => switchArg.key) || ['privacy_mode']);
    return amcrestSwitchDescriptions
      .filter((descriptionArg) => enabled.has(descriptionArg.key))
      .map((descriptionArg) => ({
        key: descriptionArg.key,
        name: descriptionArg.name,
        isOn: booleanValue(settingsArg[descriptionArg.key]) ?? false,
        command: descriptionArg.command,
        entityCategory: descriptionArg.entityCategory,
        available: connectedArg,
      }));
  }

  private eventsFromEventStates(eventStatesArg: Record<string, boolean | undefined>): IAmcrestEvent[] {
    return Object.entries(eventStatesArg)
      .filter(([, valueArg]) => valueArg !== undefined)
      .map(([codeArg, isOnArg]) => ({
        id: codeArg,
        name: codeArg,
        code: codeArg,
        isOn: isOnArg,
        state: isOnArg ? 'on' : 'off',
        updatedAt: new Date().toISOString(),
      }));
  }

  private async setBooleanConfig(kindArg: 'privacy_mode' | 'video' | 'audio' | 'motion_detection' | 'motion_recording', enabledArg: boolean, channelArg?: number): Promise<IAmcrestCommandResponse> {
    const channel = channelArg ?? this.channel();
    const subtype = this.subtype();
    const streamFormat = this.streamFormat(subtype);
    const value = String(enabledArg).toLowerCase();
    const field = kindArg === 'privacy_mode'
      ? `LeLensMask[${channel}].Enable`
      : kindArg === 'video'
        ? `Encode[${channel}].${streamFormat}[0].VideoEnable`
        : kindArg === 'audio'
          ? `Encode[${channel}].${streamFormat}[0].AudioEnable`
          : kindArg === 'motion_detection'
            ? `MotionDetect[${channel}].Enable`
            : `MotionDetect[${channel}].EventHandler.RecordEnable`;
    const command = this.httpCommand(kindArg, `/cgi-bin/configManager.cgi?action=setConfig&${field}=${value}`);
    return this.requestOk(command);
  }

  private async setRecordMode(enabledArg: boolean, channelArg?: number): Promise<IAmcrestCommandResponse> {
    const channel = channelArg ?? this.channel();
    return this.requestOk(this.httpCommand('recording', `/cgi-bin/configManager.cgi?action=setConfig&RecordMode[${channel}].Mode=${enabledArg ? 1 : 0}`));
  }

  private async setColorBw(colorBwArg: TAmcrestColorBw, channelArg?: number): Promise<IAmcrestCommandResponse> {
    const channel = channelArg ?? this.channel();
    const modeIndex = amcrestColorModes.indexOf(colorBwArg);
    return this.requestOk(this.httpCommand('color_bw', `/cgi-bin/configManager.cgi?action=setConfig&VideoInMode[${channel}].Config[0]=${modeIndex}`));
  }

  private async gotoPreset(presetArg: number, channelArg?: number): Promise<IAmcrestCommandResponse> {
    const channel = channelArg ?? this.channel();
    return this.requestOk(this.httpCommand('goto_preset', `/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${Math.round(presetArg)}&arg3=0`));
  }

  private async ptzControl(movementArg: TAmcrestPtzMovement, travelTimeArg = 0.2, channelArg?: number): Promise<IAmcrestCommandResponse[]> {
    const channel = channelArg ?? this.channel();
    const code = ptzCodes[movementArg];
    let arg1 = 0;
    let arg2 = 0;
    if (ptzMoveOneArg2.has(code)) {
      arg2 = 1;
    } else if (ptzMoveBothArgs.has(code)) {
      arg1 = 1;
      arg2 = 1;
    }
    const query = `channel=${channel}&code=${code}&arg1=${arg1}&arg2=${arg2}&arg3=0`;
    const start = await this.requestOk(this.httpCommand('ptz_start', `/cgi-bin/ptz.cgi?action=start&${query}`));
    await sleep(Math.max(0, Math.min(1, travelTimeArg)) * 1000);
    const stop = await this.requestOk(this.httpCommand('ptz_stop', `/cgi-bin/ptz.cgi?action=stop&${query}`));
    return [start, stop];
  }

  private async tour(startArg: boolean, channelArg?: number): Promise<IAmcrestCommandResponse> {
    const channel = channelArg ?? this.channel();
    return this.requestOk(this.httpCommand(startArg ? 'start_tour' : 'stop_tour', `/cgi-bin/ptz.cgi?action=${startArg ? 'start' : 'stop'}&channel=${channel}&code=StartTour&arg1=0&arg2=0&arg3=0`));
  }

  private async requestOk(commandArg: IAmcrestHttpCommand): Promise<IAmcrestCommandResponse> {
    const response = await this.request(commandArg.path);
    const responseText = await response.text();
    if (!this.commandSucceeded(responseText)) {
      throw new Error(`Amcrest ${commandArg.label} command did not return a successful response: ${responseText.slice(0, 200)}`);
    }
    return {
      ok: true,
      label: commandArg.label,
      method: commandArg.method,
      path: commandArg.path,
      status: response.status,
      responseText,
    };
  }

  private commandSucceeded(valueArg: string): boolean {
    const value = valueArg.trim().toLowerCase();
    if (!value) {
      return true;
    }
    if (value.includes('error') || value.includes('fail')) {
      return false;
    }
    if (value === 'false' || /result\s*=\s*false/.test(value)) {
      return false;
    }
    return true;
  }

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

  private async request(pathArg: string, initArg: RequestInit = {}, timeoutMsArg = this.config.timeoutMs || amcrestDefaultTimeoutMs): Promise<Response> {
    const baseUrl = this.baseUrl();
    if (!baseUrl) {
      throw new Error('Amcrest live HTTP client requires config.host or config.url.');
    }
    const url = `${baseUrl}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`;
    const headers = new Headers(initArg.headers);
    if (this.authScheme() === 'basic') {
      headers.set('authorization', this.basicAuthorization());
    }
    const response = await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers }, timeoutMsArg);
    if (response.status === 401 && this.authScheme() !== 'basic') {
      const challenge = response.headers.get('www-authenticate') || '';
      const retryHeaders = new Headers(initArg.headers);
      if (/digest/i.test(challenge)) {
        const requestUrl = new URL(url);
        retryHeaders.set('authorization', this.digestAuthorization(challenge, initArg.method || 'GET', `${requestUrl.pathname}${requestUrl.search}`));
        return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers: retryHeaders }, timeoutMsArg), pathArg);
      }
      if (/basic/i.test(challenge) || this.authScheme() === 'auto') {
        retryHeaders.set('authorization', this.basicAuthorization());
        return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers: retryHeaders }, timeoutMsArg), pathArg);
      }
    }
    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) {
        throw new AmcrestHttpError(responseArg.status, 'Amcrest authentication failed.');
      }
      throw new AmcrestHttpError(responseArg.status, `Amcrest request ${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 });
    } finally {
      clearTimeout(timeout);
    }
  }

  private digestAuthorization(challengeArg: string, methodArg: string, uriArg: string): string {
    const challenge = parseDigestChallenge(challengeArg);
    if (!challenge.realm || !challenge.nonce) {
      throw new Error('Amcrest digest authentication challenge is missing realm or nonce.');
    }
    const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
    if (algorithm !== 'MD5' && algorithm !== 'MD5-SESS') {
      throw new Error(`Amcrest 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 httpCommand(labelArg: string, pathArg: string): IAmcrestHttpCommand {
    return { label: labelArg, method: 'GET', path: pathArg, expect: 'ok' };
  }

  private snapshotHttpCommand(channelArg?: number): IAmcrestHttpCommand {
    return { label: 'snapshot', method: 'GET', path: `/cgi-bin/snapshot.cgi?channel=${channelArg ?? this.channel()}`, expect: 'image' };
  }

  private snapshotUrl(channelArg: number): string | undefined {
    const baseUrl = this.baseUrl();
    return baseUrl ? `${baseUrl}${this.snapshotHttpCommand(channelArg).path}` : undefined;
  }

  private mjpegUrl(channelArg: number, subtypeArg: 0 | 1): string | undefined {
    const baseUrl = this.baseUrl();
    return baseUrl ? `${baseUrl}/cgi-bin/mjpg/video.cgi?channel=${channelArg}&subtype=${subtypeArg}` : undefined;
  }

  private rtspUrl(channelArg: number, subtypeArg: 0 | 1): string | undefined {
    const endpoint = this.endpoint();
    if (!endpoint.host) {
      return undefined;
    }
    const credentials = this.rtspCredentials();
    return `rtsp://${credentials}${endpoint.host}:${this.config.rtspPort || amcrestDefaultRtspPort}/cam/realmonitor?channel=${channelArg + 1}&subtype=${subtypeArg}`;
  }

  private streamSourceUrl(cameraArg: IAmcrestCamera, streamSourceArg?: TAmcrestStreamSource): string | undefined {
    const streamSource = streamSourceArg || cameraArg.streamSource;
    if (streamSource === 'rtsp') {
      return cameraArg.rtspUrl;
    }
    if (streamSource === 'mjpeg') {
      return cameraArg.mjpegUrl;
    }
    return cameraArg.snapshotUrl;
  }

  private baseUrl(): string | undefined {
    if (this.config.url) {
      const url = safeUrl(this.config.url);
      if (url) {
        return `${url.protocol}//${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : amcrestDefaultPort)}`;
      }
    }
    const endpoint = this.endpoint();
    if (!endpoint.host) {
      return undefined;
    }
    return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || amcrestDefaultPort}`;
  }

  private endpoint(): { protocol: TAmcrestProtocol; host?: string; port: number } {
    const url = safeUrl(this.config.url || this.config.host);
    if (url) {
      return {
        protocol: url.protocol === 'https:' ? 'https' : 'http',
        host: url.hostname,
        port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : amcrestDefaultPort,
      };
    }
    return {
      protocol: this.config.protocol || 'http',
      host: this.config.host,
      port: this.config.port || amcrestDefaultPort,
    };
  }

  private rtspCredentials(): string {
    if (!this.config.username || this.config.password === undefined) {
      return '';
    }
    return `${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}@`;
  }

  private hasLiveTarget(): boolean {
    return Boolean(this.baseUrl());
  }

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

  private resolution(): TAmcrestResolution {
    return this.config.resolution || 'high';
  }

  private subtype(): 0 | 1 {
    return amcrestResolutionSubtype[this.resolution()];
  }

  private streamFormat(subtypeArg: 0 | 1): string {
    return `${amcrestSubtypeStream[subtypeArg]}Format`;
  }

  private channel(): number {
    return Number.isInteger(this.config.channel) && this.config.channel! >= 0 ? this.config.channel! : 0;
  }

  private requireEnabled(commandArg: IAmcrestClientCommand): boolean {
    if (typeof commandArg.enabled !== 'boolean') {
      throw new Error(`Amcrest ${commandArg.type} requires a boolean enabled value.`);
    }
    return commandArg.enabled;
  }

  private findCamera(snapshotArg: IAmcrestSnapshot, cameraIdArg?: string): IAmcrestCamera {
    const cameraId = cameraIdArg || '';
    const camera = snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.channel) === cameraId) || snapshotArg.cameras[0];
    if (!camera) {
      throw new Error('Amcrest camera command requires a configured or discovered camera.');
    }
    return camera;
  }

  private patchCachedCamera(valuesArg: Partial<IAmcrestCamera>): void {
    if (!this.snapshot) {
      return;
    }
    for (const camera of this.snapshot.cameras) {
      Object.assign(camera, valuesArg);
    }
  }

  private patchCachedSwitch(keyArg: string, isOnArg: boolean): void {
    if (!this.snapshot) {
      return;
    }
    this.snapshot.currentSettings[keyArg] = isOnArg;
    for (const switchArg of this.snapshot.switches) {
      if (switchArg.key === keyArg) {
        switchArg.isOn = isOnArg;
      }
    }
  }

  private colorModeFromValue(valueArg: unknown): TAmcrestColorBw | undefined {
    if (typeof valueArg === 'number') {
      return amcrestColorModes[valueArg];
    }
    if (typeof valueArg === 'string') {
      const numeric = Number(valueArg);
      if (Number.isInteger(numeric)) {
        return amcrestColorModes[numeric];
      }
      return amcrestColorModes.find((modeArg) => modeArg === valueArg.toLowerCase());
    }
    return undefined;
  }

  private eventTextIsOn(textArg: string | undefined): boolean | undefined {
    if (textArg === undefined) {
      return undefined;
    }
    const text = textArg.trim().toLowerCase();
    if (!text || text.includes('error') || text.includes('false')) {
      return false;
    }
    return /channels\s*\[\s*\d+\s*\]\s*=/.test(text) || /\bindex(?:es)?\s*\[\s*\d+\s*\]\s*=/.test(text) || text.includes('true');
  }

  private presetCount(textArg: string | undefined): number | undefined {
    if (textArg === undefined) {
      return undefined;
    }
    const matches = new Set<string>();
    for (const match of textArg.matchAll(/(?:presets?|Preset)\[(\d+)\]/g)) {
      matches.add(match[1]);
    }
    return matches.size;
  }

  private storageFromText(textArg: string | undefined): { usedPercent?: number; total?: string; used?: string } | undefined {
    if (!textArg) {
      return undefined;
    }
    const values = parseKeyValues(textArg);
    let totalBytes: number | undefined;
    let usedBytes: number | undefined;
    let usedPercent: number | undefined;
    for (const [key, value] of Object.entries(values)) {
      const lowerKey = key.toLowerCase();
      const number = numberValue(value);
      if (number === undefined) {
        continue;
      }
      if (lowerKey.includes('percent')) {
        usedPercent = number;
      } else if (lowerKey.includes('total') && (lowerKey.includes('byte') || lowerKey.includes('space'))) {
        totalBytes = number;
      } else if (lowerKey.includes('used') && (lowerKey.includes('byte') || lowerKey.includes('space'))) {
        usedBytes = number;
      }
    }
    if (usedPercent === undefined && totalBytes && usedBytes !== undefined) {
      usedPercent = Number(((usedBytes / totalBytes) * 100).toFixed(2));
    }
    if (usedPercent === undefined && totalBytes === undefined && usedBytes === undefined) {
      return undefined;
    }
    return {
      usedPercent,
      total: totalBytes === undefined ? undefined : formatBytes(totalBytes),
      used: usedBytes === undefined ? undefined : formatBytes(usedBytes),
    };
  }

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

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

const sleep = (msArg: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, msArg));

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 safeUrl = (valueArg: string | undefined): URL | undefined => {
  if (!valueArg) {
    return undefined;
  }
  try {
    return new URL(valueArg);
  } catch {
    return undefined;
  }
};

const parseKeyValues = (textArg: string | undefined): Record<string, string> => {
  const values: Record<string, string> = {};
  for (const line of (textArg || '').split(/\r?\n/)) {
    const [rawKey, ...rawValue] = line.split('=');
    if (!rawKey || !rawValue.length) {
      continue;
    }
    const key = rawKey.trim().replace(/^table\./, '');
    values[key] = rawValue.join('=').trim();
  }
  return values;
};

const firstPlainValue = (textArg: string | undefined): string | undefined => {
  const line = (textArg || '').split(/\r?\n/).map((entryArg) => entryArg.trim()).find(Boolean);
  if (!line) {
    return undefined;
  }
  const [, ...rest] = line.split('=');
  return rest.length ? rest.join('=').trim() || undefined : line;
};

const valueBySuffix = (valuesArg: Record<string, string>, suffixesArg: string[]): string | undefined => {
  for (const suffix of suffixesArg) {
    const exact = valuesArg[suffix];
    if (exact !== undefined) {
      return exact;
    }
    const entry = Object.entries(valuesArg).find(([key]) => key.endsWith(suffix));
    if (entry) {
      return entry[1];
    }
  }
  return undefined;
};

const booleanValue = (valueArg: unknown): boolean | undefined => {
  if (typeof valueArg === 'boolean') {
    return valueArg;
  }
  if (typeof valueArg === 'number') {
    return valueArg !== 0;
  }
  if (typeof valueArg === 'string') {
    if (['true', 'yes', 'on', '1', 'manual'].includes(valueArg.toLowerCase())) {
      return true;
    }
    if (['false', 'no', 'off', '0', 'automatic'].includes(valueArg.toLowerCase())) {
      return false;
    }
  }
  return undefined;
};

const 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;
};

const formatBytes = (valueArg: number): string => {
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let value = valueArg;
  let unitIndex = 0;
  while (value >= 1024 && unitIndex < units.length - 1) {
    value /= 1024;
    unitIndex += 1;
  }
  return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
};
