/**
 * @license
 * Copyright 2022 Google Inc.
 * SPDX-License-Identifier: Apache-2.0
 */

import type {Protocol} from 'devtools-protocol';

import type {TargetFilterCallback} from '../api/Browser.js';
import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
import {EventEmitter} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';

import type {CdpCDPSession} from './CDPSession.js';
import type {Connection} from './Connection.js';
import {CdpTarget, InitializationStatus} from './Target.js';
import {
  type TargetFactory,
  type TargetManager,
  TargetManagerEvent,
  type TargetManagerEvents,
} from './TargetManager.js';

function isPageTargetBecomingPrimary(
  target: CdpTarget,
  newTargetInfo: Protocol.Target.TargetInfo,
): boolean {
  return Boolean(target._subtype()) && !newTargetInfo.subtype;
}

/**
 * ChromeTargetManager uses the CDP's auto-attach mechanism to intercept
 * new targets and allow the rest of Puppeteer to configure listeners while
 * the target is paused.
 *
 * @internal
 */
export class ChromeTargetManager
  extends EventEmitter<TargetManagerEvents>
  implements TargetManager
{
  #connection: Connection;
  /**
   * Keeps track of the following events: 'Target.targetCreated',
   * 'Target.targetDestroyed', 'Target.targetInfoChanged'.
   *
   * A target becomes discovered when 'Target.targetCreated' is received.
   * A target is removed from this map once 'Target.targetDestroyed' is
   * received.
   *
   * `targetFilterCallback` has no effect on this map.
   */
  #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>();
  /**
   * A target is added to this map once ChromeTargetManager has created
   * a Target and attached at least once to it.
   */
  #attachedTargetsByTargetId = new Map<string, CdpTarget>();
  /**
   * Tracks which sessions attach to which target.
   */
  #attachedTargetsBySessionId = new Map<string, CdpTarget>();
  /**
   * If a target was filtered out by `targetFilterCallback`, we still receive
   * events about it from CDP, but we don't forward them to the rest of Puppeteer.
   */
  #ignoredTargets = new Set<string>();
  #targetFilterCallback: TargetFilterCallback | undefined;
  #targetFactory: TargetFactory;

  #attachedToTargetListenersBySession = new WeakMap<
    CDPSession | Connection,
    (event: Protocol.Target.AttachedToTargetEvent) => void
  >();
  #detachedFromTargetListenersBySession = new WeakMap<
    CDPSession | Connection,
    (event: Protocol.Target.DetachedFromTargetEvent) => void
  >();

  #initializeDeferred = Deferred.create<void>();
  #targetsIdsForInit = new Set<string>();
  #waitForInitiallyDiscoveredTargets = true;

  #discoveryFilter: Protocol.Target.FilterEntry[] = [{}];

  constructor(
    connection: Connection,
    targetFactory: TargetFactory,
    targetFilterCallback?: TargetFilterCallback,
    waitForInitiallyDiscoveredTargets = true,
  ) {
    super();
    this.#connection = connection;
    this.#targetFilterCallback = targetFilterCallback;
    this.#targetFactory = targetFactory;
    this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets;

    this.#connection.on('Target.targetCreated', this.#onTargetCreated);
    this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
    this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged);
    this.#connection.on(
      CDPSessionEvent.SessionDetached,
      this.#onSessionDetached,
    );
    this.#setupAttachmentListeners(this.#connection);
  }

  #storeExistingTargetsForInit = () => {
    if (!this.#waitForInitiallyDiscoveredTargets) {
      return;
    }
    for (const [
      targetId,
      targetInfo,
    ] of this.#discoveredTargetsByTargetId.entries()) {
      const targetForFilter = new CdpTarget(
        targetInfo,
        undefined,
        undefined,
        this,
        undefined,
      );
      // Only wait for pages and frames (except those from extensions)
      // to auto-attach.
      const isPageOrFrame =
        targetInfo.type === 'page' || targetInfo.type === 'iframe';
      const isExtension = targetInfo.url.startsWith('chrome-extension://');
      if (
        (!this.#targetFilterCallback ||
          this.#targetFilterCallback(targetForFilter)) &&
        isPageOrFrame &&
        !isExtension
      ) {
        this.#targetsIdsForInit.add(targetId);
      }
    }
  };

  async initialize(): Promise<void> {
    await this.#connection.send('Target.setDiscoverTargets', {
      discover: true,
      filter: this.#discoveryFilter,
    });

    this.#storeExistingTargetsForInit();

    await this.#connection.send('Target.setAutoAttach', {
      waitForDebuggerOnStart: true,
      flatten: true,
      autoAttach: true,
      filter: [
        {
          type: 'page',
          exclude: true,
        },
        ...this.#discoveryFilter,
      ],
    });
    this.#finishInitializationIfReady();
    await this.#initializeDeferred.valueOrThrow();
  }

  getChildTargets(target: CdpTarget): ReadonlySet<CdpTarget> {
    return target._childTargets();
  }

  dispose(): void {
    this.#connection.off('Target.targetCreated', this.#onTargetCreated);
    this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
    this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged);
    this.#connection.off(
      CDPSessionEvent.SessionDetached,
      this.#onSessionDetached,
    );

    this.#removeAttachmentListeners(this.#connection);
  }

  getAvailableTargets(): ReadonlyMap<string, CdpTarget> {
    return this.#attachedTargetsByTargetId;
  }

  #setupAttachmentListeners(session: CDPSession | Connection): void {
    const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
      void this.#onAttachedToTarget(session, event);
    };
    assert(!this.#attachedToTargetListenersBySession.has(session));
    this.#attachedToTargetListenersBySession.set(session, listener);
    session.on('Target.attachedToTarget', listener);

    const detachedListener = (
      event: Protocol.Target.DetachedFromTargetEvent,
    ) => {
      return this.#onDetachedFromTarget(session, event);
    };
    assert(!this.#detachedFromTargetListenersBySession.has(session));
    this.#detachedFromTargetListenersBySession.set(session, detachedListener);
    session.on('Target.detachedFromTarget', detachedListener);
  }

  #removeAttachmentListeners(session: CDPSession | Connection): void {
    const listener = this.#attachedToTargetListenersBySession.get(session);
    if (listener) {
      session.off('Target.attachedToTarget', listener);
      this.#attachedToTargetListenersBySession.delete(session);
    }

    if (this.#detachedFromTargetListenersBySession.has(session)) {
      session.off(
        'Target.detachedFromTarget',
        this.#detachedFromTargetListenersBySession.get(session)!,
      );
      this.#detachedFromTargetListenersBySession.delete(session);
    }
  }

  #onSessionDetached = (session: CDPSession) => {
    this.#removeAttachmentListeners(session);
  };

  #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => {
    this.#discoveredTargetsByTargetId.set(
      event.targetInfo.targetId,
      event.targetInfo,
    );

    this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo);

    // The connection is already attached to the browser target implicitly,
    // therefore, no new CDPSession is created and we have special handling
    // here.
    if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
      if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) {
        return;
      }
      const target = this.#targetFactory(event.targetInfo, undefined);
      target._initialize();
      this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target);
    }
  };

  #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => {
    const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId);
    this.#discoveredTargetsByTargetId.delete(event.targetId);
    this.#finishInitializationIfReady(event.targetId);
    if (
      targetInfo?.type === 'service_worker' &&
      this.#attachedTargetsByTargetId.has(event.targetId)
    ) {
      // Special case for service workers: report TargetGone event when
      // the worker is destroyed.
      const target = this.#attachedTargetsByTargetId.get(event.targetId);
      if (target) {
        this.emit(TargetManagerEvent.TargetGone, target);
        this.#attachedTargetsByTargetId.delete(event.targetId);
      }
    }
  };

  #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => {
    this.#discoveredTargetsByTargetId.set(
      event.targetInfo.targetId,
      event.targetInfo,
    );

    if (
      this.#ignoredTargets.has(event.targetInfo.targetId) ||
      !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) ||
      !event.targetInfo.attached
    ) {
      return;
    }

    const target = this.#attachedTargetsByTargetId.get(
      event.targetInfo.targetId,
    );
    if (!target) {
      return;
    }
    const previousURL = target.url();
    const wasInitialized =
      target._initializedDeferred.value() === InitializationStatus.SUCCESS;

    if (isPageTargetBecomingPrimary(target, event.targetInfo)) {
      const session = target?._session();
      assert(
        session,
        'Target that is being activated is missing a CDPSession.',
      );
      session.parentSession()?.emit(CDPSessionEvent.Swapped, session);
    }

    target._targetInfoChanged(event.targetInfo);

    if (wasInitialized && previousURL !== target.url()) {
      this.emit(TargetManagerEvent.TargetChanged, {
        target,
        wasInitialized,
        previousURL,
      });
    }
  };

  #onAttachedToTarget = async (
    parentSession: Connection | CDPSession,
    event: Protocol.Target.AttachedToTargetEvent,
  ) => {
    const targetInfo = event.targetInfo;
    const session = this.#connection.session(event.sessionId);
    if (!session) {
      throw new Error(`Session ${event.sessionId} was not created.`);
    }

    const silentDetach = async () => {
      await session.send('Runtime.runIfWaitingForDebugger').catch(debugError);
      // We don't use `session.detach()` because that dispatches all commands on
      // the connection instead of the parent session.
      await parentSession
        .send('Target.detachFromTarget', {
          sessionId: session.id(),
        })
        .catch(debugError);
    };

    if (!this.#connection.isAutoAttached(targetInfo.targetId)) {
      return;
    }

    // Special case for service workers: being attached to service workers will
    // prevent them from ever being destroyed. Therefore, we silently detach
    // from service workers unless the connection was manually created via
    // `page.worker()`. To determine this, we use
    // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we
    // should determine if a target is auto-attached or not with the help of
    // CDP.
    if (targetInfo.type === 'service_worker') {
      this.#finishInitializationIfReady(targetInfo.targetId);
      await silentDetach();
      if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) {
        return;
      }
      const target = this.#targetFactory(targetInfo);
      target._initialize();
      this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
      this.emit(TargetManagerEvent.TargetAvailable, target);
      return;
    }

    const isExistingTarget = this.#attachedTargetsByTargetId.has(
      targetInfo.targetId,
    );

    const target = isExistingTarget
      ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
      : this.#targetFactory(
          targetInfo,
          session,
          parentSession instanceof CDPSession ? parentSession : undefined,
        );

    if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
      this.#ignoredTargets.add(targetInfo.targetId);
      this.#finishInitializationIfReady(targetInfo.targetId);
      await silentDetach();
      return;
    }

    this.#setupAttachmentListeners(session);

    if (isExistingTarget) {
      (session as CdpCDPSession)._setTarget(target);
      this.#attachedTargetsBySessionId.set(
        session.id(),
        this.#attachedTargetsByTargetId.get(targetInfo.targetId)!,
      );
    } else {
      target._initialize();
      this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
      this.#attachedTargetsBySessionId.set(session.id(), target);
    }

    const parentTarget =
      parentSession instanceof CDPSession
        ? (parentSession as CdpCDPSession)._target()
        : null;
    parentTarget?._addChildTarget(target);

    parentSession.emit(CDPSessionEvent.Ready, session);

    this.#targetsIdsForInit.delete(target._targetId);
    if (!isExistingTarget) {
      this.emit(TargetManagerEvent.TargetAvailable, target);
    }
    this.#finishInitializationIfReady();

    // TODO: the browser might be shutting down here. What do we do with the
    // error?
    await Promise.all([
      session.send('Target.setAutoAttach', {
        waitForDebuggerOnStart: true,
        flatten: true,
        autoAttach: true,
        filter: this.#discoveryFilter,
      }),
      session.send('Runtime.runIfWaitingForDebugger'),
    ]).catch(debugError);
  };

  #finishInitializationIfReady(targetId?: string): void {
    if (targetId !== undefined) {
      this.#targetsIdsForInit.delete(targetId);
    }
    if (this.#targetsIdsForInit.size === 0) {
      this.#initializeDeferred.resolve();
    }
  }

  #onDetachedFromTarget = (
    parentSession: Connection | CDPSession,
    event: Protocol.Target.DetachedFromTargetEvent,
  ) => {
    const target = this.#attachedTargetsBySessionId.get(event.sessionId);

    this.#attachedTargetsBySessionId.delete(event.sessionId);

    if (!target) {
      return;
    }

    if (parentSession instanceof CDPSession) {
      (parentSession as CdpCDPSession)._target()._removeChildTarget(target);
    }
    this.#attachedTargetsByTargetId.delete(target._targetId);
    this.emit(TargetManagerEvent.TargetGone, target);
  };
}
