// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */

import type * as Common from '../../core/common/common.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as EmulationModel from '../../models/emulation/emulation.js';
import * as UI from '../../ui/legacy/legacy.js';

import {DeviceModeView} from './DeviceModeView.js';
import type {InspectedPagePlaceholder} from './InspectedPagePlaceholder.js';

let deviceModeWrapperInstance: DeviceModeWrapper;

export class DeviceModeWrapper extends UI.Widget.VBox {
  private readonly inspectedPagePlaceholder: InspectedPagePlaceholder;
  private deviceModeView: DeviceModeView|null;
  private readonly toggleDeviceModeAction: UI.ActionRegistration.Action;
  private showDeviceModeSetting: Common.Settings.Setting<boolean>;

  private constructor(inspectedPagePlaceholder: InspectedPagePlaceholder) {
    super();
    this.inspectedPagePlaceholder = inspectedPagePlaceholder;
    this.deviceModeView = null;
    this.toggleDeviceModeAction = UI.ActionRegistry.ActionRegistry.instance().getAction('emulation.toggle-device-mode');
    const model = EmulationModel.DeviceModeModel.DeviceModeModel.instance();
    this.showDeviceModeSetting = model.enabledSetting();
    this.showDeviceModeSetting.setRequiresUserAction(Boolean(Root.Runtime.Runtime.queryParam('hasOtherClients')));
    this.showDeviceModeSetting.addChangeListener(this.update.bind(this, false));
    SDK.TargetManager.TargetManager.instance().addModelListener(
        SDK.OverlayModel.OverlayModel, SDK.OverlayModel.Events.SCREENSHOT_REQUESTED,
        this.screenshotRequestedFromOverlay, this);
    this.update(true);
  }

  static instance(opts: {
    forceNew: boolean|null,
    inspectedPagePlaceholder: InspectedPagePlaceholder|null,
  } = {forceNew: null, inspectedPagePlaceholder: null}): DeviceModeWrapper {
    const {forceNew, inspectedPagePlaceholder} = opts;
    if (!deviceModeWrapperInstance || forceNew) {
      if (!inspectedPagePlaceholder) {
        throw new Error(
            `Unable to create DeviceModeWrapper: inspectedPagePlaceholder must be provided: ${new Error().stack}`);
      }

      deviceModeWrapperInstance = new DeviceModeWrapper(inspectedPagePlaceholder);
    }

    return deviceModeWrapperInstance;
  }

  toggleDeviceMode(): void {
    this.showDeviceModeSetting.set(!this.showDeviceModeSetting.get());
  }

  isDeviceModeOn(): boolean {
    return this.showDeviceModeSetting.get();
  }

  captureScreenshot(fullSize?: boolean, clip?: Protocol.Page.Viewport): boolean {
    if (!this.deviceModeView) {
      this.deviceModeView = new DeviceModeView();
    }
    this.deviceModeView.setNonEmulatedAvailableSize(this.inspectedPagePlaceholder.element);
    if (fullSize) {
      void this.deviceModeView.captureFullSizeScreenshot();
    } else if (clip) {
      void this.deviceModeView.captureAreaScreenshot(clip);
    } else {
      void this.deviceModeView.captureScreenshot();
    }
    return true;
  }

  private screenshotRequestedFromOverlay(event: Common.EventTarget.EventTargetEvent<Protocol.Page.Viewport>): void {
    const clip = event.data;
    this.captureScreenshot(false, clip);
  }

  update(force?: boolean): void {
    this.toggleDeviceModeAction.setToggled(this.showDeviceModeSetting.get());

    const shouldShow = this.showDeviceModeSetting.get();
    if (!force && shouldShow === this.deviceModeView?.isShowing()) {
      return;
    }

    if (shouldShow) {
      if (!this.deviceModeView) {
        this.deviceModeView = new DeviceModeView();
      }
      this.deviceModeView.show(this.element);
      this.inspectedPagePlaceholder.clearMinimumSize();
      this.inspectedPagePlaceholder.show(this.deviceModeView.element);
    } else {
      if (this.deviceModeView) {
        this.deviceModeView.exitHingeMode();
        this.deviceModeView.detach();
      }
      this.inspectedPagePlaceholder.restoreMinimumSize();
      this.inspectedPagePlaceholder.show(this.element);
    }
  }
}

export class ActionDelegate implements UI.ActionRegistration.ActionDelegate {
  handleAction(context: UI.Context.Context, actionId: string): boolean {
    switch (actionId) {
      case 'emulation.capture-screenshot':
        return DeviceModeWrapper.instance().captureScreenshot();

      case 'emulation.capture-node-screenshot': {
        const node = context.flavor(SDK.DOMModel.DOMNode);
        if (!node) {
          return true;
        }
        async function captureClip(): Promise<void> {
          if (!node) {
            return;
          }

          // Resolve to a remote object to ensure the node is alive in the context.
          const object = await node.resolveToObject();
          if (!object) {
            return;
          }

          // Get the Box Model via CDP.
          // This returns the quads relative to the target's viewport.
          // We use the 'border' quad to include the border and padding in the screenshot,
          // matching the 'width' and 'height' properties which are also Border Box dimensions.
          const nodeBoxModel = await node.boxModel();
          if (!nodeBoxModel) {
            throw new Error(`Unable to get box model of the node: ${new Error().stack}`);
          }
          const nodeBorderQuad = nodeBoxModel.border;

          // Get Layout Metrics to account for the Visual Viewport scroll and zoom.
          const metrics = await node.domModel().target().pageAgent().invoke_getLayoutMetrics();
          if (metrics.getError()) {
            throw new Error(`Unable to get metrics: ${new Error().stack}`);
          }

          const scrollX = metrics.cssVisualViewport.pageX;
          const scrollY = metrics.cssVisualViewport.pageY;

          // Calculate the global offset for OOPiFs (Out-of-Process iframes).
          // This accounts for the position of the target's frame within the main page.
          const {x: oopifOffsetX, y: oopifOffsetY} = await getOopifOffset(node.domModel().target());

          // Assemble the final Clip.
          // The absolute coordinates are: Global (OOPiF) + Viewport Scroll + Local Node Position (Border Box).
          const clip = {
            x: oopifOffsetX + scrollX + nodeBorderQuad[0],
            y: oopifOffsetY + scrollY + nodeBorderQuad[1],
            width: nodeBoxModel.width,
            height: nodeBoxModel.height,
            scale: 1,
          };

          // Apply Zoom factor.
          const zoom = metrics.cssVisualViewport.zoom ?? 1;
          clip.x *= zoom;
          clip.y *= zoom;
          clip.width *= zoom;
          clip.height *= zoom;
          DeviceModeWrapper.instance().captureScreenshot(false, clip);
        }
        void captureClip();
        return true;
      }

      case 'emulation.capture-full-height-screenshot':
        return DeviceModeWrapper.instance().captureScreenshot(true);

      case 'emulation.toggle-device-mode':
        DeviceModeWrapper.instance().toggleDeviceMode();
        return true;
    }
    return false;
  }
}

/**
 * Calculate the offset of the "Local Root" frame relative to the "Global Root" (the main frame).
 * This involves traversing the CDP Targets for OOPiFs.
 */
async function getOopifOffset(target: SDK.Target.Target|null): Promise<{x: number, y: number}> {
  if (!target) {
    return {x: 0, y: 0};
  }

  // Get the parent target. If there's no parent (we are at root) or it's not a frame, we are done.
  const parentTarget = target.parentTarget();
  if (!parentTarget || parentTarget.type() !== SDK.Target.Type.FRAME) {
    return {x: 0, y: 0};
  }

  // Identify the current frame's ID to find its owner in the parent.
  const frameId = target.model(SDK.ResourceTreeModel.ResourceTreeModel)?.mainFrame?.id;
  if (!frameId) {
    return {x: 0, y: 0};
  }

  // Get the DOMModel of the parent to query the frame owner element.
  const parentDOMModel = parentTarget.model(SDK.DOMModel.DOMModel);
  if (!parentDOMModel) {
    return {x: 0, y: 0};
  }

  // Retrieve the frame owner node (e.g. the <iframe> element) in the parent's document.
  const frameOwnerDeferred = await parentDOMModel.getOwnerNodeForFrame(frameId);
  const frameOwner = await frameOwnerDeferred?.resolvePromise();
  if (!frameOwner) {
    return {x: 0, y: 0};
  }

  // Get the content box of the iframe element.
  // This is relative to the parent target's viewport.
  const boxModel = await frameOwner.boxModel();
  if (!boxModel) {
    return {x: 0, y: 0};
  }

  // content is a Quad [x1, y1, x2, y2, x3, y3, x4, y4]
  const contentQuad = boxModel.content;
  const iframeContentX = contentQuad[0];
  const iframeContentY = contentQuad[1];

  // Get the scroll position of the parent target to convert viewport-relative coordinates
  // to document-relative coordinates.
  const parentMetrics = await parentTarget.pageAgent().invoke_getLayoutMetrics();
  if (parentMetrics.getError()) {
    return {x: 0, y: 0};
  }

  const scrollX = parentMetrics.cssVisualViewport.pageX;
  const scrollY = parentMetrics.cssVisualViewport.pageY;

  // Recursively add the offset of the parent target itself (if it is also an OOPiF).
  const parentOffset = await getOopifOffset(parentTarget);

  return {
    x: iframeContentX + scrollX + parentOffset.x,
    y: iframeContentY + scrollY + parentOffset.y,
  };
}
