// Copyright 2021 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 */

/*
 * Copyright (C) 2008 Apple Inc. All Rights Reserved.
 * Copyright (C) 2011 Google Inc. All Rights Reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL APPLE INC. OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import '../../core/dom_extension/dom_extension.js';

import * as Platform from '../../core/platform/platform.js';
import * as Geometry from '../../models/geometry/geometry.js';
import * as Lit from '../../ui/lit/lit.js';

import {appendStyle, deepActiveElement} from './DOMUtilities.js';
import {cloneCustomElement, createShadowRootWithCoreStyles} from './UIUtils.js';

const {html} = Lit;

// Remember the original DOM mutation methods here, since we
// will override them below to sanity check the Widget system.
const originalAppendChild = Element.prototype.appendChild;
const originalInsertBefore = Element.prototype.insertBefore;
const originalRemoveChild = Element.prototype.removeChild;
const originalRemoveChildren = Element.prototype.removeChildren;

function assert(condition: unknown, message: string): void {
  if (!condition) {
    throw new Error(message);
  }
}

type WidgetConstructor<WidgetT extends Widget> = new (element: HTMLElement) => WidgetT;
type WidgetProducer<WidgetT extends Widget> = (element: HTMLElement) => WidgetT;
type WidgetFactory<WidgetT extends Widget> = WidgetConstructor<WidgetT>|WidgetProducer<WidgetT>;
type InferWidgetTFromFactory<F> = F extends WidgetFactory<infer WidgetT>? WidgetT : never;

export class WidgetConfig<WidgetT extends Widget> {
  constructor(readonly widgetClass: WidgetFactory<WidgetT>, readonly widgetParams?: Partial<WidgetT>) {
  }
}

export function widgetConfig<F extends WidgetFactory<Widget>, ParamKeys extends keyof InferWidgetTFromFactory<F>>(
    widgetClass: F, widgetParams?: Pick<InferWidgetTFromFactory<F>, ParamKeys>&Partial<InferWidgetTFromFactory<F>>):
    // This is a workaround for https://github.com/runem/lit-analyzer/issues/163
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    WidgetConfig<any> {
  return new WidgetConfig(widgetClass, widgetParams);
}

let currentUpdateQueue: Map<Widget, PromiseWithResolvers<void>>|null = null;
const currentlyProcessed = new Set<Widget>();
let nextUpdateQueue = new Map<Widget, PromiseWithResolvers<void>>();
let pendingAnimationFrame: number|null = null;
let overallUpdatePromise: PromiseWithResolvers<void>|null = null;

function enqueueIntoNextUpdateQueue(widget: Widget): Promise<void> {
  const scheduledUpdate = nextUpdateQueue.get(widget) ?? Promise.withResolvers<void>();
  nextUpdateQueue.delete(widget);
  nextUpdateQueue.set(widget, scheduledUpdate);
  if (pendingAnimationFrame === null) {
    pendingAnimationFrame = requestAnimationFrame(runNextUpdate);
  }
  return scheduledUpdate.promise;
}

function enqueueWidgetUpdate(widget: Widget): Promise<void> {
  if (currentUpdateQueue) {
    if (currentlyProcessed.has(widget)) {
      return enqueueIntoNextUpdateQueue(widget);
    }
    const scheduledUpdate = currentUpdateQueue.get(widget) ?? Promise.withResolvers<void>();
    currentUpdateQueue.delete(widget);
    currentUpdateQueue.set(widget, scheduledUpdate);
    return scheduledUpdate.promise;
  }
  return enqueueIntoNextUpdateQueue(widget);
}

function cancelUpdate(widget: Widget): void {
  widget.cancelUpdateController();
  if (currentUpdateQueue) {
    const scheduledUpdate = currentUpdateQueue.get(widget);
    if (scheduledUpdate) {
      scheduledUpdate.resolve();
      currentUpdateQueue.delete(widget);
    }
  }
  const scheduledUpdate = nextUpdateQueue.get(widget);
  if (scheduledUpdate) {
    scheduledUpdate.resolve();
    nextUpdateQueue.delete(widget);
  }
}

function runNextUpdate(): void {
  pendingAnimationFrame = null;
  if (!currentUpdateQueue) {
    currentUpdateQueue = nextUpdateQueue;
    nextUpdateQueue = new Map();
  }
  for (const [widget, {resolve}] of currentUpdateQueue) {
    currentlyProcessed.add(widget);
    void (async () => {
      try {
        const controller = new AbortController();
        widget.addUpdateController(controller);
        await widget.performUpdate(controller.signal);
      } catch (e) {
        if (e.name !== 'AbortError') {
          throw e;
        }
      } finally {
        resolve();
      }
    })();
  }
  currentUpdateQueue.clear();
  queueMicrotask(() => {
    if (currentUpdateQueue && currentUpdateQueue.size > 0) {
      runNextUpdate();
    } else {
      currentUpdateQueue = null;
      currentlyProcessed.clear();
      if (!pendingAnimationFrame && overallUpdatePromise) {
        overallUpdatePromise.resolve();
        overallUpdatePromise = null;
      }
    }
  });
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const widgetConfigs = new WeakMap<HTMLElement, WidgetConfig<any>>();

export function registerWidgetConfig<WidgetT extends Widget>(
    element: HTMLElement, config: WidgetConfig<WidgetT>): void {
  if (!widgetConfigs.has(element)) {
    setUpLifecycleTracking(element);
  }
  widgetConfigs.set(element, config);
}

function instantiateWidget<WidgetT extends Widget>(element: HTMLElement, widgetConfig: WidgetConfig<WidgetT>): WidgetT {
  if (!widgetConfig.widgetClass) {
    throw new Error('No widgetClass defined');
  }

  let newWidget: WidgetT;
  if (Widget.isPrototypeOf(widgetConfig.widgetClass)) {
    const ctor = widgetConfig.widgetClass as WidgetConstructor<WidgetT>;
    newWidget = new ctor(element);
  } else {
    const factory = widgetConfig.widgetClass as WidgetProducer<WidgetT>;
    newWidget = factory(element);
  }

  if (widgetConfig.widgetParams) {
    Object.assign(newWidget, widgetConfig.widgetParams);
  }
  newWidget.requestUpdate();
  return newWidget;
}

function setUpLifecycleTracking<WidgetT extends Widget>(element: HTMLElement): void {
  let tracker: WidgetElement<WidgetT>;
  if (element instanceof WidgetElement) {
    tracker = element as WidgetElement<WidgetT>;
  } else {
    tracker = document.createElement('devtools-widget') as WidgetElement<WidgetT>;
    tracker.style.display = 'none';
    element.appendChild(tracker);
  }

  tracker.onDisconnect = () => {
    const widget = Widget.get(element);
    if (widget) {
      widget.setHideOnDetach();
      widget.detach();
    }
  };
  tracker.onConnect = () => {
    let widget = Widget.get(element) as WidgetT;
    if (!widget) {
      const config = widgetConfigs.get(element);
      if (!config) {
        throw new Error('No widgetConfig defined');
      }
      widget = instantiateWidget(element, config);
    }
    const parent = element.parentElementOrShadowHost() as HTMLElement | null;
    if (!parent) {
      widget.markAsRoot();
    }
    widget.show(parent as HTMLElement, undefined, /* suppressOrphanWidgetError= */ true);
  };
}

export class WidgetElement<WidgetT extends Widget> extends HTMLElement {
  onDisconnect?: () => void;
  onConnect?: () => void;
  #disconnectTimeout?: ReturnType<typeof setTimeout>;

  getWidget(): WidgetT|undefined {
    return Widget.get(this) as WidgetT | undefined;
  }

  connectedCallback(): void {
    if (this.#disconnectTimeout) {
      clearTimeout(this.#disconnectTimeout);
      this.#disconnectTimeout = undefined;
    }
    if (this.onConnect) {
      this.onConnect();
      return;
    }
  }

  disconnectedCallback(): void {
    if (this.onDisconnect) {
      this.#disconnectTimeout = setTimeout(() => {
        this.onDisconnect?.();
      }, 0);
      return;
    }
  }

  override appendChild<T extends Node>(child: T): T {
    const widget = child instanceof HTMLElement ? Widget.get(child) : null;
    if (widget) {
      widget.show(this, undefined, /* suppressOrphanWidgetError= */ true);
      return child;
    }
    return super.appendChild(child);
  }

  override insertBefore<T extends Node>(child: T, referenceChild: Node): T {
    const widget = child instanceof HTMLElement ? Widget.get(child) : null;
    if (widget) {
      widget.show(this, referenceChild, /* suppressOrphanWidgetError= */ true);
      return child;
    }
    return super.insertBefore(child, referenceChild);
  }

  override removeChild<T extends Node>(child: T): T {
    const childWidget = Widget.get(child);
    if (childWidget) {
      childWidget.detach(/* overrideHideOnDetach= */ true);
      return child;
    }
    return super.removeChild(child);
  }

  override removeChildren(): void {
    for (const child of this.children) {
      const childWidget = Widget.get(child);
      if (childWidget) {
        childWidget.detach(/* overrideHideOnDetach= */ true);
      }
    }
    super.removeChildren();
  }

  override cloneNode(deep: boolean): Node {
    const clone = cloneCustomElement(this, deep) as WidgetElement<WidgetT>;
    const config = widgetConfigs.get(this);
    if (config) {
      registerWidgetConfig(clone, config);
    }
    return clone;
  }

  override focus(): void {
    const widget = Widget.get(this);
    if (widget) {
      widget.focus();
    }
  }
}

customElements.define('devtools-widget', WidgetElement);

export class WidgetDirective extends Lit.Directive.Directive {
  #partType: Lit.Directive.PartType;

  constructor(partInfo: Lit.Directive.PartInfo) {
    super(partInfo);
    this.#partType = partInfo.type;
    if (this.#partType !== Lit.Directive.PartType.CHILD && this.#partType !== Lit.Directive.PartType.ELEMENT) {
      throw new Error('Widget directive must be used as a child or element directive.');
    }
  }

  override update(part: Lit.Directive.Part, [widgetClass, widgetParams]: Parameters<this['render']>): unknown {
    if (this.#partType === Lit.Directive.PartType.ELEMENT) {
      const element = (part as Lit.Directive.ElementPart).element as HTMLElement;

      const config = widgetConfig(widgetClass, widgetParams);
      const oldConfig = widgetConfigs.get(element);
      const widget = Widget.get(element);
      if (widget && config.widgetParams) {
        let needsUpdate = false;
        for (const key in config.widgetParams) {
          if (Object.prototype.hasOwnProperty.call(config.widgetParams, key) &&
              config.widgetParams[key] !== oldConfig?.widgetParams?.[key]) {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            (widget as any)[key] = config.widgetParams[key];
            needsUpdate = true;
          }
        }
        if (needsUpdate) {
          widget.requestUpdate();
        }
      }
      registerWidgetConfig(element, config);
      return Lit.nothing;
    }
    return this.render(widgetClass, widgetParams);
  }

  render<F extends WidgetFactory<Widget>, ParamKeys extends keyof InferWidgetTFromFactory<F>>(
      widgetClass: F,
      widgetParams?: Pick<InferWidgetTFromFactory<F>, ParamKeys>&Partial<InferWidgetTFromFactory<F>>): unknown {
    if (this.#partType === Lit.Directive.PartType.ELEMENT) {
      return Lit.nothing;
    }
    // We use `repeat` to force Lit to recreate the `<devtools-widget>` DOM node when the `widgetClass` changes.
    // If we didn't use `repeat` and used `html` directly, Lit would reuse the same `<devtools-widget>` instance
    // even if `widgetClass` changed (for example, in a ternary operator `condition ? widget(A) : widget(B)`).
    // This is because the template string is the same, so Lit reuses the DOM node and only updates `.widgetConfig`,
    // which does not properly recreate the widget instance.
    return Lit.Directives.repeat(
        [widgetClass], () => widgetClass,
        () => html`<devtools-widget ${widget<F, ParamKeys>(widgetClass, widgetParams)}></devtools-widget>`);
  }
}

export const widget = Lit.Directive.directive(WidgetDirective) as
    <F extends WidgetFactory<Widget>, ParamKeys extends keyof InferWidgetTFromFactory<F>>(
                          widgetClass: F,
                          widgetParams?: Pick<InferWidgetTFromFactory<F>, ParamKeys>&
                          Partial<InferWidgetTFromFactory<F>>) => Lit.Directive.DirectiveResult<typeof WidgetDirective>;

export function widgetRef<T extends Widget, Args extends unknown[]>(
    type: Platform.Constructor.Constructor<T, Args>, callback: (_: T) => void): ReturnType<typeof Lit.Directives.ref> {
  return Lit.Directives.ref((e?: Element) => {
    if (!(e instanceof HTMLElement)) {
      return;
    }
    const widget = Widget.getOrCreateWidget(e);
    if (!(widget instanceof type)) {
      throw new Error(`Expected an element with a widget of type ${type.name} but got ${e?.constructor?.name}`);
    }
    callback(widget as T);
  });
}

const widgetCounterMap = new WeakMap<Node, number>();
const widgetMap = new WeakMap<Node, Widget>();

function incrementWidgetCounter(parentElement: Element, childElement: Element): void {
  const count = (widgetCounterMap.get(childElement) || 0) + (widgetMap.get(childElement) ? 1 : 0);
  for (let el: Element|null = parentElement; el; el = el.parentElementOrShadowHost()) {
    widgetCounterMap.set(el, (widgetCounterMap.get(el) || 0) + count);
  }
}

function decrementWidgetCounter(parentElement: Element, childElement: Element): void {
  const count = (widgetCounterMap.get(childElement) || 0) + (widgetMap.get(childElement) ? 1 : 0);
  for (let el: Element|null = parentElement; el; el = el.parentElementOrShadowHost()) {
    const elCounter = widgetCounterMap.get(el);
    if (elCounter) {
      widgetCounterMap.set(el, elCounter - count);
    }
  }
}

// The resolved `updateComplete` promise, which is used as a marker for the
// Widget's `#updateComplete` private property to indicate that there's no
// pending update.
const UPDATE_COMPLETE = Promise.resolve();

/**
 * Additional options passed to the `Widget` constructor to configure the
 * behavior of the resulting instance.
 */
export interface WidgetOptions {
  /**
   * If you pass `true` here, the `contentElement` of the resulting `Widget`
   * will be placed into the shadow DOM of its `element`. If the `element`
   * doesn't already have a `shadowRoot`, a new one will be created.
   *
   * Otherwise, the `contentElement` will be a regular child of the `element`.
   *
   * Its default value is `false`.
   */
  useShadowDom?: boolean;

  /**
   * A boolean that, when set to `true`, specifies behavior that mitigates
   * custom element issues around focusability. When a non-focusable part of
   * the shadow DOM is clicked, the first focusable part is given focus, and
   * the shadow host is given any available `:focus` styling.
   *
   * Its default value is `false`.
   *
   * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow
   */
  delegatesFocus?: boolean;

  /**
   * The Visual Logging configuration to put onto the `element` of the resulting
   * `Widget`.
   */
  jslog?: string;
  /**
   * The additional classes to put onto the `element` of the resulting `Widget`.
   */
  classes?: string[];
}
export class Widget {
  readonly element: HTMLElement;
  contentElement: HTMLElement;
  #shadowRoot: typeof Element.prototype.shadowRoot;
  #visible = false;
  #isRoot = false;
  #isShowing = false;
  readonly #children: Widget[] = [];
  #hideOnDetach = false;
  #notificationDepth = 0;
  #invalidationsSuspended = 0;
  #parentWidget: Widget|null = null;
  #cachedConstraints?: Geometry.Constraints;
  #constraints?: Geometry.Constraints;
  #invalidationsRequested?: boolean;
  #externallyManaged?: boolean;
  #updateComplete = UPDATE_COMPLETE;
  #updateController?: AbortController;

  /**
   * Constructs a new `Widget` with the given `options`.
   *
   * @param options optional settings to configure the behavior.
   */
  constructor(options?: WidgetOptions);

  /**
   * Constructs a new `Widget` with the given `options` and attached to the
   * given `element`.
   *
   * If `element` is `undefined`, a new `<div>` element will be created instead
   * and the widget will be attached to that.
   *
   * @param element an (optional) `HTMLElement` to attach the `Widget` to.
   * @param options optional settings to configure the behavior.
   */
  constructor(element?: HTMLElement, options?: WidgetOptions);

  constructor(elementOrOptions?: HTMLElement|WidgetOptions, options?: WidgetOptions) {
    if (elementOrOptions instanceof HTMLElement) {
      this.element = elementOrOptions;
    } else {
      this.element = document.createElement('div');
      if (elementOrOptions !== undefined) {
        options = elementOrOptions;
      }
    }
    this.#shadowRoot = this.element.shadowRoot;
    if (options?.useShadowDom && !this.#shadowRoot) {
      this.element.classList.add('vbox');
      this.element.classList.add('flex-auto');
      this.#shadowRoot = createShadowRootWithCoreStyles(this.element, {
        delegatesFocus: options?.delegatesFocus,
      });
      this.contentElement = document.createElement('div');
      this.#shadowRoot.appendChild(this.contentElement);
    } else {
      this.contentElement = this.element;
    }
    if (options?.classes) {
      this.element.classList.add(...options.classes);
    }
    if (options?.jslog) {
      this.contentElement.setAttribute('jslog', options.jslog);
    }
    this.contentElement.classList.add('widget');
    widgetMap.set(this.element, this);
  }

  /**
   * Returns the {@link Widget} whose element is the given `node`, or `undefined`
   * if the `node` is not an element for a widget.
   *
   * @param node a DOM node.
   * @returns the {@link Widget} that is attached to the `node` or `undefined`.
   */
  static get(node: Node): Widget|undefined {
    return widgetMap.get(node);
  }

  static get allUpdatesComplete(): Promise<void> {
    if (!pendingAnimationFrame && !currentUpdateQueue) {
      return Promise.resolve();
    }
    if (!overallUpdatePromise) {
      overallUpdatePromise = Promise.withResolvers<void>();
    }
    return overallUpdatePromise.promise;
  }

  static getOrCreateWidget(element: HTMLElement): Widget {
    const widget = Widget.get(element);
    if (widget) {
      return widget;
    }
    let config = widgetConfigs.get(element as WidgetElement<Widget>);
    if (!config) {
      config = widgetConfig(element => new Widget(element));
    }
    return instantiateWidget(element as WidgetElement<Widget>, config);
  }

  markAsRoot(): void {
    assert(!this.element.parentElement, 'Attempt to mark as root attached node');
    this.#isRoot = true;
  }

  parentWidget(): Widget|null {
    return this.#parentWidget;
  }

  children(): Widget[] {
    return this.#children;
  }

  childWasDetached(_widget: Widget): void {
  }

  isShowing(): boolean {
    return this.#isShowing;
  }

  shouldHideOnDetach(): boolean {
    if (!this.element.parentElement) {
      return false;
    }
    if (this.#hideOnDetach) {
      return true;
    }
    for (const child of this.#children) {
      if (child.shouldHideOnDetach()) {
        return true;
      }
    }
    return false;
  }

  setHideOnDetach(): void {
    this.#hideOnDetach = true;
  }

  private inNotification(): boolean {
    return Boolean(this.#notificationDepth) || Boolean(this.#parentWidget?.inNotification());
  }

  private parentIsShowing(): boolean {
    if (this.#isRoot) {
      return true;
    }
    return this.#parentWidget?.isShowing() ?? false;
  }

  protected callOnVisibleChildren(method: (this: Widget) => void): void {
    const copy = this.#children.slice();
    for (let i = 0; i < copy.length; ++i) {
      if (copy[i].#parentWidget === this && copy[i].#visible) {
        method.call(copy[i]);
      }
    }
  }

  private processWillShow(): void {
    this.callOnVisibleChildren(this.processWillShow);
    this.#isShowing = true;
  }

  private processWasShown(): void {
    if (this.inNotification()) {
      return;
    }
    this.restoreScrollPositions();
    this.notify(this.wasShown);
    this.callOnVisibleChildren(this.processWasShown);
  }

  private processWillHide(): void {
    if (this.inNotification()) {
      return;
    }
    this.storeScrollPositions();

    this.callOnVisibleChildren(this.processWillHide);
    this.notify(this.willHide);
    this.#isShowing = false;
  }

  private processWasHidden(): void {
    this.callOnVisibleChildren(this.processWasHidden);
    this.notify(this.wasHidden);
  }

  private processOnResize(): void {
    if (this.inNotification()) {
      return;
    }
    if (!this.isShowing()) {
      return;
    }
    this.notify(this.onResize);
    this.callOnVisibleChildren(this.processOnResize);
  }

  private notify(notification: (this: Widget) => void): void {
    ++this.#notificationDepth;
    try {
      notification.call(this);
    } finally {
      --this.#notificationDepth;
    }
  }

  wasShown(): void {
  }

  willHide(): void {
  }

  wasHidden(): void {
  }

  onResize(): void {
  }

  onLayout(): void {
  }

  onDetach(): void {
  }

  async ownerViewDisposed(): Promise<void> {
  }

  show(parentElement: Element, insertBefore?: Node|null, suppressOrphanWidgetError = false): void {
    assert(parentElement, 'Attempt to attach widget with no parent element');

    if (!this.#isRoot) {
      // Update widget hierarchy.
      let currentParent: Element|null = parentElement;
      let currentWidget = undefined;
      while (!currentWidget) {
        if (!currentParent) {
          if (suppressOrphanWidgetError) {
            this.#isRoot = true;
            this.show(parentElement, insertBefore);
            return;
          }
          throw new Error('Attempt to attach widget to orphan node');
        }
        currentWidget = widgetMap.get(currentParent);
        currentParent = currentParent.parentElementOrShadowHost();
      }
      this.attach(currentWidget);
    }
    this.#showWidget(parentElement, insertBefore);
  }

  private attach(parentWidget: Widget): void {
    if (parentWidget === this.#parentWidget) {
      return;
    }
    if (this.#parentWidget) {
      this.detach();
    }
    this.#parentWidget = parentWidget;
    this.#parentWidget.#children.push(this);
    this.#isRoot = false;
  }

  showWidget(): void {
    if (this.#visible) {
      return;
    }
    if (!this.element.parentElement) {
      throw new Error('Attempt to show widget that is not hidden using hideWidget().');
    }
    this.#showWidget(this.element.parentElement, this.element.nextSibling);
  }

  #showWidget(parentElement: Element, insertBefore?: Node|null): void {
    let currentParent: Element|null = parentElement;
    while (currentParent && !widgetMap.get(currentParent)) {
      currentParent = currentParent.parentElementOrShadowHost();
    }

    if (this.#isRoot) {
      assert(!currentParent, 'Attempt to show root widget under another widget');
    } else {
      assert(
          currentParent && widgetMap.get(currentParent) === this.#parentWidget,
          'Attempt to show under node belonging to alien widget');
    }

    const wasVisible = this.#visible;
    if (wasVisible && this.element.parentElement === parentElement) {
      return;
    }

    this.#visible = true;

    if (!wasVisible && this.parentIsShowing()) {
      this.processWillShow();
    }

    this.element.classList.remove('hidden');

    // Reparent
    if (this.element.parentElement !== parentElement) {
      if (!this.#externallyManaged) {
        incrementWidgetCounter(parentElement, this.element);
      }
      if (insertBefore) {
        originalInsertBefore.call(parentElement, this.element, insertBefore);
      } else {
        originalAppendChild.call(parentElement, this.element);
      }
    }
    const focusedElementsCount = this.#parentWidget?.getDefaultFocusedElements?.()?.length ?? 0;
    if (this.element.hasAttribute('autofocus') && focusedElementsCount > 1) {
      this.element.removeAttribute('autofocus');
    }

    if (!wasVisible && this.parentIsShowing()) {
      this.processWasShown();
    }

    if (this.#parentWidget && this.hasNonZeroConstraints()) {
      this.#parentWidget.invalidateConstraints();
    } else {
      this.processOnResize();
    }
  }

  hideWidget(): void {
    if (!this.#visible) {
      return;
    }
    this.#hideWidget(false);
  }

  #hideWidget(removeFromDOM: boolean): void {
    this.#visible = false;
    const {parentElement} = this.element;

    if (this.parentIsShowing()) {
      this.processWillHide();
    }

    if (removeFromDOM) {
      if (parentElement) {
        // Force legal removal
        decrementWidgetCounter(parentElement, this.element);
        originalRemoveChild.call(parentElement, this.element);
      }
      this.onDetach();
    } else {
      this.element.classList.add('hidden');
    }

    if (this.parentIsShowing()) {
      this.processWasHidden();
    }
    if (this.#parentWidget && this.hasNonZeroConstraints()) {
      this.#parentWidget.invalidateConstraints();
    }
  }

  detach(overrideHideOnDetach?: boolean): void {
    if (!this.#parentWidget && !this.#isRoot) {
      return;
    }

    cancelUpdate(this);

    // hideOnDetach means that we should never remove element from dom - content
    // has iframes and detaching it will hurt.
    //
    // overrideHideOnDetach will override hideOnDetach and the client takes
    // responsibility for the consequences.
    const removeFromDOM = overrideHideOnDetach || !this.shouldHideOnDetach();
    if (this.#visible) {
      this.#hideWidget(removeFromDOM);
    } else if (removeFromDOM) {
      const {parentElement} = this.element;
      if (parentElement) {
        // Force kick out from DOM.
        decrementWidgetCounter(parentElement, this.element);
        originalRemoveChild.call(parentElement, this.element);
      }
    }

    // Update widget hierarchy.
    if (this.#parentWidget) {
      const childIndex = this.#parentWidget.#children.indexOf(this);
      assert(childIndex >= 0, 'Attempt to remove non-child widget');
      this.#parentWidget.#children.splice(childIndex, 1);
      this.#parentWidget.childWasDetached(this);
      this.#parentWidget = null;
    } else {
      assert(this.#isRoot, 'Removing non-root widget from DOM');
    }
  }

  detachChildWidgets(): void {
    const children = this.#children.slice();
    for (let i = 0; i < children.length; ++i) {
      children[i].detach();
    }
  }

  elementsToRestoreScrollPositionsFor(): Element[] {
    return [this.element];
  }

  storeScrollPositions(): void {
    const elements = this.elementsToRestoreScrollPositionsFor();
    for (const container of elements) {
      storedScrollPositions.set(container, {scrollLeft: container.scrollLeft, scrollTop: container.scrollTop});
    }
  }

  restoreScrollPositions(): void {
    const elements = this.elementsToRestoreScrollPositionsFor();
    for (const container of elements) {
      const storedPositions = storedScrollPositions.get(container);
      if (storedPositions) {
        container.scrollLeft = storedPositions.scrollLeft;
        container.scrollTop = storedPositions.scrollTop;
      }
    }
  }

  doResize(): void {
    if (!this.isShowing()) {
      return;
    }
    // No matter what notification we are in, dispatching onResize is not needed.
    if (!this.inNotification()) {
      this.callOnVisibleChildren(this.processOnResize);
    }
  }

  doLayout(): void {
    if (!this.isShowing()) {
      return;
    }
    this.notify(this.onLayout);
    this.doResize();
  }

  registerRequiredCSS(...cssFiles: Array<string&{_tag: 'CSS-in-JS'}>): void {
    for (const cssFile of cssFiles) {
      appendStyle(this.#shadowRoot ?? this.element, cssFile);
    }
  }

  // Unused, but useful for debugging.
  printWidgetHierarchy(): void {
    const lines: string[] = [];
    this.collectWidgetHierarchy('', lines);
    console.log(lines.join('\n'));  // eslint-disable-line no-console
  }

  private collectWidgetHierarchy(prefix: string, lines: string[]): void {
    lines.push(prefix + '[' + this.element.className + ']' + (this.#children.length ? ' {' : ''));

    for (let i = 0; i < this.#children.length; ++i) {
      this.#children[i].collectWidgetHierarchy(prefix + '    ', lines);
    }

    if (this.#children.length) {
      lines.push(prefix + '}');
    }
  }

  setDefaultFocusedElement(element: Element|null): void {
    const defaultFocusedElement = this.getDefaultFocusedElement();
    if (defaultFocusedElement) {
      defaultFocusedElement.removeAttribute('autofocus');
    }
    if (element) {
      element.setAttribute('autofocus', '');
    }
  }

  setDefaultFocusedChild(child: Widget): void {
    assert(child.#parentWidget === this, 'Attempt to set non-child widget as default focused.');

    const defaultFocusedElement = this.getDefaultFocusedElement();
    if (defaultFocusedElement) {
      defaultFocusedElement.removeAttribute('autofocus');
    }
    child.element.setAttribute('autofocus', '');
  }

  getDefaultFocusedElements(): HTMLElement[] {
    const autofocusElements = [...this.contentElement.querySelectorAll<HTMLElement>('[autofocus]')];
    if (this.contentElement !== this.element) {
      if (this.contentElement.hasAttribute('autofocus')) {
        autofocusElements.push(this.contentElement);
      }
      if (autofocusElements.length === 0) {
        autofocusElements.push(...this.element.querySelectorAll<HTMLElement>('[autofocus]'));
      }
    }
    return autofocusElements.filter(autofocusElement => {
      let widgetElement: Element|null = autofocusElement;
      while (widgetElement) {
        const widget = Widget.get(widgetElement);
        if (widget) {
          if (widgetElement === autofocusElement && widget.#parentWidget === this && widget.#visible) {
            return true;
          }
          return widget === this;
        }
        widgetElement = widgetElement.parentElementOrShadowHost();
      }
      return false;
    });
  }

  getDefaultFocusedElement(): HTMLElement|null {
    const elements = this.getDefaultFocusedElements();
    if (elements.length > 1) {
      console.error(
          'Multiple autofocus elements found', this.constructor.name,
          ...elements.map(e => Platform.StringUtilities.trimMiddle(e.outerHTML, 250)));
    }
    return elements[0] || null;
  }

  focus(): void {
    if (!this.isShowing()) {
      return;
    }
    const autofocusElement = this.getDefaultFocusedElement();
    if (autofocusElement) {
      const widget = Widget.get(autofocusElement);
      if (widget && widget !== this) {
        widget.focus();
      } else if (autofocusElement === this.element && autofocusElement instanceof WidgetElement) {
        // If the autofocus element is the widget itself, we need to call the native focus method
        // to avoid infinite recursion if the element is a WidgetElement.
        HTMLElement.prototype.focus.call(autofocusElement);
      } else {
        autofocusElement.focus();
      }
      return;
    }

    for (const child of this.#children) {
      if (child.#visible) {
        child.focus();
        return;
      }
    }

    if (this.element === this.contentElement && this.element.hasAttribute('autofocus')) {
      if (this.element instanceof WidgetElement) {
        // If the autofocus element is the widget itself, we need to call the native focus method
        // to avoid infinite recursion if the element is a WidgetElement.
        HTMLElement.prototype.focus.call(this.element);
      } else {
        this.element.focus();
      }
    }
  }

  hasFocus(): boolean {
    return this.element.hasFocus();
  }

  calculateConstraints(): Geometry.Constraints {
    return new Geometry.Constraints();
  }

  constraints(): Geometry.Constraints {
    if (typeof this.#constraints !== 'undefined') {
      return this.#constraints;
    }
    if (typeof this.#cachedConstraints === 'undefined') {
      this.#cachedConstraints = this.calculateConstraints();
    }
    return this.#cachedConstraints;
  }

  setMinimumAndPreferredSizes(width: number, height: number, preferredWidth: number, preferredHeight: number): void {
    this.#constraints =
        new Geometry.Constraints(new Geometry.Size(width, height), new Geometry.Size(preferredWidth, preferredHeight));
    this.invalidateConstraints();
  }

  setMinimumSize(width: number, height: number): void {
    this.minimumSize = new Geometry.Size(width, height);
  }

  set minimumSize(size: Geometry.Size) {
    this.#constraints = new Geometry.Constraints(size);
    this.invalidateConstraints();
  }

  private hasNonZeroConstraints(): boolean {
    const constraints = this.constraints();
    return Boolean(
        constraints.minimum.width || constraints.minimum.height || constraints.preferred.width ||
        constraints.preferred.height);
  }

  suspendInvalidations(): void {
    ++this.#invalidationsSuspended;
  }

  resumeInvalidations(): void {
    --this.#invalidationsSuspended;
    if (!this.#invalidationsSuspended && this.#invalidationsRequested) {
      this.invalidateConstraints();
    }
  }

  invalidateConstraints(): void {
    if (this.#invalidationsSuspended) {
      this.#invalidationsRequested = true;
      return;
    }
    this.#invalidationsRequested = false;
    const cached = this.#cachedConstraints;
    this.#cachedConstraints = undefined;
    const actual = this.constraints();
    if (!actual.isEqual(cached || null) && this.#parentWidget) {
      this.#parentWidget.invalidateConstraints();
    } else {
      this.doLayout();
    }
  }

  // Excludes the widget from being tracked by its parents/ancestors via
  // widgetCounter because the widget is being handled by external code.
  // Widgets marked as being externally managed are responsible for
  // finishing out their own lifecycle (i.e. calling detach() before being
  // removed from the DOM). This is e.g. used for CodeMirror.
  //
  // Also note that this must be called before the widget is shown so that
  // so that its ancestor's widgetCounter is not incremented.
  markAsExternallyManaged(): void {
    assert(!this.#parentWidget, 'Attempt to mark widget as externally managed after insertion to the DOM');
    this.#externallyManaged = true;
  }

  /**
   * Override this method in derived classes to perform the actual view update.
   *
   * This is not meant to be called directly, but invoked (indirectly) through
   * the `requestAnimationFrame` and executed with the animation frame. Instead,
   * use the `requestUpdate()` method to schedule an asynchronous update.
   *
   * @returns can either return nothing or a promise; in that latter case, the
   *          update logic will await the resolution of the returned promise
   *          before proceeding.
   */
  performUpdate(): Promise<void>|void;
  performUpdate(signal: AbortSignal): Promise<void>|void;
  performUpdate(_signal?: AbortSignal): Promise<void>|void {
  }

  addUpdateController(controller: AbortController): void {
    this.#updateController?.abort();
    this.#updateController = controller;
  }

  cancelUpdateController(): void {
    this.#updateController?.abort();
  }

  /**
   * Schedules an asynchronous update for this widget.
   *
   * The update will be deduplicated and executed with the next animation
   * frame.
   */
  requestUpdate(): void {
    this.#updateController?.abort();
    this.#updateComplete = enqueueWidgetUpdate(this);
  }

  /**
   * The `updateComplete` promise resolves when the widget has finished updating.
   *
   * Use `updateComplete` to wait for an update:
   * ```js
   * await widget.updateComplete;
   * // do stuff
   * ```
   *
   * This method is primarily useful for unit tests, to wait for widgets to build
   * their DOM. For example:
   * ```js
   * // Set up the test widget, and wait for the initial update cycle to complete.
   * const widget = new SomeWidget(someData);
   * widget.requestUpdate();
   * await widget.updateComplete;
   *
   * // Assert state of the widget.
   * assert.isTrue(widget.someDataLoaded);
   * ```
   *
   * @returns a promise that resolves when the widget has finished updating.
   */
  get updateComplete(): Promise<void> {
    return this.#updateComplete;
  }
}

const storedScrollPositions = new WeakMap<Element, {
  scrollLeft: number,
  scrollTop: number,
}>();

export class VBox extends Widget {
  /**
   * Constructs a new `VBox` with the given `options`.
   *
   * @param options optional settings to configure the behavior.
   */
  constructor(options?: WidgetOptions);

  /**
   * Constructs a new `VBox` with the given `options` and attached to the
   * given `element`.
   *
   * If `element` is `undefined`, a new `<div>` element will be created instead
   * and the widget will be attached to that.
   *
   * @param element an (optional) `HTMLElement` to attach the `VBox` to.
   * @param options optional settings to configure the behavior.
   */
  constructor(element?: HTMLElement, options?: WidgetOptions);

  constructor() {
    super(...arguments);
    this.contentElement.classList.add('vbox');
  }

  override calculateConstraints(): Geometry.Constraints {
    let constraints: Geometry.Constraints = new Geometry.Constraints();

    function updateForChild(this: Widget): void {
      const child = this.constraints();
      constraints = constraints.widthToMax(child);
      constraints = constraints.addHeight(child);
    }

    this.callOnVisibleChildren(updateForChild);
    return constraints;
  }
}

export class HBox extends Widget {
  /**
   * Constructs a new `HBox` with the given `options`.
   *
   * @param options optional settings to configure the behavior.
   */
  constructor(options?: WidgetOptions);

  /**
   * Constructs a new `HBox` with the given `options` and attached to the
   * given `element`.
   *
   * If `element` is `undefined`, a new `<div>` element will be created instead
   * and the widget will be attached to that.
   *
   * @param element an (optional) `HTMLElement` to attach the `HBox` to.
   * @param options optional settings to configure the behavior.
   */
  constructor(element?: HTMLElement, options?: WidgetOptions);

  constructor() {
    super(...arguments);
    this.contentElement.classList.add('hbox');
  }

  override calculateConstraints(): Geometry.Constraints {
    let constraints: Geometry.Constraints = new Geometry.Constraints();

    function updateForChild(this: Widget): void {
      const child = this.constraints();
      constraints = constraints.addWidth(child);
      constraints = constraints.heightToMax(child);
    }

    this.callOnVisibleChildren(updateForChild);
    return constraints;
  }
}

export class VBoxWithResizeCallback extends VBox {
  private readonly resizeCallback: () => void;
  constructor(resizeCallback: () => void) {
    super();
    this.resizeCallback = resizeCallback;
  }

  override onResize(): void {
    this.resizeCallback();
  }
}

export class WidgetFocusRestorer {
  private widget: Widget|null;
  private previous: HTMLElement|null;
  constructor(widget: Widget) {
    this.widget = widget;
    this.previous = (deepActiveElement(widget.element.ownerDocument) as HTMLElement | null);
    widget.focus();
  }

  restore(): void {
    if (!this.widget) {
      return;
    }
    if (this.widget.hasFocus() && this.previous) {
      this.previous.focus();
    }
    this.previous = null;
    this.widget = null;
  }
}

function domOperationError(funcName: 'appendChild'|'insertBefore'|'removeChild'|'removeChildren'): Error {
  return new Error(`Attempt to modify widget with native DOM method \`${funcName}\``);
}

Element.prototype.appendChild = function<T extends Node>(node: T): T {
  if (widgetMap.get(node) && node.parentElement !== this) {
    throw domOperationError('appendChild');
  }
  return originalAppendChild.call(this, node) as T;
};

Element.prototype.insertBefore = function<T extends Node>(node: T, child: Node|null): T {
  if (widgetMap.get(node) && node.parentElement !== this) {
    throw domOperationError('insertBefore');
  }
  return originalInsertBefore.call(this, node, child) as T;
};

Element.prototype.removeChild = function<T extends Node>(child: T): T {
  if (widgetCounterMap.get(child) || widgetMap.get(child)) {
    throw domOperationError('removeChild');
  }
  return originalRemoveChild.call(this, child) as T;
};

Element.prototype.removeChildren = function(): void {
  if (widgetCounterMap.get(this)) {
    throw domOperationError('removeChildren');
  }
  return originalRemoveChildren.call(this);
};
