// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * as Protocol from '../../generated/protocol.js';

import {CSSModel} from './CSSModel.js';
import {MultitargetNetworkManager} from './NetworkManager.js';
import {Events, OverlayModel} from './OverlayModel.js';
import {SDKModel} from './SDKModel.js';
import {Capability, type Target} from './Target.js';

export const enum DataSaverOverride {
  UNSET = 'unset',
  ENABLED = 'enabled',
  DISABLED = 'disabled',
}

export class EmulationModel extends SDKModel<EmulationModelEventTypes> implements ProtocolProxyApi.EmulationDispatcher {
  readonly #emulationAgent: ProtocolProxyApi.EmulationApi;
  readonly #deviceOrientationAgent: ProtocolProxyApi.DeviceOrientationApi;
  #cssModel: CSSModel|null;
  readonly #overlayModel: OverlayModel|null;
  readonly #mediaConfiguration: Map<string, string>;
  #cpuPressureEnabled: boolean;
  #touchEnabled: boolean;
  #touchMobile: boolean;
  #touchEmulationAllowed: boolean;
  #customTouchEnabled: boolean;
  #touchConfiguration: {
    enabled: boolean,
    configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration,
  };
  #screenOrientationLocked: boolean;
  #lockedOrientation: Protocol.Emulation.ScreenOrientation|null;

  constructor(target: Target) {
    super(target);
    this.#emulationAgent = target.emulationAgent();
    this.#deviceOrientationAgent = target.deviceOrientationAgent();
    this.#screenOrientationLocked = false;
    this.#lockedOrientation = null;
    this.#cssModel = target.model(CSSModel);
    this.#overlayModel = target.model(OverlayModel);
    if (this.#overlayModel) {
      this.#overlayModel.addEventListener(Events.INSPECT_MODE_WILL_BE_TOGGLED, () => {
        void this.updateTouch();
      }, this);
    }

    const settings = this.target().targetManager().settings;
    const disableJavascriptSetting = settings.moduleSetting('java-script-disabled');
    disableJavascriptSetting.addChangeListener(
        async () =>
            await this.#emulationAgent.invoke_setScriptExecutionDisabled({value: disableJavascriptSetting.get()}));
    if (disableJavascriptSetting.get()) {
      void this.#emulationAgent.invoke_setScriptExecutionDisabled({value: true});
    }

    const touchSetting = settings.moduleSetting('emulation.touch');
    touchSetting.addChangeListener(() => {
      const settingValue = touchSetting.get();

      void this.overrideEmulateTouch(settingValue === 'force');
    });

    const idleDetectionSetting = settings.moduleSetting('emulation.idle-detection');
    idleDetectionSetting.addChangeListener(async () => {
      const settingValue = idleDetectionSetting.get();
      if (settingValue === 'none') {
        await this.clearIdleOverride();
        return;
      }

      const emulationParams = (JSON.parse(settingValue) as {
        isUserActive: boolean,
        isScreenUnlocked: boolean,
      });
      await this.setIdleOverride(emulationParams);
    });

    const cpuPressureDetectionSetting = settings.moduleSetting('emulation.cpu-pressure');
    cpuPressureDetectionSetting.addChangeListener(async () => {
      const settingValue = cpuPressureDetectionSetting.get();

      if (settingValue === 'none') {
        await this.setPressureSourceOverrideEnabled(false);
        this.#cpuPressureEnabled = false;
        return;
      }

      if (!this.#cpuPressureEnabled) {
        this.#cpuPressureEnabled = true;
        await this.setPressureSourceOverrideEnabled(true);
      }

      await this.setPressureStateOverride(settingValue);
    });

    const mediaTypeSetting = settings.moduleSetting<string>('emulated-css-media');
    const mediaFeatureColorGamutSetting = settings.moduleSetting<string>('emulated-css-media-feature-color-gamut');
    const mediaFeaturePrefersColorSchemeSetting =
        settings.moduleSetting<string>('emulated-css-media-feature-prefers-color-scheme');
    const mediaFeatureForcedColorsSetting = settings.moduleSetting('emulated-css-media-feature-forced-colors');
    const mediaFeaturePrefersContrastSetting =
        settings.moduleSetting<string>('emulated-css-media-feature-prefers-contrast');
    const mediaFeaturePrefersReducedDataSetting =
        settings.moduleSetting<string>('emulated-css-media-feature-prefers-reduced-data');
    const mediaFeaturePrefersReducedTransparencySetting =
        settings.moduleSetting<string>('emulated-css-media-feature-prefers-reduced-transparency');
    const mediaFeaturePrefersReducedMotionSetting =
        settings.moduleSetting<string>('emulated-css-media-feature-prefers-reduced-motion');
    // Note: this uses a different format than what the CDP API expects,
    // because we want to update these values per media type/feature
    // without having to search the `features` array (inefficient) or
    // hardcoding the indices (not readable/maintainable).
    this.#mediaConfiguration = new Map([
      ['type', mediaTypeSetting.get()],
      ['color-gamut', mediaFeatureColorGamutSetting.get()],
      ['prefers-color-scheme', mediaFeaturePrefersColorSchemeSetting.get()],
      ['forced-colors', mediaFeatureForcedColorsSetting.get()],
      ['prefers-contrast', mediaFeaturePrefersContrastSetting.get()],
      ['prefers-reduced-data', mediaFeaturePrefersReducedDataSetting.get()],
      ['prefers-reduced-motion', mediaFeaturePrefersReducedMotionSetting.get()],
      ['prefers-reduced-transparency', mediaFeaturePrefersReducedTransparencySetting.get()],
    ]);
    mediaTypeSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('type', mediaTypeSetting.get());
      void this.updateCssMedia();
    });
    mediaFeatureColorGamutSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('color-gamut', mediaFeatureColorGamutSetting.get());
      void this.updateCssMedia();
    });
    mediaFeaturePrefersColorSchemeSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('prefers-color-scheme', mediaFeaturePrefersColorSchemeSetting.get());
      void this.updateCssMedia();
    });
    mediaFeatureForcedColorsSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('forced-colors', mediaFeatureForcedColorsSetting.get());
      void this.updateCssMedia();
    });
    mediaFeaturePrefersContrastSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('prefers-contrast', mediaFeaturePrefersContrastSetting.get());
      void this.updateCssMedia();
    });
    mediaFeaturePrefersReducedDataSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('prefers-reduced-data', mediaFeaturePrefersReducedDataSetting.get());
      void this.updateCssMedia();
    });
    mediaFeaturePrefersReducedMotionSetting.addChangeListener(() => {
      this.#mediaConfiguration.set('prefers-reduced-motion', mediaFeaturePrefersReducedMotionSetting.get());
      void this.updateCssMedia();
    });
    mediaFeaturePrefersReducedTransparencySetting.addChangeListener(() => {
      this.#mediaConfiguration.set('prefers-reduced-transparency', mediaFeaturePrefersReducedTransparencySetting.get());
      void this.updateCssMedia();
    });
    void this.updateCssMedia();

    const autoDarkModeSetting = settings.moduleSetting('emulate-auto-dark-mode');
    autoDarkModeSetting.addChangeListener(() => {
      const enabled = autoDarkModeSetting.get();
      mediaFeaturePrefersColorSchemeSetting.setDisabled(enabled);
      mediaFeaturePrefersColorSchemeSetting.set(enabled ? 'dark' : '');
      void this.emulateAutoDarkMode(enabled);
    });
    if (autoDarkModeSetting.get()) {
      mediaFeaturePrefersColorSchemeSetting.setDisabled(true);
      mediaFeaturePrefersColorSchemeSetting.set('dark');
      void this.emulateAutoDarkMode(true);
    }

    const visionDeficiencySetting = settings.moduleSetting('emulated-vision-deficiency');
    visionDeficiencySetting.addChangeListener(() => this.emulateVisionDeficiency(visionDeficiencySetting.get()));
    if (visionDeficiencySetting.get()) {
      void this.emulateVisionDeficiency(visionDeficiencySetting.get());
    }

    const osTextScaleSetting = settings.moduleSetting('emulated-os-text-scale');
    osTextScaleSetting.addChangeListener(() => {
      void this.emulateOSTextScale(parseFloat(osTextScaleSetting.get()) || undefined);
    });
    if (osTextScaleSetting.get()) {
      void this.emulateOSTextScale(parseFloat(osTextScaleSetting.get()) || undefined);
    }

    const localFontsDisabledSetting = settings.moduleSetting('local-fonts-disabled');
    localFontsDisabledSetting.addChangeListener(() => this.setLocalFontsDisabled(localFontsDisabledSetting.get()));
    if (localFontsDisabledSetting.get()) {
      this.setLocalFontsDisabled(localFontsDisabledSetting.get());
    }

    const avifFormatDisabledSetting = settings.moduleSetting('avif-format-disabled');
    const jpegXlFormatDisabledSetting = settings.moduleSetting('jpeg-xl-format-disabled');
    const webpFormatDisabledSetting = settings.moduleSetting('webp-format-disabled');

    const updateDisabledImageFormats = (): void => {
      const types = [];
      if (avifFormatDisabledSetting.get()) {
        types.push(Protocol.Emulation.DisabledImageType.Avif);
      }
      if (jpegXlFormatDisabledSetting.get()) {
        types.push(Protocol.Emulation.DisabledImageType.Jxl);
      }
      if (webpFormatDisabledSetting.get()) {
        types.push(Protocol.Emulation.DisabledImageType.Webp);
      }
      this.setDisabledImageTypes(types);
    };

    avifFormatDisabledSetting.addChangeListener(updateDisabledImageFormats);
    jpegXlFormatDisabledSetting.addChangeListener(updateDisabledImageFormats);
    webpFormatDisabledSetting.addChangeListener(updateDisabledImageFormats);

    if (avifFormatDisabledSetting.get() || jpegXlFormatDisabledSetting.get() || webpFormatDisabledSetting.get()) {
      updateDisabledImageFormats();
    }

    this.#cpuPressureEnabled = false;
    this.#touchEmulationAllowed = true;
    this.#touchEnabled = false;
    this.#touchMobile = false;
    this.#customTouchEnabled = false;
    this.#touchConfiguration = {
      enabled: false,
      configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile,
    };
    target.registerEmulationDispatcher(this);
  }

  setTouchEmulationAllowed(touchEmulationAllowed: boolean): void {
    this.#touchEmulationAllowed = touchEmulationAllowed;
  }

  supportsDeviceEmulation(): boolean {
    return this.target().hasAllCapabilities(Capability.DEVICE_EMULATION);
  }

  async resetPageScaleFactor(): Promise<void> {
    await this.#emulationAgent.invoke_resetPageScaleFactor();
  }

  async emulateDevice(metrics: Protocol.Page.SetDeviceMetricsOverrideRequest|null): Promise<void> {
    if (metrics) {
      await this.#emulationAgent.invoke_setDeviceMetricsOverride(metrics);
    } else {
      await this.#emulationAgent.invoke_clearDeviceMetricsOverride();
    }
  }

  overlayModel(): OverlayModel|null {
    return this.#overlayModel;
  }

  async setPressureSourceOverrideEnabled(enabled: boolean): Promise<void> {
    await this.#emulationAgent.invoke_setPressureSourceOverrideEnabled(
        {source: Protocol.Emulation.PressureSource.Cpu, enabled});
  }

  async setPressureStateOverride(pressureState: string): Promise<void> {
    await this.#emulationAgent.invoke_setPressureStateOverride({
      source: Protocol.Emulation.PressureSource.Cpu,
      state: pressureState as Protocol.Emulation.PressureState,
    });
  }

  async emulateLocation(location: Location|null): Promise<void> {
    if (!location) {
      await Promise.all([
        this.#emulationAgent.invoke_clearGeolocationOverride(),
        this.#emulationAgent.invoke_setTimezoneOverride({timezoneId: ''}),
        this.#emulationAgent.invoke_setLocaleOverride({locale: ''}),
        this.#emulationAgent.invoke_setUserAgentOverride(
            {userAgent: MultitargetNetworkManager.instance().currentUserAgent()}),
      ]);
    } else if (location.unavailable) {
      await Promise.all([
        this.#emulationAgent.invoke_setGeolocationOverride({}),
        this.#emulationAgent.invoke_setTimezoneOverride({timezoneId: ''}),
        this.#emulationAgent.invoke_setLocaleOverride({locale: ''}),
        this.#emulationAgent.invoke_setUserAgentOverride(
            {userAgent: MultitargetNetworkManager.instance().currentUserAgent()}),
      ]);
    } else {
      function processEmulationResult(errorType: string, result: Protocol.ProtocolResponseWithError): Promise<void> {
        const errorMessage = result.getError();
        if (errorMessage) {
          return Promise.reject({
            type: errorType,
            message: errorMessage,
          });
        }
        return Promise.resolve();
      }

      await Promise.all([
        this.#emulationAgent
            .invoke_setGeolocationOverride({
              latitude: location.latitude,
              longitude: location.longitude,
              accuracy: location.accuracy,
            })
            .then(result => processEmulationResult('emulation-set-location', result)),
        this.#emulationAgent
            .invoke_setTimezoneOverride({
              timezoneId: location.timezoneId,
            })
            .then(result => processEmulationResult('emulation-set-timezone', result)),
        this.#emulationAgent
            .invoke_setLocaleOverride({
              locale: location.locale,
            })
            .then(result => processEmulationResult('emulation-set-locale', result)),
        this.#emulationAgent
            .invoke_setUserAgentOverride({
              userAgent: MultitargetNetworkManager.instance().currentUserAgent(),
              acceptLanguage: location.locale,
            })
            .then(result => processEmulationResult('emulation-set-user-agent', result)),
      ]);
    }
  }

  async emulateDeviceOrientation(deviceOrientation: DeviceOrientation|null): Promise<void> {
    if (deviceOrientation) {
      await this.#deviceOrientationAgent.invoke_setDeviceOrientationOverride(
          {alpha: deviceOrientation.alpha, beta: deviceOrientation.beta, gamma: deviceOrientation.gamma});
    } else {
      await this.#deviceOrientationAgent.invoke_clearDeviceOrientationOverride();
    }
  }

  async setIdleOverride(emulationParams: {
    isUserActive: boolean,
    isScreenUnlocked: boolean,
  }): Promise<void> {
    await this.#emulationAgent.invoke_setIdleOverride(emulationParams);
  }

  async clearIdleOverride(): Promise<void> {
    await this.#emulationAgent.invoke_clearIdleOverride();
  }

  private async emulateCSSMedia(type: string, features: Array<{
                                  name: string,
                                  value: string,
                                }>): Promise<void> {
    await this.#emulationAgent.invoke_setEmulatedMedia({media: type, features});
    if (this.#cssModel) {
      this.#cssModel.mediaQueryResultChanged();
    }
  }

  private async emulateAutoDarkMode(enabled: boolean): Promise<void> {
    if (enabled) {
      this.#mediaConfiguration.set('prefers-color-scheme', 'dark');
      await this.updateCssMedia();
    }
    // We never send `enabled: false` since that would explicitly disable
    // autodark mode. We either enable it or clear any existing override.
    await this.#emulationAgent.invoke_setAutoDarkModeOverride({enabled: enabled || undefined});
  }

  private async emulateVisionDeficiency(type: Protocol.Emulation.SetEmulatedVisionDeficiencyRequestType):
      Promise<void> {
    await this.#emulationAgent.invoke_setEmulatedVisionDeficiency({type});
  }

  private async emulateOSTextScale(scale: number|undefined): Promise<void> {
    await this.#emulationAgent.invoke_setEmulatedOSTextScale({scale: scale || undefined});
  }

  private setLocalFontsDisabled(disabled: boolean): void {
    if (!this.#cssModel) {
      return;
    }
    void this.#cssModel.setLocalFontsEnabled(!disabled);
  }

  private setDisabledImageTypes(imageTypes: Protocol.Emulation.DisabledImageType[]): void {
    void this.#emulationAgent.invoke_setDisabledImageTypes({imageTypes});
  }

  async setDataSaverOverride(dataSaverOverride: DataSaverOverride): Promise<void> {
    const dataSaverEnabled = dataSaverOverride === DataSaverOverride.UNSET ? undefined :
        dataSaverOverride === DataSaverOverride.ENABLED                    ? true :
                                                                             false;
    await this.#emulationAgent.invoke_setDataSaverOverride({dataSaverEnabled});
  }

  async setCPUThrottlingRate(rate: number): Promise<void> {
    await this.#emulationAgent.invoke_setCPUThrottlingRate({rate});
  }

  async setHardwareConcurrency(hardwareConcurrency: number): Promise<void> {
    if (hardwareConcurrency < 1) {
      throw new Error('hardwareConcurrency must be a positive value');
    }
    await this.#emulationAgent.invoke_setHardwareConcurrencyOverride({hardwareConcurrency});
  }

  async emulateTouch(enabled: boolean, mobile: boolean): Promise<void> {
    this.#touchEnabled = enabled && this.#touchEmulationAllowed;
    this.#touchMobile = mobile && this.#touchEmulationAllowed;
    await this.updateTouch();
  }

  async overrideEmulateTouch(enabled: boolean): Promise<void> {
    this.#customTouchEnabled = enabled && this.#touchEmulationAllowed;
    await this.updateTouch();
  }

  private async updateTouch(): Promise<void> {
    let configuration = {
      enabled: this.#touchEnabled,
      configuration: this.#touchMobile ? Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile :
                                         Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Desktop,
    };
    if (this.#customTouchEnabled) {
      configuration = {
        enabled: true,
        configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile,
      };
    }

    if (this.#overlayModel && this.#overlayModel.inspectModeEnabled()) {
      configuration = {
        enabled: false,
        configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile,
      };
    }

    if (!this.#touchConfiguration.enabled && !configuration.enabled) {
      return;
    }
    if (this.#touchConfiguration.enabled && configuration.enabled &&
        this.#touchConfiguration.configuration === configuration.configuration) {
      return;
    }

    this.#touchConfiguration = configuration;
    await this.#emulationAgent.invoke_setTouchEmulationEnabled({enabled: configuration.enabled, maxTouchPoints: 1});
    await this.#emulationAgent.invoke_setEmitTouchEventsForMouse(
        {enabled: configuration.enabled, configuration: configuration.configuration});
  }

  private async updateCssMedia(): Promise<void> {
    // See the note above, where this.#mediaConfiguration is defined.
    const type = this.#mediaConfiguration.get('type') ?? '';
    const features = [
      {
        name: 'color-gamut',
        value: this.#mediaConfiguration.get('color-gamut') ?? '',
      },
      {
        name: 'prefers-color-scheme',
        value: this.#mediaConfiguration.get('prefers-color-scheme') ?? '',
      },
      {
        name: 'forced-colors',
        value: this.#mediaConfiguration.get('forced-colors') ?? '',
      },
      {
        name: 'prefers-contrast',
        value: this.#mediaConfiguration.get('prefers-contrast') ?? '',
      },
      {
        name: 'prefers-reduced-data',
        value: this.#mediaConfiguration.get('prefers-reduced-data') ?? '',
      },
      {
        name: 'prefers-reduced-motion',
        value: this.#mediaConfiguration.get('prefers-reduced-motion') ?? '',
      },
      {
        name: 'prefers-reduced-transparency',
        value: this.#mediaConfiguration.get('prefers-reduced-transparency') ?? '',
      },
    ];
    return await this.emulateCSSMedia(type, features);
  }

  // ProtocolProxyApi.EmulationDispatcher implementation

  virtualTimeBudgetExpired(): void {
    // No-op for now; not used by the frontend.
  }

  screenOrientationLockChanged(event: Protocol.Emulation.ScreenOrientationLockChangedEvent): void {
    this.#screenOrientationLocked = event.locked;
    this.#lockedOrientation = event.orientation ?? null;
    this.dispatchEventToListeners(
        EmulationModelEvents.SCREEN_ORIENTATION_LOCK_CHANGED,
        {locked: event.locked, orientation: event.orientation ?? null});
  }

  isScreenOrientationLocked(): boolean {
    return this.#screenOrientationLocked;
  }

  lockedOrientation(): Protocol.Emulation.ScreenOrientation|null {
    return this.#lockedOrientation;
  }
}

export const enum EmulationModelEvents {
  SCREEN_ORIENTATION_LOCK_CHANGED = 'ScreenOrientationLockChanged',
}

export interface ScreenOrientationLockChangedEvent {
  locked: boolean;
  orientation: Protocol.Emulation.ScreenOrientation|null;
}

export interface EmulationModelEventTypes {
  [EmulationModelEvents.SCREEN_ORIENTATION_LOCK_CHANGED]: ScreenOrientationLockChangedEvent;
}

export class Location {
  static readonly DEFAULT_ACCURACY = 150;
  latitude: number;
  longitude: number;
  timezoneId: string;
  locale: string;
  accuracy: number;
  unavailable: boolean;

  constructor(
      latitude: number, longitude: number, timezoneId: string, locale: string, accuracy: number, unavailable: boolean) {
    this.latitude = latitude;
    this.longitude = longitude;
    this.timezoneId = timezoneId;
    this.locale = locale;
    this.accuracy = accuracy;
    this.unavailable = unavailable;
  }

  static parseSetting(value: string): Location {
    if (value) {
      const [position, timezoneId, locale, unavailable, ...maybeAccuracy] = value.split(':');
      const accuracy = maybeAccuracy.length ? Number(maybeAccuracy[0]) : Location.DEFAULT_ACCURACY;
      const [latitude, longitude] = position.split('@');
      return new Location(
          parseFloat(latitude), parseFloat(longitude), timezoneId, locale, accuracy, Boolean(unavailable));
    }
    return new Location(0, 0, '', '', Location.DEFAULT_ACCURACY, false);
  }

  static parseUserInput(
      latitudeString: string, longitudeString: string, timezoneId: string, locale: string,
      accuracyString: string): Location|null {
    if (!latitudeString && !longitudeString && !accuracyString) {
      return null;
    }

    const isLatitudeValid = Location.latitudeValidator(latitudeString);
    const isLongitudeValid = Location.longitudeValidator(longitudeString);
    const {valid: isAccuracyValid} = Location.accuracyValidator(accuracyString);

    if (!isLatitudeValid && !isLongitudeValid && !isAccuracyValid) {
      return null;
    }

    const latitude = isLatitudeValid ? parseFloat(latitudeString) : -1;
    const longitude = isLongitudeValid ? parseFloat(longitudeString) : -1;
    const accuracy = isAccuracyValid ? parseFloat(accuracyString) : Location.DEFAULT_ACCURACY;
    return new Location(latitude, longitude, timezoneId, locale, accuracy, false);
  }

  static latitudeValidator(value: string): boolean {
    const numValue = parseFloat(value);
    return /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= -90 && numValue <= 90;
  }

  static longitudeValidator(value: string): boolean {
    const numValue = parseFloat(value);
    return /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= -180 && numValue <= 180;
  }

  static timezoneIdValidator(value: string): boolean {
    // Chromium uses ICU's timezone implementation, which is very
    // liberal in what it accepts. ICU does not simply use an allowlist
    // but instead tries to make sense of the input, even for
    // weird-looking timezone IDs. There's not much point in validating
    // the input other than checking if it contains at least one alphabet.
    // The empty string resets the override, and is accepted as well.
    return value === '' || /[a-zA-Z]/.test(value);
  }

  static localeValidator(value: string): boolean {
    // Similarly to timezone IDs, there's not much point in validating
    // input locales other than checking if it contains at least two
    // alphabetic characters.
    // https://unicode.org/reports/tr35/#Unicode_language_identifier
    // The empty string resets the override, and is accepted as
    // well.
    return value === '' || /[a-zA-Z]{2}/.test(value);
  }

  static accuracyValidator(value: string): {
    valid: boolean,
    errorMessage?: string,
  } {
    if (!value) {
      return {valid: true};
    }
    const numValue = parseFloat(value);
    const valid = /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= 0;
    return {valid};
  }

  toSetting(): string {
    return `${this.latitude}@${this.longitude}:${this.timezoneId}:${this.locale}:${this.unavailable || ''}:${
        this.accuracy || ''}`;
  }
}

export class DeviceOrientation {
  alpha: number;
  beta: number;
  gamma: number;

  constructor(alpha: number, beta: number, gamma: number) {
    this.alpha = alpha;
    this.beta = beta;
    this.gamma = gamma;
  }

  static parseSetting(value: string): DeviceOrientation {
    if (value) {
      const jsonObject = JSON.parse(value);
      return new DeviceOrientation(jsonObject.alpha, jsonObject.beta, jsonObject.gamma);
    }
    return new DeviceOrientation(0, 0, 0);
  }

  static parseUserInput(alphaString: string, betaString: string, gammaString: string): DeviceOrientation|null {
    if (!alphaString && !betaString && !gammaString) {
      return null;
    }

    const isAlphaValid = DeviceOrientation.alphaAngleValidator(alphaString);
    const isBetaValid = DeviceOrientation.betaAngleValidator(betaString);
    const isGammaValid = DeviceOrientation.gammaAngleValidator(gammaString);

    if (!isAlphaValid && !isBetaValid && !isGammaValid) {
      return null;
    }

    const alpha = isAlphaValid ? parseFloat(alphaString) : -1;
    const beta = isBetaValid ? parseFloat(betaString) : -1;
    const gamma = isGammaValid ? parseFloat(gammaString) : -1;

    return new DeviceOrientation(alpha, beta, gamma);
  }

  static angleRangeValidator(value: string, interval: {
    minimum: number,
    maximum: number,
  }): boolean {
    const numValue = parseFloat(value);
    return /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= interval.minimum &&
        numValue < interval.maximum;
  }

  static alphaAngleValidator(value: string): boolean {
    // https://w3c.github.io/deviceorientation/#device-orientation-model
    // Alpha must be within the [0, 360) interval.
    return DeviceOrientation.angleRangeValidator(value, {minimum: 0, maximum: 360});
  }

  static betaAngleValidator(value: string): boolean {
    // https://w3c.github.io/deviceorientation/#device-orientation-model
    // Beta must be within the [-180, 180) interval.
    return DeviceOrientation.angleRangeValidator(value, {minimum: -180, maximum: 180});
  }

  static gammaAngleValidator(value: string): boolean {
    // https://w3c.github.io/deviceorientation/#device-orientation-model
    // Gamma must be within the [-90, 90) interval.
    return DeviceOrientation.angleRangeValidator(value, {minimum: -90, maximum: 90});
  }

  toSetting(): string {
    return JSON.stringify(this);
  }
}

SDKModel.register(EmulationModel, {capabilities: Capability.EMULATION, autostart: true});
