// 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 Platform from '../platform/platform.js';
import * as ProtocolClient from '../protocol_client/protocol_client.js';
import * as Root from '../root/root.js';

import {SDKModel, type SDKModelConstructor} from './SDKModel.js';
import type {TargetManager} from './TargetManager.js';

export class Target extends ProtocolClient.InspectorBackend.TargetBase {
  readonly #targetManager: TargetManager;
  #name: string;
  #inspectedURL: Platform.DevToolsPath.UrlString = Platform.DevToolsPath.EmptyUrlString;
  #inspectedURLName = '';
  readonly #capabilitiesMask: number;
  #type: Type;
  readonly #parentTarget: Target|null;
  #id: Protocol.Target.TargetID|'main';
  #modelByConstructor = new Map<new(arg1: Target) => SDKModel, SDKModel>();
  #isSuspended: boolean;
  /**
   * Generally when a target crashes we don't need to know, with one exception.
   * If a target crashes during the recording of a performance trace, after the
   * trace when we try to resume() it, it will fail because it has crashed. This
   * causes the performance panel to freeze (see crbug.com/333989070). So we
   * mark the target as crashed so we can exit without trying to resume it. In
   * `ChildTargetManager` we will mark a target as "un-crashed" when we get the
   * `targetInfoChanged` event. This helps ensure we can deal with cases where
   * the page crashes, but a reload fixes it and the targets get restored (see
   * crbug.com/387258086).
   */
  #hasCrashed = false;
  #targetInfo: Protocol.Target.TargetInfo|undefined;
  #creatingModels?: boolean;

  constructor(
      targetManager: TargetManager, id: Protocol.Target.TargetID|'main', name: string, type: Type,
      parentTarget: Target|null, sessionId: string, suspended: boolean,
      connection: ProtocolClient.CDPConnection.CDPConnection|null, targetInfo?: Protocol.Target.TargetInfo) {
    super(parentTarget, sessionId, connection);
    this.#targetManager = targetManager;
    this.#name = name;
    this.#capabilitiesMask = 0;
    switch (type) {
      case Type.FRAME:
        this.#capabilitiesMask = Capability.BROWSER | Capability.STORAGE | Capability.DOM | Capability.JS |
            Capability.LOG | Capability.NETWORK | Capability.TARGET | Capability.TRACING | Capability.EMULATION |
            Capability.INPUT | Capability.INSPECTOR | Capability.AUDITS | Capability.WEB_AUTHN | Capability.IO |
            Capability.MEDIA | Capability.EVENT_BREAKPOINTS | Capability.DOM_STORAGE;
        if (Root.Runtime.hostConfig.devToolsWebMCPSupport?.enabled) {
          this.#capabilitiesMask |= Capability.WEB_MCP;
        }
        if (parentTarget?.type() !== Type.FRAME) {
          // This matches backend exposing certain capabilities only for the main frame.
          this.#capabilitiesMask |=
              Capability.DEVICE_EMULATION | Capability.SCREEN_CAPTURE | Capability.SECURITY | Capability.SERVICE_WORKER;
          if (Common.ParsedURL.schemeIs(targetInfo?.url as Platform.DevToolsPath.UrlString, 'chrome-extension:')) {
            this.#capabilitiesMask &= ~Capability.SECURITY;
          }

          // TODO(dgozman): we report service workers for the whole frame tree on the main frame,
          // while we should be able to only cover the subtree corresponding to the target.
        }
        break;
      case Type.ServiceWorker:
        this.#capabilitiesMask = Capability.JS | Capability.LOG | Capability.NETWORK | Capability.TARGET |
            Capability.INSPECTOR | Capability.IO | Capability.EVENT_BREAKPOINTS;
        if (parentTarget?.type() !== Type.FRAME) {
          // TODO(crbug.com/406991275): This should also grant the `STORAGE` capability, but first the
          // crashers in https://crbug.com/466134219 have to be resolved.
          this.#capabilitiesMask |= Capability.BROWSER;
        }
        break;
      case Type.SHARED_WORKER:
        this.#capabilitiesMask = Capability.JS | Capability.LOG | Capability.NETWORK | Capability.TARGET |
            Capability.IO | Capability.MEDIA | Capability.INSPECTOR | Capability.EVENT_BREAKPOINTS;
        if (parentTarget?.type() !== Type.FRAME) {
          this.#capabilitiesMask |= Capability.STORAGE;
        }
        break;
      case Type.SHARED_STORAGE_WORKLET:
        this.#capabilitiesMask = Capability.JS | Capability.LOG | Capability.INSPECTOR | Capability.EVENT_BREAKPOINTS;
        break;
      case Type.Worker:
        this.#capabilitiesMask = Capability.JS | Capability.LOG | Capability.NETWORK | Capability.TARGET |
            Capability.IO | Capability.MEDIA | Capability.EMULATION | Capability.EVENT_BREAKPOINTS;
        if (parentTarget?.type() !== Type.FRAME) {
          this.#capabilitiesMask |= Capability.STORAGE;
        }
        break;
      case Type.WORKLET:
        this.#capabilitiesMask = Capability.JS | Capability.LOG | Capability.EVENT_BREAKPOINTS | Capability.NETWORK;
        break;
      case Type.NODE:
        this.#capabilitiesMask =
            Capability.JS | Capability.NETWORK | Capability.TARGET | Capability.IO | Capability.DOM_STORAGE;
        break;
      case Type.AUCTION_WORKLET:
        this.#capabilitiesMask = Capability.JS | Capability.EVENT_BREAKPOINTS;
        break;
      case Type.BROWSER:
        this.#capabilitiesMask = Capability.TARGET | Capability.IO;
        break;
      case Type.TAB:
        this.#capabilitiesMask = Capability.TARGET | Capability.TRACING;
        break;
      case Type.NODE_WORKER:
        this.#capabilitiesMask = Capability.JS | Capability.NETWORK | Capability.TARGET | Capability.IO;
    }
    this.#type = type;
    this.#parentTarget = parentTarget;
    this.#id = id;
    this.#isSuspended = suspended;
    this.#targetInfo = targetInfo;
  }

  /** Creates the models in the order in which they are provided */
  createModels(models: SDKModelConstructor[]): void {
    this.#creatingModels = true;
    for (const model of models) {
      this.model(model);
    }
    this.#creatingModels = false;
  }

  id(): Protocol.Target.TargetID|'main' {
    return this.#id;
  }

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

  setName(name: string): void {
    if (this.#name === name) {
      return;
    }
    this.#name = name;
    this.#targetManager.onNameChange(this);
  }

  type(): Type {
    return this.#type;
  }

  markAsNodeJSForTest(): void {
    this.#type = Type.NODE;
  }

  targetManager(): TargetManager {
    return this.#targetManager;
  }

  hasAllCapabilities(capabilitiesMask: number): boolean {
    // TODO(dgozman): get rid of this method, once we never observe targets with
    // capability mask.
    return (this.#capabilitiesMask & capabilitiesMask) === capabilitiesMask;
  }

  decorateLabel(label: string): string {
    return (this.#type === Type.Worker || this.#type === Type.ServiceWorker) ? '\u2699 ' + label : label;
  }

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

  outermostTarget(): Target|null {
    let lastTarget: Target|null = null;
    let currentTarget: Target|null = this;
    do {
      if (currentTarget.type() !== Type.TAB && currentTarget.type() !== Type.BROWSER) {
        lastTarget = currentTarget;
      }
      currentTarget = currentTarget.parentTarget();
    } while (currentTarget);

    return lastTarget;
  }

  override dispose(reason: string): void {
    super.dispose(reason);
    this.#targetManager.removeTarget(this);
    for (const model of this.#modelByConstructor.values()) {
      model.dispose();
    }
  }

  model<T extends SDKModel>(modelClass: new(arg1: Target) => T): T|null {
    if (!this.#modelByConstructor.get(modelClass)) {
      const info = SDKModel.registeredModels.get(modelClass);
      if (info === undefined) {
        throw new Error('Model class is not registered');
      }
      if ((this.#capabilitiesMask & info.capabilities) === info.capabilities) {
        const model = new modelClass(this);
        this.#modelByConstructor.set(modelClass, model);
        if (!this.#creatingModels) {
          this.#targetManager.modelAdded(modelClass, model, this.#targetManager.isInScope(this));
        }
      }
    }
    return (this.#modelByConstructor.get(modelClass) as T) || null;
  }

  models(): Map<new(arg1: Target) => SDKModel, SDKModel> {
    return this.#modelByConstructor;
  }

  inspectedURL(): Platform.DevToolsPath.UrlString {
    return this.#inspectedURL;
  }

  setInspectedURL(inspectedURL: Platform.DevToolsPath.UrlString): void {
    this.#inspectedURL = inspectedURL;
    const parsedURL = Common.ParsedURL.ParsedURL.fromString(inspectedURL);
    this.#inspectedURLName = parsedURL ? parsedURL.lastPathComponentWithFragment() : '#' + this.#id;
    this.#targetManager.onInspectedURLChange(this);
    if (!this.#name) {
      this.#targetManager.onNameChange(this);
    }
  }

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

  setHasCrashed(isCrashed: boolean): void {
    const wasCrashed = this.#hasCrashed;

    this.#hasCrashed = isCrashed;
    // If the target has now been restored, check to see if it needs resuming.
    // This ensures that if a target crashes whilst suspended, it is resumed
    // when it is recovered.
    // If the target is not suspended, resume() is a no-op, so it's safe to call.
    if (wasCrashed && !isCrashed) {
      void this.resume();
    }
  }

  async suspend(reason?: string): Promise<void> {
    if (this.#isSuspended) {
      return;
    }
    this.#isSuspended = true;

    // If the target has crashed, we will not attempt to suspend all the
    // models, but we still mark it as suspended so we correctly track the
    // state.
    if (this.#hasCrashed) {
      return;
    }

    await Promise.all(Array.from(this.models().values(), m => m.preSuspendModel(reason)));
    await Promise.all(Array.from(this.models().values(), m => m.suspendModel(reason)));
  }

  async resume(): Promise<void> {
    if (!this.#isSuspended) {
      return;
    }
    this.#isSuspended = false;

    if (this.#hasCrashed) {
      return;
    }

    await Promise.all(Array.from(this.models().values(), m => m.resumeModel()));
    await Promise.all(Array.from(this.models().values(), m => m.postResumeModel()));
  }

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

  updateTargetInfo(targetInfo: Protocol.Target.TargetInfo): void {
    this.#targetInfo = targetInfo;
  }

  targetInfo(): Protocol.Target.TargetInfo|undefined {
    return this.#targetInfo;
  }
}

export enum Type {
  FRAME = 'frame',
  // eslint-disable-next-line @typescript-eslint/naming-convention -- Used by web_tests.
  ServiceWorker = 'service-worker',
  // eslint-disable-next-line @typescript-eslint/naming-convention -- Used by web_tests.
  Worker = 'worker',
  SHARED_WORKER = 'shared-worker',
  SHARED_STORAGE_WORKLET = 'shared-storage-worklet',
  NODE = 'node',
  BROWSER = 'browser',
  AUCTION_WORKLET = 'auction-worklet',
  WORKLET = 'worklet',
  TAB = 'tab',
  NODE_WORKER = 'node-worker',
}

export const enum Capability {
  BROWSER = 1 << 0,
  DOM = 1 << 1,
  JS = 1 << 2,
  LOG = 1 << 3,
  NETWORK = 1 << 4,
  TARGET = 1 << 5,
  SCREEN_CAPTURE = 1 << 6,
  TRACING = 1 << 7,
  EMULATION = 1 << 8,
  SECURITY = 1 << 9,
  INPUT = 1 << 10,
  INSPECTOR = 1 << 11,
  DEVICE_EMULATION = 1 << 12,
  STORAGE = 1 << 13,
  SERVICE_WORKER = 1 << 14,
  AUDITS = 1 << 15,
  WEB_AUTHN = 1 << 16,
  IO = 1 << 17,
  MEDIA = 1 << 18,
  EVENT_BREAKPOINTS = 1 << 19,
  DOM_STORAGE = 1 << 20,
  WEB_MCP = 1 << 21,
  NONE = 0,
}
