// 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 Platform from '../../core/platform/platform.js';
import type * as ProtocolClient from '../../core/protocol_client/protocol_client.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import type * as LighthouseModel from '../../models/lighthouse/lighthouse.js';

/**
 * @file
 *                                                 ┌───────────────┐
 *                                                 │ CDPConnection │
 *                                                 └───────────────┘
 *                                                        │ ▲
 *                                                        │ │
 *                          ┌┐                            ▼ │                     ┌┐
 *                          ││   dispatchProtocolMessage     sendProtocolMessage  ││
 *                          ││                     │          ▲                   ││
 *          ProtocolService ││                     |          │                   ││
 *                          ││    sendWithResponse ▼          │                   ││
 *                          ││              │    send          onWorkerMessage    ││
 *                          └┘              │    │                 ▲              └┘
 *          worker boundary - - - - - - - - ┼ - -│- - - - - - - - -│- - - - - - - - - - - -
 *                          ┌┐              ▼    ▼                 │                    ┌┐
 *                          ││   onFrontendMessage      notifyFrontendViaWorkerMessage  ││
 *                          ││                   │       ▲                              ││
 *                          ││                   ▼       │                              ││
 *  LighthouseWorkerService ││               WorkerConnectionTransport                  ││
 *                          ││                           │ ▲                            ││
 *                          ││                           ▼ │                            ││
 *                          ││                     CDPConnection                        ││
 *                          ││                           │ ▲                            ││
 *                          ││     ┌─────────────────────┼─┼───────────────────────┐    ││
 *                          ││     │  Lighthouse    ┌────▼──────┐                  │    ││
 *                          ││     │                │connection │                  │    ││
 *                          ││     │                └───────────┘                  │    ││
 *                          └┘     └───────────────────────────────────────────────┘    └┘
 *
 * - All messages traversing the worker boundary are action-wrapped.
 * - All messages over the CDPConnection speak pure CDP.
 * - Within the worker we also use a 'CDPConnection' but with a custom
 *   transport called WorkerConnectionTransport.
 * - All messages within WorkerConnectionTransport/LegacyPort speak pure CDP.
 */

let lastId = 1;

export interface LighthouseRun {
  inspectedURL: Platform.DevToolsPath.UrlString;
  categoryIDs: string[];
  flags: {
    formFactor: (string|undefined),
    mode: string,
  };
  isAIControlled?: boolean;
}

export class CancelledError extends Error {
  constructor() {
    super('Lighthouse run cancelled');
  }
}

/**
 * ProtocolService manages a connection between the frontend (Lighthouse panel) and the Lighthouse worker.
 */
export class ProtocolService implements ProtocolClient.CDPConnection.CDPConnectionObserver {
  private mainSessionId?: Protocol.Target.SessionID;
  private rootTargetId?: string;
  private rootTarget?: SDK.Target.Target;
  private lighthouseWorkerPromise?: Promise<Worker>;
  private lighthouseMessageUpdateCallback?: ((arg0: string) => void);
  private removeDialogHandler?: () => void;
  private configForTesting?: object;
  private connection?: ProtocolClient.CDPConnection.CDPConnection;

  /**
   * Tracks pending requests to the Lighthouse worker.
   * Key: The message ID sent to the worker.
   * Value: The rejection function for the corresponding promise.
   * This is used to gracefully cancel hanging promises if the ProtocolService is detached before the worker replies.
   */
  readonly #pendingRequests = new Map<number, (reason: Error) => void>();

  async attach(): Promise<void> {
    await SDK.TargetManager.TargetManager.instance().suspendAllTargets();
    const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
    if (!mainTarget) {
      throw new Error('Unable to find main target required for Lighthouse');
    }
    const rootTarget = SDK.TargetManager.TargetManager.instance().rootTarget();
    if (!rootTarget) {
      throw new Error('Could not find the root target');
    }
    const childTargetManager = mainTarget.model(SDK.ChildTargetManager.ChildTargetManager);
    if (!childTargetManager) {
      throw new Error('Unable to find child target manager required for Lighthouse');
    }
    const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel.ResourceTreeModel);
    if (!resourceTreeModel) {
      throw new Error('Unable to find resource tree model required for Lighthouse');
    }

    const rootChildTargetManager = rootTarget.model(SDK.ChildTargetManager.ChildTargetManager);
    if (!rootChildTargetManager) {
      throw new Error('Could not find the child target manager class for the root target');
    }

    const connection = rootTarget.router()?.connection;
    if (!connection) {
      throw new Error('Expected root target to have a session router');
    }

    const rootTargetId = await rootChildTargetManager.getParentTargetId();
    const {sessionId} = await rootTarget.targetAgent().invoke_attachToTarget({targetId: rootTargetId, flatten: true});
    this.connection = connection;
    this.connection.observe(this);

    // Lighthouse implements its own dialog handler like this, however its lifecycle ends when
    // the internal Lighthouse session is disposed.
    //
    // If the page is reloaded near the end of the run (e.g. bfcache testing), the Lighthouse
    // internal session can be disposed before a dialog message appears. This allows the dialog
    // to block important Lighthouse teardown operations in LighthouseProtocolService.
    //
    // To ensure the teardown operations can proceed, we need a dialog handler which lasts until
    // the LighthouseProtocolService detaches.
    const dialogHandler = (): void => {
      void mainTarget.pageAgent().invoke_handleJavaScriptDialog({accept: true});
    };

    resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.JavaScriptDialogOpening, dialogHandler);
    this.removeDialogHandler = () =>
        resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.JavaScriptDialogOpening, dialogHandler);

    this.rootTargetId = rootTargetId;
    this.rootTarget = rootTarget;
    this.mainSessionId = sessionId;
  }

  getLocales(): readonly string[] {
    return [i18n.DevToolsLocale.DevToolsLocale.instance().locale];
  }

  async startTimespan(currentLighthouseRun: LighthouseRun): Promise<void> {
    const {inspectedURL, categoryIDs, flags} = currentLighthouseRun;

    if (!this.mainSessionId || !this.rootTargetId) {
      throw new Error('Unable to get target info required for Lighthouse');
    }

    await this.sendWithResponse('startTimespan', {
      url: inspectedURL,
      categoryIDs,
      flags,
      config: this.configForTesting,
      locales: this.getLocales(),
      mainSessionId: this.mainSessionId,
      rootTargetId: this.rootTargetId,
    });
  }

  async collectLighthouseResults(currentLighthouseRun: LighthouseRun):
      Promise<LighthouseModel.ReporterTypes.RunnerResult> {
    const {inspectedURL, categoryIDs, flags} = currentLighthouseRun;

    if (!this.mainSessionId || !this.rootTargetId) {
      throw new Error('Unable to get target info required for Lighthouse');
    }

    let mode = flags.mode as string;
    if (mode === 'timespan') {
      mode = 'endTimespan';
    }

    return await this.sendWithResponse(mode, {
      url: inspectedURL,
      categoryIDs,
      flags,
      config: this.configForTesting,
      locales: this.getLocales(),
      mainSessionId: this.mainSessionId,
      rootTargetId: this.rootTargetId,
    });
  }

  async detach(): Promise<void> {
    for (const reject of this.#pendingRequests.values()) {
      reject(new CancelledError());
    }
    this.#pendingRequests.clear();
    const oldLighthouseWorker = this.lighthouseWorkerPromise;
    const oldRootTarget = this.rootTarget;

    // When detaching, make sure that we remove the old promises, before we
    // perform any async cleanups. That way, if there is a message coming from
    // lighthouse while we are in the process of cleaning up, we shouldn't deliver
    // them to the backend.
    this.lighthouseWorkerPromise = undefined;
    this.rootTarget = undefined;
    this.connection?.unobserve(this);
    this.connection = undefined;

    if (oldLighthouseWorker) {
      (await oldLighthouseWorker).terminate();
    }
    if (oldRootTarget && this.mainSessionId) {
      await oldRootTarget.targetAgent().invoke_detachFromTarget({sessionId: this.mainSessionId});
    }
    await SDK.TargetManager.TargetManager.instance().resumeAllTargets();
    this.removeDialogHandler?.();
  }

  registerStatusCallback(callback: (arg0: string) => void): void {
    this.lighthouseMessageUpdateCallback = callback;
  }

  onEvent<T extends ProtocolClient.CDPConnection.Event>(event: ProtocolClient.CDPConnection.CDPEvent<T>): void {
    this.dispatchProtocolMessage(event);
  }

  private dispatchProtocolMessage(message: ProtocolClient.CDPConnection.CDPReceivableMessage): void {
    // A message without a sessionId is the main session of the main target (call it "Main session").
    // A parallel connection and session was made that connects to the same main target (call it "Lighthouse session").
    // Messages from the "Lighthouse session" have a sessionId.
    // Without some care, there is a risk of sending the same events for the same main frame to Lighthouse–the backend
    // will create events for the "Main session" and the "Lighthouse session".
    // The workaround–only send message to Lighthouse if:
    //   * the message has a sessionId (is not for the "Main session")
    //   * the message does not have a sessionId (is for the "Main session"), but only for the Target domain
    //     (to kickstart autoAttach in LH).
    if (message.sessionId || ('method' in message && message.method?.startsWith('Target'))) {
      void this.send('dispatchProtocolMessage', {message});
    }
  }

  onDisconnect(): void {
    // Do nothing.
  }

  private initWorker(): Promise<Worker> {
    this.lighthouseWorkerPromise = new Promise<Worker>(resolve => {
      const workerUrl = new URL('../../entrypoints/lighthouse_worker/lighthouse_worker.js', import.meta.url);
      const remoteBaseSearchParam = new URL(self.location.href).searchParams.get('remoteBase');
      if (remoteBaseSearchParam) {
        // Allows Lighthouse worker to fetch remote locale files.
        workerUrl.searchParams.set('remoteBase', remoteBaseSearchParam);
      }
      const worker = new Worker(workerUrl, {type: 'module'});

      worker.addEventListener('message', event => {
        if (event.data === 'workerReady') {
          resolve(worker);
          return;
        }

        this.onWorkerMessage(event);
      });
    });
    return this.lighthouseWorkerPromise;
  }

  async ensureWorkerExists(): Promise<Worker> {
    let worker: Worker;
    if (!this.lighthouseWorkerPromise) {
      worker = await this.initWorker();
    } else {
      worker = await this.lighthouseWorkerPromise;
    }
    return worker;
  }

  private onWorkerMessage(event: MessageEvent): void {
    const lighthouseMessage = event.data;

    if (lighthouseMessage.action === 'statusUpdate') {
      if (this.lighthouseMessageUpdateCallback && lighthouseMessage.args && 'message' in lighthouseMessage.args) {
        this.lighthouseMessageUpdateCallback(lighthouseMessage.args.message as string);
      }
    } else if (lighthouseMessage.action === 'sendProtocolMessage') {
      if (lighthouseMessage.args && 'message' in lighthouseMessage.args) {
        this.sendProtocolMessage(lighthouseMessage.args.message as string);
      }
    }
  }

  private sendProtocolMessage(message: string): void {
    const {id, method, params, sessionId} = JSON.parse(message);
    // CDPConnection manages it's own message IDs and it's important, otherwise we'd clash
    // with the rest of the DevTools traffic.
    // Instead, we ignore the ID coming from the worker when sending the command, but
    // patch it back in when sending the response back to the worker.
    void this.connection?.send(method, params, sessionId).then(response => {
      const message =
          'result' in response ? {id, sessionId, result: response.result} : {id, sessionId, error: response.error};
      this.dispatchProtocolMessage(message);
    });
  }

  private async send(action: string, args: Record<string, string|string[]|object> = {}): Promise<void> {
    const worker = await this.ensureWorkerExists();
    const messageId = lastId++;
    worker.postMessage({id: messageId, action, args: {...args, id: messageId}});
  }

  /** sendWithResponse currently only handles the original startLighthouse request and LHR-filled response. */
  private async sendWithResponse(action: string, args: Record<string, string|string[]|object|undefined> = {}):
      Promise<LighthouseModel.ReporterTypes.RunnerResult> {
    const worker = await this.ensureWorkerExists();
    const messageId = lastId++;
    const messageResult = new Promise<LighthouseModel.ReporterTypes.RunnerResult>((resolve, reject) => {
      const workerListener = (event: MessageEvent): void => {
        const lighthouseMessage = event.data;

        if (lighthouseMessage.id === messageId) {
          worker.removeEventListener('message', workerListener);
          this.#pendingRequests.delete(messageId);
          resolve(lighthouseMessage.result);
        }
      };
      worker.addEventListener('message', workerListener);
      this.#pendingRequests.set(messageId, (err: Error) => {
        worker.removeEventListener('message', workerListener);
        reject(err);
      });
    });
    worker.postMessage({id: messageId, action, args: {...args, id: messageId}});

    return await messageResult;
  }
}
