// 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.

import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Host from '../host/host.js';
import * as Platform from '../platform/platform.js';
import {assertNotNullOrUndefined} from '../platform/platform.js';
import type * as ProtocolClient from '../protocol_client/protocol_client.js';
import * as Root from '../root/root.js';

import {type RegistrationInfo, SDKModel, type SDKModelConstructor} from './SDKModel.js';
import {Target, Type as TargetType} from './Target.js';

export class TargetManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  /**
   * @deprecated
   *
   * Intended for {@link SDKModel} classes to be able to retrieve scoped singletons like
   * the "PageResourceLoader" or the "FrameManager".
   *
   * This is only an intermediate step to migrate towards our "layering vision" where
   * SDKModels don't require things from the next layer.
   */
  readonly context: Root.DevToolsContext.DevToolsContext;
  #targets: Set<Target>;
  readonly #observers: Set<Observer>;

  get settings(): Common.Settings.Settings {
    return this.context.get(Common.Settings.Settings);
  }

  /* eslint-disable @typescript-eslint/no-explicit-any */
  #modelListeners: Platform.MapUtilities.Multimap<string|symbol|number, {
    modelClass: SDKModelConstructor,
    thisObject: Object|undefined,
    listener: Common.EventTarget.EventListener<any, any>,
    wrappedListener: Common.EventTarget.EventListener<any, any>,
  }>;
  readonly #modelObservers: Platform.MapUtilities.Multimap<SDKModelConstructor, SDKModelObserver<any>>;
  #scopedObservers: WeakSet<Observer|SDKModelObserver<any>>;
  /* eslint-enable @typescript-eslint/no-explicit-any */
  #isSuspended: boolean;
  #browserTarget: Target|null;
  #scopeTarget: Target|null;
  #defaultScopeSet: boolean;
  readonly #scopeChangeListeners: Set<() => void>;
  readonly #overrideAutoStartModels?: Set<SDKModelConstructor>;

  /**
   * @param overrideAutoStartModels If provided, then the `autostart` flag on {@link RegistrationInfo} will be ignored.
   */
  constructor(context: Root.DevToolsContext.DevToolsContext, overrideAutoStartModels?: Set<SDKModelConstructor>) {
    super();
    this.context = context;
    this.#targets = new Set();
    this.#observers = new Set();
    this.#modelListeners = new Platform.MapUtilities.Multimap();
    this.#modelObservers = new Platform.MapUtilities.Multimap();
    this.#isSuspended = false;
    this.#browserTarget = null;
    this.#scopeTarget = null;
    this.#scopedObservers = new WeakSet();
    this.#defaultScopeSet = false;
    this.#scopeChangeListeners = new Set();
    this.#overrideAutoStartModels = overrideAutoStartModels;
  }

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

    return Root.DevToolsContext.globalInstance().get(TargetManager);
  }

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

  onInspectedURLChange(target: Target): void {
    if (target !== this.#scopeTarget) {
      return;
    }
    Host.InspectorFrontendHost.InspectorFrontendHostInstance.inspectedURLChanged(
        target.inspectedURL() || Platform.DevToolsPath.EmptyUrlString);
    this.dispatchEventToListeners(Events.INSPECTED_URL_CHANGED, target);
  }

  onNameChange(target: Target): void {
    this.dispatchEventToListeners(Events.NAME_CHANGED, target);
  }

  async suspendAllTargets(reason?: string): Promise<void> {
    if (this.#isSuspended) {
      return;
    }
    this.#isSuspended = true;
    this.dispatchEventToListeners(Events.SUSPEND_STATE_CHANGED);
    const suspendPromises = Array.from(this.#targets.values(), target => target.suspend(reason));
    await Promise.all(suspendPromises);
  }

  async resumeAllTargets(): Promise<void> {
    if (!this.#isSuspended) {
      return;
    }
    this.#isSuspended = false;
    this.dispatchEventToListeners(Events.SUSPEND_STATE_CHANGED);
    const resumePromises = Array.from(this.#targets.values(), target => target.resume());
    await Promise.all(resumePromises);
  }

  allTargetsSuspended(): boolean {
    return this.#isSuspended;
  }

  models<T extends SDKModel>(modelClass: SDKModelConstructor<T>, opts?: {scoped: boolean}): T[] {
    const result = [];
    for (const target of this.#targets) {
      if (opts?.scoped && !this.isInScope(target)) {
        continue;
      }
      const model = target.model(modelClass);
      if (!model) {
        continue;
      }
      result.push(model);
    }
    return result;
  }

  inspectedURL(): string {
    const mainTarget = this.primaryPageTarget();
    return mainTarget ? mainTarget.inspectedURL() : '';
  }

  observeModels<T extends SDKModel>(modelClass: SDKModelConstructor<T>, observer: SDKModelObserver<T>, opts?: {
    scoped: boolean,
  }): void {
    const models = this.models(modelClass, opts);
    this.#modelObservers.set(modelClass, observer);
    if (opts?.scoped) {
      this.#scopedObservers.add(observer);
    }
    for (const model of models) {
      observer.modelAdded(model);
    }
  }

  unobserveModels<T extends SDKModel>(modelClass: SDKModelConstructor<T>, observer: SDKModelObserver<T>): void {
    this.#modelObservers.delete(modelClass, observer);
    this.#scopedObservers.delete(observer);
  }

  modelAdded(modelClass: SDKModelConstructor, model: SDKModel, inScope: boolean): void {
    for (const observer of this.#modelObservers.get(modelClass).values()) {
      if (!this.#scopedObservers.has(observer) || inScope) {
        observer.modelAdded(model);
      }
    }
  }

  private modelRemoved(modelClass: SDKModelConstructor, model: SDKModel, inScope: boolean): void {
    for (const observer of this.#modelObservers.get(modelClass).values()) {
      if (!this.#scopedObservers.has(observer) || inScope) {
        observer.modelRemoved(model);
      }
    }
  }

  addModelListener<Events, T extends keyof Events>(
      modelClass: SDKModelConstructor<SDKModel<Events>>, eventType: T,
      listener: Common.EventTarget.EventListener<Events, T>, thisObject?: Object, opts?: {scoped: boolean}): void {
    const wrappedListener = (event: Common.EventTarget.EventTargetEvent<Events[T], Events>): void => {
      if (!opts?.scoped || this.isInScope(event)) {
        listener.call(thisObject, event);
      }
    };
    for (const model of this.models(modelClass)) {
      model.addEventListener(eventType, wrappedListener);
    }
    this.#modelListeners.set(eventType, {modelClass, thisObject, listener, wrappedListener});
  }

  removeModelListener<Events, T extends keyof Events>(
      modelClass: SDKModelConstructor<SDKModel<Events>>, eventType: T,
      listener: Common.EventTarget.EventListener<Events, T>, thisObject?: Object): void {
    if (!this.#modelListeners.has(eventType)) {
      return;
    }
    let wrappedListener = null;
    for (const info of this.#modelListeners.get(eventType)) {
      if (info.modelClass === modelClass && info.listener === listener && info.thisObject === thisObject) {
        wrappedListener = info.wrappedListener;
        this.#modelListeners.delete(eventType, info);
      }
    }
    if (wrappedListener) {
      for (const model of this.models(modelClass)) {
        model.removeEventListener(eventType, wrappedListener);
      }
    }
  }

  observeTargets(targetObserver: Observer, opts?: {scoped: boolean}): void {
    if (this.#observers.has(targetObserver)) {
      throw new Error('Observer can only be registered once');
    }
    if (opts?.scoped) {
      this.#scopedObservers.add(targetObserver);
    }
    for (const target of this.#targets) {
      if (!opts?.scoped || this.isInScope(target)) {
        targetObserver.targetAdded(target);
      }
    }
    this.#observers.add(targetObserver);
  }

  unobserveTargets(targetObserver: Observer): void {
    this.#observers.delete(targetObserver);
    this.#scopedObservers.delete(targetObserver);
  }

  /** @returns The set of models we create unconditionally for new targets in the order in which they should be created */
  #autoStartModels(): SDKModelConstructor[] {
    const earlyModels = new Set<SDKModelConstructor>();
    const models = new Set<SDKModelConstructor>();
    const shouldAutostart = (model: SDKModelConstructor, info: RegistrationInfo): boolean =>
        this.#overrideAutoStartModels ? this.#overrideAutoStartModels.has(model) : info.autostart;

    for (const [model, info] of SDKModel.registeredModels) {
      if (info.early) {
        earlyModels.add(model);
      } else if (shouldAutostart(model, info) || this.#modelObservers.has(model)) {
        models.add(model);
      }
    }
    return [...earlyModels, ...models];
  }

  createTarget(
      id: Protocol.Target.TargetID|'main', name: string, type: TargetType, parentTarget: Target|null,
      sessionId?: string, waitForDebuggerInPage?: boolean, connection?: ProtocolClient.CDPConnection.CDPConnection,
      targetInfo?: Protocol.Target.TargetInfo): Target {
    const target = new Target(
        this, id, name, type, parentTarget, sessionId || '', this.#isSuspended, connection || null, targetInfo);
    if (waitForDebuggerInPage) {
      void target.pageAgent().invoke_waitForDebugger();
    }
    target.createModels(this.#autoStartModels());
    this.#targets.add(target);

    const inScope = this.isInScope(target);
    // Iterate over a copy. #observers might be modified during iteration.
    for (const observer of [...this.#observers]) {
      if (!this.#scopedObservers.has(observer) || inScope) {
        observer.targetAdded(target);
      }
    }

    for (const [modelClass, model] of target.models().entries()) {
      this.modelAdded(modelClass, model, inScope);
    }

    for (const key of this.#modelListeners.keysArray()) {
      for (const info of this.#modelListeners.get(key)) {
        const model = target.model(info.modelClass);
        if (model) {
          model.addEventListener(key, info.wrappedListener);
        }
      }
    }

    if ((target === target.outermostTarget() &&
         (target.type() !== TargetType.FRAME || target === this.primaryPageTarget())) &&
        !this.#defaultScopeSet) {
      this.setScopeTarget(target);
    }

    return target;
  }

  removeTarget(target: Target): void {
    if (!this.#targets.has(target)) {
      return;
    }

    const inScope = this.isInScope(target);
    this.#targets.delete(target);
    for (const modelClass of target.models().keys()) {
      const model = target.models().get(modelClass);
      assertNotNullOrUndefined(model);
      this.modelRemoved(modelClass, model, inScope);
    }

    // Iterate over a copy. #observers might be modified during iteration.
    for (const observer of [...this.#observers]) {
      if (!this.#scopedObservers.has(observer) || inScope) {
        observer.targetRemoved(target);
      }
    }

    for (const key of this.#modelListeners.keysArray()) {
      for (const info of this.#modelListeners.get(key)) {
        const model = target.model(info.modelClass);
        if (model) {
          model.removeEventListener(key, info.wrappedListener);
        }
      }
    }
  }

  targets(): Target[] {
    return [...this.#targets];
  }

  targetById(id: string): Target|null {
    // TODO(dgozman): add a map #id -> #target.
    return this.targets().find(target => target.id() === id) || null;
  }

  rootTarget(): Target|null {
    if (this.#targets.size === 0) {
      return null;
    }
    return this.#targets.values().next().value ?? null;
  }

  primaryPageTarget(): Target|null {
    let target = this.rootTarget();
    if (target?.type() === TargetType.TAB) {
      target =
          this.targets().find(
              t => t.parentTarget() === target && t.type() === TargetType.FRAME && !t.targetInfo()?.subtype?.length) ||
          null;
    }
    return target;
  }

  browserTarget(): Target|null {
    return this.#browserTarget;
  }

  async maybeAttachInitialTarget(): Promise<boolean> {
    if (!Boolean(Root.Runtime.Runtime.queryParam('browserConnection'))) {
      return false;
    }
    if (!this.#browserTarget) {
      this.#browserTarget = new Target(
          this, /* #id*/ 'main', /* #name*/ 'browser', TargetType.BROWSER, /* #parentTarget*/ null,
          /* #sessionId */ '', /* suspended*/ false, /* #connection*/ null, /* targetInfo*/ undefined);
      this.#browserTarget.createModels(this.#autoStartModels());
    }
    const targetId =
        await Host.InspectorFrontendHost.InspectorFrontendHostInstance.initialTargetId() as Protocol.Target.TargetID;
    // Do not await for Target.autoAttachRelated to return, as it goes throguh the renderer and we don't want to block early
    // at front-end initialization if a renderer is stuck. The rest of #target discovery and auto-attach process should happen
    // asynchronously upon Target.attachedToTarget.
    void this.#browserTarget.targetAgent().invoke_autoAttachRelated({
      targetId,
      waitForDebuggerOnStart: true,
    });
    return true;
  }

  clearAllTargetsForTest(): void {
    this.#targets.clear();
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  isInScope(arg: SDKModel|Target|Common.EventTarget.EventTargetEvent<any, any>|null): boolean {
    if (!arg) {
      return false;
    }
    if (isSDKModelEvent(arg)) {
      arg = arg.source as SDKModel;
    }
    if (arg instanceof SDKModel) {
      arg = arg.target();
    }
    while (arg && arg !== this.#scopeTarget) {
      arg = arg.parentTarget();
    }
    return Boolean(arg) && arg === this.#scopeTarget;
  }

  // Sets a root of a scope substree.
  // TargetManager API invoked with `scoped: true` will behave as if targets
  // outside of the scope subtree don't exist. Concretely this means that
  // target observers, model observers and model listeners won't be invoked for targets outside of the
  // scope tree. This method will invoke targetRemoved and modelRemoved for
  // objects in the previous scope, as if they disappear and then will invoke
  // targetAdded and modelAdded as if they just appeared.
  // Note that scopeTarget could be null, which will effectively prevent scoped
  // observes from getting any events.
  setScopeTarget(scopeTarget: Target|null): void {
    if (scopeTarget === this.#scopeTarget) {
      return;
    }
    for (const target of this.targets()) {
      if (!this.isInScope(target)) {
        continue;
      }
      for (const modelClass of this.#modelObservers.keysArray()) {
        const model = target.models().get(modelClass);
        if (!model) {
          continue;
        }
        for (const observer of [...this.#modelObservers.get(modelClass)].filter(o => this.#scopedObservers.has(o))) {
          observer.modelRemoved(model);
        }
      }

      // Iterate over a copy. #observers might be modified during iteration.
      for (const observer of [...this.#observers].filter(o => this.#scopedObservers.has(o))) {
        observer.targetRemoved(target);
      }
    }
    this.#scopeTarget = scopeTarget;
    for (const target of this.targets()) {
      if (!this.isInScope(target)) {
        continue;
      }

      for (const observer of [...this.#observers].filter(o => this.#scopedObservers.has(o))) {
        observer.targetAdded(target);
      }

      for (const [modelClass, model] of target.models().entries()) {
        for (const observer of [...this.#modelObservers.get(modelClass)].filter(o => this.#scopedObservers.has(o))) {
          observer.modelAdded(model);
        }
      }
    }
    for (const scopeChangeListener of this.#scopeChangeListeners) {
      scopeChangeListener();
    }
    if (scopeTarget?.inspectedURL()) {
      this.onInspectedURLChange(scopeTarget);
    }
  }

  addScopeChangeListener(listener: () => void): void {
    this.#scopeChangeListeners.add(listener);
  }

  scopeTarget(): Target|null {
    return this.#scopeTarget;
  }
}

export const enum Events {
  AVAILABLE_TARGETS_CHANGED = 'AvailableTargetsChanged',
  INSPECTED_URL_CHANGED = 'InspectedURLChanged',
  NAME_CHANGED = 'NameChanged',
  SUSPEND_STATE_CHANGED = 'SuspendStateChanged',
}

export interface EventTypes {
  [Events.AVAILABLE_TARGETS_CHANGED]: Protocol.Target.TargetInfo[];
  [Events.INSPECTED_URL_CHANGED]: Target;
  [Events.NAME_CHANGED]: Target;
  [Events.SUSPEND_STATE_CHANGED]: void;
}

export class Observer {
  targetAdded(_target: Target): void {
  }
  targetRemoved(_target: Target): void {
  }
}

export class SDKModelObserver<T> {
  modelAdded(_model: T): void {
  }
  modelRemoved(_model: T): void {
  }
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isSDKModelEvent(arg: Object): arg is Common.EventTarget.EventTargetEvent<any, any> {
  return 'source' in arg && arg.source instanceof SDKModel;
}
