import * as plugins from '../../plugins.js';
import type {
  IAxisApiDescription,
  IAxisApiDiscoveryResponse,
  IAxisBasicDeviceInfoResponse,
  IAxisBinarySensor,
  IAxisCameraStream,
  IAxisConfig,
  IAxisDeviceInfo,
  IAxisLight,
  IAxisLightInformationResponse,
  IAxisParamTree,
  IAxisPort,
  IAxisPortManagementItem,
  IAxisPortsResponse,
  IAxisPtzCommand,
  IAxisRelay,
  IAxisSensor,
  IAxisSnapshot,
  IAxisSnapshotImage,
  TAxisAuthScheme,
  TAxisPortDirection,
  TAxisPortState,
  TAxisProtocol,
} from './axis.types.js';

const defaultProtocol: TAxisProtocol = 'https';
const defaultPort = 443;
const defaultTimeoutMs = 10000;
const defaultStreamProfile = 'No stream profile';
const defaultVideoSource = 'No video source';
const axisContext = 'smarthome.exchange';

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

export class AxisClient {
  private snapshot?: IAxisSnapshot;

  constructor(private readonly config: IAxisConfig) {}

  public async getSnapshot(): Promise<IAxisSnapshot> {
    if (this.config.snapshot) {
      this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
      return this.snapshot;
    }

    if (this.hasManualSnapshotData()) {
      this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true));
      return this.snapshot;
    }

    if (!this.config.host) {
      this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(false));
      return this.snapshot;
    }

    this.snapshot = this.normalizeSnapshot(await this.fetchSnapshot());
    return this.snapshot;
  }

  public async getCameraSnapshot(cameraIdArg?: string | number): Promise<IAxisSnapshotImage> {
    if (!this.config.host) {
      throw new Error('Axis host is required to fetch a camera snapshot image.');
    }
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, cameraIdArg);
    const path = `/axis-cgi/jpg/image.cgi${this.cameraQuery(camera, true)}`;
    const response = await this.request(path, { method: 'GET' });
    return {
      contentType: response.headers.get('content-type') || 'image/jpeg',
      data: new Uint8Array(await response.arrayBuffer()),
    };
  }

  public async setRelayState(portIdArg: string, stateArg: 'open' | 'closed'): Promise<void> {
    if (!this.config.host) {
      throw new Error('Axis host is required for relay commands.');
    }
    const snapshot = await this.getSnapshot().catch(() => undefined);
    const hasPortManagement = snapshot?.apiDiscovery.some((apiArg) => apiArg.id === 'io-port-management');
    if (hasPortManagement !== false) {
      try {
        await this.postAxisJson('/axis-cgi/io/portmanagement.cgi', {
          apiVersion: '1.0',
          context: axisContext,
          method: 'setPorts',
          params: { ports: [{ port: portIdArg, state: stateArg }] },
        });
        this.patchCachedRelay(portIdArg, stateArg);
        return;
      } catch (errorArg) {
        if (hasPortManagement) {
          throw errorArg;
        }
      }
    }

    await this.legacyPortAction(portIdArg, stateArg);
    this.patchCachedRelay(portIdArg, stateArg);
  }

  public async ptzControl(commandArg: IAxisPtzCommand): Promise<void> {
    if (!this.config.host) {
      throw new Error('Axis host is required for PTZ commands.');
    }
    const data = this.ptzCommandData(commandArg);
    if (!Object.keys(data).some((keyArg) => keyArg !== 'camera')) {
      throw new Error('Axis PTZ command requires at least one movement, preset, focus, iris, brightness, or auxiliary parameter.');
    }
    await this.request('/axis-cgi/com/ptz.cgi', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams(data).toString(),
    });
  }

  public streamSource(cameraArg: IAxisCameraStream): string | undefined {
    return cameraArg.rtspUrl || cameraArg.mjpegUrl || cameraArg.snapshotUrl;
  }

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

  private async fetchSnapshot(): Promise<IAxisSnapshot> {
    const updatedAt = new Date().toISOString();
    const apiDiscovery = await this.fetchApiDiscovery().catch(() => []);
    const basicInfo = await this.fetchBasicDeviceInfo().catch(() => undefined);
    const [propertiesParams, imageParams, ioPortParams, ptzParams] = await Promise.all([
      this.fetchParams('Properties').catch(() => undefined),
      this.fetchParams('Image').catch(() => undefined),
      this.fetchParams('IOPort').catch(() => undefined),
      this.fetchParams('PTZ').catch(() => undefined),
    ]);
    const deviceInfo = this.deviceInfoFromResponses(basicInfo, propertiesParams);
    const ports = await this.fetchPorts(apiDiscovery, ioPortParams).catch(() => this.portsFromParams(ioPortParams));
    const relays = ports.filter((portArg): portArg is IAxisRelay => portArg.direction === 'output');
    const binarySensors = this.binarySensorsFromPorts(ports);
    const lights = await this.fetchLights(apiDiscovery, propertiesParams).catch(() => []);
    const cameras = this.camerasFromParams(imageParams, propertiesParams, ptzParams);
    const sensors = this.sensorsFromDeviceInfo(deviceInfo, updatedAt);

    return {
      deviceInfo,
      cameras,
      sensors,
      binarySensors,
      events: this.config.events || [],
      ports,
      relays,
      switches: relays,
      lights,
      apiDiscovery,
      params: this.mergeParams(propertiesParams, imageParams, ioPortParams, ptzParams),
      connected: true,
      updatedAt,
    };
  }

  private async fetchApiDiscovery(): Promise<IAxisApiDescription[]> {
    const data = await this.postAxisJson<IAxisApiDiscoveryResponse>('/axis-cgi/apidiscovery.cgi', {
      apiVersion: '1.0',
      context: axisContext,
      method: 'getApiList',
    });
    return data.data?.apiList || [];
  }

  private async fetchBasicDeviceInfo(): Promise<Record<string, string | undefined> | undefined> {
    const data = await this.postAxisJson<IAxisBasicDeviceInfoResponse>('/axis-cgi/basicdeviceinfo.cgi', {
      apiVersion: '1.1',
      context: axisContext,
      method: 'getAllProperties',
    });
    return data.data?.propertyList;
  }

  private async fetchParams(groupArg: 'Properties' | 'Image' | 'IOPort' | 'PTZ'): Promise<IAxisParamTree> {
    const query = new URLSearchParams({ action: 'list', group: `root.${groupArg}` }).toString();
    const text = await this.requestText(`/axis-cgi/param.cgi?${query}`, { method: 'GET' });
    return this.paramsToTree(text);
  }

  private async fetchPorts(apiDiscoveryArg: IAxisApiDescription[], ioPortParamsArg: IAxisParamTree | undefined): Promise<IAxisPort[]> {
    const supportsPortManagement = apiDiscoveryArg.some((apiArg) => apiArg.id === 'io-port-management');
    if (!supportsPortManagement) {
      return this.portsFromParams(ioPortParamsArg);
    }
    const data = await this.postAxisJson<IAxisPortsResponse>('/axis-cgi/io/portmanagement.cgi', {
      apiVersion: '1.0',
      context: axisContext,
      method: 'getPorts',
    });
    return (data.data?.items || []).map((portArg) => this.portFromPortManagement(portArg));
  }

  private async fetchLights(apiDiscoveryArg: IAxisApiDescription[], propertiesParamsArg: IAxisParamTree | undefined): Promise<IAxisLight[]> {
    const supportsLightControl = apiDiscoveryArg.some((apiArg) => apiArg.id === 'light-control') || this.booleanAt(this.group(propertiesParamsArg, 'Properties'), ['LightControl', 'LightControl2']) || this.booleanAt(this.group(propertiesParamsArg, 'Properties'), ['LightControl', 'LightControlAvailable']);
    if (!supportsLightControl) {
      return [];
    }
    const data = await this.postAxisJson<IAxisLightInformationResponse>('/axis-cgi/lightcontrol.cgi', {
      apiVersion: '1.1',
      context: axisContext,
      method: 'getLightInformation',
    });
    return (data.data?.items || []).map((itemArg) => ({
      id: itemArg.lightID || 'light',
      name: itemArg.lightType ? `${itemArg.lightType} light` : itemArg.lightID,
      enabled: itemArg.enabled,
      isOn: itemArg.lightState,
      lightType: itemArg.lightType,
      numberOfLeds: itemArg.nrOfLEDs,
      available: !itemArg.error,
      attributes: {
        automaticAngleOfIlluminationMode: itemArg.automaticAngleOfIlluminationMode,
        automaticIntensityMode: itemArg.automaticIntensityMode,
        synchronizeDayNightMode: itemArg.synchronizeDayNightMode,
        errorInfo: itemArg.errorInfo,
      },
    }));
  }

  private snapshotFromConfig(connectedArg: boolean): IAxisSnapshot {
    const deviceInfo = {
      ...this.deviceInfoFromConfig(),
      online: connectedArg,
    };
    const ports = this.config.ports || [];
    const relays = [...(this.config.relays || []), ...(this.config.switches || [])];
    const allPorts = this.uniquePorts([...ports, ...relays]);
    return {
      deviceInfo,
      cameras: this.config.cameras || [],
      sensors: this.config.sensors || [],
      binarySensors: this.config.binarySensors || this.binarySensorsFromPorts(allPorts),
      events: this.config.events || [],
      ports: allPorts,
      relays,
      switches: relays,
      lights: this.config.lights || [],
      apiDiscovery: this.config.apiDiscovery || [],
      params: this.config.params,
      connected: connectedArg,
      updatedAt: new Date().toISOString(),
    };
  }

  private normalizeSnapshot(snapshotArg: IAxisSnapshot): IAxisSnapshot {
    const deviceInfo = {
      ...this.deviceInfoFromConfig(),
      ...snapshotArg.deviceInfo,
    };
    const connected = snapshotArg.connected && deviceInfo.online !== false;
    deviceInfo.online = connected;
    const ports = this.uniquePorts(snapshotArg.ports || []);
    const relays = this.uniquePorts([...(snapshotArg.relays || []), ...(snapshotArg.switches || []), ...ports.filter((portArg) => portArg.direction === 'output')]) as IAxisRelay[];
    return {
      ...snapshotArg,
      deviceInfo,
      cameras: this.normalizeCameras(snapshotArg.cameras || []),
      sensors: snapshotArg.sensors || [],
      binarySensors: snapshotArg.binarySensors || [],
      events: snapshotArg.events || [],
      ports,
      relays,
      switches: relays,
      lights: snapshotArg.lights || [],
      apiDiscovery: snapshotArg.apiDiscovery || [],
      connected,
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
    };
  }

  private normalizeCameras(camerasArg: IAxisCameraStream[]): IAxisCameraStream[] {
    return camerasArg.map((cameraArg, indexArg) => {
      const id = cameraArg.id || String(indexArg + 1);
      const streamCamera = { ...cameraArg, id, videoSource: cameraArg.videoSource || id };
      return {
        ...streamCamera,
        name: streamCamera.name || `Camera ${id}`,
        enabled: streamCamera.enabled !== false,
        snapshotUrl: streamCamera.snapshotUrl || this.cameraUrl('/axis-cgi/jpg/image.cgi', streamCamera, true),
        mjpegUrl: streamCamera.mjpegUrl || this.cameraUrl('/axis-cgi/mjpg/video.cgi', streamCamera, false),
        rtspUrl: streamCamera.rtspUrl || this.rtspUrl(streamCamera),
      };
    });
  }

  private deviceInfoFromResponses(basicInfoArg: Record<string, string | undefined> | undefined, propertiesParamsArg: IAxisParamTree | undefined): IAxisDeviceInfo {
    const properties = this.group(propertiesParamsArg, 'Properties');
    return {
      ...this.deviceInfoFromConfig(),
      serialNumber: basicInfoArg?.SerialNumber || this.stringAt(properties, ['System', 'SerialNumber']) || this.config.deviceInfo?.serialNumber,
      macAddress: normalizeMac(basicInfoArg?.SerialNumber || this.stringAt(properties, ['System', 'SerialNumber']) || this.config.deviceInfo?.macAddress),
      name: this.config.name || this.config.deviceInfo?.name || basicInfoArg?.ProdShortName || basicInfoArg?.ProdNbr || this.config.host || 'Axis device',
      model: this.config.model || this.config.deviceInfo?.model || basicInfoArg?.ProdNbr || basicInfoArg?.ProdShortName,
      productNumber: basicInfoArg?.ProdNbr || this.config.deviceInfo?.productNumber,
      productType: basicInfoArg?.ProdType || this.config.deviceInfo?.productType,
      firmwareVersion: basicInfoArg?.Version || this.stringAt(properties, ['Firmware', 'Version']) || this.config.deviceInfo?.firmwareVersion,
      hardwareId: basicInfoArg?.HardwareID || this.config.deviceInfo?.hardwareId,
      online: true,
    };
  }

  private deviceInfoFromConfig(): IAxisDeviceInfo {
    const mac = normalizeMac(this.config.deviceInfo?.macAddress || this.config.deviceInfo?.serialNumber || this.config.uniqueId);
    return {
      ...this.config.deviceInfo,
      id: this.config.deviceInfo?.id || this.config.uniqueId || mac || this.config.host,
      serialNumber: this.config.deviceInfo?.serialNumber || this.config.uniqueId,
      macAddress: mac || this.config.deviceInfo?.macAddress,
      name: this.config.deviceInfo?.name || this.config.name || this.config.host || 'Axis device',
      manufacturer: this.config.deviceInfo?.manufacturer || 'Axis Communications AB',
      model: this.config.deviceInfo?.model || this.config.model,
      host: this.config.deviceInfo?.host || this.config.host,
      port: this.config.deviceInfo?.port || this.config.port || (this.protocol() === 'https' ? defaultPort : 80),
      protocol: this.config.deviceInfo?.protocol || this.protocol(),
    };
  }

  private camerasFromParams(imageParamsArg: IAxisParamTree | undefined, propertiesParamsArg: IAxisParamTree | undefined, ptzParamsArg: IAxisParamTree | undefined): IAxisCameraStream[] {
    if (this.config.cameras?.length) {
      return this.config.cameras;
    }
    const imageGroup = this.group(imageParamsArg, 'Image');
    const propertyGroup = this.group(propertiesParamsArg, 'Properties');
    const imageFormats = splitCsv(this.stringAt(propertyGroup, ['Image', 'Format']) || 'jpeg,mjpeg,h264');
    const supportsPtz = this.booleanAt(propertyGroup, ['PTZ', 'PTZ']) || Boolean(this.group(ptzParamsArg, 'PTZ'));
    const cameras: IAxisCameraStream[] = [];
    for (const [key, value] of Object.entries(imageGroup || {})) {
      if (!/^I\d+$/.test(key)) {
        continue;
      }
      const raw = record(value);
      if (!raw) {
        continue;
      }
      const source = String(Number(key.slice(1)) + 1);
      const appearance = record(raw.Appearance);
      cameras.push({
        id: source,
        name: stringValue(raw.Name) || `Camera ${source}`,
        enabled: booleanValue(raw.Enabled) ?? true,
        videoSource: source,
        streamProfile: this.config.streamProfile,
        resolution: stringValue(appearance?.Resolution),
        imageFormats,
        supportsPtz,
      });
    }
    if (!cameras.length && imageFormats.length) {
      cameras.push({
        id: String(this.config.videoSource || 1),
        name: this.config.name || this.config.host || 'Axis Camera',
        enabled: true,
        videoSource: this.config.videoSource || 1,
        streamProfile: this.config.streamProfile,
        imageFormats,
        supportsPtz,
      });
    }
    return cameras;
  }

  private portsFromParams(ioPortParamsArg: IAxisParamTree | undefined): IAxisPort[] {
    const ioPortGroup = this.group(ioPortParamsArg, 'IOPort');
    const ports: IAxisPort[] = [];
    for (const [key, value] of Object.entries(ioPortGroup || {})) {
      if (!/^I?\d+$/.test(key)) {
        continue;
      }
      const raw = record(value);
      if (!raw) {
        continue;
      }
      const id = key.replace(/^I/, '');
      const direction = portDirection(stringValue(raw.Direction));
      const input = record(raw.Input);
      const output = record(raw.Output);
      ports.push({
        id,
        name: direction === 'input' ? stringValue(input?.Name) : stringValue(output?.Name) || stringValue(raw.Usage) || `Port ${id}`,
        usage: stringValue(raw.Usage),
        direction,
        state: 'unknown',
        normalState: direction === 'input' ? portState(stringValue(input?.Trig)) : portState(stringValue(output?.Active)),
        configurable: booleanValue(raw.Configurable),
        available: true,
      });
    }
    return ports;
  }

  private binarySensorsFromPorts(portsArg: IAxisPort[]): IAxisBinarySensor[] {
    return portsArg.filter((portArg) => portArg.direction === 'input').map((portArg) => ({
      id: `port_${portArg.id}`,
      name: portArg.name || `Input ${portArg.id}`,
      isOn: portArg.state !== 'unknown' && portArg.normalState !== undefined ? portArg.state !== portArg.normalState : false,
      deviceClass: 'connectivity',
      source: portArg.id,
      available: portArg.available !== false,
      attributes: {
        portId: portArg.id,
        usage: portArg.usage,
        state: portArg.state,
        normalState: portArg.normalState,
      },
    }));
  }

  private sensorsFromDeviceInfo(deviceInfoArg: IAxisDeviceInfo, updatedAtArg: string): IAxisSensor[] {
    void updatedAtArg;
    const sensors: IAxisSensor[] = [];
    if (deviceInfoArg.firmwareVersion) {
      sensors.push({ id: 'firmware_version', name: 'Firmware version', value: deviceInfoArg.firmwareVersion, category: 'diagnostic', available: deviceInfoArg.online !== false });
    }
    if (deviceInfoArg.productType) {
      sensors.push({ id: 'product_type', name: 'Product type', value: deviceInfoArg.productType, category: 'diagnostic', available: deviceInfoArg.online !== false });
    }
    return [...sensors, ...(this.config.sensors || [])];
  }

  private portFromPortManagement(portArg: IAxisPortManagementItem): IAxisPort {
    return {
      id: portArg.port,
      name: portArg.name || portArg.usage || `Port ${portArg.port}`,
      usage: portArg.usage,
      direction: portDirection(portArg.direction),
      state: portState(portArg.state),
      normalState: portState(portArg.normalState),
      configurable: portArg.configurable,
      readonly: portArg.readonly,
      available: true,
    };
  }

  private async postAxisJson<TResponse = Record<string, unknown>>(pathArg: string, payloadArg: Record<string, unknown>): Promise<TResponse> {
    const response = await this.request(pathArg, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(payloadArg),
    });
    const text = await response.text();
    const data = text ? JSON.parse(text) as TResponse & { error?: { code?: number; message?: string } } : {} as TResponse & { error?: { code?: number; message?: string } };
    if (data.error) {
      throw new Error(`Axis VAPIX ${String(payloadArg.method || pathArg)} failed${typeof data.error.code === 'number' ? ` (${data.error.code})` : ''}: ${data.error.message || 'Unknown error'}`);
    }
    return data as TResponse;
  }

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

  private async request(pathArg: string, initArg: RequestInit): Promise<Response> {
    const url = `${this.baseUrl()}${pathArg}`;
    const headers = new Headers(initArg.headers);
    if (this.authScheme() === 'basic') {
      headers.set('authorization', this.basicAuthorization());
    }

    const response = await this.fetchWithTimeout(url, { ...initArg, headers });
    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)) {
        retryHeaders.set('authorization', this.digestAuthorization(challenge, initArg.method || 'GET', new URL(url).pathname + new URL(url).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 AxisHttpError(responseArg.status, `Axis request ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
    }
    return responseArg;
  }

  private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
    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('Axis digest authentication challenge is missing realm or nonce.');
    }
    const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
    if (algorithm !== 'MD5' && algorithm !== 'MD5-SESS') {
      throw new Error(`Axis 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 legacyPortAction(portIdArg: string, stateArg: 'open' | 'closed'): Promise<Response> {
    const numericPort = Number(portIdArg);
    const actionPort = Number.isFinite(numericPort) ? String(numericPort + 1) : portIdArg;
    const action = stateArg === 'closed' ? '/' : '\\';
    const query = new URLSearchParams({ action: `${actionPort}:${action}` }).toString();
    return this.request(`/axis-cgi/io/port.cgi?${query}`, { method: 'GET' });
  }

  private ptzCommandData(commandArg: IAxisPtzCommand): Record<string, string> {
    const data: Record<string, string> = {};
    this.setNumber(data, 'camera', commandArg.camera, 1, 9999);
    this.setNumber(data, 'pan', commandArg.pan, -180, 180);
    this.setNumber(data, 'tilt', commandArg.tilt, -180, 180);
    this.setNumber(data, 'zoom', commandArg.zoom, 1, 9999);
    this.setNumber(data, 'focus', commandArg.focus, 1, 9999);
    this.setNumber(data, 'iris', commandArg.iris, 1, 9999);
    this.setNumber(data, 'brightness', commandArg.brightness, 1, 9999);
    this.setNumber(data, 'rpan', commandArg.rpan, -360, 360);
    this.setNumber(data, 'rtilt', commandArg.rtilt, -360, 360);
    this.setNumber(data, 'rzoom', commandArg.rzoom, -9999, 9999);
    this.setNumber(data, 'rfocus', commandArg.rfocus, -9999, 9999);
    this.setNumber(data, 'riris', commandArg.riris, -9999, 9999);
    this.setNumber(data, 'rbrightness', commandArg.rbrightness, -9999, 9999);
    this.setNumber(data, 'continuouszoommove', commandArg.continuouszoommove, -100, 100);
    this.setNumber(data, 'continuousfocusmove', commandArg.continuousfocusmove, -100, 100);
    this.setNumber(data, 'continuousirismove', commandArg.continuousirismove, -100, 100);
    this.setNumber(data, 'continuousbrightnessmove', commandArg.continuousbrightnessmove, -100, 100);
    this.setNumber(data, 'speed', commandArg.speed, 1, 100);
    this.setNumber(data, 'imagewidth', commandArg.imagewidth, 1, 100000);
    this.setNumber(data, 'imageheight', commandArg.imageheight, 1, 100000);
    if (commandArg.move) {
      data.move = commandArg.move;
    }
    if (commandArg.autofocus !== undefined) {
      data.autofocus = commandArg.autofocus ? 'on' : 'off';
    }
    if (commandArg.autoiris !== undefined) {
      data.autoiris = commandArg.autoiris ? 'on' : 'off';
    }
    if (commandArg.backlight !== undefined) {
      data.backlight = commandArg.backlight ? 'on' : 'off';
    }
    if (commandArg.imagerotation) {
      data.imagerotation = commandArg.imagerotation;
    }
    if (commandArg.ircutfilter) {
      data.ircutfilter = commandArg.ircutfilter;
    }
    if (commandArg.continuouspantiltmove) {
      data.continuouspantiltmove = `${clamp(commandArg.continuouspantiltmove[0], -100, 100)},${clamp(commandArg.continuouspantiltmove[1], -100, 100)}`;
    }
    if (commandArg.center) {
      data.center = `${Math.round(commandArg.center[0])},${Math.round(commandArg.center[1])}`;
    }
    if (commandArg.areazoom) {
      data.areazoom = `${Math.round(commandArg.areazoom[0])},${Math.round(commandArg.areazoom[1])},${Math.max(1, Math.round(commandArg.areazoom[2]))}`;
    }
    for (const [key, value] of Object.entries({
      auxiliary: commandArg.auxiliary,
      gotoserverpresetname: commandArg.gotoserverpresetname,
      gotoserverpresetno: commandArg.gotoserverpresetno,
      gotodevicepreset: commandArg.gotodevicepreset,
    })) {
      if (value !== undefined && value !== '') {
        data[key] = String(value);
      }
    }
    return data;
  }

  private setNumber(dataArg: Record<string, string>, keyArg: string, valueArg: unknown, minArg: number, maxArg: number): void {
    if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
      dataArg[keyArg] = String(clamp(valueArg, minArg, maxArg));
    } else if (keyArg === 'camera' && typeof valueArg === 'string' && valueArg.trim()) {
      dataArg[keyArg] = valueArg.trim();
    }
  }

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

  private cameraUrl(pathArg: string, cameraArg: IAxisCameraStream, skipStreamProfileArg: boolean): string | undefined {
    if (!this.config.host) {
      return undefined;
    }
    return `${this.baseUrl()}${pathArg}${this.cameraQuery(cameraArg, skipStreamProfileArg)}`;
  }

  private rtspUrl(cameraArg: IAxisCameraStream): string | undefined {
    if (!this.config.host) {
      return undefined;
    }
    const params = new URLSearchParams();
    params.set('videocodec', 'h264');
    if (cameraArg.streamProfile && cameraArg.streamProfile !== defaultStreamProfile) {
      params.set('streamprofile', cameraArg.streamProfile);
    }
    const videoSource = cameraArg.videoSource || this.config.videoSource;
    if (videoSource !== undefined && String(videoSource) !== defaultVideoSource) {
      params.set('camera', String(videoSource));
    }
    const query = params.toString();
    return `rtsp://${this.config.host}/axis-media/media.amp${query ? `?${query}` : ''}`;
  }

  private cameraQuery(cameraArg: IAxisCameraStream, skipStreamProfileArg: boolean): string {
    const params = new URLSearchParams();
    if (!skipStreamProfileArg && cameraArg.streamProfile && cameraArg.streamProfile !== defaultStreamProfile) {
      params.set('streamprofile', cameraArg.streamProfile);
    }
    const videoSource = cameraArg.videoSource || this.config.videoSource;
    if (videoSource !== undefined && String(videoSource) !== defaultVideoSource) {
      params.set('camera', String(videoSource));
    }
    const query = params.toString();
    return query ? `?${query}` : '';
  }

  private paramsToTree(paramsArg: string): IAxisParamTree {
    const tree: IAxisParamTree = {};
    for (const line of paramsArg.split(/\r?\n/)) {
      const [path, ...rest] = line.split('=');
      if (!path || !rest.length) {
        continue;
      }
      populatePath(tree, path.split('.'), convertParamValue(rest.join('=')));
    }
    return tree;
  }

  private group(paramsArg: IAxisParamTree | undefined, groupArg: string): Record<string, unknown> | undefined {
    return record(record(paramsArg?.root)?.[groupArg]);
  }

  private stringAt(recordArg: Record<string, unknown> | undefined, pathArg: string[]): string | undefined {
    let current: unknown = recordArg;
    for (const key of pathArg) {
      current = record(current)?.[key];
    }
    return stringValue(current);
  }

  private booleanAt(recordArg: Record<string, unknown> | undefined, pathArg: string[]): boolean | undefined {
    let current: unknown = recordArg;
    for (const key of pathArg) {
      current = record(current)?.[key];
    }
    return booleanValue(current);
  }

  private mergeParams(...paramsArgs: Array<IAxisParamTree | undefined>): IAxisParamTree | undefined {
    const root: Record<string, unknown> = {};
    for (const params of paramsArgs) {
      const paramsRoot = record(params?.root);
      if (paramsRoot) {
        Object.assign(root, paramsRoot);
      }
    }
    return Object.keys(root).length ? { root } : undefined;
  }

  private uniquePorts(portsArg: IAxisPort[]): IAxisPort[] {
    const ports = new Map<string, IAxisPort>();
    for (const port of portsArg) {
      ports.set(port.id, { ...ports.get(port.id), ...port });
    }
    return [...ports.values()];
  }

  private hasManualSnapshotData(): boolean {
    return Boolean(this.config.deviceInfo || this.config.cameras?.length || this.config.ports?.length || this.config.relays?.length || this.config.switches?.length || this.config.sensors?.length || this.config.binarySensors?.length || this.config.events?.length || this.config.lights?.length || this.config.apiDiscovery?.length);
  }

  private patchCachedRelay(portIdArg: string, stateArg: 'open' | 'closed'): void {
    if (!this.snapshot) {
      return;
    }
    for (const collection of [this.snapshot.ports, this.snapshot.relays, this.snapshot.switches]) {
      for (const port of collection) {
        if (port.id === portIdArg) {
          port.state = stateArg;
        }
      }
    }
  }

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

  private protocol(): TAxisProtocol {
    return this.config.protocol || defaultProtocol;
  }

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

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

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

const clamp = (valueArg: number, minArg: number, maxArg: number): number => Math.max(minArg, Math.min(maxArg, Math.round(valueArg)));

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

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

const portDirection = (valueArg: unknown): TAxisPortDirection => {
  return valueArg === 'input' || valueArg === 'output' ? valueArg : 'unknown';
};

const portState = (valueArg: unknown): TAxisPortState => {
  return valueArg === 'open' || valueArg === 'closed' ? valueArg : 'unknown';
};

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

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

const record = (valueArg: unknown): Record<string, unknown> | undefined => {
  return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
};

const convertParamValue = (valueArg: string): string | number | boolean => {
  if (valueArg === 'true' || valueArg === 'yes') {
    return true;
  }
  if (valueArg === 'false' || valueArg === 'no') {
    return false;
  }
  if (/^-?\d+$/.test(valueArg)) {
    return Number(valueArg);
  }
  return valueArg;
};

const populatePath = (storeArg: Record<string, unknown>, pathArg: string[], valueArg: unknown): void => {
  const [head, ...tail] = pathArg;
  if (!head) {
    return;
  }
  if (!tail.length) {
    storeArg[head] = valueArg;
    return;
  }
  const next = record(storeArg[head]) || {};
  storeArg[head] = next;
  populatePath(next, tail, valueArg);
};
