import * as plugins from '../../plugins.js';
import type {
  IMotionEyeCamera,
  IMotionEyeClientCommand,
  IMotionEyeCommandResponse,
  IMotionEyeConfig,
  IMotionEyeDeviceInfo,
  IMotionEyeRawCamera,
  IMotionEyeSensor,
  IMotionEyeSnapshot,
  IMotionEyeSnapshotImage,
  IMotionEyeSwitch,
  TMotionEyeMediaKind,
  TMotionEyeProtocol,
} from './motioneye.types.js';
import {
  motionEyeDefaultAdminUsername,
  motionEyeDefaultPort,
  motionEyeDefaultSurveillanceUsername,
  motionEyeDefaultTimeoutMs,
  motionEyeSwitchDescriptions,
} from './motioneye.types.js';

const signatureRegex = /[^a-zA-Z0-9/?_.=&{}\[\]":, -]/g;

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

export class MotionEyeClient {
  private snapshot?: IMotionEyeSnapshot;

  constructor(private readonly config: IMotionEyeConfig) {}

  public async getSnapshot(forceRefreshArg = false): Promise<IMotionEyeSnapshot> {
    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.requestJson('/login');
  }

  public async execute(commandArg: IMotionEyeClientCommand): 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,
        numericId: camera.numericId,
        streamSource: camera.mjpegUrl,
        mjpegUrl: camera.mjpegUrl,
        stillImageUrl: camera.snapshotUrl,
        snapshotUrl: camera.snapshotUrl,
        streamingAuthMode: camera.streamingAuthMode,
        verified: false,
      };
    }
    if (commandArg.type === 'snapshot_image') {
      if (commandArg.filename) {
        throw new Error('motionEye snapshot file writes are not implemented; request data as base64 without data.filename.');
      }
      const image = await this.getSnapshotImage(commandArg.cameraId);
      return {
        contentType: image.contentType,
        dataBase64: Buffer.from(image.data).toString('base64'),
      };
    }
    if (commandArg.type === 'action') {
      if (!commandArg.action) {
        throw new Error('motionEye action command requires a non-empty action.');
      }
      const response = await this.action(commandArg.cameraId, commandArg.action);
      return { ok: true, command: commandArg.type, action: commandArg.action, response };
    }
    if (commandArg.type === 'set_switch') {
      if (!commandArg.key || typeof commandArg.enabled !== 'boolean') {
        throw new Error('motionEye set_switch requires key and boolean enabled values.');
      }
      const response = await this.setCameraValue(commandArg.cameraId, commandArg.key, commandArg.enabled);
      this.patchCachedSwitch(commandArg.cameraId, commandArg.key, commandArg.enabled);
      return { ok: true, command: commandArg.type, key: commandArg.key, enabled: commandArg.enabled, response };
    }
    if (commandArg.type === 'set_text_overlay') {
      const response = await this.setTextOverlay(commandArg);
      return { ok: true, command: commandArg.type, response };
    }
    if (commandArg.type === 'media_list') {
      const kind = commandArg.mediaKind || 'images';
      return this.getMediaList(commandArg.cameraId, kind, commandArg.prefix);
    }
    throw new Error(`Unsupported motionEye command: ${commandArg.type}`);
  }

  public async getSnapshotImage(cameraIdArg?: string): Promise<IMotionEyeSnapshotImage> {
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, cameraIdArg);
    if (camera.numericId === undefined) {
      throw new Error('motionEye snapshot image requires a numeric camera id.');
    }
    const response = await this.requestResponse(`/picture/${camera.numericId}/current/`, { admin: false });
    return {
      contentType: response.headers.get('content-type') || 'image/jpeg',
      data: new Uint8Array(await response.arrayBuffer()),
    };
  }

  public getCameraStreamUrl(cameraArg: IMotionEyeRawCamera): string | undefined {
    if (!this.isCameraStreaming(cameraArg)) {
      return undefined;
    }
    const endpoint = this.endpoint();
    const host = stringValue(cameraArg.host) || endpoint.host;
    const port = numberValue(cameraArg.streaming_port);
    if (!host || port === undefined) {
      return undefined;
    }
    return `http://${host}:${port}/`;
  }

  public getCameraSnapshotUrl(cameraArg: IMotionEyeRawCamera): string | undefined {
    const cameraId = numberValue(cameraArg.id);
    if (!this.isCameraStreaming(cameraArg) || cameraId === undefined || !this.hasLiveTarget()) {
      return undefined;
    }
    return this.buildSignedUrl(`/picture/${cameraId}/current/`, undefined, undefined, 'GET', false);
  }

  public getMovieUrl(cameraIdArg: number, pathArg: string, previewArg = false): string | undefined {
    if (!this.hasLiveTarget()) {
      return undefined;
    }
    return this.buildSignedUrl(`/movie/${cameraIdArg}/${previewArg ? 'preview' : 'playback'}/${this.stripLeadingSlash(pathArg)}`, undefined, undefined, 'GET', false);
  }

  public getImageUrl(cameraIdArg: number, pathArg: string, previewArg = false): string | undefined {
    if (!this.hasLiveTarget()) {
      return undefined;
    }
    return this.buildSignedUrl(`/picture/${cameraIdArg}/${previewArg ? 'preview' : 'download'}/${this.stripLeadingSlash(pathArg)}`, undefined, undefined, 'GET', false);
  }

  public isCameraStreaming(cameraArg: IMotionEyeRawCamera | undefined): boolean {
    return Boolean(cameraArg && numberValue(cameraArg.streaming_port) !== undefined && cameraArg.video_streaming === true);
  }

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

  private async fetchLiveSnapshot(): Promise<IMotionEyeSnapshot> {
    await this.requestJson('/login');
    const camerasResponse = await this.requestJson('/config/list');
    const [manifest, serverConfig] = await Promise.all([
      this.requestJson('/manifest.json').then((responseArg) => responseArg.data).catch(() => undefined),
      this.requestJson('/config/main/get').then((responseArg) => responseArg.data).catch(() => undefined),
    ]);
    const rawCameras = this.rawCamerasFromResponse(camerasResponse.data);
    return this.normalizeSnapshot({
      deviceInfo: this.deviceInfo(true),
      cameras: this.camerasFromRaw(rawCameras, true),
      sensors: [],
      switches: [],
      rawCameras,
      connected: true,
      updatedAt: new Date().toISOString(),
      manifest: record(manifest),
      serverConfig: record(serverConfig),
    });
  }

  private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IMotionEyeSnapshot {
    const rawCameras = this.config.rawCameras || this.config.snapshot?.rawCameras || this.rawCamerasFromConfiguredCameras(this.config.cameras || this.config.snapshot?.cameras || []);
    const cameras = this.config.cameras || this.config.snapshot?.cameras || this.camerasFromRaw(rawCameras, connectedArg);
    return this.normalizeSnapshot({
      deviceInfo: this.deviceInfo(connectedArg),
      cameras,
      sensors: this.config.sensors || this.config.snapshot?.sensors || [],
      switches: this.config.switches || this.config.snapshot?.switches || [],
      rawCameras,
      connected: connectedArg,
      updatedAt: new Date().toISOString(),
      manifest: this.config.manifest || this.config.snapshot?.manifest,
      serverConfig: this.config.serverConfig || this.config.snapshot?.serverConfig,
      metadata: {
        ...this.config.snapshot?.metadata,
        lastLiveError: lastErrorArg,
      },
    });
  }

  private normalizeSnapshot(snapshotArg: IMotionEyeSnapshot): IMotionEyeSnapshot {
    const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
    const deviceInfo = {
      ...this.deviceInfo(connected),
      ...snapshotArg.deviceInfo,
      online: connected,
    };
    const cameras = (snapshotArg.cameras || []).map((cameraArg) => this.normalizeCamera(cameraArg, connected));
    const sensors = snapshotArg.sensors.length ? snapshotArg.sensors : this.sensorsFromCameras(cameras, connected);
    const switches = snapshotArg.switches.length ? snapshotArg.switches : this.switchesFromCameras(cameras, connected);
    return {
      ...snapshotArg,
      deviceInfo,
      cameras,
      sensors,
      switches,
      rawCameras: snapshotArg.rawCameras || [],
      connected,
      updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
    };
  }

  private normalizeCamera(cameraArg: IMotionEyeCamera, connectedArg: boolean): IMotionEyeCamera {
    const raw = cameraArg.raw;
    const numericId = cameraArg.numericId ?? numberValue(raw?.id) ?? numberValue(cameraArg.id);
    const rawStreamUrl = raw ? this.getCameraStreamUrl(raw) : undefined;
    const rawSnapshotUrl = raw ? this.getCameraSnapshotUrl(raw) : undefined;
    return {
      ...cameraArg,
      id: String(cameraArg.id || numericId),
      numericId,
      name: cameraArg.name || `Camera ${cameraArg.id || numericId}`,
      streamingPort: cameraArg.streamingPort ?? numberValue(raw?.streaming_port),
      streamingAuthMode: cameraArg.streamingAuthMode ?? raw?.streaming_auth_mode,
      mjpegUrl: cameraArg.mjpegUrl || this.renderStreamUrlTemplate(raw) || rawStreamUrl,
      snapshotUrl: cameraArg.snapshotUrl || rawSnapshotUrl,
      isStreaming: cameraArg.isStreaming ?? Boolean(raw && this.isCameraStreaming(raw)),
      motionDetectionEnabled: cameraArg.motionDetectionEnabled ?? Boolean(raw?.motion_detection),
      actions: cameraArg.actions || this.stringList(raw?.actions),
      available: connectedArg && cameraArg.available !== false && cameraArg.isStreaming !== false,
    };
  }

  private camerasFromRaw(rawCamerasArg: IMotionEyeRawCamera[], connectedArg: boolean): IMotionEyeCamera[] {
    return rawCamerasArg
      .filter((cameraArg) => cameraArg.id !== undefined && cameraArg.name !== undefined)
      .map((cameraArg) => {
        const numericId = numberValue(cameraArg.id);
        const id = String(cameraArg.id);
        return this.normalizeCamera({
          id,
          numericId,
          name: stringValue(cameraArg.name) || `Camera ${id}`,
          host: stringValue(cameraArg.host),
          streamingPort: numberValue(cameraArg.streaming_port),
          streamingAuthMode: cameraArg.streaming_auth_mode,
          mjpegUrl: this.renderStreamUrlTemplate(cameraArg) || this.getCameraStreamUrl(cameraArg),
          snapshotUrl: this.getCameraSnapshotUrl(cameraArg),
          isStreaming: this.isCameraStreaming(cameraArg),
          motionDetectionEnabled: Boolean(cameraArg.motion_detection),
          textOverlayEnabled: booleanValue(cameraArg.text_overlay),
          stillImagesEnabled: booleanValue(cameraArg.still_images),
          moviesEnabled: booleanValue(cameraArg.movies),
          uploadEnabled: booleanValue(cameraArg.upload_enabled),
          actions: this.stringList(cameraArg.actions),
          rootDirectory: stringValue(cameraArg.root_directory),
          raw: cameraArg,
          available: connectedArg,
        }, connectedArg);
      });
  }

  private rawCamerasFromResponse(valueArg: unknown): IMotionEyeRawCamera[] {
    const cameras = record(valueArg)?.cameras;
    return Array.isArray(cameras) ? cameras.filter(record).map((cameraArg) => cameraArg as IMotionEyeRawCamera) : [];
  }

  private rawCamerasFromConfiguredCameras(camerasArg: IMotionEyeCamera[]): IMotionEyeRawCamera[] {
    return camerasArg.map((cameraArg) => ({
      id: cameraArg.numericId ?? cameraArg.id,
      name: cameraArg.name,
      host: cameraArg.host,
      streaming_port: cameraArg.streamingPort,
      streaming_auth_mode: cameraArg.streamingAuthMode,
      video_streaming: cameraArg.isStreaming,
      motion_detection: cameraArg.motionDetectionEnabled,
      text_overlay: cameraArg.textOverlayEnabled,
      still_images: cameraArg.stillImagesEnabled,
      movies: cameraArg.moviesEnabled,
      upload_enabled: cameraArg.uploadEnabled,
      actions: cameraArg.actions,
      root_directory: cameraArg.rootDirectory,
      ...cameraArg.raw,
    }));
  }

  private sensorsFromCameras(camerasArg: IMotionEyeCamera[], connectedArg: boolean): IMotionEyeSensor[] {
    return camerasArg.map((cameraArg) => ({
      key: 'actions',
      name: `${cameraArg.name} Actions`,
      cameraId: cameraArg.id,
      value: cameraArg.actions.length,
      entityCategory: 'diagnostic',
      available: connectedArg,
      attributes: cameraArg.actions.length ? { actions: cameraArg.actions } : undefined,
    }));
  }

  private switchesFromCameras(camerasArg: IMotionEyeCamera[], connectedArg: boolean): IMotionEyeSwitch[] {
    const valuesByKey = (cameraArg: IMotionEyeCamera): Record<string, boolean | undefined> => ({
      motion_detection: cameraArg.motionDetectionEnabled,
      text_overlay: cameraArg.textOverlayEnabled,
      video_streaming: cameraArg.isStreaming,
      still_images: cameraArg.stillImagesEnabled,
      movies: cameraArg.moviesEnabled,
      upload_enabled: cameraArg.uploadEnabled,
    });
    return camerasArg.flatMap((cameraArg) => {
      const values = valuesByKey(cameraArg);
      return motionEyeSwitchDescriptions.map((descriptionArg) => ({
        key: descriptionArg.key,
        name: `${cameraArg.name} ${descriptionArg.name}`,
        cameraId: cameraArg.id,
        isOn: Boolean(values[descriptionArg.key]),
        entityCategory: descriptionArg.entityCategory,
        available: connectedArg,
      }));
    });
  }

  private async action(cameraIdArg: string | undefined, actionArg: string): Promise<IMotionEyeCommandResponse> {
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, cameraIdArg);
    if (camera.numericId === undefined) {
      throw new Error('motionEye action requires a numeric camera id.');
    }
    return this.requestCommand(`action:${actionArg}`, 'POST', `/action/${camera.numericId}/${encodeURIComponent(actionArg)}`, {}, true);
  }

  private async setCameraValue(cameraIdArg: string | undefined, keyArg: string, valueArg: unknown): Promise<IMotionEyeCommandResponse> {
    const camera = await this.getLatestRawCamera(cameraIdArg);
    camera[keyArg] = valueArg;
    return this.setRawCamera(camera, keyArg);
  }

  private async setTextOverlay(commandArg: IMotionEyeClientCommand): Promise<IMotionEyeCommandResponse> {
    const camera = await this.getLatestRawCamera(commandArg.cameraId);
    if (commandArg.leftText !== undefined) {
      camera.left_text = commandArg.leftText;
    }
    if (commandArg.rightText !== undefined) {
      camera.right_text = commandArg.rightText;
    }
    if (commandArg.customLeftText !== undefined) {
      camera.custom_left_text = unicodeEscape(commandArg.customLeftText);
    }
    if (commandArg.customRightText !== undefined) {
      camera.custom_right_text = unicodeEscape(commandArg.customRightText);
    }
    return this.setRawCamera(camera, 'set_text_overlay');
  }

  private async getLatestRawCamera(cameraIdArg: string | undefined): Promise<IMotionEyeRawCamera> {
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, cameraIdArg);
    if (camera.numericId === undefined) {
      throw new Error('motionEye camera configuration updates require a numeric camera id.');
    }
    const response = await this.requestJson(`/config/${camera.numericId}/get`);
    const raw = record(response.data) as IMotionEyeRawCamera | undefined;
    if (!raw) {
      throw new Error(`motionEye camera ${camera.numericId} config response was empty.`);
    }
    return raw;
  }

  private async setRawCamera(cameraArg: IMotionEyeRawCamera, labelArg: string): Promise<IMotionEyeCommandResponse> {
    const cameraId = numberValue(cameraArg.id);
    if (cameraId === undefined) {
      throw new Error('motionEye set camera requires a numeric camera id.');
    }
    return this.requestCommand(labelArg, 'POST', `/config/${cameraId}/set`, cameraArg as Record<string, unknown>, true);
  }

  private async getMediaList(cameraIdArg: string | undefined, kindArg: TMotionEyeMediaKind, prefixArg?: string): Promise<unknown> {
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, cameraIdArg);
    if (camera.numericId === undefined) {
      throw new Error('motionEye media list requires a numeric camera id.');
    }
    const response = await this.requestJson(`/${kindArg === 'movies' ? 'movie' : 'picture'}/${camera.numericId}/list`, prefixArg ? { prefix: prefixArg } : undefined);
    return response.data;
  }

  private async requestCommand(labelArg: string, methodArg: 'GET' | 'POST', pathArg: string, dataArg: Record<string, unknown> | undefined, adminArg: boolean): Promise<IMotionEyeCommandResponse> {
    const response = await this.requestJson(pathArg, undefined, dataArg, methodArg, adminArg);
    return {
      ok: true,
      label: labelArg,
      method: methodArg,
      path: pathArg,
      status: response.status,
      response: response.data,
    };
  }

  private async requestJson(pathArg: string, paramsArg?: Record<string, unknown>, dataArg?: Record<string, unknown>, methodArg: 'GET' | 'POST' = 'GET', adminArg = true): Promise<{ status: number; data: unknown }> {
    const serializedData = dataArg === undefined ? undefined : JSON.stringify(dataArg);
    const response = await this.requestResponse(pathArg, { params: paramsArg, data: serializedData, method: methodArg, admin: adminArg });
    const text = await response.text();
    return {
      status: response.status,
      data: text.trim() ? JSON.parse(text) : undefined,
    };
  }

  private async requestResponse(pathArg: string, optionsArg: { params?: Record<string, unknown>; data?: string; method?: 'GET' | 'POST'; admin?: boolean } = {}): Promise<Response> {
    const method = optionsArg.method || 'GET';
    const url = this.buildSignedUrl(pathArg, optionsArg.params, optionsArg.data, method, optionsArg.admin !== false);
    const headers = new Headers();
    if (optionsArg.data !== undefined) {
      headers.set('content-type', 'application/json');
    }
    const response = await this.fetchWithTimeout(url, { method, headers, body: method === 'GET' ? undefined : optionsArg.data });
    if (!response.ok) {
      const text = await response.text().catch(() => '');
      if (response.status === 403) {
        throw new MotionEyeHttpError(response.status, 'motionEye authentication failed.');
      }
      throw new MotionEyeHttpError(response.status, `motionEye request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
    }
    return response;
  }

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

  private buildSignedUrl(pathArg: string, paramsArg: Record<string, unknown> | undefined, dataArg: string | undefined, methodArg: 'GET' | 'POST', adminArg: boolean): string {
    const baseUrl = this.baseUrl();
    if (!baseUrl) {
      throw new Error('motionEye live HTTP client requires config.url or config.host.');
    }
    const url = safeUrl(pathArg) || new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, `${baseUrl}/`);
    const params = new URLSearchParams();
    for (const [key, value] of Object.entries(paramsArg || {})) {
      if (value !== undefined && value !== null) {
        params.set(key, String(value));
      }
    }
    params.set('_username', adminArg ? this.adminUsername() : this.surveillanceUsername());
    url.search = params.toString();
    const key = sha1(adminArg ? this.adminPassword() : this.surveillancePassword());
    url.searchParams.set('_signature', computeMotionEyeSignature(methodArg, url.toString(), dataArg, key));
    return url.toString();
  }

  private renderStreamUrlTemplate(cameraArg: IMotionEyeRawCamera | undefined): string | undefined {
    const template = this.config.streamUrlTemplate?.trim();
    if (!template || !cameraArg) {
      return undefined;
    }
    return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_matchArg, keyArg: string) => String(cameraArg[keyArg] ?? ''));
  }

  private stripLeadingSlash(pathArg: string): string {
    const path = pathArg.trim();
    if (!path) {
      throw new Error('motionEye media path must not be empty.');
    }
    return path.replace(/^\/+/, '');
  }

  private patchCachedSwitch(cameraIdArg: string | undefined, keyArg: string, valueArg: boolean): void {
    if (!this.snapshot) {
      return;
    }
    const camera = this.findCamera(this.snapshot, cameraIdArg);
    const raw = this.snapshot.rawCameras.find((rawArg) => String(rawArg.id) === camera.id || String(rawArg.id) === String(camera.numericId));
    if (raw) {
      raw[keyArg] = valueArg;
    }
    const propertyByKey: Record<string, keyof IMotionEyeCamera> = {
      motion_detection: 'motionDetectionEnabled',
      text_overlay: 'textOverlayEnabled',
      video_streaming: 'isStreaming',
      still_images: 'stillImagesEnabled',
      movies: 'moviesEnabled',
      upload_enabled: 'uploadEnabled',
    };
    const property = propertyByKey[keyArg];
    if (property) {
      (camera[property] as boolean | undefined) = valueArg;
    }
    for (const switchArg of this.snapshot.switches) {
      if (switchArg.cameraId === camera.id && switchArg.key === keyArg) {
        switchArg.isOn = valueArg;
      }
    }
  }

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

  private deviceInfo(connectedArg: boolean): IMotionEyeDeviceInfo {
    const endpoint = this.endpoint();
    return {
      ...this.config.deviceInfo,
      id: this.config.deviceInfo?.id || this.config.uniqueId || endpoint.host || this.config.url || 'manual-motioneye',
      name: this.config.deviceInfo?.name || this.config.name || endpoint.host || this.config.url || 'motionEye',
      manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'motionEye',
      model: this.config.deviceInfo?.model || this.config.model,
      host: this.config.deviceInfo?.host || endpoint.host,
      port: this.config.deviceInfo?.port || endpoint.port,
      protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
      url: this.config.deviceInfo?.url || this.baseUrl(),
      online: connectedArg,
    };
  }

  private baseUrl(): string | undefined {
    if (this.config.url) {
      const url = safeUrl(this.config.url);
      if (url) {
        return url.toString().replace(/\/$/, '');
      }
    }
    const endpoint = this.endpoint();
    if (!endpoint.host) {
      return undefined;
    }
    return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || motionEyeDefaultPort}`;
  }

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

  private adminUsername(): string {
    return this.config.adminUsername || motionEyeDefaultAdminUsername;
  }

  private adminPassword(): string {
    return this.config.adminPassword || '';
  }

  private surveillanceUsername(): string {
    return this.config.surveillanceUsername || motionEyeDefaultSurveillanceUsername;
  }

  private surveillancePassword(): string {
    return this.config.surveillancePassword || '';
  }

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

  private stringList(valueArg: unknown): string[] {
    return Array.isArray(valueArg) ? valueArg.map((entryArg) => stringValue(entryArg)).filter((entryArg): entryArg is string => Boolean(entryArg)) : [];
  }

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

export const computeMotionEyeSignature = (methodArg: string, pathArg: string, bodyArg: string | undefined, keyArg: string): string => {
  const url = new URL(pathArg);
  const query = [...url.searchParams.entries()].filter(([nameArg]) => nameArg !== '_signature').sort(([leftArg], [rightArg]) => leftArg.localeCompare(rightArg));
  const queryString = query.map(([nameArg, valueArg]) => `${nameArg}=${encodeURIComponent(valueArg)}`).join('&');
  const unsignedPath = `${url.pathname}${queryString ? `?${queryString}` : ''}`.replace(signatureRegex, '-');
  const key = keyArg.replace(signatureRegex, '-');
  const body = bodyArg && bodyArg.startsWith('---') ? '' : (bodyArg || '').replace(signatureRegex, '-');
  return sha1(`${methodArg}:${unsignedPath}:${body}:${key}`).toLowerCase();
};

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

const safeUrl = (valueArg: string | undefined): URL | undefined => {
  if (!valueArg) {
    return undefined;
  }
  try {
    return new URL(valueArg);
  } catch {
    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 stringValue = (valueArg: unknown): string | undefined => {
  return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' || typeof valueArg === 'boolean' ? String(valueArg) : 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 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'].includes(valueArg.toLowerCase())) {
      return true;
    }
    if (['false', 'no', 'off', '0'].includes(valueArg.toLowerCase())) {
      return false;
    }
  }
  return undefined;
};

const unicodeEscape = (valueArg: string): string => {
  return valueArg
    .replace(/\\/g, '\\\\')
    .replace(/\n/g, '\\n')
    .replace(/\r/g, '\\r')
    .replace(/\t/g, '\\t')
    .replace(/[^\x20-\x7e]/g, (charArg) => `\\u${charArg.charCodeAt(0).toString(16).padStart(4, '0')}`);
};
