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

import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Geometry from '../geometry/geometry.js';

import {
  type EmulatedDevice,
  Horizontal,
  HorizontalSpanned,
  type Mode,
  Vertical,
  VerticalSpanned,
} from './EmulatedDevices.js';

const UIStrings = {
  /**
   * @description Error message shown in the Devices settings pane when the user enters an empty
   * width for a custom device.
   */
  widthCannotBeEmpty: 'Width cannot be empty.',
  /**
   * @description Error message shown in the Devices settings pane when the user enters an invalid
   * width for a custom device.
   */
  widthMustBeANumber: 'Width must be a number.',
  /**
   * @description Error message shown in the Devices settings pane when the user has entered a width
   * for a custom device that is too large.
   * @example {9999} PH1
   */
  widthMustBeLessThanOrEqualToS: 'Width must be less than or equal to {PH1}.',
  /**
   * @description Error message shown in the Devices settings pane when the user has entered a width
   * for a custom device that is too small.
   * @example {50} PH1
   */
  widthMustBeGreaterThanOrEqualToS: 'Width must be greater than or equal to {PH1}.',
  /**
   * @description Error message shown in the Devices settings pane when the user enters an empty
   * height for a custom device.
   */
  heightCannotBeEmpty: 'Height cannot be empty.',
  /**
   * @description Error message shown in the Devices settings pane when the user enters an invalid
   * height for a custom device.
   */
  heightMustBeANumber: 'Height must be a number.',
  /**
   * @description Error message shown in the Devices settings pane when the user has entered a height
   * for a custom device that is too large.
   * @example {9999} PH1
   */
  heightMustBeLessThanOrEqualToS: 'Height must be less than or equal to {PH1}.',
  /**
   * @description Error message shown in the Devices settings pane when the user has entered a height
   * for a custom device that is too small.
   * @example {50} PH1
   */
  heightMustBeGreaterThanOrEqualTo: 'Height must be greater than or equal to {PH1}.',
  /**
   * @description Error message shown in the Devices settings pane when the user enters an invalid
   * device pixel ratio for a custom device.
   */
  devicePixelRatioMustBeANumberOr: 'Device pixel ratio must be a number or blank.',
  /**
   * @description Error message shown in the Devices settings pane when the user enters a device
   * pixel ratio for a custom device that is too large.
   * @example {10} PH1
   */
  devicePixelRatioMustBeLessThanOr: 'Device pixel ratio must be less than or equal to {PH1}.',
  /**
   * @description Error message shown in the Devices settings pane when the user enters a device
   * pixel ratio for a custom device that is too small.
   * @example {0} PH1
   */
  devicePixelRatioMustBeGreater: 'Device pixel ratio must be greater than or equal to {PH1}.',
} as const;
const str_ = i18n.i18n.registerUIStrings('models/emulation/DeviceModeModel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

let deviceModeModelInstance: DeviceModeModel|null;

export class DeviceModeModel extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
    SDK.TargetManager.SDKModelObserver<SDK.EmulationModel.EmulationModel> {
  #screenRect: Rect;
  #visiblePageRect: Rect;
  #availableSize: Geometry.Size;
  #preferredSize: Geometry.Size;
  #initialized: boolean;
  #appliedDeviceSize: Geometry.Size;
  #appliedDeviceScaleFactor: number;
  #appliedUserAgentType: UA;
  readonly #scaleSetting: Common.Settings.Setting<number>;
  #scale: number;
  #widthSetting: Common.Settings.Setting<number>;
  #heightSetting: Common.Settings.Setting<number>;
  #uaSetting: Common.Settings.Setting<UA>;
  readonly #deviceScaleFactorSetting: Common.Settings.Setting<number>;
  readonly #deviceOutlineSetting: Common.Settings.Setting<boolean>;
  readonly #toolbarControlsEnabledSetting: Common.Settings.Setting<boolean>;
  #type: Type;
  #device: EmulatedDevice|null;
  #mode: Mode|null;
  #fitScale: number;
  #touchEnabled: boolean;
  #touchMobile: boolean;
  #emulationModel: SDK.EmulationModel.EmulationModel|null;
  #onModelAvailable: (() => void)|null;
  #outlineRect?: Rect;
  #screenOrientationLocked: boolean;

  private constructor() {
    super();
    this.#screenRect = new Rect(0, 0, 1, 1);
    this.#visiblePageRect = new Rect(0, 0, 1, 1);
    this.#availableSize = new Geometry.Size(1, 1);
    this.#preferredSize = new Geometry.Size(1, 1);
    this.#initialized = false;
    this.#appliedDeviceSize = new Geometry.Size(1, 1);
    this.#appliedDeviceScaleFactor = globalThis.devicePixelRatio;
    this.#appliedUserAgentType = UA.DESKTOP;

    this.#scaleSetting = Common.Settings.Settings.instance().createSetting('emulation.device-scale', 1);
    // We've used to allow zero before.
    if (!this.#scaleSetting.get()) {
      this.#scaleSetting.set(1);
    }
    this.#scaleSetting.addChangeListener(this.scaleSettingChanged, this);
    this.#scale = 1;

    this.#widthSetting = Common.Settings.Settings.instance().createSetting('emulation.device-width', 400);
    if (this.#widthSetting.get() < MinDeviceSize) {
      this.#widthSetting.set(MinDeviceSize);
    }
    if (this.#widthSetting.get() > MaxDeviceSize) {
      this.#widthSetting.set(MaxDeviceSize);
    }
    this.#widthSetting.addChangeListener(this.widthSettingChanged, this);

    this.#heightSetting = Common.Settings.Settings.instance().createSetting('emulation.device-height', 0);
    if (this.#heightSetting.get() && this.#heightSetting.get() < MinDeviceSize) {
      this.#heightSetting.set(MinDeviceSize);
    }
    if (this.#heightSetting.get() > MaxDeviceSize) {
      this.#heightSetting.set(MaxDeviceSize);
    }
    this.#heightSetting.addChangeListener(this.heightSettingChanged, this);

    this.#uaSetting = Common.Settings.Settings.instance().createSetting('emulation.device-ua', UA.MOBILE);
    this.#uaSetting.addChangeListener(this.uaSettingChanged, this);
    this.#deviceScaleFactorSetting =
        Common.Settings.Settings.instance().createSetting('emulation.device-scale-factor', 0);
    this.#deviceScaleFactorSetting.addChangeListener(this.deviceScaleFactorSettingChanged, this);

    this.#deviceOutlineSetting = Common.Settings.Settings.instance().moduleSetting('emulation.show-device-outline');
    this.#deviceOutlineSetting.addChangeListener(this.deviceOutlineSettingChanged, this);

    this.#toolbarControlsEnabledSetting = Common.Settings.Settings.instance().createSetting(
        'emulation.toolbar-controls-enabled', true, Common.Settings.SettingStorageType.SESSION);

    this.#type = Type.None;
    this.#device = null;
    this.#mode = null;
    this.#fitScale = 1;
    this.#touchEnabled = false;
    this.#touchMobile = false;

    this.#emulationModel = null;
    this.#onModelAvailable = null;
    this.#screenOrientationLocked = false;
    SDK.TargetManager.TargetManager.instance().observeModels(SDK.EmulationModel.EmulationModel, this);
  }

  static instance(opts?: {forceNew: boolean}): DeviceModeModel {
    if (!deviceModeModelInstance || opts?.forceNew) {
      deviceModeModelInstance = new DeviceModeModel();
    }

    return deviceModeModelInstance;
  }

  /**
   * This wraps `instance()` in a try/catch because in some DevTools entry points
   * (such as worker_app.ts) the Emulation panel is not included and as such
   * the below code fails; it tries to instantiate the model which requires
   * reading the value of a setting which has not been registered.
   * See crbug.com/361515458 for an example bug that this resolves.
   */
  static tryInstance(opts?: {forceNew: boolean}): DeviceModeModel|null {
    try {
      return this.instance(opts);
    } catch {
      return null;
    }
  }

  static widthValidator(value: string): {
    valid: boolean,
    errorMessage: (string|undefined),
  } {
    let valid = false;
    let errorMessage;

    if (!value) {
      errorMessage = i18nString(UIStrings.widthCannotBeEmpty);
    } else if (!/^[\d]+$/.test(value)) {
      errorMessage = i18nString(UIStrings.widthMustBeANumber);
    } else if (Number(value) > MaxDeviceSize) {
      errorMessage = i18nString(UIStrings.widthMustBeLessThanOrEqualToS, {PH1: MaxDeviceSize});
    } else if (Number(value) < MinDeviceSize) {
      errorMessage = i18nString(UIStrings.widthMustBeGreaterThanOrEqualToS, {PH1: MinDeviceSize});
    } else {
      valid = true;
    }

    return {valid, errorMessage};
  }

  static heightValidator(value: string): {
    valid: boolean,
    errorMessage: (string|undefined),
  } {
    let valid = false;
    let errorMessage;

    if (!value) {
      errorMessage = i18nString(UIStrings.heightCannotBeEmpty);
    } else if (!/^[\d]+$/.test(value)) {
      errorMessage = i18nString(UIStrings.heightMustBeANumber);
    } else if (Number(value) > MaxDeviceSize) {
      errorMessage = i18nString(UIStrings.heightMustBeLessThanOrEqualToS, {PH1: MaxDeviceSize});
    } else if (Number(value) < MinDeviceSize) {
      errorMessage = i18nString(UIStrings.heightMustBeGreaterThanOrEqualTo, {PH1: MinDeviceSize});
    } else {
      valid = true;
    }

    return {valid, errorMessage};
  }

  static scaleValidator(value: string): {
    valid: boolean,
    errorMessage: (string|undefined),
  } {
    let valid = false;
    let errorMessage;
    const parsedValue = Number(value.trim());

    if (!value) {
      valid = true;
    } else if (Number.isNaN(parsedValue)) {
      errorMessage = i18nString(UIStrings.devicePixelRatioMustBeANumberOr);
    } else if (Number(value) > MaxDeviceScaleFactor) {
      errorMessage = i18nString(UIStrings.devicePixelRatioMustBeLessThanOr, {PH1: MaxDeviceScaleFactor});
    } else if (Number(value) < MinDeviceScaleFactor) {
      errorMessage = i18nString(UIStrings.devicePixelRatioMustBeGreater, {PH1: MinDeviceScaleFactor});
    } else {
      valid = true;
    }

    return {valid, errorMessage};
  }

  get scaleSettingInternal(): Common.Settings.Setting<number> {
    return this.#scaleSetting;
  }

  setAvailableSize(availableSize: Geometry.Size, preferredSize: Geometry.Size): void {
    this.#availableSize = availableSize;
    this.#preferredSize = preferredSize;
    this.#initialized = true;
    this.calculateAndEmulate(false);
  }

  emulate(type: Type, device: EmulatedDevice|null, mode: Mode|null, scale?: number): void {
    const resetPageScaleFactor = this.#type !== type || this.#device !== device || this.#mode !== mode;
    this.#type = type;

    if (type === Type.Device && device && mode) {
      console.assert(Boolean(device) && Boolean(mode), 'Must pass device and mode for device emulation');
      this.#mode = mode;
      this.#device = device;
      if (this.#initialized) {
        const orientation = device.orientationByName(mode.orientation);
        this.#scaleSetting.set(
            scale ||
            this.calculateFitScale(orientation.width, orientation.height, this.currentOutline(), this.currentInsets()));
      }
    } else {
      this.#device = null;
      this.#mode = null;
    }

    if (type !== Type.None) {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.DeviceModeEnabled);
    }
    this.calculateAndEmulate(resetPageScaleFactor);
  }

  setWidth(width: number): void {
    const max = Math.min(MaxDeviceSize, this.preferredScaledWidth());
    width = Math.max(Math.min(width, max), 1);
    this.#widthSetting.set(width);
  }

  setWidthAndScaleToFit(width: number): void {
    width = Math.max(Math.min(width, MaxDeviceSize), 1);
    this.#scaleSetting.set(this.calculateFitScale(width, this.#heightSetting.get()));
    this.#widthSetting.set(width);
  }

  setHeight(height: number): void {
    const max = Math.min(MaxDeviceSize, this.preferredScaledHeight());
    height = Math.max(Math.min(height, max), 0);
    if (height === this.preferredScaledHeight()) {
      height = 0;
    }
    this.#heightSetting.set(height);
  }

  setHeightAndScaleToFit(height: number): void {
    height = Math.max(Math.min(height, MaxDeviceSize), 0);
    this.#scaleSetting.set(this.calculateFitScale(this.#widthSetting.get(), height));
    this.#heightSetting.set(height);
  }

  setScale(scale: number): void {
    this.#scaleSetting.set(scale);
  }

  device(): EmulatedDevice|null {
    return this.#device;
  }

  mode(): Mode|null {
    return this.#mode;
  }

  type(): Type {
    return this.#type;
  }

  screenImage(): string {
    return (this.#device && this.#mode) ? this.#device.modeImage(this.#mode) : '';
  }

  outlineImage(): string {
    return (this.#device && this.#mode && this.#deviceOutlineSetting.get()) ? this.#device.outlineImage(this.#mode) :
                                                                              '';
  }

  outlineRect(): Rect|null {
    return this.#outlineRect || null;
  }

  screenRect(): Rect {
    return this.#screenRect;
  }

  visiblePageRect(): Rect {
    return this.#visiblePageRect;
  }

  scale(): number {
    return this.#scale;
  }

  fitScale(): number {
    return this.#fitScale;
  }

  appliedDeviceSize(): Geometry.Size {
    return this.#appliedDeviceSize;
  }

  appliedDeviceScaleFactor(): number {
    return this.#appliedDeviceScaleFactor;
  }

  appliedUserAgentType(): UA {
    return this.#appliedUserAgentType;
  }

  isFullHeight(): boolean {
    return !this.#heightSetting.get();
  }

  isMobile(): boolean {
    switch (this.#type) {
      case Type.Device:
        return this.#device ? this.#device.mobile() : false;
      case Type.None:
        return false;
      case Type.Responsive:
        return this.#uaSetting.get() === UA.MOBILE || this.#uaSetting.get() === UA.MOBILE_NO_TOUCH;
    }
    return false;
  }

  enabledSetting(): Common.Settings.Setting<boolean> {
    return Common.Settings.Settings.instance().createSetting('emulation.show-device-mode', false);
  }

  scaleSetting(): Common.Settings.Setting<number> {
    return this.#scaleSetting;
  }

  uaSetting(): Common.Settings.Setting<UA> {
    return this.#uaSetting;
  }

  deviceScaleFactorSetting(): Common.Settings.Setting<number> {
    return this.#deviceScaleFactorSetting;
  }

  deviceOutlineSetting(): Common.Settings.Setting<boolean> {
    return this.#deviceOutlineSetting;
  }

  toolbarControlsEnabledSetting(): Common.Settings.Setting<boolean> {
    return this.#toolbarControlsEnabledSetting;
  }

  reset(): void {
    this.#deviceScaleFactorSetting.set(0);
    this.#scaleSetting.set(1);
    this.setWidth(400);
    this.setHeight(0);
    this.#uaSetting.set(UA.MOBILE);
  }

  modelAdded(emulationModel: SDK.EmulationModel.EmulationModel): void {
    if (emulationModel.target() === SDK.TargetManager.TargetManager.instance().primaryPageTarget() &&
        emulationModel.supportsDeviceEmulation()) {
      this.#emulationModel = emulationModel;
      if (this.#onModelAvailable) {
        const callback = this.#onModelAvailable;
        this.#onModelAvailable = null;
        callback();
      }
      emulationModel.addEventListener(
          SDK.EmulationModel.EmulationModelEvents.SCREEN_ORIENTATION_LOCK_CHANGED, this.onScreenOrientationLockChanged,
          this);
      const resourceTreeModel = emulationModel.target().model(SDK.ResourceTreeModel.ResourceTreeModel);
      if (resourceTreeModel) {
        resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.FrameResized, this.onFrameChange, this);
        resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.FrameNavigated, this.onFrameChange, this);
      }
    } else {
      void emulationModel.emulateTouch(this.#touchEnabled, this.#touchMobile);
    }
  }

  modelRemoved(emulationModel: SDK.EmulationModel.EmulationModel): void {
    if (this.#emulationModel === emulationModel) {
      emulationModel.removeEventListener(
          SDK.EmulationModel.EmulationModelEvents.SCREEN_ORIENTATION_LOCK_CHANGED, this.onScreenOrientationLockChanged,
          this);
      this.#emulationModel = null;
      this.#screenOrientationLocked = false;
      this.dispatchEventToListeners(Events.UPDATED);
    }
  }

  inspectedURL(): string|null {
    return this.#emulationModel ? this.#emulationModel.target().inspectedURL() : null;
  }

  private onFrameChange(): void {
    const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null;
    if (!overlayModel) {
      return;
    }

    this.showHingeIfApplicable(overlayModel);
  }

  private onScreenOrientationLockChanged(
      event: Common.EventTarget.EventTargetEvent<SDK.EmulationModel.ScreenOrientationLockChangedEvent>): void {
    this.#screenOrientationLocked = event.data.locked;
    if (event.data.locked && event.data.orientation) {
      this.applyOrientationLock(event.data.orientation);
    }
    this.dispatchEventToListeners(Events.UPDATED);
  }

  private applyOrientationLock(orientation: Protocol.Emulation.ScreenOrientation): void {
    const wantsLandscape = orientation.type === Protocol.Emulation.ScreenOrientationType.LandscapePrimary ||
        orientation.type === Protocol.Emulation.ScreenOrientationType.LandscapeSecondary;

    if (this.#type === Type.Device && this.#device && this.#mode) {
      // For device emulation, switch to the matching orientation mode.
      const isCurrentlyLandscape =
          this.#mode.orientation === Horizontal || this.#mode.orientation === HorizontalSpanned;
      if (wantsLandscape !== isCurrentlyLandscape) {
        const rotationPartner = this.#device.getRotationPartner(this.#mode);
        if (rotationPartner) {
          this.emulate(this.#type, this.#device, rotationPartner);
        }
      }
    } else if (this.#type === Type.Responsive) {
      // For responsive mode, swap width/height if orientation doesn't match.
      const appliedSize = this.appliedDeviceSize();
      const isCurrentlyLandscape = appliedSize.width > appliedSize.height;
      if (wantsLandscape !== isCurrentlyLandscape) {
        this.setSizeAndScaleToFit(appliedSize.height, appliedSize.width);
      }
    }
  }

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

  private scaleSettingChanged(): void {
    this.calculateAndEmulate(false);
  }

  private widthSettingChanged(): void {
    this.calculateAndEmulate(false);
  }

  private heightSettingChanged(): void {
    this.calculateAndEmulate(false);
  }

  private uaSettingChanged(): void {
    this.calculateAndEmulate(true);
  }

  private deviceScaleFactorSettingChanged(): void {
    this.calculateAndEmulate(false);
  }

  private deviceOutlineSettingChanged(): void {
    this.calculateAndEmulate(false);
  }

  private preferredScaledWidth(): number {
    return Math.floor(this.#preferredSize.width / (this.#scaleSetting.get() || 1));
  }

  private preferredScaledHeight(): number {
    return Math.floor(this.#preferredSize.height / (this.#scaleSetting.get() || 1));
  }

  private currentOutline(): Insets {
    let outline: Insets = new Insets(0, 0, 0, 0);
    if (this.#type !== Type.Device || !this.#device || !this.#mode) {
      return outline;
    }
    const orientation = this.#device.orientationByName(this.#mode.orientation);
    if (this.#deviceOutlineSetting.get()) {
      outline = orientation.outlineInsets || outline;
    }
    return outline;
  }

  private currentInsets(): Insets {
    if (this.#type !== Type.Device || !this.#mode) {
      return new Insets(0, 0, 0, 0);
    }
    return this.#mode.insets;
  }

  private getScreenOrientationType(): Protocol.Emulation.ScreenOrientationType {
    if (!this.#mode) {
      throw new Error('Mode required to get orientation type.');
    }
    switch (this.#mode.orientation) {
      case VerticalSpanned:
      case Vertical:
        return Protocol.Emulation.ScreenOrientationType.PortraitPrimary;
      case HorizontalSpanned:
      case Horizontal:
      default:
        return Protocol.Emulation.ScreenOrientationType.LandscapePrimary;
    }
  }

  private calculateAndEmulate(resetPageScaleFactor: boolean): void {
    if (!this.#emulationModel) {
      this.#onModelAvailable = this.calculateAndEmulate.bind(this, resetPageScaleFactor);
    }
    const mobile = this.isMobile();
    const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null;
    if (overlayModel) {
      this.showHingeIfApplicable(overlayModel);
    }
    if (this.#type === Type.Device && this.#device && this.#mode) {
      const orientation = this.#device.orientationByName(this.#mode.orientation);
      const outline = this.currentOutline();
      const insets = this.currentInsets();
      this.#fitScale = this.calculateFitScale(orientation.width, orientation.height, outline, insets);
      if (mobile) {
        this.#appliedUserAgentType = this.#device.touch() ? UA.MOBILE : UA.MOBILE_NO_TOUCH;
      } else {
        this.#appliedUserAgentType = this.#device.touch() ? UA.DESKTOP_TOUCH : UA.DESKTOP;
      }
      this.applyDeviceMetrics(
          new Geometry.Size(orientation.width, orientation.height), insets, outline, this.#scaleSetting.get(),
          this.#device.deviceScaleFactor, mobile, this.getScreenOrientationType(), resetPageScaleFactor);
      this.applyUserAgent(this.#device.userAgent, this.#device.userAgentMetadata);
      this.applyTouch(this.#device.touch(), mobile);
    } else if (this.#type === Type.None) {
      this.#fitScale = this.calculateFitScale(this.#availableSize.width, this.#availableSize.height);
      this.#appliedUserAgentType = UA.DESKTOP;
      this.applyDeviceMetrics(
          this.#availableSize, new Insets(0, 0, 0, 0), new Insets(0, 0, 0, 0), 1, 0, mobile, null,
          resetPageScaleFactor);
      this.applyUserAgent('', null);
      this.applyTouch(false, false);
    } else if (this.#type === Type.Responsive) {
      let screenWidth = this.#widthSetting.get();
      if (!screenWidth || screenWidth > this.preferredScaledWidth()) {
        screenWidth = this.preferredScaledWidth();
      }
      let screenHeight = this.#heightSetting.get();
      if (!screenHeight || screenHeight > this.preferredScaledHeight()) {
        screenHeight = this.preferredScaledHeight();
      }
      const defaultDeviceScaleFactor = mobile ? defaultMobileScaleFactor : 0;
      this.#fitScale = this.calculateFitScale(this.#widthSetting.get(), this.#heightSetting.get());
      this.#appliedUserAgentType = this.#uaSetting.get();
      this.applyDeviceMetrics(
          new Geometry.Size(screenWidth, screenHeight), new Insets(0, 0, 0, 0), new Insets(0, 0, 0, 0),
          this.#scaleSetting.get(), this.#deviceScaleFactorSetting.get() || defaultDeviceScaleFactor, mobile,
          screenHeight >= screenWidth ? Protocol.Emulation.ScreenOrientationType.PortraitPrimary :
                                        Protocol.Emulation.ScreenOrientationType.LandscapePrimary,
          resetPageScaleFactor);
      this.applyUserAgent(mobile ? defaultMobileUserAgent : '', mobile ? defaultMobileUserAgentMetadata : null);
      this.applyTouch(
          this.#uaSetting.get() === UA.DESKTOP_TOUCH || this.#uaSetting.get() === UA.MOBILE,
          this.#uaSetting.get() === UA.MOBILE);
    }

    if (overlayModel) {
      overlayModel.setShowViewportSizeOnResize(this.#type === Type.None);
    }
    this.dispatchEventToListeners(Events.UPDATED);
  }

  private calculateFitScale(screenWidth: number, screenHeight: number, outline?: Insets, insets?: Insets): number {
    const outlineWidth = outline ? outline.left + outline.right : 0;
    const outlineHeight = outline ? outline.top + outline.bottom : 0;
    const insetsWidth = insets ? insets.left + insets.right : 0;
    const insetsHeight = insets ? insets.top + insets.bottom : 0;
    let scale = Math.min(
        screenWidth ? this.#preferredSize.width / (screenWidth + outlineWidth) : 1,
        screenHeight ? this.#preferredSize.height / (screenHeight + outlineHeight) : 1);
    scale = Math.min(Math.floor(scale * 100), 100);

    let sharpScale = scale;
    while (sharpScale > scale * 0.7) {
      let sharp = true;
      if (screenWidth) {
        sharp = sharp && Number.isInteger((screenWidth - insetsWidth) * sharpScale / 100);
      }
      if (screenHeight) {
        sharp = sharp && Number.isInteger((screenHeight - insetsHeight) * sharpScale / 100);
      }
      if (sharp) {
        return sharpScale / 100;
      }
      sharpScale -= 1;
    }
    return scale / 100;
  }

  setSizeAndScaleToFit(width: number, height: number): void {
    this.#scaleSetting.set(this.calculateFitScale(width, height));
    this.setWidth(width);
    this.setHeight(height);
  }

  private applyUserAgent(userAgent: string, userAgentMetadata: Protocol.Emulation.UserAgentMetadata|null): void {
    // When the user agent string is empty (e.g. custom desktop device without
    // a UA override), metadata must also be cleared. The backend rejects
    // setUserAgentOverride calls that provide metadata without a UA string.
    SDK.NetworkManager.MultitargetNetworkManager.instance().setUserAgentOverride(
        userAgent, userAgent ? userAgentMetadata : null);
  }

  private applyDeviceMetrics(
      screenSize: Geometry.Size, insets: Insets, outline: Insets, scale: number, deviceScaleFactor: number,
      mobile: boolean, screenOrientation: Protocol.Emulation.ScreenOrientationType|null,
      resetPageScaleFactor: boolean): void {
    screenSize.width = Math.max(1, Math.floor(screenSize.width));
    screenSize.height = Math.max(1, Math.floor(screenSize.height));

    let pageWidth: 0|number = screenSize.width - insets.left - insets.right;
    let pageHeight: 0|number = screenSize.height - insets.top - insets.bottom;

    const positionX = insets.left;
    const positionY = insets.top;
    const screenOrientationAngle =
        screenOrientation === Protocol.Emulation.ScreenOrientationType.LandscapePrimary ? 90 : 0;

    this.#appliedDeviceSize = screenSize;
    this.#appliedDeviceScaleFactor = deviceScaleFactor || window.devicePixelRatio;
    this.#screenRect = new Rect(
        Math.max(0, (this.#availableSize.width - screenSize.width * scale) / 2), outline.top * scale,
        screenSize.width * scale, screenSize.height * scale);
    this.#outlineRect = new Rect(
        this.#screenRect.left - outline.left * scale, 0, (outline.left + screenSize.width + outline.right) * scale,
        (outline.top + screenSize.height + outline.bottom) * scale);
    this.#visiblePageRect = new Rect(
        positionX * scale, positionY * scale,
        Math.min(pageWidth * scale, this.#availableSize.width - this.#screenRect.left - positionX * scale),
        Math.min(pageHeight * scale, this.#availableSize.height - this.#screenRect.top - positionY * scale));
    this.#scale = scale;
    const displayFeature = this.getDisplayFeature();
    if (!displayFeature) {
      // When sending displayFeature, we cannot use the optimization below due to backend restrictions.
      if (scale === 1 && this.#availableSize.width >= screenSize.width &&
          this.#availableSize.height >= screenSize.height) {
        // When we have enough space, no page size override is required. This will speed things up and remove lag.
        pageWidth = 0;
        pageHeight = 0;
      }
      if (this.#visiblePageRect.width === pageWidth * scale && this.#visiblePageRect.height === pageHeight * scale &&
          Number.isInteger(pageWidth * scale) && Number.isInteger(pageHeight * scale)) {
        // When we only have to apply scale, do not resize the page. This will speed things up and remove lag.
        pageWidth = 0;
        pageHeight = 0;
      }
    }

    if (!this.#emulationModel) {
      return;
    }

    if (resetPageScaleFactor) {
      void this.#emulationModel.resetPageScaleFactor();
    }
    if (pageWidth || pageHeight || mobile || deviceScaleFactor || scale !== 1 || screenOrientation || displayFeature) {
      const metrics: Protocol.Emulation.SetDeviceMetricsOverrideRequest = {
        width: pageWidth,
        height: pageHeight,
        deviceScaleFactor,
        mobile,
        scale,
        screenWidth: screenSize.width,
        screenHeight: screenSize.height,
        positionX,
        positionY,
        dontSetVisibleSize: true,
      };
      if (displayFeature) {
        metrics.displayFeature = displayFeature;
        metrics.devicePosture = {type: Protocol.Emulation.DevicePostureType.Folded};
      } else {
        metrics.devicePosture = {type: Protocol.Emulation.DevicePostureType.Continuous};
      }
      if (screenOrientation) {
        metrics.screenOrientation = {type: screenOrientation, angle: screenOrientationAngle};
      }
      void this.#emulationModel.emulateDevice(metrics);
    } else {
      void this.#emulationModel.emulateDevice(null);
    }
  }

  exitHingeMode(): void {
    const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null;
    if (overlayModel) {
      overlayModel.showHingeForDualScreen(null);
    }
  }

  async captureScreenshot(fullSize: boolean, clip?: Protocol.Page.Viewport): Promise<string|null> {
    const screenCaptureModel =
        this.#emulationModel ? this.#emulationModel.target().model(SDK.ScreenCaptureModel.ScreenCaptureModel) : null;
    if (!screenCaptureModel) {
      return null;
    }

    let screenshotMode;
    if (clip) {
      screenshotMode = SDK.ScreenCaptureModel.ScreenshotMode.FROM_CLIP;
    } else if (fullSize) {
      screenshotMode = SDK.ScreenCaptureModel.ScreenshotMode.FULLPAGE;
    } else {
      screenshotMode = SDK.ScreenCaptureModel.ScreenshotMode.FROM_VIEWPORT;
    }

    const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null;
    if (overlayModel) {
      overlayModel.setShowViewportSizeOnResize(false);
    }

    if (this.#emulationModel && this.#device && this.#mode) {
      const orientation = this.#device.orientationByName(this.#mode.orientation);
      const deviceMetrics: Protocol.Emulation.SetDeviceMetricsOverrideRequest = {
        width: orientation.width,
        height: orientation.height,
        deviceScaleFactor: this.#device.deviceScaleFactor,
        mobile: this.isMobile(),
      };
      const dispFeature = this.getDisplayFeature();
      if (dispFeature) {
        deviceMetrics.displayFeature = dispFeature;
      }
      await this.#emulationModel.emulateDevice(deviceMetrics);
    }

    try {
      const screenshot = await screenCaptureModel.captureScreenshot(
          Protocol.Page.CaptureScreenshotRequestFormat.Png, 100, screenshotMode, clip);
      return screenshot;
    } finally {
      await this.#emulationModel?.emulateDevice(null);
      overlayModel?.setShowViewportSizeOnResize(this.#type === Type.None);
      this.calculateAndEmulate(false);
    }
  }

  private applyTouch(touchEnabled: boolean, mobile: boolean): void {
    this.#touchEnabled = touchEnabled;
    this.#touchMobile = mobile;
    for (const emulationModel of SDK.TargetManager.TargetManager.instance().models(SDK.EmulationModel.EmulationModel)) {
      void emulationModel.emulateTouch(touchEnabled, mobile);
    }
  }

  private showHingeIfApplicable(overlayModel: SDK.OverlayModel.OverlayModel): void {
    const orientation = (this.#device && this.#mode) ? this.#device.orientationByName(this.#mode.orientation) : null;
    if (orientation?.hinge) {
      overlayModel.showHingeForDualScreen(orientation.hinge);
      return;
    }

    overlayModel.showHingeForDualScreen(null);
  }

  private getDisplayFeatureOrientation(): Protocol.Emulation.DisplayFeatureOrientation {
    if (!this.#mode) {
      throw new Error('Mode required to get display feature orientation.');
    }
    switch (this.#mode.orientation) {
      case VerticalSpanned:
      case Vertical:
        return Protocol.Emulation.DisplayFeatureOrientation.Vertical;
      case HorizontalSpanned:
      case Horizontal:
      default:
        return Protocol.Emulation.DisplayFeatureOrientation.Horizontal;
    }
  }

  private getDisplayFeature(): Protocol.Emulation.DisplayFeature|null {
    if (!this.#device || !this.#mode ||
        (this.#mode.orientation !== VerticalSpanned && this.#mode.orientation !== HorizontalSpanned)) {
      return null;
    }

    const orientation = this.#device.orientationByName(this.#mode.orientation);
    if (!orientation?.hinge) {
      return null;
    }

    const hinge = orientation.hinge;
    return {
      orientation: this.getDisplayFeatureOrientation(),
      offset: (this.#mode.orientation === VerticalSpanned) ? hinge.x : hinge.y,
      maskLength: (this.#mode.orientation === VerticalSpanned) ? hinge.width : hinge.height,
    };
  }
}

export class Insets {
  constructor(public left: number, public top: number, public right: number, public bottom: number) {
  }

  isEqual(insets: Insets|null): boolean {
    return insets !== null && this.left === insets.left && this.top === insets.top && this.right === insets.right &&
        this.bottom === insets.bottom;
  }
}

export class Rect {
  constructor(public left: number, public top: number, public width: number, public height: number) {
  }

  isEqual(rect: Rect|null): boolean {
    return rect !== null && this.left === rect.left && this.top === rect.top && this.width === rect.width &&
        this.height === rect.height;
  }

  scale(scale: number): Rect {
    return new Rect(this.left * scale, this.top * scale, this.width * scale, this.height * scale);
  }

  relativeTo(origin: Rect): Rect {
    return new Rect(this.left - origin.left, this.top - origin.top, this.width, this.height);
  }

  rebaseTo(origin: Rect): Rect {
    return new Rect(this.left + origin.left, this.top + origin.top, this.width, this.height);
  }
}

export const enum Events {
  UPDATED = 'Updated',
}

export interface EventTypes {
  [Events.UPDATED]: void;
}

export enum Type {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  None = 'None',
  Responsive = 'Responsive',
  Device = 'Device',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export const enum UA {
  // TODO(crbug.com/1136655): This enum is used for both display and code functionality.
  // we should refactor this so localization of these strings only happens for user display.
  MOBILE = 'Mobile',
  MOBILE_NO_TOUCH = 'Mobile (no touch)',
  DESKTOP = 'Desktop',
  DESKTOP_TOUCH = 'Desktop (touch)',
}

export const MinDeviceSize = 50;
export const MaxDeviceSize = 9999;
export const MinDeviceScaleFactor = 0;
export const MaxDeviceScaleFactor = 10;
export const MaxDeviceNameLength = 50;

const mobileUserAgent =
    'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36';
const defaultMobileUserAgent =
    SDK.NetworkManager.MultitargetNetworkManager.patchUserAgentWithChromeVersion(mobileUserAgent);

const defaultMobileUserAgentMetadata = {
  platform: 'Android',
  platformVersion: '6.0',
  architecture: '',
  model: 'Nexus 5',
  mobile: true,
};
export const defaultMobileScaleFactor = 2;
