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

import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Root from '../root/root.js';

import type {Resource} from './Resource.js';
import {Events as ResourceTreeModelEvents, type ResourceTreeFrame, ResourceTreeModel} from './ResourceTreeModel.js';
import type {Target} from './Target.js';
import {type SDKModelObserver, TargetManager} from './TargetManager.js';

/**
 * The FrameManager is a central storage for all #frames. It collects #frames from all
 * ResourceTreeModel-instances (one per target), so that #frames can be found by id
 * without needing to know their target.
 */
export class FrameManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
    SDKModelObserver<ResourceTreeModel> {
  readonly #eventListeners = new WeakMap<ResourceTreeModel, Common.EventTarget.EventDescriptor[]>();

  // Maps frameIds to #frames and a count of how many ResourceTreeModels contain this frame.
  // (OOPIFs are usually first attached to a new target and then detached from their old target,
  // therefore being contained in 2 models for a short period of time.)
  #frames = new Map<string, {
    frame: ResourceTreeFrame,
    count: number,
  }>();

  readonly #framesForTarget = new Map<Protocol.Target.TargetID|'main', Set<Protocol.Page.FrameId>>();
  #outermostFrame: ResourceTreeFrame|null = null;
  #transferringFramesDataCache = new Map<string, {
    creationStackTrace?: Protocol.Runtime.StackTrace,
    creationStackTraceTarget?: Target,
  }>();
  #awaitedFrames = new Map<string, Array<{resolve: (frame: ResourceTreeFrame) => void, notInTarget?: Target}>>();

  constructor(targetManager: TargetManager) {
    super();
    targetManager.observeModels(ResourceTreeModel, this);
  }

  static instance({forceNew}: {
    forceNew: boolean,
  } = {forceNew: false}): FrameManager {
    if (!Root.DevToolsContext.globalInstance().has(FrameManager) || forceNew) {
      Root.DevToolsContext.globalInstance().set(FrameManager, new FrameManager(TargetManager.instance()));
    }
    return Root.DevToolsContext.globalInstance().get(FrameManager);
  }

  static removeInstance(): void {
    Root.DevToolsContext.globalInstance().delete(FrameManager);
  }

  modelAdded(resourceTreeModel: ResourceTreeModel): void {
    const addListener = resourceTreeModel.addEventListener(ResourceTreeModelEvents.FrameAdded, this.frameAdded, this);
    const detachListener =
        resourceTreeModel.addEventListener(ResourceTreeModelEvents.FrameDetached, this.frameDetached, this);
    const navigatedListener =
        resourceTreeModel.addEventListener(ResourceTreeModelEvents.FrameNavigated, this.frameNavigated, this);
    const resourceAddedListener =
        resourceTreeModel.addEventListener(ResourceTreeModelEvents.ResourceAdded, this.resourceAdded, this);
    this.#eventListeners.set(
        resourceTreeModel, [addListener, detachListener, navigatedListener, resourceAddedListener]);
    this.#framesForTarget.set(resourceTreeModel.target().id(), new Set());
  }

  modelRemoved(resourceTreeModel: ResourceTreeModel): void {
    const listeners = this.#eventListeners.get(resourceTreeModel);
    if (listeners) {
      Common.EventTarget.removeEventListeners(listeners);
    }

    // Iterate over this model's #frames and decrease their count or remove them.
    // (The ResourceTreeModel does not send FrameDetached events when a model
    // is removed.)
    const frameSet = this.#framesForTarget.get(resourceTreeModel.target().id());
    if (frameSet) {
      for (const frameId of frameSet) {
        this.decreaseOrRemoveFrame(frameId);
      }
    }
    this.#framesForTarget.delete(resourceTreeModel.target().id());
  }

  private frameAdded(event: Common.EventTarget.EventTargetEvent<ResourceTreeFrame>): void {
    const frame = event.data;
    const frameData = this.#frames.get(frame.id);
    // If the frame is already in the map, increase its count, otherwise add it to the map.
    if (frameData) {
      // In order to not lose the following attributes of a frame during
      // an OOPIF transfer we need to copy them to the new frame
      frame.setCreationStackTrace(frameData.frame.getCreationStackTraceData());
      this.#frames.set(frame.id, {frame, count: frameData.count + 1});
    } else {
      // If the transferring frame's detached event is received before its frame added
      // event in the new target, the frame's cached attributes are reassigned.
      const cachedFrameAttributes = this.#transferringFramesDataCache.get(frame.id);
      if (cachedFrameAttributes?.creationStackTrace && cachedFrameAttributes?.creationStackTraceTarget) {
        frame.setCreationStackTrace({
          creationStackTrace: cachedFrameAttributes.creationStackTrace,
          creationStackTraceTarget: cachedFrameAttributes.creationStackTraceTarget,
        });
      }
      this.#frames.set(frame.id, {frame, count: 1});
      this.#transferringFramesDataCache.delete(frame.id);
    }
    this.resetOutermostFrame();

    // Add the frameId to the the targetId's set of frameIds.
    const frameSet = this.#framesForTarget.get(frame.resourceTreeModel().target().id());
    if (frameSet) {
      frameSet.add(frame.id);
    }

    this.dispatchEventToListeners(Events.FRAME_ADDED_TO_TARGET, {frame});
    this.resolveAwaitedFrame(frame);
  }

  private frameDetached(event: Common.EventTarget.EventTargetEvent<{frame: ResourceTreeFrame, isSwap: boolean}>): void {
    const {frame, isSwap} = event.data;
    // Decrease the frame's count or remove it entirely from the map.
    this.decreaseOrRemoveFrame(frame.id);

    // If the transferring frame's detached event is received before its frame
    // added event in the new target, we persist some attributes of the frame here
    // so that later on the frame added event in the new target they can be reassigned.
    if (isSwap && !this.#frames.get(frame.id)) {
      const traceData = frame.getCreationStackTraceData();
      const cachedFrameAttributes = {
        ...(traceData.creationStackTrace && {creationStackTrace: traceData.creationStackTrace}),
        ...(traceData.creationStackTrace && {creationStackTraceTarget: traceData.creationStackTraceTarget}),
      };
      this.#transferringFramesDataCache.set(frame.id, cachedFrameAttributes);
    }

    // Remove the frameId from the target's set of frameIds.
    const frameSet = this.#framesForTarget.get(frame.resourceTreeModel().target().id());
    if (frameSet) {
      frameSet.delete(frame.id);
    }
  }

  private frameNavigated(event: Common.EventTarget.EventTargetEvent<ResourceTreeFrame>): void {
    const frame = event.data;
    this.dispatchEventToListeners(Events.FRAME_NAVIGATED, {frame});
    if (frame.isOutermostFrame()) {
      this.dispatchEventToListeners(Events.OUTERMOST_FRAME_NAVIGATED, {frame});
    }
  }

  private resourceAdded(event: Common.EventTarget.EventTargetEvent<Resource>): void {
    this.dispatchEventToListeners(Events.RESOURCE_ADDED, {resource: event.data});
  }

  private decreaseOrRemoveFrame(frameId: Protocol.Page.FrameId): void {
    const frameData = this.#frames.get(frameId);
    if (frameData) {
      if (frameData.count === 1) {
        this.#frames.delete(frameId);
        this.resetOutermostFrame();
        this.dispatchEventToListeners(Events.FRAME_REMOVED, {frameId});
      } else {
        frameData.count--;
      }
    }
  }

  /**
   * Looks for the outermost frame in `#frames` and sets `#outermostFrame` accordingly.
   *
   * Important: This method needs to be called everytime `#frames` is updated.
   */
  private resetOutermostFrame(): void {
    const outermostFrames = this.getAllFrames().filter(frame => frame.isOutermostFrame());
    this.#outermostFrame = outermostFrames.length > 0 ? outermostFrames[0] : null;
  }

  /**
   * Returns the ResourceTreeFrame with a given frameId.
   * When a frame is being detached a new ResourceTreeFrame but with the same
   * frameId is created. Consequently getFrame() will return a different
   * ResourceTreeFrame after detachment. Callers of getFrame() should therefore
   * immediately use the function return value and not store it for later use.
   */
  getFrame(frameId: Protocol.Page.FrameId): ResourceTreeFrame|null {
    const frameData = this.#frames.get(frameId);
    if (frameData) {
      return frameData.frame;
    }
    return null;
  }

  getAllFrames(): ResourceTreeFrame[] {
    return Array.from(this.#frames.values(), frameData => frameData.frame);
  }

  getOutermostFrame(): ResourceTreeFrame|null {
    return this.#outermostFrame;
  }

  async getOrWaitForFrame(frameId: Protocol.Page.FrameId, notInTarget?: Target): Promise<ResourceTreeFrame> {
    const frame = this.getFrame(frameId);
    if (frame && (!notInTarget || notInTarget !== frame.resourceTreeModel().target())) {
      return frame;
    }
    return await new Promise<ResourceTreeFrame>(resolve => {
      const waiting = this.#awaitedFrames.get(frameId);
      if (waiting) {
        waiting.push({notInTarget, resolve});
      } else {
        this.#awaitedFrames.set(frameId, [{notInTarget, resolve}]);
      }
    });
  }

  private resolveAwaitedFrame(frame: ResourceTreeFrame): void {
    const waiting = this.#awaitedFrames.get(frame.id);
    if (!waiting) {
      return;
    }
    const newWaiting = waiting.filter(({notInTarget, resolve}) => {
      if (!notInTarget || notInTarget !== frame.resourceTreeModel().target()) {
        resolve(frame);
        return false;
      }
      return true;
    });
    if (newWaiting.length > 0) {
      this.#awaitedFrames.set(frame.id, newWaiting);
    } else {
      this.#awaitedFrames.delete(frame.id);
    }
  }
}

export const enum Events {
  // The FrameAddedToTarget event is sent whenever a frame is added to a target.
  // This means that for OOPIFs it is sent twice: once when it's added to a
  // parent target and a second time when it's added to its own target.
  FRAME_ADDED_TO_TARGET = 'FrameAddedToTarget',
  FRAME_NAVIGATED = 'FrameNavigated',
  // The FrameRemoved event is only sent when a frame has been detached from
  // all targets.
  FRAME_REMOVED = 'FrameRemoved',
  RESOURCE_ADDED = 'ResourceAdded',
  OUTERMOST_FRAME_NAVIGATED = 'OutermostFrameNavigated',
}

export interface EventTypes {
  [Events.FRAME_ADDED_TO_TARGET]: {frame: ResourceTreeFrame};
  [Events.FRAME_NAVIGATED]: {frame: ResourceTreeFrame};
  [Events.FRAME_REMOVED]: {frameId: Protocol.Page.FrameId};
  [Events.RESOURCE_ADDED]: {resource: Resource};
  [Events.OUTERMOST_FRAME_NAVIGATED]: {frame: ResourceTreeFrame};
}
