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

import {
  type PolicyRule,
  PolicyDecision,
  ApprovalMode,
  type SafetyCheckerConfig,
  type SafetyCheckerRule,
  InProcessCheckerType,
} from './types.js';
import { buildArgsPatterns, isSafeRegExp } from './utils.js';
import {
  isValidToolName,
  ALL_BUILTIN_TOOL_NAMES,
} from '../tools/tool-names.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import levenshtein from 'fast-levenshtein';
import fs from 'node:fs/promises';
import path from 'node:path';
import toml from '@iarna/toml';
import { z, type ZodError } from 'zod';
import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX, formatMcpToolName } from '../tools/mcp-tool.js';

/**
 * Maximum Levenshtein distance to consider a name a likely typo of a built-in tool.
 * Names further from all built-in tools are assumed to be intentional
 * (e.g., dynamically registered agent tools) and are not warned about.
 */
const MAX_TYPO_DISTANCE = 3;

/**
 * Schema for a single policy rule in the TOML file (before transformation).
 */
const PolicyRuleSchema = z.object({
  toolName: z.union([z.string(), z.array(z.string())]),
  subagent: z.string().optional(),
  mcpName: z.string().optional(),
  argsPattern: z.string().optional(),
  commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
  commandRegex: z.string().optional(),
  decision: z.nativeEnum(PolicyDecision),
  // Priority must be in range [0, 999] to prevent tier overflow.
  // With tier transformation (tier + priority/1000), this ensures:
  // - Tier 1 (default): range [1.000, 1.999]
  // - Tier 2 (user): range [2.000, 2.999]
  // - Tier 3 (admin): range [3.000, 3.999]
  priority: z
    .number({
      required_error: 'priority is required',
      invalid_type_error: 'priority must be a number',
    })
    .int({ message: 'priority must be an integer' })
    .min(0, { message: 'priority must be >= 0' })
    .max(999, {
      message:
        'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
    }),
  modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
  interactive: z.boolean().optional(),
  toolAnnotations: z.record(z.any()).optional(),
  allowRedirection: z.boolean().optional(),
  allow_redirection: z.boolean().optional(), // deprecated snake_case for backward compatibility
  denyMessage: z.string().optional(),
  deny_message: z.string().optional(), // deprecated snake_case for backward compatibility
});

/**
 * Schema for a single safety checker rule in the TOML file.
 */
const SafetyCheckerRuleSchema = z.object({
  toolName: z.union([z.string(), z.array(z.string())]),
  mcpName: z.string().optional(),
  argsPattern: z.string().optional(),
  commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
  commandRegex: z.string().optional(),
  priority: z.number().int().default(0),
  modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
  toolAnnotations: z.record(z.any()).optional(),
  checker: z.discriminatedUnion('type', [
    z.object({
      type: z.literal('in-process'),
      name: z.nativeEnum(InProcessCheckerType),
      required_context: z.array(z.string()).optional(),
      config: z.record(z.unknown()).optional(),
    }),
    z.object({
      type: z.literal('external'),
      name: z.string(),
      required_context: z.array(z.string()).optional(),
      config: z.record(z.unknown()).optional(),
    }),
  ]),
});

/**
 * Schema for the entire policy TOML file.
 */
const PolicyFileSchema = z.object({
  rule: z.array(PolicyRuleSchema).optional(),
  safety_checker: z.array(SafetyCheckerRuleSchema).optional(),
});

/**
 * Type for a raw policy rule from TOML (before transformation).
 */
type PolicyRuleToml = z.infer<typeof PolicyRuleSchema>;

/**
 * Types of errors that can occur while loading policy files.
 */
export type PolicyFileErrorType =
  | 'file_read'
  | 'toml_parse'
  | 'schema_validation'
  | 'rule_validation'
  | 'regex_compilation'
  | 'tool_name_warning';

/**
 * Detailed error information for policy file loading failures.
 */
export interface PolicyFileError {
  filePath: string;
  fileName: string;
  tier: 'default' | 'extension' | 'user' | 'workspace' | 'admin';
  ruleIndex?: number;
  errorType: PolicyFileErrorType;
  message: string;
  details?: string;
  suggestion?: string;
  severity?: 'error' | 'warning';
}

/**
 * Result of loading policies from TOML files.
 */
export interface PolicyLoadResult {
  rules: PolicyRule[];
  checkers: SafetyCheckerRule[];
  errors: PolicyFileError[];
}

export interface PolicyFile {
  path: string;
  content: string;
}

/**
 * Reads policy files from a directory or a single file.
 *
 * @param policyPath Path to a directory or a .toml file.
 * @returns Array of PolicyFile objects.
 */
export async function readPolicyFiles(
  policyPath: string,
): Promise<PolicyFile[]> {
  let filesToLoad: string[] = [];
  let baseDir = '';

  try {
    const stats = await fs.stat(policyPath);
    if (stats.isDirectory()) {
      baseDir = policyPath;
      const dirEntries = await fs.readdir(policyPath, { withFileTypes: true });
      filesToLoad = dirEntries
        .filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
        .map((entry) => entry.name);
    } else if (stats.isFile() && policyPath.endsWith('.toml')) {
      baseDir = path.dirname(policyPath);
      filesToLoad = [path.basename(policyPath)];
    }
  } catch (e) {
    if (isNodeError(e) && e.code === 'ENOENT') {
      return [];
    }
    throw e;
  }

  const results: PolicyFile[] = [];
  for (const file of filesToLoad) {
    const filePath = path.join(baseDir, file);
    const content = await fs.readFile(filePath, 'utf-8');
    results.push({ path: filePath, content });
  }
  return results;
}

/**
 * Converts a tier number to a human-readable tier name.
 */
function getTierName(
  tier: number,
): 'default' | 'extension' | 'user' | 'workspace' | 'admin' {
  if (tier === 1) return 'default';
  if (tier === 2) return 'extension';
  if (tier === 3) return 'workspace';
  if (tier === 4) return 'user';
  if (tier === 5) return 'admin';
  return 'default';
}

/**
 * Formats a Zod validation error into a readable error message.
 */
function formatSchemaError(error: ZodError, ruleIndex: number): string {
  const issues = error.issues
    .map((issue) => {
      const path = issue.path.join('.');
      return `  - Field "${path}": ${issue.message}`;
    })
    .join('\n');
  return `Invalid policy rule (rule #${ruleIndex + 1}):\n${issues}`;
}

/**
 * Validates shell command convenience syntax rules.
 * Returns an error message if invalid, or null if valid.
 */
function validateShellCommandSyntax(
  rule: PolicyRuleToml,
  ruleIndex: number,
): string | null {
  const hasCommandPrefix = rule.commandPrefix !== undefined;
  const hasCommandRegex = rule.commandRegex !== undefined;
  const hasArgsPattern = rule.argsPattern !== undefined;

  if (hasCommandPrefix || hasCommandRegex) {
    // Must have exactly toolName = "run_shell_command"
    if (rule.toolName !== 'run_shell_command' || Array.isArray(rule.toolName)) {
      return (
        `Rule #${ruleIndex + 1}: commandPrefix and commandRegex can only be used with toolName = "run_shell_command"\n` +
        `  Found: toolName = ${JSON.stringify(rule.toolName)}\n` +
        `  Fix: Set toolName = "run_shell_command" (not an array)`
      );
    }

    // Can't combine with argsPattern
    if (hasArgsPattern) {
      return (
        `Rule #${ruleIndex + 1}: cannot use both commandPrefix/commandRegex and argsPattern\n` +
        `  These fields are mutually exclusive\n` +
        `  Fix: Use either commandPrefix/commandRegex OR argsPattern, not both`
      );
    }

    // Can't use both commandPrefix and commandRegex
    if (hasCommandPrefix && hasCommandRegex) {
      return (
        `Rule #${ruleIndex + 1}: cannot use both commandPrefix and commandRegex\n` +
        `  These fields are mutually exclusive\n` +
        `  Fix: Use either commandPrefix OR commandRegex, not both`
      );
    }
  }

  return null;
}

/**
 * Validates that a tool name is recognized.
 * Returns a warning message if the tool name is a likely typo of a built-in
 * tool name, or null if valid or not close to any built-in name.
 */
function validateToolName(name: string, ruleIndex: number): string | null {
  if (name.includes('__')) {
    return `Rule #${ruleIndex + 1}: The "__" syntax for MCP tools is strictly deprecated. Please use the 'mcpName = "..."' property or the 'mcp_server_tool' format instead.`;
  }

  // A name that looks like an MCP tool (e.g., "re__ad") could be a typo of a
  // built-in tool ("read_file"). We should let such names fall through to the
  // Levenshtein distance check below. Non-MCP-like names that are valid can
  // be safely skipped.
  if (isValidToolName(name, { allowWildcards: true })) {
    return null;
  }

  // Only warn if the name is close to a built-in name (likely typo).
  // Names that are very different from all built-in names are likely
  // intentional (dynamic tools, agent tools, etc.).
  const allNames = [...ALL_BUILTIN_TOOL_NAMES];
  const minDistance = Math.min(
    ...allNames.map((n) => levenshtein.get(name, n)),
  );

  if (minDistance > MAX_TYPO_DISTANCE) {
    return null;
  }

  const suggestion = getToolSuggestion(name, allNames);
  return `Rule #${ruleIndex + 1}: Unrecognized tool name "${name}".${suggestion}`;
}

/**
 * Transforms a priority number based on the policy tier.
 * Formula: tier + priority/1000
 *
 * @param priority The priority value from the TOML file
 * @param tier The tier (1=default, 2=user, 3=admin)
 * @returns The transformed priority
 */
function transformPriority(priority: number, tier: number): number {
  return tier + priority / 1000;
}

/**
 * Loads and parses policies from TOML files in the specified paths (directories or individual files).
 *
 * This function:
 * 1. Scans paths for .toml files (if directory) or processes individual files
 * 2. Parses and validates each file
 * 3. Transforms rules (commandPrefix, arrays, mcpName, priorities)
 * 4. Collects detailed error information for any failures
 *
 * @param policyPaths Array of paths (directories or files) to scan for policy files
 * @param getPolicyTier Function to determine tier (1-4) for a path
 * @returns Object containing successfully parsed rules and any errors encountered
 */
export async function loadPoliciesFromToml(
  policyPaths: string[],
  getPolicyTier: (path: string) => number,
): Promise<PolicyLoadResult> {
  const rules: PolicyRule[] = [];
  const checkers: SafetyCheckerRule[] = [];
  const errors: PolicyFileError[] = [];

  for (const p of policyPaths) {
    const tier = getPolicyTier(p);
    const tierName = getTierName(tier);

    let policyFiles: PolicyFile[] = [];

    try {
      policyFiles = await readPolicyFiles(p);
    } catch (e) {
      errors.push({
        filePath: p,
        fileName: path.basename(p),
        tier: tierName,
        errorType: 'file_read',
        message: `Failed to read policy path`,
        details: isNodeError(e) ? e.message : String(e),
      });
      continue;
    }

    for (const { path: filePath, content: fileContent } of policyFiles) {
      const file = path.basename(filePath);

      try {
        // Parse TOML
        let parsed: unknown;
        try {
          parsed = toml.parse(fileContent);
        } catch (e) {
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          const error = e as Error;
          errors.push({
            filePath,
            fileName: file,
            tier: tierName,
            errorType: 'toml_parse',
            message: 'TOML parsing failed',
            details: error.message,
            suggestion:
              'Check for syntax errors like missing quotes, brackets, or commas',
          });
          continue;
        }

        // Validate schema
        const validationResult = PolicyFileSchema.safeParse(parsed);
        if (!validationResult.success) {
          errors.push({
            filePath,
            fileName: file,
            tier: tierName,
            errorType: 'schema_validation',
            message: 'Schema validation failed',
            details: formatSchemaError(validationResult.error, 0),
            suggestion:
              'Ensure all required fields (decision, priority) are present with correct types',
          });
          continue;
        }

        // Validate shell command convenience syntax
        const tomlRules = validationResult.data.rule ?? [];

        for (let i = 0; i < tomlRules.length; i++) {
          const rule = tomlRules[i];
          const validationError = validateShellCommandSyntax(rule, i);
          if (validationError) {
            errors.push({
              filePath,
              fileName: file,
              tier: tierName,
              ruleIndex: i,
              errorType: 'rule_validation',
              message: 'Invalid shell command syntax',
              details: validationError,
            });
            // Continue to next rule, don't skip the entire file
          }
        }

        // Validate tool names in rules
        for (let i = 0; i < tomlRules.length; i++) {
          const rule = tomlRules[i];

          const toolNamesRaw: string[] = Array.isArray(rule.toolName)
            ? rule.toolName
            : [rule.toolName];

          if (toolNamesRaw.some((name) => name === '')) {
            errors.push({
              filePath,
              fileName: file,
              tier: tierName,
              ruleIndex: i,
              errorType: 'rule_validation',
              message: 'Invalid policy rule: toolName cannot be empty string',
              details: `Rule #${i + 1} contains an empty toolName string. Use "*" to match all tools.`,
            });
            continue;
          }

          // We no longer skip MCP-scoped rules because we need to specifically
          // warn users if they use deprecated "__" syntax for MCP tool names

          const toolNames: string[] = toolNamesRaw;

          for (const name of toolNames) {
            const warning = validateToolName(name, i);
            if (warning) {
              errors.push({
                filePath,
                fileName: file,
                tier: tierName,
                ruleIndex: i,
                errorType: 'tool_name_warning',
                message: 'Unrecognized tool name',
                details: warning,
                severity: 'warning',
              });
            }
          }
        }

        // Transform rules
        const parsedRules: PolicyRule[] = (validationResult.data.rule ?? [])
          .flatMap((rule) => {
            const argsPatterns = buildArgsPatterns(
              rule.argsPattern,
              rule.commandPrefix,
              rule.commandRegex,
            );

            // For each argsPattern, expand toolName arrays
            return argsPatterns.flatMap((argsPattern) => {
              const toolNames: string[] = Array.isArray(rule.toolName)
                ? rule.toolName
                : [rule.toolName];

              // Create a policy rule for each tool name
              return toolNames.map((toolName) => {
                let effectiveToolName: string = toolName;
                const mcpName = rule.mcpName;

                if (mcpName) {
                  // TODO(mcp): Decouple mcpName rules from FQN string parsing
                  // to support underscores in server aliases natively. Leaving
                  // mcpName and toolName separate here and relying on metadata
                  // during policy evaluation will avoid underscore splitting bugs.
                  // See: https://github.com/google-gemini/gemini-cli/issues/21727
                  effectiveToolName = formatMcpToolName(
                    mcpName,
                    effectiveToolName,
                  );
                }

                const policyRule: PolicyRule = {
                  toolName: effectiveToolName,
                  subagent: rule.subagent,
                  mcpName: rule.mcpName,
                  decision: rule.decision,
                  priority: transformPriority(rule.priority, tier),
                  modes: rule.modes,
                  interactive: rule.interactive,
                  toolAnnotations: rule.toolAnnotations,
                  allowRedirection:
                    rule.allowRedirection ?? rule.allow_redirection,
                  source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
                  denyMessage: rule.denyMessage ?? rule.deny_message,
                };

                // Compile regex pattern
                if (argsPattern) {
                  try {
                    new RegExp(argsPattern);
                  } catch (e) {
                    // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                    const error = e as Error;
                    errors.push({
                      filePath,
                      fileName: file,
                      tier: tierName,
                      errorType: 'regex_compilation',
                      message: 'Invalid regex pattern',
                      details: `Pattern: ${argsPattern}\nError: ${error.message}`,
                      suggestion:
                        'Check regex syntax for errors like unmatched brackets or invalid escape sequences',
                    });
                    return null;
                  }

                  if (!isSafeRegExp(argsPattern)) {
                    errors.push({
                      filePath,
                      fileName: file,
                      tier: tierName,
                      errorType: 'regex_compilation',
                      message: 'Unsafe regex pattern (potential ReDoS)',
                      details: `Pattern: ${argsPattern}`,
                      suggestion:
                        'Avoid nested quantifiers or extremely long patterns',
                    });
                    return null;
                  }

                  policyRule.argsPattern = new RegExp(argsPattern);
                }

                return policyRule;
              });
            });
          })
          .filter((rule): rule is PolicyRule => rule !== null);

        rules.push(...parsedRules);

        // Validate tool names in safety checker rules
        const tomlCheckerRules = validationResult.data.safety_checker ?? [];
        for (let i = 0; i < tomlCheckerRules.length; i++) {
          const checker = tomlCheckerRules[i];

          const checkerToolNamesRaw: string[] = Array.isArray(checker.toolName)
            ? checker.toolName
            : [checker.toolName];

          if (checkerToolNamesRaw.some((name) => name === '')) {
            errors.push({
              filePath,
              fileName: file,
              tier: tierName,
              ruleIndex: i,
              errorType: 'rule_validation',
              message:
                'Invalid safety checker rule: toolName cannot be empty string',
              details: `Checker #${i + 1} contains an empty toolName string. Use "*" to match all tools.`,
            });
            continue;
          }

          if (checker.mcpName) continue;

          const checkerToolNames: string[] = checkerToolNamesRaw;

          for (const name of checkerToolNames) {
            const warning = validateToolName(name, i);
            if (warning) {
              errors.push({
                filePath,
                fileName: file,
                tier: tierName,
                ruleIndex: i,
                errorType: 'tool_name_warning',
                message: 'Unrecognized tool name in safety checker',
                details: warning,
                severity: 'warning',
              });
            }
          }
        }

        // Transform checkers
        const parsedCheckers: SafetyCheckerRule[] = (
          validationResult.data.safety_checker ?? []
        )
          .flatMap((checker) => {
            const argsPatterns = buildArgsPatterns(
              checker.argsPattern,
              checker.commandPrefix,
              checker.commandRegex,
            );

            return argsPatterns.flatMap((argsPattern) => {
              const toolNames: string[] = Array.isArray(checker.toolName)
                ? checker.toolName
                : [checker.toolName];

              return toolNames.map((toolName) => {
                let effectiveToolName: string;
                if (checker.mcpName && toolName !== '*') {
                  effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_${toolName}`;
                } else if (checker.mcpName) {
                  effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_*`;
                } else {
                  effectiveToolName = toolName;
                }

                const safetyCheckerRule: SafetyCheckerRule = {
                  toolName: effectiveToolName,
                  mcpName: checker.mcpName,
                  priority: transformPriority(checker.priority, tier),
                  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                  checker: checker.checker as SafetyCheckerConfig,
                  modes: checker.modes,
                  toolAnnotations: checker.toolAnnotations,
                  source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
                };

                if (argsPattern) {
                  try {
                    new RegExp(argsPattern);
                  } catch (e) {
                    // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                    const error = e as Error;
                    errors.push({
                      filePath,
                      fileName: file,
                      tier: tierName,
                      errorType: 'regex_compilation',
                      message: 'Invalid regex pattern in safety checker',
                      details: `Pattern: ${argsPattern}\nError: ${error.message}`,
                    });
                    return null;
                  }

                  if (!isSafeRegExp(argsPattern)) {
                    errors.push({
                      filePath,
                      fileName: file,
                      tier: tierName,
                      errorType: 'regex_compilation',
                      message:
                        'Unsafe regex pattern in safety checker (potential ReDoS)',
                      details: `Pattern: ${argsPattern}`,
                    });
                    return null;
                  }

                  safetyCheckerRule.argsPattern = new RegExp(argsPattern);
                }

                return safetyCheckerRule;
              });
            });
          })
          .filter((checker): checker is SafetyCheckerRule => checker !== null);

        checkers.push(...parsedCheckers);
      } catch (e) {
        // Catch-all for unexpected errors
        if (!isNodeError(e) || e.code !== 'ENOENT') {
          errors.push({
            filePath,
            fileName: file,
            tier: tierName,
            errorType: 'file_read',
            message: 'Failed to read policy file',
            details: isNodeError(e) ? e.message : String(e),
          });
        }
      }
    }
  }

  return { rules, checkers, errors };
}

/**
 * Validates MCP tool names in policy rules against actually discovered MCP tools.
 * Called after an MCP server connects and its tools are discovered.
 *
 * For each policy rule that references the given MCP server, checks if the
 * tool name matches any discovered tool. Emits warnings for likely typos
 * using Levenshtein distance.
 *
 * @param serverName The MCP server name (e.g., "google-workspace")
 * @param discoveredToolNames The tool names discovered from this server (simple names, not fully qualified)
 * @param policyRules The current set of policy rules to validate against
 * @returns Array of warning messages for unrecognized MCP tool names
 */
export function validateMcpPolicyToolNames(
  serverName: string,
  discoveredToolNames: string[],
  policyRules: ReadonlyArray<{
    toolName: string;
    mcpName?: string;
    source?: string;
  }>,
): string[] {
  const prefix = `${MCP_TOOL_PREFIX}${serverName}_`;
  const warnings: string[] = [];

  for (const rule of policyRules) {
    if (!rule.toolName) continue;

    let toolPart: string | undefined;

    // The toolName is typically transformed into an FQN if mcpName was used.
    if (rule.mcpName === serverName && rule.toolName.startsWith(prefix)) {
      toolPart = rule.toolName.slice(prefix.length);
    } else if (rule.toolName.startsWith(prefix)) {
      toolPart = rule.toolName.slice(prefix.length);
    } else {
      continue;
    }

    // Skip wildcards
    if (toolPart === '*') continue;

    // Check if the tool exists
    if (discoveredToolNames.includes(toolPart)) continue;

    // Tool not found — check if it's a likely typo
    if (discoveredToolNames.length === 0) continue;

    const minDistance = Math.min(
      ...discoveredToolNames.map((n) => levenshtein.get(toolPart ?? '', n)),
    );

    if (minDistance > MAX_TYPO_DISTANCE) continue;

    const suggestion = getToolSuggestion(toolPart, discoveredToolNames);
    const source = rule.source ? ` (from ${rule.source})` : '';
    warnings.push(
      `Unrecognized MCP tool "${toolPart}" for server "${serverName}"${source}.${suggestion}`,
    );
  }

  return warnings;
}
