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

import { spawn, execSync } from 'node:child_process';
import {
  HookEventName,
  ConfigSource,
  HookType,
  type HookConfig,
  type CommandHookConfig,
  type RuntimeHookConfig,
  type HookInput,
  type HookOutput,
  type HookExecutionResult,
  type BeforeAgentInput,
  type BeforeModelInput,
  type BeforeModelOutput,
  type BeforeToolInput,
} from './types.js';
import type { Config } from '../config/config.js';
import type { LLMRequest } from './hookTranslator.js';
import { debugLogger } from '../utils/debugLogger.js';
import { sanitizeEnvironment } from '../services/environmentSanitization.js';
import {
  escapeShellArg,
  getShellConfiguration,
  type ShellType,
} from '../utils/shell-utils.js';

/**
 * Default timeout for hook execution (60 seconds)
 */
const DEFAULT_HOOK_TIMEOUT = 60000;

/**
 * Exit code constants for hook execution
 */
const EXIT_CODE_SUCCESS = 0;
const EXIT_CODE_NON_BLOCKING_ERROR = 1;

/**
 * Hook runner that executes command hooks
 */
export class HookRunner {
  private readonly config: Config;

  constructor(config: Config) {
    this.config = config;
  }

  /**
   * Execute a single hook
   */
  async executeHook(
    hookConfig: HookConfig,
    eventName: HookEventName,
    input: HookInput,
  ): Promise<HookExecutionResult> {
    const startTime = Date.now();

    // Secondary security check: Ensure project hooks are not executed in untrusted folders
    if (
      hookConfig.source === ConfigSource.Project &&
      !this.config.isTrustedFolder()
    ) {
      const errorMessage =
        'Security: Blocked execution of project hook in untrusted folder';
      debugLogger.warn(errorMessage);
      return {
        hookConfig,
        eventName,
        success: false,
        error: new Error(errorMessage),
        duration: 0,
      };
    }

    try {
      if (hookConfig.type === HookType.Runtime) {
        return await this.executeRuntimeHook(
          hookConfig,
          eventName,
          input,
          startTime,
        );
      }

      return await this.executeCommandHook(
        hookConfig,
        eventName,
        input,
        startTime,
      );
    } catch (error) {
      const duration = Date.now() - startTime;
      const hookId =
        hookConfig.name ||
        (hookConfig.type === HookType.Command ? hookConfig.command : '') ||
        'unknown';
      const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
      debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);

      return {
        hookConfig,
        eventName,
        success: false,
        error: error instanceof Error ? error : new Error(errorMessage),
        duration,
      };
    }
  }

  /**
   * Execute multiple hooks in parallel
   */
  async executeHooksParallel(
    hookConfigs: HookConfig[],
    eventName: HookEventName,
    input: HookInput,
    onHookStart?: (config: HookConfig, index: number) => void,
    onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
  ): Promise<HookExecutionResult[]> {
    const promises = hookConfigs.map(async (config, index) => {
      onHookStart?.(config, index);
      const result = await this.executeHook(config, eventName, input);
      onHookEnd?.(config, result);
      return result;
    });

    return Promise.all(promises);
  }

  /**
   * Execute multiple hooks sequentially
   */
  async executeHooksSequential(
    hookConfigs: HookConfig[],
    eventName: HookEventName,
    input: HookInput,
    onHookStart?: (config: HookConfig, index: number) => void,
    onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
  ): Promise<HookExecutionResult[]> {
    const results: HookExecutionResult[] = [];
    let currentInput = input;

    for (let i = 0; i < hookConfigs.length; i++) {
      const config = hookConfigs[i];
      onHookStart?.(config, i);
      const result = await this.executeHook(config, eventName, currentInput);
      onHookEnd?.(config, result);
      results.push(result);

      // If the hook succeeded and has output, use it to modify the input for the next hook
      if (result.success && result.output) {
        currentInput = this.applyHookOutputToInput(
          currentInput,
          result.output,
          eventName,
        );
      }
    }

    return results;
  }

  /**
   * Apply hook output to modify input for the next hook in sequential execution
   */
  private applyHookOutputToInput(
    originalInput: HookInput,
    hookOutput: HookOutput,
    eventName: HookEventName,
  ): HookInput {
    // Create a copy of the original input
    const modifiedInput = { ...originalInput };

    // Apply modifications based on hook output and event type
    if (hookOutput.hookSpecificOutput) {
      switch (eventName) {
        case HookEventName.BeforeAgent:
          if ('additionalContext' in hookOutput.hookSpecificOutput) {
            // For BeforeAgent, we could modify the prompt with additional context
            const additionalContext =
              hookOutput.hookSpecificOutput['additionalContext'];
            if (
              typeof additionalContext === 'string' &&
              'prompt' in modifiedInput
            ) {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              (modifiedInput as BeforeAgentInput).prompt +=
                '\n\n' + additionalContext;
            }
          }
          break;

        case HookEventName.BeforeModel:
          if ('llm_request' in hookOutput.hookSpecificOutput) {
            // For BeforeModel, we update the LLM request
            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
            const hookBeforeModelOutput = hookOutput as BeforeModelOutput;
            if (
              hookBeforeModelOutput.hookSpecificOutput?.llm_request &&
              'llm_request' in modifiedInput
            ) {
              // Merge the partial request with the existing request
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              const currentRequest = (modifiedInput as BeforeModelInput)
                .llm_request;
              const partialRequest =
                hookBeforeModelOutput.hookSpecificOutput.llm_request;
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              (modifiedInput as BeforeModelInput).llm_request = {
                ...currentRequest,
                ...partialRequest,
              } as LLMRequest;
            }
          }
          break;

        case HookEventName.BeforeTool:
          if ('tool_input' in hookOutput.hookSpecificOutput) {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
            const newToolInput = hookOutput.hookSpecificOutput[
              'tool_input'
            ] as Record<string, unknown>;
            if (newToolInput && 'tool_input' in modifiedInput) {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              (modifiedInput as BeforeToolInput).tool_input = {
                // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                ...(modifiedInput as BeforeToolInput).tool_input,
                ...newToolInput,
              };
            }
          }
          break;

        default:
          // For other events, no special input modification is needed
          break;
      }
    }

    return modifiedInput;
  }

  /**
   * Execute a runtime hook
   */
  private async executeRuntimeHook(
    hookConfig: RuntimeHookConfig,
    eventName: HookEventName,
    input: HookInput,
    startTime: number,
  ): Promise<HookExecutionResult> {
    const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
    let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
    const controller = new AbortController();

    try {
      // Create a promise that rejects after timeout
      const timeoutPromise = new Promise<never>((_, reject) => {
        timeoutHandle = setTimeout(
          () => reject(new Error(`Hook timed out after ${timeout}ms`)),
          timeout,
        );
      });

      // Execute action with timeout race
      const result = await Promise.race([
        hookConfig.action(input, { signal: controller.signal }),
        timeoutPromise,
      ]);

      const output =
        result === null || result === undefined ? undefined : result;

      return {
        hookConfig,
        eventName,
        success: true,
        output,
        duration: Date.now() - startTime,
      };
    } catch (error) {
      // Abort the ongoing hook action if it timed out or errored
      controller.abort();
      return {
        hookConfig,
        eventName,
        success: false,
        error: error instanceof Error ? error : new Error(String(error)),
        duration: Date.now() - startTime,
      };
    } finally {
      if (timeoutHandle) {
        clearTimeout(timeoutHandle);
      }
    }
  }

  /**
   * Execute a command hook
   */
  private async executeCommandHook(
    hookConfig: CommandHookConfig,
    eventName: HookEventName,
    input: HookInput,
    startTime: number,
  ): Promise<HookExecutionResult> {
    const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;

    return new Promise((resolve) => {
      if (!hookConfig.command) {
        const errorMessage = 'Command hook missing command';
        debugLogger.warn(
          `Hook configuration error (non-fatal): ${errorMessage}`,
        );
        resolve({
          hookConfig,
          eventName,
          success: false,
          error: new Error(errorMessage),
          duration: Date.now() - startTime,
        });
        return;
      }

      let stdout = '';
      let stderr = '';
      let timedOut = false;

      const shellConfig = getShellConfiguration();
      let command = this.expandCommand(
        hookConfig.command,
        input,
        shellConfig.shell,
      );

      if (shellConfig.shell === 'powershell') {
        // Append exit code check to ensure the exit code of the command is propagated
        command = `${command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`;
      }

      // Set up environment variables
      const env = {
        ...sanitizeEnvironment(process.env, this.config.sanitizationConfig),
        GEMINI_PROJECT_DIR: input.cwd,
        GEMINI_PLANS_DIR: this.config.storage.getPlansDir(),
        GEMINI_CWD: input.cwd,
        GEMINI_SESSION_ID: input.session_id,
        CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
        ...hookConfig.env,
      };

      const child = spawn(
        shellConfig.executable,
        [...shellConfig.argsPrefix, command],
        {
          env,
          cwd: input.cwd,
          stdio: ['pipe', 'pipe', 'pipe'],
          shell: false,
        },
      );

      // Set up timeout
      const timeoutHandle = setTimeout(() => {
        timedOut = true;

        if (process.platform === 'win32' && child.pid) {
          try {
            execSync(`taskkill /pid ${child.pid} /f /t`, { timeout: 2000 });
          } catch (e) {
            // Ignore errors if process is already dead or access denied
            debugLogger.debug(`Taskkill failed: ${e}`);
          }
        } else {
          child.kill('SIGTERM');
        }

        // Force kill after 5 seconds
        setTimeout(() => {
          if (!child.killed) {
            if (process.platform === 'win32' && child.pid) {
              try {
                execSync(`taskkill /pid ${child.pid} /f /t`, { timeout: 2000 });
              } catch (e) {
                // Ignore
                debugLogger.debug(`Taskkill failed: ${e}`);
              }
            } else {
              child.kill('SIGKILL');
            }
          }
        }, 5000);
      }, timeout);

      // Send input to stdin
      if (child.stdin) {
        child.stdin.on('error', (err: NodeJS.ErrnoException) => {
          // Ignore EPIPE errors which happen when the child process closes stdin early
          if (err.code !== 'EPIPE') {
            debugLogger.debug(`Hook stdin error: ${err}`);
          }
        });

        // Wrap write operations in try-catch to handle synchronous EPIPE errors
        // that occur when the child process exits before we finish writing
        try {
          child.stdin.write(JSON.stringify(input));
          child.stdin.end();
        } catch (err) {
          // Ignore EPIPE errors which happen when the child process closes stdin early
          if (err instanceof Error && 'code' in err && err.code !== 'EPIPE') {
            debugLogger.debug(`Hook stdin write error: ${err}`);
          }
        }
      }

      // Collect stdout
      child.stdout?.on('data', (data: Buffer) => {
        stdout += data.toString();
      });

      // Collect stderr
      child.stderr?.on('data', (data: Buffer) => {
        stderr += data.toString();
      });

      // Handle process exit
      child.on('close', (exitCode) => {
        clearTimeout(timeoutHandle);
        const duration = Date.now() - startTime;

        if (timedOut) {
          resolve({
            hookConfig,
            eventName,
            success: false,
            error: new Error(`Hook timed out after ${timeout}ms`),
            stdout,
            stderr,
            duration,
          });
          return;
        }

        // Parse output
        let output: HookOutput | undefined;
        let outputFormat: 'json' | 'text' | undefined;

        const textToParse = stdout.trim() || stderr.trim();
        if (textToParse) {
          try {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            let parsed = JSON.parse(textToParse);
            if (typeof parsed === 'string') {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
              parsed = JSON.parse(parsed);
            }
            if (parsed && typeof parsed === 'object') {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              output = parsed as HookOutput;
              outputFormat = 'json';
            }
          } catch {
            // Not JSON, convert plain text to structured output
            output = this.convertPlainTextToHookOutput(
              textToParse,
              exitCode || EXIT_CODE_SUCCESS,
            );
            outputFormat = 'text';
          }
        }

        resolve({
          hookConfig,
          eventName,
          success: exitCode === EXIT_CODE_SUCCESS,
          output,
          outputFormat,
          stdout,
          stderr,
          exitCode: exitCode || EXIT_CODE_SUCCESS,
          duration,
        });
      });

      // Handle process errors
      child.on('error', (error) => {
        clearTimeout(timeoutHandle);
        const duration = Date.now() - startTime;

        resolve({
          hookConfig,
          eventName,
          success: false,
          error,
          stdout,
          stderr,
          duration,
        });
      });
    });
  }

  /**
   * Expand command with environment variables and input context
   */
  private expandCommand(
    command: string,
    input: HookInput,
    shellType: ShellType,
  ): string {
    debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`);
    const escapedCwd = escapeShellArg(input.cwd, shellType);
    const escapedPlansDir = escapeShellArg(
      this.config.storage.getPlansDir(),
      shellType,
    );
    const escapedSessionId = escapeShellArg(input.session_id, shellType);

    return command
      .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd)
      .replace(/\$GEMINI_CWD/g, () => escapedCwd)
      .replace(/\$GEMINI_PLANS_DIR/g, () => escapedPlansDir)
      .replace(/\$GEMINI_SESSION_ID/g, () => escapedSessionId)
      .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility
  }

  /**
   * Convert plain text output to structured HookOutput
   */
  private convertPlainTextToHookOutput(
    text: string,
    exitCode: number,
  ): HookOutput {
    if (exitCode === EXIT_CODE_SUCCESS) {
      // Success
      return {
        decision: 'allow',
        systemMessage: text,
      };
    } else if (exitCode === EXIT_CODE_NON_BLOCKING_ERROR) {
      // Non-blocking error (EXIT_CODE_NON_BLOCKING_ERROR = 1)
      return {
        decision: 'allow',
        systemMessage: `Warning: ${text}`,
      };
    } else {
      // All other non-zero exit codes (including 2) are blocking
      return {
        decision: 'deny',
        reason: text,
      };
    }
  }
}
