// Copyright 2014 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 type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * as Protocol from '../../generated/protocol.js';

import {DeferredDOMNode, type DOMNode} from './DOMModel.js';
import {RemoteObject} from './RemoteObject.js';
import {Events as ResourceTreeModelEvents, ResourceTreeModel} from './ResourceTreeModel.js';
import {Events as RuntimeModelEvents, type EventTypes as RuntimeModelEventTypes, RuntimeModel} from './RuntimeModel.js';
import {SDKModel} from './SDKModel.js';
import {Capability, type Target} from './Target.js';

const DEVTOOLS_ANIMATIONS_WORLD_NAME = 'devtools_animations';
const REPORT_SCROLL_POSITION_BINDING_NAME = '__devtools_report_scroll_position__';

const getScrollListenerNameInPage = (id: number): string => `__devtools_scroll_listener_${id}__`;

type ScrollListener = (param: {scrollLeft: number, scrollTop: number}) => void;
type BindingListener =
    (ev: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent, RuntimeModelEventTypes>) => void;

async function resolveToObjectInWorld(domNode: DOMNode, worldName: string): Promise<RemoteObject|null> {
  const resourceTreeModel = domNode.domModel().target().model(ResourceTreeModel) as ResourceTreeModel;
  const pageAgent = domNode.domModel().target().pageAgent();
  for (const frame of resourceTreeModel.frames()) {
    // This returns previously created world if it exists for the frame.
    const {executionContextId} = await pageAgent.invoke_createIsolatedWorld({frameId: frame.id, worldName});
    const object = await domNode.resolveToObject(undefined, executionContextId);
    if (object) {
      return object;
    }
  }
  return null;
}

/**
 * Provides an extension over `DOMNode` that gives it additional
 * capabilities for animation debugging, mainly:
 * - getting a node's scroll information (scroll offsets and scroll range).
 * - updating a node's scroll offset.
 * - tracking the node's scroll offsets with event listeners.
 *
 * It works by running functions on the target page, see `DOMNode`s `callFunction` method
 * for more details on how a function is called on the target page.
 *
 * For listening to events on the target page and getting notified on the devtools frontend
 * side, we're adding a binding to the page `__devtools_report_scroll_position__` in a world `devtools_animation`
 * we've created. Then, we're setting scroll listeners of the `node` in the same world which calls the binding
 * itself with the scroll offsets.
 */
export class AnimationDOMNode {
  #domNode: DOMNode;
  #scrollListenersById = new Map<number, ScrollListener>();
  #scrollBindingListener?: BindingListener;

  static lastAddedListenerId = 0;

  constructor(domNode: DOMNode) {
    this.#domNode = domNode;
  }

  async #addReportScrollPositionBinding(): Promise<void> {
    // The binding is already added so we don't need to add it again.
    if (this.#scrollBindingListener) {
      return;
    }

    this.#scrollBindingListener = ev => {
      const {name, payload} = ev.data;
      if (name !== REPORT_SCROLL_POSITION_BINDING_NAME) {
        return;
      }

      const {scrollTop, scrollLeft, id} = JSON.parse(payload) as {scrollTop: number, scrollLeft: number, id: number};
      const scrollListener = this.#scrollListenersById.get(id);
      if (!scrollListener) {
        return;
      }

      scrollListener({scrollTop, scrollLeft});
    };

    const runtimeModel = this.#domNode.domModel().target().model(RuntimeModel) as RuntimeModel;
    await runtimeModel.addBinding({
      name: REPORT_SCROLL_POSITION_BINDING_NAME,
      executionContextName: DEVTOOLS_ANIMATIONS_WORLD_NAME,
    });
    runtimeModel.addEventListener(RuntimeModelEvents.BindingCalled, this.#scrollBindingListener);
  }

  async #removeReportScrollPositionBinding(): Promise<void> {
    // There isn't any binding added yet.
    if (!this.#scrollBindingListener) {
      return;
    }

    const runtimeModel = this.#domNode.domModel().target().model(RuntimeModel) as RuntimeModel;
    await runtimeModel.removeBinding({
      name: REPORT_SCROLL_POSITION_BINDING_NAME,
    });
    runtimeModel.removeEventListener(RuntimeModelEvents.BindingCalled, this.#scrollBindingListener);
    this.#scrollBindingListener = undefined;
  }

  async addScrollEventListener(onScroll: ({scrollLeft, scrollTop}: {scrollLeft: number, scrollTop: number}) => void):
      Promise<number|null> {
    AnimationDOMNode.lastAddedListenerId++;
    const id = AnimationDOMNode.lastAddedListenerId;
    this.#scrollListenersById.set(id, onScroll);
    // Add the binding for reporting scroll events from the page if it doesn't exist.
    if (!this.#scrollBindingListener) {
      await this.#addReportScrollPositionBinding();
    }

    const object = await resolveToObjectInWorld(this.#domNode, DEVTOOLS_ANIMATIONS_WORLD_NAME);
    if (!object) {
      return null;
    }

    await object.callFunction(scrollListenerInPage, [
      id,
      REPORT_SCROLL_POSITION_BINDING_NAME,
      getScrollListenerNameInPage(id),
    ].map(arg => RemoteObject.toCallArgument(arg)));
    object.release();
    return id;

    function scrollListenerInPage(
        this: HTMLElement|Document, id: number, reportScrollPositionBindingName: string,
        scrollListenerNameInPage: string): void {
      if ('scrollingElement' in this && !this.scrollingElement) {
        return;
      }

      const scrollingElement = ('scrollingElement' in this ? this.scrollingElement : this) as HTMLElement;
      // @ts-expect-error We're setting a custom field on `Element` or `Document` for retaining the function on the page.
      this[scrollListenerNameInPage] = () => {
        // @ts-expect-error `reportScrollPosition` binding is injected to the page before calling the function.
        globalThis[reportScrollPositionBindingName](
            JSON.stringify({scrollTop: scrollingElement.scrollTop, scrollLeft: scrollingElement.scrollLeft, id}));
      };

      // @ts-expect-error We've already defined the function used below.
      this.addEventListener('scroll', this[scrollListenerNameInPage], true);
    }
  }

  async removeScrollEventListener(id: number): Promise<void> {
    const object = await resolveToObjectInWorld(this.#domNode, DEVTOOLS_ANIMATIONS_WORLD_NAME);
    if (!object) {
      return;
    }

    await object.callFunction(
        removeScrollListenerInPage, [getScrollListenerNameInPage(id)].map(arg => RemoteObject.toCallArgument(arg)));
    object.release();

    this.#scrollListenersById.delete(id);
    // There aren't any scroll listeners remained on the page
    // so we remove the binding.
    if (this.#scrollListenersById.size === 0) {
      await this.#removeReportScrollPositionBinding();
    }

    function removeScrollListenerInPage(this: HTMLElement|Document, scrollListenerNameInPage: string): void {
      // @ts-expect-error We've already set this custom field while adding scroll listener.
      this.removeEventListener('scroll', this[scrollListenerNameInPage]);
      // @ts-expect-error We've already set this custom field while adding scroll listener.
      delete this[scrollListenerNameInPage];
    }
  }

  async scrollTop(): Promise<number|null> {
    return await this.#domNode.callFunction(scrollTopInPage).then(res => res?.value ?? null);

    function scrollTopInPage(this: Element|Document): number {
      if ('scrollingElement' in this) {
        if (!this.scrollingElement) {
          return 0;
        }

        return this.scrollingElement.scrollTop;
      }
      return this.scrollTop;
    }
  }

  async scrollLeft(): Promise<number|null> {
    return await this.#domNode.callFunction(scrollLeftInPage).then(res => res?.value ?? null);

    function scrollLeftInPage(this: Element|Document): number {
      if ('scrollingElement' in this) {
        if (!this.scrollingElement) {
          return 0;
        }

        return this.scrollingElement.scrollLeft;
      }
      return this.scrollLeft;
    }
  }

  async setScrollTop(offset: number): Promise<void> {
    await this.#domNode.callFunction(setScrollTopInPage, [offset]);

    function setScrollTopInPage(this: Element|Document, offsetInPage: number): void {
      if ('scrollingElement' in this) {
        if (!this.scrollingElement) {
          return;
        }

        this.scrollingElement.scrollTop = offsetInPage;
      } else {
        this.scrollTop = offsetInPage;
      }
    }
  }

  async setScrollLeft(offset: number): Promise<void> {
    await this.#domNode.callFunction(setScrollLeftInPage, [offset]);

    function setScrollLeftInPage(this: Element|Document, offsetInPage: number): void {
      if ('scrollingElement' in this) {
        if (!this.scrollingElement) {
          return;
        }

        this.scrollingElement.scrollLeft = offsetInPage;
      } else {
        this.scrollLeft = offsetInPage;
      }
    }
  }

  async verticalScrollRange(): Promise<number|null> {
    return await this.#domNode.callFunction(verticalScrollRangeInPage).then(res => res?.value ?? null);

    function verticalScrollRangeInPage(this: Element|Document): number {
      if ('scrollingElement' in this) {
        if (!this.scrollingElement) {
          return 0;
        }

        return this.scrollingElement.scrollHeight - this.scrollingElement.clientHeight;
      }

      return this.scrollHeight - this.clientHeight;
    }
  }

  async horizontalScrollRange(): Promise<number|null> {
    return await this.#domNode.callFunction(horizontalScrollRangeInPage).then(res => res?.value ?? null);

    function horizontalScrollRangeInPage(this: Element|Document): number {
      if ('scrollingElement' in this) {
        if (!this.scrollingElement) {
          return 0;
        }

        return this.scrollingElement.scrollWidth - this.scrollingElement.clientWidth;
      }

      return this.scrollWidth - this.clientWidth;
    }
  }
}

function shouldGroupAnimations(firstAnimation: AnimationImpl, anim: AnimationImpl): boolean {
  const firstAnimationTimeline = firstAnimation.viewOrScrollTimeline();
  const animationTimeline = anim.viewOrScrollTimeline();
  if (firstAnimationTimeline) {
    // This is a SDA group so check whether the animation's
    // scroll container and scroll axis is the same with the first animation.
    return Boolean(
        animationTimeline && firstAnimationTimeline.sourceNodeId === animationTimeline.sourceNodeId &&
        firstAnimationTimeline.axis === animationTimeline.axis);
  }
  // This is a non-SDA group so check whether the coming animation
  // is a time based one too and if so, compare their start times.
  return !animationTimeline && firstAnimation.startTime() === anim.startTime();
}

export class AnimationModel extends SDKModel<EventTypes> {
  readonly runtimeModel: RuntimeModel;
  readonly agent: ProtocolProxyApi.AnimationApi;
  #animationsById = new Map<string, AnimationImpl>();
  readonly animationGroups = new Map<string, AnimationGroup>();
  #pendingAnimations = new Set<string>();
  playbackRate = 1;
  #flushPendingAnimations: () => void;

  constructor(target: Target) {
    super(target);
    this.runtimeModel = (target.model(RuntimeModel) as RuntimeModel);
    this.agent = target.animationAgent();
    target.registerAnimationDispatcher(new AnimationDispatcher(this));

    if (!target.suspended()) {
      void this.agent.invoke_enable();
    }

    const resourceTreeModel = (target.model(ResourceTreeModel) as ResourceTreeModel);
    resourceTreeModel.addEventListener(ResourceTreeModelEvents.PrimaryPageChanged, this.reset, this);

    this.#flushPendingAnimations = Common.Debouncer.debounce(() => {
      while (this.#pendingAnimations.size) {
        this.matchExistingGroups(this.createGroupFromPendingAnimations());
      }
    }, 100);
  }

  private reset(): void {
    this.#animationsById.clear();
    this.animationGroups.clear();
    this.#pendingAnimations.clear();
    this.dispatchEventToListeners(Events.ModelReset);
  }

  async devicePixelRatio(): Promise<number> {
    const evaluateResult = await this.target().runtimeAgent().invoke_evaluate({expression: 'window.devicePixelRatio'});
    if (evaluateResult?.result.type === 'number') {
      return evaluateResult?.result.value as number ?? 1;
    }

    return 1;
  }

  async getAnimationGroupForAnimation(name: string, nodeId: Protocol.DOM.NodeId): Promise<AnimationGroup|null> {
    for (const animationGroup of this.animationGroups.values()) {
      for (const animation of animationGroup.animations()) {
        if (animation.name() === name) {
          const animationNode = await animation.source().node();
          if (animationNode?.id === nodeId) {
            return animationGroup;
          }
        }
      }
    }

    return null;
  }

  animationCanceled(id: string): void {
    this.#pendingAnimations.delete(id);
  }

  async animationUpdated(payload: Protocol.Animation.Animation): Promise<void> {
    let foundAnimationGroup: AnimationGroup|undefined;
    let foundAnimation: AnimationImpl|undefined;
    for (const animationGroup of this.animationGroups.values()) {
      foundAnimation = animationGroup.animations().find(animation => animation.id() === payload.id);
      if (foundAnimation) {
        foundAnimationGroup = animationGroup;
        break;
      }
    }

    if (!foundAnimation || !foundAnimationGroup) {
      return;
    }

    await foundAnimation.setPayload(payload);
    this.dispatchEventToListeners(Events.AnimationGroupUpdated, foundAnimationGroup);
  }

  async animationStarted(payload: Protocol.Animation.Animation): Promise<void> {
    // We are not interested in animations without effect or target.
    if (!payload.source?.backendNodeId) {
      return;
    }

    const animation = await AnimationImpl.parsePayload(this, payload);
    // Ignore Web Animations custom effects & groups.
    const keyframesRule = animation.source().keyframesRule();
    if (animation.type() === 'WebAnimation' && keyframesRule?.keyframes().length === 0) {
      this.#pendingAnimations.delete(animation.id());
    } else {
      this.#animationsById.set(animation.id(), animation);
      this.#pendingAnimations.add(animation.id());
    }

    this.#flushPendingAnimations();
  }

  private matchExistingGroups(incomingGroup: AnimationGroup): boolean {
    let matchedGroup: AnimationGroup|null = null;
    for (const group of this.animationGroups.values()) {
      if (group.matches(incomingGroup)) {
        matchedGroup = group;
        group.rebaseTo(incomingGroup);
        break;
      }

      if (group.shouldInclude(incomingGroup)) {
        matchedGroup = group;
        group.appendAnimations(incomingGroup.animations());
        break;
      }
    }

    if (!matchedGroup) {
      this.animationGroups.set(incomingGroup.id(), incomingGroup);
      this.dispatchEventToListeners(Events.AnimationGroupStarted, incomingGroup);
    } else {
      this.dispatchEventToListeners(Events.AnimationGroupUpdated, matchedGroup);
    }
    return Boolean(matchedGroup);
  }

  private createGroupFromPendingAnimations(): AnimationGroup {
    console.assert(this.#pendingAnimations.size > 0);
    const firstAnimationId = this.#pendingAnimations.values().next().value as string;
    this.#pendingAnimations.delete(firstAnimationId);

    const firstAnimation = this.#animationsById.get(firstAnimationId);
    if (!firstAnimation) {
      throw new Error('Unable to locate first animation');
    }

    const groupedAnimations = [firstAnimation];
    const remainingAnimations = new Set<string>();

    for (const id of this.#pendingAnimations) {
      const anim = this.#animationsById.get(id) as AnimationImpl;
      if (shouldGroupAnimations(firstAnimation, anim)) {
        groupedAnimations.push(anim);
      } else {
        remainingAnimations.add(id);
      }
    }

    this.#pendingAnimations = remainingAnimations;
    // Show the first starting animation at the top of the animations of the animation group.
    groupedAnimations.sort((anim1, anim2) => anim1.startTime() - anim2.startTime());
    return new AnimationGroup(this, firstAnimationId, groupedAnimations);
  }

  setPlaybackRate(playbackRate: number): void {
    this.playbackRate = playbackRate;
    void this.agent.invoke_setPlaybackRate({playbackRate});
  }

  releaseAnimations(animations: string[]): void {
    void this.agent.invoke_releaseAnimations({animations});
  }

  override async suspendModel(): Promise<void> {
    await this.agent.invoke_disable().then(() => this.reset());
  }

  override async resumeModel(): Promise<void> {
    await this.agent.invoke_enable();
  }
}

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  AnimationGroupStarted = 'AnimationGroupStarted',
  AnimationGroupUpdated = 'AnimationGroupUpdated',
  ModelReset = 'ModelReset',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface EventTypes {
  [Events.AnimationGroupStarted]: AnimationGroup;
  [Events.AnimationGroupUpdated]: AnimationGroup;
  [Events.ModelReset]: void;
}

export class AnimationImpl {
  readonly #animationModel: AnimationModel;
  #payload!: Protocol.Animation
      .Animation;  // Assertion is safe because only way to create `AnimationImpl` is to use `parsePayload` which calls `setPayload` and sets the value.
  #source!:
      AnimationEffect;  // Assertion is safe because only way to create `AnimationImpl` is to use `parsePayload` which calls `setPayload` and sets the value.
  #playState?: string;

  private constructor(animationModel: AnimationModel) {
    this.#animationModel = animationModel;
  }

  static async parsePayload(animationModel: AnimationModel, payload: Protocol.Animation.Animation):
      Promise<AnimationImpl> {
    const animation = new AnimationImpl(animationModel);
    await animation.setPayload(payload);
    return animation;
  }

  async setPayload(payload: Protocol.Animation.Animation): Promise<void> {
    // TODO(b/40929569): Remove normalizing by devicePixelRatio after the attached bug is resolved.
    if (payload.viewOrScrollTimeline) {
      const devicePixelRatio = await this.#animationModel.devicePixelRatio();
      if (payload.viewOrScrollTimeline.startOffset) {
        payload.viewOrScrollTimeline.startOffset /= devicePixelRatio;
      }

      if (payload.viewOrScrollTimeline.endOffset) {
        payload.viewOrScrollTimeline.endOffset /= devicePixelRatio;
      }
    }

    this.#payload = payload;
    if (this.#source && payload.source) {
      this.#source.setPayload(payload.source);
    } else if (!this.#source && payload.source) {
      this.#source = new AnimationEffect(this.#animationModel, payload.source);
    }
  }

  // `startTime` and `duration` is represented as the
  // percentage of the view timeline range that starts at `startOffset`px
  // from the scroll container and ends at `endOffset`px of the scroll container.
  // This takes a percentage of the timeline range and returns the absolute
  // pixels values as a scroll offset of the scroll container.
  private percentageToPixels(percentage: number, viewOrScrollTimeline: Protocol.Animation.ViewOrScrollTimeline):
      number {
    const {startOffset, endOffset} = viewOrScrollTimeline;
    if (startOffset === undefined || endOffset === undefined) {
      // We don't expect this situation to occur since after an animation is started
      // we expect the scroll offsets to be resolved and provided correctly. If `startOffset`
      // or `endOffset` is not provided in a viewOrScrollTimeline; we can assume that there is a bug here
      // so it's fine to throw an error.
      throw new Error('startOffset or endOffset does not exist in viewOrScrollTimeline');
    }

    return (endOffset - startOffset) * (percentage / 100);
  }

  viewOrScrollTimeline(): Protocol.Animation.ViewOrScrollTimeline|undefined {
    return this.#payload.viewOrScrollTimeline;
  }

  id(): string {
    return this.#payload.id;
  }

  name(): string {
    return this.#payload.name;
  }

  paused(): boolean {
    return this.#payload.pausedState;
  }

  playState(): string {
    return this.#playState || this.#payload.playState;
  }

  playbackRate(): number {
    return this.#payload.playbackRate;
  }

  // For scroll driven animations, it returns the pixel offset in the scroll container
  // For time animations, it returns milliseconds.
  startTime(): number {
    const viewOrScrollTimeline = this.viewOrScrollTimeline();
    if (viewOrScrollTimeline) {
      return this.percentageToPixels(
                 this.playbackRate() > 0 ? this.#payload.startTime : 100 - this.#payload.startTime,
                 viewOrScrollTimeline) +
          (this.viewOrScrollTimeline()?.startOffset ?? 0);
    }

    return this.#payload.startTime;
  }

  // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end)
  // For time animations, it returns milliseconds.
  iterationDuration(): number {
    const viewOrScrollTimeline = this.viewOrScrollTimeline();
    if (viewOrScrollTimeline) {
      return this.percentageToPixels(this.source().duration(), viewOrScrollTimeline);
    }

    return this.source().duration();
  }

  // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end)
  // For time animations, it returns milliseconds.
  endTime(): number {
    if (!this.source().iterations) {
      return Infinity;
    }

    if (this.viewOrScrollTimeline()) {
      return this.startTime() + this.iterationDuration() * this.source().iterations();
    }

    return this.startTime() + this.source().delay() + this.source().duration() * this.source().iterations() +
        this.source().endDelay();
  }

  // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end)
  // For time animations, it returns milliseconds.
  finiteDuration(): number {
    const iterations = Math.min(this.source().iterations(), 3);
    if (this.viewOrScrollTimeline()) {
      return this.iterationDuration() * iterations;
    }

    return this.source().delay() + this.source().duration() * iterations;
  }

  // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end)
  // For time animations, it returns milliseconds.
  currentTime(): number {
    const viewOrScrollTimeline = this.viewOrScrollTimeline();
    if (viewOrScrollTimeline) {
      return this.percentageToPixels(this.#payload.currentTime, viewOrScrollTimeline);
    }

    return this.#payload.currentTime;
  }

  source(): AnimationEffect {
    return this.#source;
  }

  type(): Protocol.Animation.AnimationType {
    return this.#payload.type;
  }

  overlaps(animation: AnimationImpl): boolean {
    // Infinite animations
    if (!this.source().iterations() || !animation.source().iterations()) {
      return true;
    }

    const firstAnimation = this.startTime() < animation.startTime() ? this : animation;
    const secondAnimation = firstAnimation === this ? animation : this;
    return firstAnimation.endTime() >= secondAnimation.startTime();
  }

  // Utility method for returning `delay` for time based animations
  // and `startTime` in pixels for scroll driven animations. It is used to
  // find the exact starting time of the first keyframe for both cases.
  delayOrStartTime(): number {
    if (this.viewOrScrollTimeline()) {
      return this.startTime();
    }

    return this.source().delay();
  }

  setTiming(duration: number, delay: number): void {
    void this.#source.node().then(node => {
      if (!node) {
        throw new Error('Unable to find node');
      }
      this.updateNodeStyle(duration, delay, node);
    });
    this.#source.durationInternal = duration;
    this.#source.delayInternal = delay;
    void this.#animationModel.agent.invoke_setTiming({animationId: this.id(), duration, delay});
  }

  private updateNodeStyle(duration: number, delay: number, node: DOMNode): void {
    let animationPrefix;
    if (this.type() === Protocol.Animation.AnimationType.CSSTransition) {
      animationPrefix = 'transition-';
    } else if (this.type() === Protocol.Animation.AnimationType.CSSAnimation) {
      animationPrefix = 'animation-';
    } else {
      return;
    }

    if (!node.id) {
      throw new Error('Node has no id');
    }

    const cssModel = node.domModel().cssModel();
    cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'duration', duration + 'ms');
    cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'delay', delay + 'ms');
  }

  async remoteObjectPromise(): Promise<RemoteObject|null> {
    const payload = await this.#animationModel.agent.invoke_resolveAnimation({animationId: this.id()});
    if (!payload) {
      return null;
    }

    return this.#animationModel.runtimeModel.createRemoteObject(payload.remoteObject);
  }

  cssId(): string {
    return this.#payload.cssId || '';
  }
}

export class AnimationEffect {
  #animationModel: AnimationModel;
  #payload!: Protocol.Animation
      .AnimationEffect;       // Assertion is safe because `setPayload` call in `constructor` sets the value.
  delayInternal!: number;     // Assertion is safe because `setPayload` call in `constructor` sets the value.
  durationInternal!: number;  // Assertion is safe because `setPayload` call in `constructor` sets the value.
  #keyframesRule: KeyframesRule|undefined;
  #deferredNode?: DeferredDOMNode;
  constructor(animationModel: AnimationModel, payload: Protocol.Animation.AnimationEffect) {
    this.#animationModel = animationModel;
    this.setPayload(payload);
  }

  setPayload(payload: Protocol.Animation.AnimationEffect): void {
    this.#payload = payload;
    if (!this.#keyframesRule && payload.keyframesRule) {
      this.#keyframesRule = new KeyframesRule(payload.keyframesRule);
    } else if (this.#keyframesRule && payload.keyframesRule) {
      this.#keyframesRule.setPayload(payload.keyframesRule);
    }

    this.delayInternal = payload.delay;
    this.durationInternal = payload.duration;
  }

  delay(): number {
    return this.delayInternal;
  }

  endDelay(): number {
    return this.#payload.endDelay;
  }

  iterations(): number {
    // Animations with zero duration, zero delays and infinite iterations can't be shown.
    if (!this.delay() && !this.endDelay() && !this.duration()) {
      return 0;
    }
    return this.#payload.iterations || Infinity;
  }

  duration(): number {
    return this.durationInternal;
  }

  direction(): string {
    return this.#payload.direction;
  }

  fill(): string {
    return this.#payload.fill;
  }

  node(): Promise<DOMNode|null> {
    if (!this.#deferredNode) {
      this.#deferredNode = new DeferredDOMNode(this.#animationModel.target(), this.backendNodeId());
    }
    return this.#deferredNode.resolvePromise();
  }

  deferredNode(): DeferredDOMNode {
    return new DeferredDOMNode(this.#animationModel.target(), this.backendNodeId());
  }

  backendNodeId(): Protocol.DOM.BackendNodeId {
    return this.#payload.backendNodeId as Protocol.DOM.BackendNodeId;
  }

  keyframesRule(): KeyframesRule|null {
    return this.#keyframesRule || null;
  }

  easing(): string {
    return this.#payload.easing;
  }
}

export class KeyframesRule {
  #payload!: Protocol.Animation
      .KeyframesRule;            // Assertion is safe because `setPayload` call in `constructor` sets the value.;
  #keyframes!: KeyframeStyle[];  // Assertion is safe because `setPayload` call in `constructor` sets the value.;
  constructor(payload: Protocol.Animation.KeyframesRule) {
    this.setPayload(payload);
  }

  setPayload(payload: Protocol.Animation.KeyframesRule): void {
    this.#payload = payload;
    if (!this.#keyframes) {
      this.#keyframes = this.#payload.keyframes.map(keyframeStyle => new KeyframeStyle(keyframeStyle));
    } else {
      this.#payload.keyframes.forEach((keyframeStyle, index) => {
        this.#keyframes[index]?.setPayload(keyframeStyle);
      });
    }
  }

  name(): string|undefined {
    return this.#payload.name;
  }

  keyframes(): KeyframeStyle[] {
    return this.#keyframes;
  }
}

export class KeyframeStyle {
  #payload!:
      Protocol.Animation.KeyframeStyle;  // Assertion is safe because `setPayload` call in `constructor` sets the value.
  #offset!: string;                      // Assertion is safe because `setPayload` call in `constructor` sets the value.
  constructor(payload: Protocol.Animation.KeyframeStyle) {
    this.setPayload(payload);
  }

  setPayload(payload: Protocol.Animation.KeyframeStyle): void {
    this.#payload = payload;
    this.#offset = payload.offset;
  }

  offset(): string {
    return this.#offset;
  }

  setOffset(offset: number): void {
    this.#offset = offset * 100 + '%';
  }

  offsetAsNumber(): number {
    return parseFloat(this.#offset) / 100;
  }

  easing(): string {
    return this.#payload.easing;
  }
}

export class AnimationGroup {
  readonly #animationModel: AnimationModel;
  readonly #id: string;
  #scrollNode: AnimationDOMNode|undefined;
  #animations: AnimationImpl[];
  #paused = false;
  constructor(animationModel: AnimationModel, id: string, animations: AnimationImpl[]) {
    this.#animationModel = animationModel;
    this.#id = id;
    this.#animations = animations;
  }

  isScrollDriven(): boolean {
    return Boolean(this.#animations[0]?.viewOrScrollTimeline());
  }

  id(): string {
    return this.#id;
  }

  animations(): AnimationImpl[] {
    return this.#animations;
  }

  release(): void {
    this.#animationModel.animationGroups.delete(this.id());
    this.#animationModel.releaseAnimations(this.animationIds());
  }

  private animationIds(): string[] {
    function extractId(animation: AnimationImpl): string {
      return animation.id();
    }

    return this.#animations.map(extractId);
  }

  startTime(): number {
    return this.#animations[0].startTime();
  }

  // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end)
  // For time animations, it returns milliseconds.
  groupDuration(): number {
    let duration = 0;
    for (const anim of this.#animations) {
      duration = Math.max(duration, anim.delayOrStartTime() + anim.iterationDuration());
    }
    return duration;
  }

  // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end)
  // For time animations, it returns milliseconds.
  finiteDuration(): number {
    let maxDuration = 0;
    for (let i = 0; i < this.#animations.length; ++i) {
      maxDuration = Math.max(maxDuration, this.#animations[i].finiteDuration());
    }
    return maxDuration;
  }

  scrollOrientation(): Protocol.DOM.ScrollOrientation|null {
    const timeline = this.#animations[0]?.viewOrScrollTimeline();
    if (!timeline) {
      return null;
    }

    return timeline.axis;
  }

  async scrollNode(): Promise<AnimationDOMNode|null> {
    if (this.#scrollNode) {
      return this.#scrollNode;
    }

    if (!this.isScrollDriven()) {
      return null;
    }

    const sourceNodeId = this.#animations[0]?.viewOrScrollTimeline()?.sourceNodeId;
    if (!sourceNodeId) {
      return null;
    }

    const deferredScrollNode = new DeferredDOMNode(this.#animationModel.target(), sourceNodeId);
    const scrollNode = await deferredScrollNode.resolvePromise();
    if (!scrollNode) {
      return null;
    }

    this.#scrollNode = new AnimationDOMNode(scrollNode);
    return this.#scrollNode;
  }

  seekTo(currentTime: number): void {
    void this.#animationModel.agent.invoke_seekAnimations({animations: this.animationIds(), currentTime});
  }

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

  togglePause(paused: boolean): void {
    if (paused === this.#paused) {
      return;
    }
    this.#paused = paused;
    void this.#animationModel.agent.invoke_setPaused({animations: this.animationIds(), paused});
  }

  currentTimePromise(): Promise<number> {
    let longestAnim: AnimationImpl|null = null;
    for (const anim of this.#animations) {
      if (!longestAnim || anim.endTime() > longestAnim.endTime()) {
        longestAnim = anim;
      }
    }
    if (!longestAnim) {
      throw new Error('No longest animation found');
    }

    return this.#animationModel.agent.invoke_getCurrentTime({id: longestAnim.id()})
        .then(({currentTime}) => currentTime || 0);
  }

  matches(group: AnimationGroup): boolean {
    function extractId(anim: AnimationImpl): string {
      const timelineId = (anim.viewOrScrollTimeline()?.sourceNodeId ?? '') + (anim.viewOrScrollTimeline()?.axis ?? '');
      const regularId =
          anim.type() === Protocol.Animation.AnimationType.WebAnimation ? anim.type() + anim.id() : anim.cssId();

      return regularId + timelineId;
    }

    if (this.#animations.length !== group.#animations.length) {
      return false;
    }
    const left = this.#animations.map(extractId).sort();
    const right = group.#animations.map(extractId).sort();
    for (let i = 0; i < left.length; i++) {
      if (left[i] !== right[i]) {
        return false;
      }
    }
    return true;
  }

  shouldInclude(group: AnimationGroup): boolean {
    // We want to include the animations coming from the incoming group
    // inside this group if they were to be grouped if the events came at the same time.
    const [firstIncomingAnimation] = group.#animations;
    const [firstAnimation] = this.#animations;
    return shouldGroupAnimations(firstAnimation, firstIncomingAnimation);
  }

  appendAnimations(animations: AnimationImpl[]): void {
    this.#animations.push(...animations);
  }

  rebaseTo(group: AnimationGroup): void {
    this.#animationModel.releaseAnimations(this.animationIds());
    this.#animations = group.#animations;
    this.#scrollNode = undefined;
  }
}

export class AnimationDispatcher implements ProtocolProxyApi.AnimationDispatcher {
  readonly #animationModel: AnimationModel;
  constructor(animationModel: AnimationModel) {
    this.#animationModel = animationModel;
  }

  animationCreated(_event: Protocol.Animation.AnimationCreatedEvent): void {
    // Previously this event was used to batch the animations into groups
    // and we were waiting for animationStarted events to be sent for
    // all the created animations and until then we weren't creating any
    // groups. This was allowing us to not miss any animations that were
    // going to be in the same group. However, now we're not using this event
    // to do batching and instead:
    // * We debounce the flush calls so that if the animationStarted events
    // for the same animation group come in different times; we create one
    // group for them.
    // * Even though an animation group is created and rendered for some animations
    // that have the same startTime (or same timeline & scroll axis for SDAs), now
    // whenever an `animationStarted` event comes we check whether there is a group
    // we can add the related animation. If so, we add it and emit `animationGroupUpdated`
    // event. So that, all the animations that were supposed to be in the same group
    // will be in the same group.
  }

  animationCanceled({id}: Protocol.Animation.AnimationCanceledEvent): void {
    this.#animationModel.animationCanceled(id);
  }

  animationStarted({animation}: Protocol.Animation.AnimationStartedEvent): void {
    void this.#animationModel.animationStarted(animation);
  }

  animationUpdated({animation}: Protocol.Animation.AnimationUpdatedEvent): void {
    void this.#animationModel.animationUpdated(animation);
  }
}

SDKModel.register(AnimationModel, {capabilities: Capability.DOM, autostart: true});
export interface Request {
  endTime: number;
}
