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

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

import {firstValueFrom, map, raceWith} from '../../third_party/rxjs/rxjs.js';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
import type {JSHandle} from '../api/JSHandle.js';
import {Realm} from '../api/Realm.js';
import {EventEmitter} from '../common/EventEmitter.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {EvaluateFunc, HandleFor} from '../common/types.js';
import {
  fromEmitterEvent,
  timeout,
  withSourcePuppeteerURLIfNone,
} from '../common/util.js';
import {disposeSymbol} from '../util/disposable.js';

import {CdpElementHandle} from './ElementHandle.js';
import type {ExecutionContext} from './ExecutionContext.js';
import type {CdpFrame} from './Frame.js';
import type {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {CdpJSHandle} from './JSHandle.js';
import type {CdpWebWorker} from './WebWorker.js';

/**
 * @internal
 */
export interface PageBinding {
  name: string;
  pptrFunction: Function;
}

/**
 * @internal
 */
export interface IsolatedWorldChart {
  [key: string]: IsolatedWorld;
  [MAIN_WORLD]: IsolatedWorld;
  [PUPPETEER_WORLD]: IsolatedWorld;
}

/**
 * @internal
 */
type IsolatedWorldEmitter = EventEmitter<{
  // Emitted when the isolated world gets a new execution context.
  context: ExecutionContext;
  // Emitted when the isolated world is disposed.
  disposed: undefined;
  // Emitted when a new console message is logged.
  consoleapicalled: Protocol.Runtime.ConsoleAPICalledEvent;
  /** Emitted when a binding that is not installed by the ExecutionContext is called. */
  bindingcalled: Protocol.Runtime.BindingCalledEvent;
}>;

/**
 * @internal
 */
export class IsolatedWorld extends Realm {
  #context?: ExecutionContext;
  #emitter: IsolatedWorldEmitter = new EventEmitter();

  readonly #frameOrWorker: CdpFrame | CdpWebWorker;

  constructor(
    frameOrWorker: CdpFrame | CdpWebWorker,
    timeoutSettings: TimeoutSettings
  ) {
    super(timeoutSettings);
    this.#frameOrWorker = frameOrWorker;
  }

  get environment(): CdpFrame | CdpWebWorker {
    return this.#frameOrWorker;
  }

  get client(): CDPSession {
    return this.#frameOrWorker.client;
  }

  get emitter(): IsolatedWorldEmitter {
    return this.#emitter;
  }

  setContext(context: ExecutionContext): void {
    this.#context?.[disposeSymbol]();
    context.once('disposed', this.#onContextDisposed.bind(this));
    context.on('consoleapicalled', this.#onContextConsoleApiCalled.bind(this));
    context.on('bindingcalled', this.#onContextBindingCalled.bind(this));
    this.#context = context;
    this.#emitter.emit('context', context);
    void this.taskManager.rerunAll();
  }

  #onContextDisposed(): void {
    this.#context = undefined;
    if ('clearDocumentHandle' in this.#frameOrWorker) {
      this.#frameOrWorker.clearDocumentHandle();
    }
  }

  #onContextConsoleApiCalled(
    event: Protocol.Runtime.ConsoleAPICalledEvent
  ): void {
    this.#emitter.emit('consoleapicalled', event);
  }

  #onContextBindingCalled(event: Protocol.Runtime.BindingCalledEvent): void {
    this.#emitter.emit('bindingcalled', event);
  }

  hasContext(): boolean {
    return !!this.#context;
  }

  get context(): ExecutionContext | undefined {
    return this.#context;
  }

  #executionContext(): ExecutionContext | undefined {
    if (this.disposed) {
      throw new Error(
        `Execution context is not available in detached frame or worker "${this.environment.url()}" (are you trying to evaluate?)`
      );
    }
    return this.#context;
  }

  /**
   * Waits for the next context to be set on the isolated world.
   */
  async #waitForExecutionContext(): Promise<ExecutionContext> {
    const result = await firstValueFrom(
      fromEmitterEvent(this.#emitter, 'context').pipe(
        raceWith(
          fromEmitterEvent(this.#emitter, 'disposed').pipe(
            map(() => {
              // The message has to match the CDP message expected by the WaitTask class.
              throw new Error('Execution context was destroyed');
            })
          ),
          timeout(this.timeoutSettings.timeout())
        )
      )
    );
    return result;
  }

  async evaluateHandle<
    Params extends unknown[],
    Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
  >(
    pageFunction: Func | string,
    ...args: Params
  ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
    pageFunction = withSourcePuppeteerURLIfNone(
      this.evaluateHandle.name,
      pageFunction
    );
    // This code needs to schedule evaluateHandle call synchroniously (at
    // least when the context is there) so we cannot unconditionally
    // await.
    let context = this.#executionContext();
    if (!context) {
      context = await this.#waitForExecutionContext();
    }
    return await context.evaluateHandle(pageFunction, ...args);
  }

  async evaluate<
    Params extends unknown[],
    Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
  >(
    pageFunction: Func | string,
    ...args: Params
  ): Promise<Awaited<ReturnType<Func>>> {
    pageFunction = withSourcePuppeteerURLIfNone(
      this.evaluate.name,
      pageFunction
    );
    // This code needs to schedule evaluate call synchroniously (at
    // least when the context is there) so we cannot unconditionally
    // await.
    let context = this.#executionContext();
    if (!context) {
      context = await this.#waitForExecutionContext();
    }
    return await context.evaluate(pageFunction, ...args);
  }

  override async adoptBackendNode(
    backendNodeId?: Protocol.DOM.BackendNodeId
  ): Promise<JSHandle<Node>> {
    // This code needs to schedule resolveNode call synchroniously (at
    // least when the context is there) so we cannot unconditionally
    // await.
    let context = this.#executionContext();
    if (!context) {
      context = await this.#waitForExecutionContext();
    }
    const {object} = await this.client.send('DOM.resolveNode', {
      backendNodeId: backendNodeId,
      executionContextId: context.id,
    });
    return this.createCdpHandle(object) as JSHandle<Node>;
  }

  async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
    if (handle.realm === this) {
      // If the context has already adopted this handle, clone it so downstream
      // disposal doesn't become an issue.
      return (await handle.evaluateHandle(value => {
        return value;
      })) as unknown as T;
    }
    const nodeInfo = await this.client.send('DOM.describeNode', {
      objectId: handle.id,
    });
    return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T;
  }

  async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
    if (handle.realm === this) {
      return handle;
    }
    // Implies it's a primitive value, probably.
    if (handle.remoteObject().objectId === undefined) {
      return handle;
    }
    const info = await this.client.send('DOM.describeNode', {
      objectId: handle.remoteObject().objectId,
    });
    const newHandle = (await this.adoptBackendNode(
      info.node.backendNodeId
    )) as T;
    await handle.dispose();
    return newHandle;
  }

  /**
   * @internal
   */
  createCdpHandle(
    remoteObject: Protocol.Runtime.RemoteObject
  ): JSHandle | ElementHandle<Node> {
    if (remoteObject.subtype === 'node') {
      return new CdpElementHandle(this, remoteObject);
    }
    return new CdpJSHandle(this, remoteObject);
  }

  [disposeSymbol](): void {
    this.#context?.[disposeSymbol]();
    this.#emitter.emit('disposed', undefined);
    super[disposeSymbol]();
    this.#emitter.removeAllListeners();
  }
}
