// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Clang-formatter and EsLint have a mismatch due to the naming of `puppeteer-replay`
/* eslint-disable import/order */

import * as Common from '../../../core/common/common.js';
import type * as Platform from '../../../core/platform/platform.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as PuppeteerService from '../../../services/puppeteer/puppeteer.js';
import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
import type * as puppeteer from '../../../third_party/puppeteer/puppeteer.js';

import type {Step, UserFlow} from './Schema.js';

export const enum PlayRecordingSpeed {
  NORMAL = 'normal',
  SLOW = 'slow',
  VERY_SLOW = 'very_slow',
  EXTREMELY_SLOW = 'extremely_slow',
}

const speedDelayMap: Record<PlayRecordingSpeed, number> = {
  [PlayRecordingSpeed.NORMAL]: 0,
  [PlayRecordingSpeed.SLOW]: 500,
  [PlayRecordingSpeed.VERY_SLOW]: 1000,
  [PlayRecordingSpeed.EXTREMELY_SLOW]: 2000,
} as const;

export const enum ReplayResult {
  FAILURE = 'Failure',
  SUCCESS = 'Success',
}

export const defaultTimeout = 5000;  // ms

function isPageTarget(target: Protocol.Target.TargetInfo): boolean {
  // Treat DevTools targets as page targets too.
  return (
      Common.ParsedURL.schemeIs(target.url as Platform.DevToolsPath.UrlString, 'devtools:') || target.type === 'page' ||
      target.type === 'background_page' || target.type === 'webview');
}

export class RecordingPlayer extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  userFlow: UserFlow;
  speed: PlayRecordingSpeed;
  timeout: number;
  breakpointIndexes: Set<number>;
  steppingOver = false;
  aborted = false;
  #stopResolver = Promise.withResolvers<void>();
  #abortResolver = Promise.withResolvers<void>();
  #runner?: PuppeteerReplay.Runner;

  constructor(
      userFlow: UserFlow,
      {
        speed,
        breakpointIndexes = new Set(),
      }: {
        speed: PlayRecordingSpeed,
        breakpointIndexes?: Set<number>,
      },
  ) {
    super();
    this.userFlow = userFlow;
    this.speed = speed;
    this.timeout = userFlow.timeout || defaultTimeout;
    this.breakpointIndexes = breakpointIndexes;
  }

  #resolveAndRefreshStopPromise(): void {
    this.#stopResolver.resolve();
    this.#stopResolver = Promise.withResolvers();
  }

  static async connectPuppeteer(): Promise<{
    page: puppeteer.Page,
    browser: puppeteer.Browser,
  }> {
    const rootTarget = SDK.TargetManager.TargetManager.instance().rootTarget();
    if (!rootTarget) {
      throw new Error('Could not find the root target');
    }

    const primaryPageTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
    if (!primaryPageTarget) {
      throw new Error('Could not find the primary page target');
    }
    const childTargetManager = primaryPageTarget.model(
        SDK.ChildTargetManager.ChildTargetManager,
    );
    if (!childTargetManager) {
      throw new Error('Could not get childTargetManager');
    }
    const resourceTreeModel = primaryPageTarget.model(
        SDK.ResourceTreeModel.ResourceTreeModel,
    );
    if (!resourceTreeModel) {
      throw new Error('Could not get resource tree model');
    }
    const mainFrame = resourceTreeModel.mainFrame;
    if (!mainFrame) {
      throw new Error('Could not find main frame');
    }

    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 router');
    }

    const mainTargetId = await childTargetManager.getParentTargetId();
    const rootTargetId = await rootChildTargetManager.getParentTargetId();
    const {sessionId} = await rootTarget.targetAgent().invoke_attachToTarget({targetId: rootTargetId, flatten: true});
    const {page, browser, puppeteerConnection} =
        await PuppeteerService.PuppeteerConnection.PuppeteerConnectionHelper.connectPuppeteerToConnectionViaTab(
            {
              connection,
              targetId: rootTargetId,
              sessionId,
              isPageTargetCallback: isPageTarget,
            },
        );

    if (!page) {
      throw new Error('could not find main page!');
    }

    browser.on('targetdiscovered', (targetInfo: ReturnType<puppeteer.Target['_getTargetInfo']>) => {
      // Pop-ups opened by the main target won't be auto-attached. Therefore,
      // we need to create a session for them explicitly. We user openedId
      // and type to classify a target as requiring a session.
      if (targetInfo.type !== 'page') {
        return;
      }
      if (targetInfo.targetId === mainTargetId) {
        return;
      }
      if (targetInfo.openerId !== mainTargetId) {
        return;
      }
      void puppeteerConnection._createSession(
          targetInfo,
          /* emulateAutoAttach= */ true,
      );
    });

    return {page, browser};
  }

  static async disconnectPuppeteer(browser: puppeteer.Browser): Promise<void> {
    try {
      const pages = await browser.pages();
      for (const page of pages) {
        const client = (page as puppeteer.Page)._client();
        await client.send('Network.disable');
        await client.send('Page.disable');
        await client.send('Log.disable');
        await client.send('Performance.disable');
        await client.send('Runtime.disable');
        await client.send('Emulation.clearDeviceMetricsOverride');
        await client.send('Emulation.setAutomationOverride', {enabled: false});
        for (const frame of page.frames()) {
          const client = frame.client;
          await client.send('Network.disable');
          await client.send('Page.disable');
          await client.send('Log.disable');
          await client.send('Performance.disable');
          await client.send('Runtime.disable');
          await client.send('Emulation.setAutomationOverride', {enabled: false});
        }
      }
      await browser.disconnect();
    } catch (err) {
      console.error('Error disconnecting Puppeteer', err.message);
    }
  }

  async stop(): Promise<void> {
    await Promise.race([this.#stopResolver.promise, this.#abortResolver.promise]);
  }

  get abortPromise(): Promise<void> {
    return this.#abortResolver.promise;
  }

  abort(): void {
    this.aborted = true;
    this.#abortResolver.resolve();
    this.#runner?.abort();
  }

  disposeForTesting(): void {
    this.#stopResolver.resolve();
    this.#abortResolver.resolve();
  }

  continue(): void {
    this.steppingOver = false;
    this.#resolveAndRefreshStopPromise();
  }

  stepOver(): void {
    this.steppingOver = true;
    this.#resolveAndRefreshStopPromise();
  }

  updateBreakpointIndexes(breakpointIndexes: Set<number>): void {
    this.breakpointIndexes = breakpointIndexes;
  }

  async play(): Promise<void> {
    const {page, browser} = await RecordingPlayer.connectPuppeteer();
    this.aborted = false;

    const player = this;
    class ExtensionWithBreak extends PuppeteerReplay.PuppeteerRunnerExtension {
      readonly #speed: PlayRecordingSpeed;

      constructor(
          browser: puppeteer.Browser,
          page: puppeteer.Page,
          {
            timeout,
            speed,
          }: {
            timeout: number,
            speed: PlayRecordingSpeed,
          },
      ) {
        super(browser, page, {timeout});
        this.#speed = speed;
      }

      override async beforeEachStep?(step: Step, flow: UserFlow): Promise<void> {
        const {resolve, promise} = Promise.withResolvers<void>();
        player.dispatchEventToListeners(Events.STEP, {
          step,
          resolve,
        });
        await promise;
        const currentStepIndex = flow.steps.indexOf(step);
        const shouldStopAtCurrentStep = player.steppingOver || player.breakpointIndexes.has(currentStepIndex);
        const shouldWaitForSpeed = step.type !== 'setViewport' && step.type !== 'navigate' && !player.aborted;
        if (shouldStopAtCurrentStep) {
          player.dispatchEventToListeners(Events.STOP);
          await player.stop();
          player.dispatchEventToListeners(Events.CONTINUE);
        } else if (shouldWaitForSpeed) {
          await Promise.race([
            new Promise(
                resolve => setTimeout(resolve, speedDelayMap[this.#speed]),
                ),
            player.abortPromise,
          ]);
        }
      }

      override async runStep(
          step: PuppeteerReplay.Schema.Step,
          flow: PuppeteerReplay.Schema.UserFlow,
          ): Promise<void> {
        // When replaying on a DevTools target we skip setViewport and navigate steps
        // because navigation and viewport changes are not supported there.
        if (Common.ParsedURL.schemeIs(page?.url() as Platform.DevToolsPath.UrlString, 'devtools:') &&
            (step.type === 'setViewport' || step.type === 'navigate')) {
          return;
        }
        if (step.type === 'navigate' &&
            Common.ParsedURL.schemeIs(step.url as Platform.DevToolsPath.UrlString, 'chrome:')) {
          throw new Error('Not allowed to replay on chrome:// URLs');
        }
        // Focus the target in case it's not focused.
        await this.page.bringToFront();
        await super.runStep(step, flow);
      }
    }

    const extension = new ExtensionWithBreak(browser, page, {
      timeout: this.timeout,
      speed: this.speed,
    });

    this.#runner = await PuppeteerReplay.createRunner(this.userFlow, extension);
    let error: Error|undefined;

    try {
      await this.#runner.run();
    } catch (err) {
      error = err;
      console.error('Replay error', err.message);
    } finally {
      await RecordingPlayer.disconnectPuppeteer(browser);
    }
    if (this.aborted) {
      this.dispatchEventToListeners(Events.ABORT);
    } else if (error) {
      this.dispatchEventToListeners(Events.ERROR, error);
    } else {
      this.dispatchEventToListeners(Events.DONE);
    }
  }
}

export const enum Events {
  ABORT = 'Abort',
  DONE = 'Done',
  STEP = 'Step',
  STOP = 'Stop',
  ERROR = 'Error',
  CONTINUE = 'Continue',
}

interface EventTypes {
  [Events.ABORT]: void;
  [Events.DONE]: void;
  [Events.STEP]: {step: Step, resolve: () => void};
  [Events.STOP]: void;
  [Events.CONTINUE]: void;
  [Events.ERROR]: Error;
}
