// Copyright 2018 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 i18n from '../../core/i18n/i18n.js';
import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Host from '../host/host.js';

import {PrimaryPageChangeType, ResourceTreeModel} from './ResourceTreeModel.js';
import {SDKModel} from './SDKModel.js';
import {SecurityOriginManager} from './SecurityOriginManager.js';
import {StorageKeyManager} from './StorageKeyManager.js';
import {Capability, type Target, Type} from './Target.js';
import {Events as TargetManagerEvents, type TargetManager} from './TargetManager.js';

const UIStrings = {
  /**
   * @description Text that refers to the main target. The main target is the primary webpage that
   * DevTools is connected to. This text is used in various places in the UI as a label/name to inform
   * the user which target/webpage they are currently connected to, as DevTools may connect to multiple
   * targets at the same time in some scenarios.
   */
  main: 'Main',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/ChildTargetManager.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class ChildTargetManager extends SDKModel<EventTypes> implements ProtocolProxyApi.TargetDispatcher {
  readonly #targetManager: TargetManager;
  #parentTarget: Target;
  readonly #targetAgent: ProtocolProxyApi.TargetApi;
  readonly #targetInfos = new Map<Protocol.Target.TargetID, Protocol.Target.TargetInfo>();
  readonly #childTargetsBySessionId = new Map<Protocol.Target.SessionID, Target>();
  readonly #childTargetsById = new Map<Protocol.Target.TargetID|'main', Target>();
  #parentTargetId: Protocol.Target.TargetID|null = null;

  constructor(parentTarget: Target) {
    super(parentTarget);
    this.#targetManager = parentTarget.targetManager();
    this.#parentTarget = parentTarget;
    this.#targetAgent = parentTarget.targetAgent();
    parentTarget.registerTargetDispatcher(this);
    const browserTarget = this.#targetManager.browserTarget();
    if (browserTarget) {
      if (browserTarget !== parentTarget) {
        void browserTarget.targetAgent().invoke_autoAttachRelated(
            {targetId: parentTarget.id() as Protocol.Target.TargetID, waitForDebuggerOnStart: true});
      }
    } else if (parentTarget.type() === Type.NODE) {
      void this.#targetAgent.invoke_setAutoAttach({autoAttach: true, waitForDebuggerOnStart: true, flatten: false});
    } else {
      void this.#targetAgent.invoke_setAutoAttach({autoAttach: true, waitForDebuggerOnStart: true, flatten: true});
    }

    if (parentTarget.parentTarget()?.type() !== Type.FRAME && !Host.InspectorFrontendHost.isUnderTest()) {
      void this.#targetAgent.invoke_setDiscoverTargets({discover: true});
      void this.#targetAgent.invoke_setRemoteLocations({locations: [{host: 'localhost', port: 9229}]});
    }
  }

  static install(attachCallback?: ((arg0: {
                                     target: Target,
                                     waitingForDebugger: boolean,
                                   }) => Promise<void>)): void {
    ChildTargetManager.attachCallback = attachCallback;
    SDKModel.register(ChildTargetManager, {capabilities: Capability.TARGET, autostart: true});
  }

  childTargets(): Target[] {
    return Array.from(this.#childTargetsBySessionId.values());
  }

  override async suspendModel(): Promise<void> {
    await this.#targetAgent.invoke_setAutoAttach({autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
  }

  override async resumeModel(): Promise<void> {
    await this.#targetAgent.invoke_setAutoAttach({autoAttach: true, waitForDebuggerOnStart: true, flatten: true});
  }

  override dispose(): void {
    for (const sessionId of this.#childTargetsBySessionId.keys()) {
      this.detachedFromTarget({sessionId});
    }
  }

  targetCreated({targetInfo}: Protocol.Target.TargetCreatedEvent): void {
    this.#targetInfos.set(targetInfo.targetId, targetInfo);
    this.fireAvailableTargetsChanged();
    this.dispatchEventToListeners(Events.TARGET_CREATED, targetInfo);
  }

  targetInfoChanged({targetInfo}: Protocol.Target.TargetInfoChangedEvent): void {
    this.#targetInfos.set(targetInfo.targetId, targetInfo);
    const target = this.#childTargetsById.get(targetInfo.targetId);
    if (target) {
      void target.setHasCrashed(false);
      if (target.targetInfo()?.subtype === 'prerender' && !targetInfo.subtype) {
        const resourceTreeModel = target.model(ResourceTreeModel);
        target.updateTargetInfo(targetInfo);
        if (resourceTreeModel?.mainFrame) {
          resourceTreeModel.primaryPageChanged(resourceTreeModel.mainFrame, PrimaryPageChangeType.ACTIVATION);
        }
        target.setName(i18nString(UIStrings.main));
      } else {
        target.updateTargetInfo(targetInfo);
      }
    }
    this.fireAvailableTargetsChanged();
    this.dispatchEventToListeners(Events.TARGET_INFO_CHANGED, targetInfo);
  }

  targetDestroyed({targetId}: Protocol.Target.TargetDestroyedEvent): void {
    this.#targetInfos.delete(targetId);
    this.fireAvailableTargetsChanged();
    this.dispatchEventToListeners(Events.TARGET_DESTROYED, targetId);
  }

  targetCrashed({targetId}: Protocol.Target.TargetCrashedEvent): void {
    const target = this.#childTargetsById.get(targetId);
    if (target) {
      target.setHasCrashed(true);
    }
  }

  private fireAvailableTargetsChanged(): void {
    this.#targetManager.dispatchEventToListeners(
        TargetManagerEvents.AVAILABLE_TARGETS_CHANGED, [...this.#targetInfos.values()]);
  }

  async getParentTargetId(): Promise<Protocol.Target.TargetID> {
    if (!this.#parentTargetId) {
      this.#parentTargetId = (await this.#parentTarget.targetAgent().invoke_getTargetInfo({})).targetInfo.targetId;
    }
    return this.#parentTargetId;
  }

  async getTargetInfo(): Promise<Protocol.Target.TargetInfo> {
    return (await this.#parentTarget.targetAgent().invoke_getTargetInfo({})).targetInfo;
  }

  async attachedToTarget({sessionId, targetInfo, waitingForDebugger}: Protocol.Target.AttachedToTargetEvent):
      Promise<void> {
    if (this.#parentTargetId === targetInfo.targetId) {
      return;
    }
    let type = Type.BROWSER;
    let targetName = '';
    if (targetInfo.type === 'worker' && targetInfo.title && targetInfo.title !== targetInfo.url) {
      targetName = targetInfo.title;
    } else if (!['page', 'iframe', 'webview'].includes(targetInfo.type)) {
      const KNOWN_FRAME_PATTERNS = [
        '^chrome://print/$',
        '^chrome://file-manager/',
        '^chrome://feedback/',
        '^chrome://.*\\.top-chrome/$',
        '^chrome://view-cert/$',
        '^devtools://',
      ];
      if (KNOWN_FRAME_PATTERNS.some(p => targetInfo.url.match(p))) {
        type = Type.FRAME;
      } else {
        const parsedURL = Common.ParsedURL.ParsedURL.fromString(targetInfo.url);
        targetName =
            parsedURL ? parsedURL.lastPathComponentWithFragment() : '#' + (++ChildTargetManager.lastAnonymousTargetId);
      }
    }

    if (targetInfo.type === 'iframe' || targetInfo.type === 'webview') {
      type = Type.FRAME;
    } else if (targetInfo.type === 'background_page' || targetInfo.type === 'app' || targetInfo.type === 'popup_page') {
      type = Type.FRAME;
    } else if (targetInfo.type === 'page') {
      type = Type.FRAME;
    } else if (targetInfo.type === 'browser_ui') {
      type = Type.FRAME;
    } else if (targetInfo.type === 'worker') {
      type = Type.Worker;
    } else if (targetInfo.type === 'worklet') {
      type = Type.WORKLET;
    } else if (targetInfo.type === 'shared_worker') {
      type = Type.SHARED_WORKER;
    } else if (targetInfo.type === 'shared_storage_worklet') {
      type = Type.SHARED_STORAGE_WORKLET;
    } else if (targetInfo.type === 'service_worker') {
      type = Type.ServiceWorker;
    } else if (targetInfo.type === 'auction_worklet') {
      type = Type.AUCTION_WORKLET;
    } else if (targetInfo.type === 'node_worker') {
      type = Type.NODE_WORKER;
    }
    const target = this.#targetManager.createTarget(
        targetInfo.targetId, targetName, type, this.#parentTarget, sessionId, undefined, undefined, targetInfo);
    this.#childTargetsBySessionId.set(sessionId, target);
    this.#childTargetsById.set(target.id(), target);

    if (ChildTargetManager.attachCallback) {
      await ChildTargetManager.attachCallback({target, waitingForDebugger});
    }

    // [crbug/1423096] Invoking this on a worker session that is not waiting for the debugger can force the worker
    // to resume even if there is another session waiting for the debugger.
    if (waitingForDebugger) {
      void target.runtimeAgent().invoke_runIfWaitingForDebugger();
    }

    // For top-level workers (those not attached to a frame), we need to
    // initialize their storage context manually. The `Capability.STORAGE` is
    // only granted in `Target.ts` to workers that are not parented by a frame,
    // which makes this check safe. Frame-associated workers have their storage
    // managed by ResourceTreeModel.
    if (type !== Type.FRAME && target.hasAllCapabilities(Capability.STORAGE)) {
      await this.initializeStorage(target);
    }
  }

  private async initializeStorage(target: Target): Promise<void> {
    const storageAgent = target.storageAgent();
    const response = await storageAgent.invoke_getStorageKey({});

    const storageKey = response.storageKey;
    if (response.getError() || !storageKey) {
      console.error(`Failed to get storage key for target ${target.id()}: ${response.getError()}`);
      return;
    }

    const storageKeyManager = target.model(StorageKeyManager);
    if (storageKeyManager) {
      storageKeyManager.setMainStorageKey(storageKey);
      storageKeyManager.updateStorageKeys(new Set([storageKey]));
    }

    const securityOriginManager = target.model(SecurityOriginManager);
    if (securityOriginManager) {
      const origin = new URL(storageKey).origin;
      securityOriginManager.setMainSecurityOrigin(origin, '');
      securityOriginManager.updateSecurityOrigins(new Set([origin]));
    }
  }

  detachedFromTarget({sessionId}: Protocol.Target.DetachedFromTargetEvent): void {
    const target = this.#childTargetsBySessionId.get(sessionId);
    if (target) {
      target.dispose('target terminated');
      this.#childTargetsBySessionId.delete(sessionId);
      this.#childTargetsById.delete(target.id());
    }
  }

  receivedMessageFromTarget({}: Protocol.Target.ReceivedMessageFromTargetEvent): void {
    // We use flatten protocol.
  }

  targetInfos(): Protocol.Target.TargetInfo[] {
    return Array.from(this.#targetInfos.values());
  }

  private static lastAnonymousTargetId = 0;

  private static attachCallback?: ((arg0: {
                                     target: Target,
                                     waitingForDebugger: boolean,
                                   }) => Promise<void>);
}

export const enum Events {
  TARGET_CREATED = 'TargetCreated',
  TARGET_DESTROYED = 'TargetDestroyed',
  TARGET_INFO_CHANGED = 'TargetInfoChanged',
}

export interface EventTypes {
  [Events.TARGET_CREATED]: Protocol.Target.TargetInfo;
  [Events.TARGET_DESTROYED]: Protocol.Target.TargetID;
  [Events.TARGET_INFO_CHANGED]: Protocol.Target.TargetInfo;
}
