// Copyright 2026 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 Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as Platform from '../../../core/platform/platform.js';
import * as Root from '../../../core/root/root.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type {ChangeManager} from '../ChangeManager.js';
import {debugLog} from '../debug.js';
import {EvaluateAction, formatError, SideEffectError} from '../EvaluateAction.js';
import {FREESTYLER_WORLD_NAME} from '../injected.js';

import type {AgentOptions as BaseAgentOptions, FunctionCallHandlerResult, FunctionHandlerOptions,} from './AiAgent.js';

const lockedString = i18n.i18n.lockedString;

export type CreateExtensionScopeFunction = (changes: ChangeManager) => {
  install(): Promise<void>, uninstall(): Promise<void>,
};

export interface ExecuteJsAgentOptions extends BaseAgentOptions {
  changeManager?: ChangeManager;
  createExtensionScope?: CreateExtensionScopeFunction;
  execJs?: typeof executeJsCode;
}

export async function executeJsCode(
    functionDeclaration: string,
    {throwOnSideEffect, contextNode}: {throwOnSideEffect: boolean, contextNode: SDK.DOMModel.DOMNode|null}):
    Promise<string> {
  if (!contextNode) {
    throw new Error('Cannot execute JavaScript because of missing context node');
  }
  const target = contextNode.domModel().target();

  if (!target) {
    throw new Error('Target is not found for executing code');
  }

  const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
  const frameId = contextNode.frameId() ?? resourceTreeModel?.mainFrame?.id;

  if (!frameId) {
    throw new Error('Main frame is not found for executing code');
  }

  const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
  const pageAgent = target.pageAgent();

  // This returns previously created world if it exists for the frame.
  const {executionContextId} = await pageAgent.invoke_createIsolatedWorld({frameId, worldName: FREESTYLER_WORLD_NAME});
  const executionContext = runtimeModel?.executionContext(executionContextId);
  if (!executionContext) {
    throw new Error('Execution context is not found for executing code');
  }

  if (executionContext.debuggerModel.selectedCallFrame()) {
    return formatError('Cannot evaluate JavaScript because the execution is paused on a breakpoint.');
  }

  const remoteObject = await contextNode.resolveToObject(undefined, executionContextId);
  if (!remoteObject) {
    throw new Error('Cannot execute JavaScript because remote object cannot be resolved');
  }

  return await EvaluateAction.execute(functionDeclaration, [remoteObject], executionContext, {throwOnSideEffect});
}

const MAX_OBSERVATION_BYTE_LENGTH = 25_000;
const OBSERVATION_TIMEOUT = 5_000;

export interface JavascriptExecutorOptions {
  readonly executionMode: Root.Runtime.HostConfigFreestylerExecutionMode;
  readonly getContextNode: () => SDK.DOMModel.DOMNode | null;
  readonly createExtensionScope: (changes: ChangeManager) => {
    install(): Promise<void>, uninstall(): Promise<void>,
  };
  readonly changes: ChangeManager;
}

export class JavascriptExecutor {
  #options: JavascriptExecutorOptions;
  #execJs: typeof executeJsCode;

  constructor(options: JavascriptExecutorOptions, execJs: typeof executeJsCode = executeJsCode) {
    this.#options = options;
    this.#execJs = execJs;
  }

  async executeAction(action: string, options?: FunctionHandlerOptions): Promise<FunctionCallHandlerResult<unknown>> {
    debugLog(`Action to execute: ${action}`);

    if (options?.approved === false) {
      return {
        error: 'Error: User denied code execution with side effects.',
      };
    }

    if (this.#options.executionMode === Root.Runtime.HostConfigFreestylerExecutionMode.NO_SCRIPTS) {
      return {
        error: 'Error: JavaScript execution is currently disabled.',
      };
    }

    const selectedNode = this.#options.getContextNode();
    if (!selectedNode) {
      return {error: 'Error: no selected node found.'};
    }
    const target = selectedNode.domModel().target();
    if (target.model(SDK.DebuggerModel.DebuggerModel)?.selectedCallFrame()) {
      return {
        error: 'Error: Cannot evaluate JavaScript because the execution is paused on a breakpoint.',
      };
    }

    const scope = this.#options.createExtensionScope(this.#options.changes);
    await scope.install();
    try {
      let throwOnSideEffect = true;
      if (options?.approved) {
        throwOnSideEffect = false;
      }

      const result = await this.generateObservation(action, {throwOnSideEffect});
      debugLog(`Action result: ${JSON.stringify(result)}`);
      if (result.sideEffect) {
        if (this.#options.executionMode ===
            Root.Runtime.HostConfigFreestylerExecutionMode.SIDE_EFFECT_FREE_SCRIPTS_ONLY) {
          return {
            error: 'Error: JavaScript execution that modifies the page is currently disabled.',
          };
        }

        if (options?.signal?.aborted) {
          return {
            error: 'Error: evaluation has been cancelled',
          };
        }

        return {
          requiresApproval: true,
          description: lockedString('This code may modify page content. Continue?'),
        };
      }
      if (result.canceled) {
        return {
          error: result.observation,
        };
      }

      return {
        result: result.observation,
      };
    } finally {
      await scope.uninstall();
    }
  }

  async generateObservation(
      action: string,
      {
        throwOnSideEffect,
      }: {
        throwOnSideEffect: boolean,
      },
      ): Promise<{
    observation: string,
    sideEffect: boolean,
    canceled: boolean,
  }> {
    const functionDeclaration = `async function ($0) {
  try {
    ${action}
    ;
    return ((typeof data !== "undefined") ? data : undefined);
  } catch (error) {
    return error;
  }
}`;
    try {
      const result = await Promise.race([
        this.#execJs(
            functionDeclaration,
            {
              throwOnSideEffect,
              contextNode: this.#options.getContextNode(),
            },
            ),
        new Promise<never>((_, reject) => {
          setTimeout(
              () => reject(new Error('Script execution exceeded the maximum allowed time.')), OBSERVATION_TIMEOUT);
        }),
      ]);
      const byteCount = Platform.StringUtilities.countWtf8Bytes(result);
      Host.userMetrics.freestylerEvalResponseSize(byteCount);
      if (byteCount > MAX_OBSERVATION_BYTE_LENGTH) {
        throw new Error('Output exceeded the maximum allowed length.');
      }
      return {
        observation: result,
        sideEffect: false,
        canceled: false,
      };
    } catch (error) {
      if (error instanceof SideEffectError) {
        return {
          observation: error.message,
          sideEffect: true,
          canceled: false,
        };
      }

      return {
        observation: `Error: ${error.message}`,
        sideEffect: false,
        canceled: false,
      };
    }
  }
}
