/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import fsPromises from 'node:fs/promises';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import type { SandboxPermissions } from '../services/sandboxManager.js';
import { ToolErrorType } from './tool-error.js';
import {
  BaseDeclarativeTool,
  BaseToolInvocation,
  ToolConfirmationOutcome,
  Kind,
  type ToolInvocation,
  type ToolResult,
  type BackgroundExecutionData,
  type ToolCallConfirmationDetails,
  type ToolExecuteConfirmationDetails,
  type PolicyUpdateOptions,
  type ToolLiveOutput,
  type ExecuteOptions,
} from './tools.js';

import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import {
  ShellExecutionService,
  type ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { formatBytes } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import {
  getCommandRoots,
  initializeShellParsers,
  stripShellWrapper,
  parseCommandDetails,
  hasRedirection,
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';

export const OUTPUT_UPDATE_INTERVAL_MS = 1000;

// Delay so user does not see the output of the process before the process is moved to the background.
const BACKGROUND_DELAY_MS = 200;

export interface ShellToolParams {
  command: string;
  description?: string;
  dir_path?: string;
  is_background?: boolean;
  [PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions;
}

export class ShellToolInvocation extends BaseToolInvocation<
  ShellToolParams,
  ToolResult
> {
  constructor(
    private readonly context: AgentLoopContext,
    params: ShellToolParams,
    messageBus: MessageBus,
    _toolName?: string,
    _toolDisplayName?: string,
  ) {
    super(params, messageBus, _toolName, _toolDisplayName);
  }

  /**
   * Wraps a command in a subshell `()` to capture background process IDs (PIDs) using pgrep.
   * Uses newlines to prevent breaking heredocs or trailing comments.
   *
   * @param command The raw command string to execute.
   * @param tempFilePath Path to the temporary file where PIDs will be written.
   * @param isWindows Whether the current platform is Windows (if true, the command is returned as-is).
   * @returns The wrapped command string.
   */
  private wrapCommandForPgrep(
    command: string,
    tempFilePath: string,
    isWindows: boolean,
  ): string {
    if (isWindows) {
      return command;
    }
    let trimmed = command.trim();
    if (!trimmed) {
      return '';
    }
    if (trimmed.endsWith('\\')) {
      trimmed += ' ';
    }
    return `(\n${trimmed}\n); __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
  }

  private getContextualDetails(): string {
    let details = '';
    // append optional [in directory]
    // note explanation is needed even if validation fails due to absolute path
    if (this.params.dir_path) {
      details += `[in ${this.params.dir_path}]`;
    } else {
      details += `[current working directory ${process.cwd()}]`;
    }
    // append optional (description), replacing any line breaks with spaces
    if (this.params.description) {
      details += ` (${this.params.description.replace(/\n/g, ' ')})`;
    }
    if (this.params.is_background) {
      details += ' [background]';
    }
    return details;
  }

  getDescription(): string {
    return `${this.params.command} ${this.getContextualDetails()}`;
  }

  override getDisplayTitle(): string {
    return this.params.command;
  }

  override getExplanation(): string {
    return this.getContextualDetails().trim();
  }

  override getPolicyUpdateOptions(
    outcome: ToolConfirmationOutcome,
  ): PolicyUpdateOptions | undefined {
    if (
      outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
      outcome === ToolConfirmationOutcome.ProceedAlways
    ) {
      const command = stripShellWrapper(this.params.command);
      const rootCommands = [...new Set(getCommandRoots(command))];
      const allowRedirection = hasRedirection(command) ? true : undefined;

      if (rootCommands.length > 0) {
        return { commandPrefix: rootCommands, allowRedirection };
      }
      return { commandPrefix: this.params.command, allowRedirection };
    }
    return undefined;
  }

  override async shouldConfirmExecute(
    abortSignal: AbortSignal,
  ): Promise<ToolCallConfirmationDetails | false> {
    if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
      return this.getConfirmationDetails(abortSignal);
    }
    return super.shouldConfirmExecute(abortSignal);
  }

  protected override async getConfirmationDetails(
    _abortSignal: AbortSignal,
  ): Promise<ToolCallConfirmationDetails | false> {
    const command = stripShellWrapper(this.params.command);

    const parsed = parseCommandDetails(command);
    let rootCommandDisplay = '';

    if (!parsed || parsed.hasError || parsed.details.length === 0) {
      // Fallback if parser fails
      const fallback = command.trim().split(/\s+/)[0];
      rootCommandDisplay = fallback || 'shell command';
      if (hasRedirection(command)) {
        rootCommandDisplay += ', redirection';
      }
    } else {
      rootCommandDisplay = parsed.details
        .map((detail) => detail.name)
        .join(', ');
    }

    const rootCommands = [...new Set(getCommandRoots(command))];

    // Rely entirely on PolicyEngine for interactive confirmation.
    // If we are here, it means PolicyEngine returned ASK_USER (or no message bus),
    // so we must provide confirmation details.
    // If additional_permissions are provided, it's an expansion request
    if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
      return {
        type: 'sandbox_expansion',
        title: 'Sandbox Expansion Request',
        command: this.params.command,
        rootCommand: rootCommandDisplay,
        additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
        onConfirm: async (outcome: ToolConfirmationOutcome) => {
          if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
            const commandName = rootCommands[0] || 'shell';
            this.context.config.sandboxPolicyManager.addPersistentApproval(
              commandName,
              this.params[PARAM_ADDITIONAL_PERMISSIONS]!,
            );
          } else if (outcome === ToolConfirmationOutcome.ProceedAlways) {
            const commandName = rootCommands[0] || 'shell';
            this.context.config.sandboxPolicyManager.addSessionApproval(
              commandName,
              this.params[PARAM_ADDITIONAL_PERMISSIONS]!,
            );
          }
        },
      };
    }

    const confirmationDetails: ToolExecuteConfirmationDetails = {
      type: 'exec',
      title: 'Confirm Shell Command',
      command: this.params.command,
      rootCommand: rootCommandDisplay,
      rootCommands,
      onConfirm: async (_outcome: ToolConfirmationOutcome) => {
        // Policy updates are now handled centrally by the scheduler
      },
    };
    return confirmationDetails;
  }

  async execute(
    signal: AbortSignal,
    updateOutput?: (output: ToolLiveOutput) => void,
    options?: ExecuteOptions,
  ): Promise<ToolResult> {
    const { shellExecutionConfig, setExecutionIdCallback } = options ?? {};
    const strippedCommand = stripShellWrapper(this.params.command);

    if (signal.aborted) {
      return {
        llmContent: 'Command was cancelled by user before it could start.',
        returnDisplay: 'Command cancelled by user.',
      };
    }

    const isWindows = os.platform() === 'win32';
    const tempFileName = `shell_pgrep_${crypto
      .randomBytes(6)
      .toString('hex')}.tmp`;
    const tempFilePath = path.join(os.tmpdir(), tempFileName);

    const timeoutMs = this.context.config.getShellToolInactivityTimeout();
    const timeoutController = new AbortController();
    let timeoutTimer: NodeJS.Timeout | undefined;

    // Handle signal combination manually to avoid TS issues or runtime missing features
    const combinedController = new AbortController();

    const onAbort = () => combinedController.abort();

    try {
      // pgrep is not available on Windows, so we can't get background PIDs
      const commandToExecute = this.wrapCommandForPgrep(
        strippedCommand,
        tempFilePath,
        isWindows,
      );

      const cwd = this.params.dir_path
        ? path.resolve(this.context.config.getTargetDir(), this.params.dir_path)
        : this.context.config.getTargetDir();

      const validationError = this.context.config.validatePathAccess(cwd);
      if (validationError) {
        return {
          llmContent: validationError,
          returnDisplay: 'Path not in workspace.',
          error: {
            message: validationError,
            type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
          },
        };
      }
      let cumulativeOutput: string | AnsiOutput = '';
      let lastUpdateTime = Date.now();
      let isBinaryStream = false;

      const resetTimeout = () => {
        if (timeoutMs <= 0) {
          return;
        }
        if (timeoutTimer) clearTimeout(timeoutTimer);
        timeoutTimer = setTimeout(() => {
          timeoutController.abort();
        }, timeoutMs);
      };

      signal.addEventListener('abort', onAbort, { once: true });
      timeoutController.signal.addEventListener('abort', onAbort, {
        once: true,
      });

      // Start timeout
      resetTimeout();

      const { result: resultPromise, pid } =
        await ShellExecutionService.execute(
          commandToExecute,
          cwd,
          (event: ShellOutputEvent) => {
            resetTimeout(); // Reset timeout on any event
            if (!updateOutput) {
              return;
            }

            let shouldUpdate = false;

            switch (event.type) {
              case 'data':
                if (isBinaryStream) break;
                cumulativeOutput = event.chunk;
                shouldUpdate = true;
                break;
              case 'binary_detected':
                isBinaryStream = true;
                cumulativeOutput =
                  '[Binary output detected. Halting stream...]';
                shouldUpdate = true;
                break;
              case 'binary_progress':
                isBinaryStream = true;
                cumulativeOutput = `[Receiving binary output... ${formatBytes(
                  event.bytesReceived,
                )} received]`;
                if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
                  shouldUpdate = true;
                }
                break;
              case 'exit':
                break;
              default: {
                throw new Error('An unhandled ShellOutputEvent was found.');
              }
            }

            if (shouldUpdate && !this.params.is_background) {
              updateOutput(cumulativeOutput);
              lastUpdateTime = Date.now();
            }
          },
          combinedController.signal,
          this.context.config.getEnableInteractiveShell(),
          {
            ...shellExecutionConfig,
            pager: 'cat',
            sanitizationConfig:
              shellExecutionConfig?.sanitizationConfig ??
              this.context.config.sanitizationConfig,
            sandboxManager: this.context.config.sandboxManager,
            additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
            backgroundCompletionBehavior:
              this.context.config.getShellBackgroundCompletionBehavior(),
          },
        );

      if (pid) {
        if (setExecutionIdCallback) {
          setExecutionIdCallback(pid);
        }

        // If the model requested to run in the background, do so after a short delay.
        if (this.params.is_background) {
          setTimeout(() => {
            ShellExecutionService.background(pid);
          }, BACKGROUND_DELAY_MS);
        }
      }

      const result = await resultPromise;

      const backgroundPIDs: number[] = [];
      if (os.platform() !== 'win32') {
        let tempFileExists = false;
        try {
          await fsPromises.access(tempFilePath);
          tempFileExists = true;
        } catch {
          tempFileExists = false;
        }

        if (tempFileExists) {
          const pgrepContent = await fsPromises.readFile(tempFilePath, 'utf8');
          const pgrepLines = pgrepContent.split(os.EOL).filter(Boolean);
          for (const line of pgrepLines) {
            if (!/^\d+$/.test(line)) {
              if (
                line.includes('sysmond service not found') ||
                line.includes('Cannot get process list') ||
                line.includes('sysmon request failed')
              ) {
                continue;
              }
              debugLogger.error(`pgrep: ${line}`);
            }
            const pid = Number(line);
            if (pid !== result.pid) {
              backgroundPIDs.push(pid);
            }
          }
        } else {
          if (!signal.aborted && !result.backgrounded) {
            debugLogger.error('missing pgrep output');
          }
        }
      }

      let data: BackgroundExecutionData | undefined;

      let llmContent = '';
      let timeoutMessage = '';
      if (result.aborted) {
        if (timeoutController.signal.aborted) {
          timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${(
            timeoutMs / 60000
          ).toFixed(1)} minutes without output.`;
          llmContent = timeoutMessage;
        } else {
          llmContent =
            'Command was cancelled by user before it could complete.';
        }
        if (result.output.trim()) {
          llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
        } else {
          llmContent += ' There was no output before it was cancelled.';
        }
      } else if (this.params.is_background || result.backgrounded) {
        llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
        data = {
          pid: result.pid,
          command: this.params.command,
          initialOutput: result.output,
        };
      } else {
        // Create a formatted error string for display, replacing the wrapper command
        // with the user-facing command.
        const llmContentParts = [`Output: ${result.output || '(empty)'}`];

        if (result.error) {
          const finalError = result.error.message.replaceAll(
            commandToExecute,
            this.params.command,
          );
          llmContentParts.push(`Error: ${finalError}`);
        }

        if (result.exitCode !== null && result.exitCode !== 0) {
          llmContentParts.push(`Exit Code: ${result.exitCode}`);
          data = {
            exitCode: result.exitCode,
            isError: true,
          };
        }

        if (result.signal) {
          llmContentParts.push(`Signal: ${result.signal}`);
        }
        if (backgroundPIDs.length) {
          llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`);
        }
        if (result.pid) {
          llmContentParts.push(`Process Group PGID: ${result.pid}`);
        }

        llmContent = llmContentParts.join('\n');
      }

      let returnDisplayMessage = '';
      if (this.context.config.getDebugMode()) {
        returnDisplayMessage = llmContent;
      } else {
        if (this.params.is_background || result.backgrounded) {
          returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
        } else if (result.aborted) {
          const cancelMsg = timeoutMessage || 'Command cancelled by user.';
          if (result.output.trim()) {
            returnDisplayMessage = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
          } else {
            returnDisplayMessage = cancelMsg;
          }
        } else if (result.output.trim()) {
          returnDisplayMessage = result.output;
        } else {
          if (result.signal) {
            returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
          } else if (result.error) {
            returnDisplayMessage = `Command failed: ${getErrorMessage(
              result.error,
            )}`;
          } else if (result.exitCode !== null && result.exitCode !== 0) {
            returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
          }
          // If output is empty and command succeeded (code 0, no error/signal/abort),
          // returnDisplayMessage will remain empty, which is fine.
        }
      }

      // Heuristic Sandbox Denial Detection
      if (
        !!result.error ||
        !!result.signal ||
        (result.exitCode !== undefined && result.exitCode !== 0) ||
        result.aborted
      ) {
        const sandboxDenial =
          this.context.config.sandboxManager.parseDenials(result);
        if (sandboxDenial) {
          const strippedCommand = stripShellWrapper(this.params.command);
          const rootCommands = getCommandRoots(strippedCommand).filter(
            (r) => r !== 'shopt',
          );
          const rootCommandDisplay =
            rootCommands.length > 0 ? rootCommands[0] : 'shell';

          const readPaths = new Set(
            this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read || [],
          );
          const writePaths = new Set(
            this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write || [],
          );

          if (sandboxDenial.filePaths) {
            for (const p of sandboxDenial.filePaths) {
              try {
                // Find an existing parent directory to add instead of a non-existent file
                let currentPath = p;
                try {
                  if (
                    fs.existsSync(currentPath) &&
                    fs.statSync(currentPath).isFile()
                  ) {
                    currentPath = path.dirname(currentPath);
                  }
                } catch (_e) {
                  /* ignore */
                }
                while (currentPath.length > 1) {
                  if (fs.existsSync(currentPath)) {
                    writePaths.add(currentPath);
                    readPaths.add(currentPath);
                    break;
                  }
                  currentPath = path.dirname(currentPath);
                }
              } catch (_e) {
                // ignore
              }
            }
          }

          const additionalPermissions = {
            network:
              sandboxDenial.network ||
              this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network ||
              undefined,
            fileSystem:
              sandboxDenial.filePaths?.length || writePaths.size > 0
                ? {
                    read: Array.from(readPaths),
                    write: Array.from(writePaths),
                  }
                : undefined,
          };

          const originalReadSize =
            this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read
              ?.length || 0;
          const originalWriteSize =
            this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write
              ?.length || 0;
          const originalNetwork =
            !!this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network;

          const newReadSize =
            additionalPermissions.fileSystem?.read?.length || 0;
          const newWriteSize =
            additionalPermissions.fileSystem?.write?.length || 0;
          const newNetwork = !!additionalPermissions.network;

          const hasNewPermissions =
            newReadSize > originalReadSize ||
            newWriteSize > originalWriteSize ||
            (!originalNetwork && newNetwork);

          if (hasNewPermissions) {
            const confirmationDetails = {
              type: 'sandbox_expansion',
              title: 'Sandbox Expansion Request',
              command: this.params.command,
              rootCommand: rootCommandDisplay,
              additionalPermissions,
            };

            return {
              llmContent: 'Sandbox expansion required',
              returnDisplay: returnDisplayMessage,
              error: {
                type: ToolErrorType.SANDBOX_EXPANSION_REQUIRED,
                message: JSON.stringify(confirmationDetails),
              },
            };
          }
          // If no new permissions were found by heuristic, do not intercept.
          // Just return the normal execution error so the LLM can try providing explicit paths itself.
        }
      }

      const summarizeConfig =
        this.context.config.getSummarizeToolOutputConfig();
      const executionError = result.error
        ? {
            error: {
              message: result.error.message,
              type: ToolErrorType.SHELL_EXECUTE_ERROR,
            },
          }
        : {};
      if (summarizeConfig && summarizeConfig[SHELL_TOOL_NAME]) {
        const summary = await summarizeToolOutput(
          this.context.config,
          { model: 'summarizer-shell' },
          llmContent,
          this.context.geminiClient,
          signal,
        );
        return {
          llmContent: summary,
          returnDisplay: returnDisplayMessage,
          ...executionError,
        };
      }

      return {
        llmContent,
        returnDisplay: returnDisplayMessage,
        data,
        ...executionError,
      };
    } finally {
      if (timeoutTimer) clearTimeout(timeoutTimer);
      signal.removeEventListener('abort', onAbort);
      timeoutController.signal.removeEventListener('abort', onAbort);
      try {
        await fsPromises.unlink(tempFilePath);
      } catch {
        // Ignore errors during unlink
      }
    }
  }
}

export class ShellTool extends BaseDeclarativeTool<
  ShellToolParams,
  ToolResult
> {
  static readonly Name = SHELL_TOOL_NAME;

  constructor(
    private readonly context: AgentLoopContext,
    messageBus: MessageBus,
  ) {
    void initializeShellParsers().catch(() => {
      // Errors are surfaced when parsing commands.
    });
    const definition = getShellDefinition(
      context.config.getEnableInteractiveShell(),
      context.config.getEnableShellOutputEfficiency(),
      context.config.getSandboxEnabled(),
    );
    super(
      ShellTool.Name,
      'Shell',
      definition.base.description!,
      Kind.Execute,
      definition.base.parametersJsonSchema,
      messageBus,
      false, // output is not markdown
      true, // output can be updated
    );
  }

  protected override validateToolParamValues(
    params: ShellToolParams,
  ): string | null {
    if (!params.command.trim()) {
      return 'Command cannot be empty.';
    }

    if (params.dir_path) {
      const resolvedPath = path.resolve(
        this.context.config.getTargetDir(),
        params.dir_path,
      );
      return this.context.config.validatePathAccess(resolvedPath);
    }
    return null;
  }

  protected createInvocation(
    params: ShellToolParams,
    messageBus: MessageBus,
    _toolName?: string,
    _toolDisplayName?: string,
  ): ToolInvocation<ShellToolParams, ToolResult> {
    return new ShellToolInvocation(
      this.context.config,
      params,
      messageBus,
      _toolName,
      _toolDisplayName,
    );
  }

  override getSchema(modelId?: string) {
    const definition = getShellDefinition(
      this.context.config.getEnableInteractiveShell(),
      this.context.config.getEnableShellOutputEfficiency(),
      this.context.config.getSandboxEnabled(),
    );
    return resolveToolDeclaration(definition, modelId);
  }
}
