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

import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import nodePath from 'node:path';
import * as fs from 'node:fs/promises';
import { type Dirent, type Stats, type PathLike } from 'node:fs';

import {
  ApprovalMode,
  PolicyDecision,
  InProcessCheckerType,
  type PolicySettings,
} from './types.js';
import { isDirectorySecure } from '../utils/security.js';
import {
  createPolicyEngineConfig,
  clearEmittedPolicyWarnings,
  getPolicyDirectories,
} from './config.js';
import { Storage } from '../config/storage.js';
import * as tomlLoader from './toml-loader.js';
import { coreEvents } from '../utils/events.js';
import { MCPServerConfig } from '../config/config.js';

vi.unmock('../config/storage.js');

vi.mock('../utils/security.js', () => ({
  isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }),
}));

vi.mock('node:fs/promises', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:fs/promises')>();
  const mockFs = {
    ...actual,
    readdir: vi.fn(actual.readdir),
    readFile: vi.fn(actual.readFile),
    stat: vi.fn(actual.stat),
    mkdir: vi.fn(actual.mkdir),
    open: vi.fn(actual.open),
    rename: vi.fn(actual.rename),
  };
  return {
    ...mockFs,
    default: mockFs,
  };
});

afterEach(() => {
  vi.resetAllMocks();
});

describe('createPolicyEngineConfig', () => {
  const MOCK_DEFAULT_DIR = nodePath.resolve('/tmp/mock/default/policies');

  beforeEach(async () => {
    clearEmittedPolicyWarnings();
    // Mock Storage to avoid host environment contamination
    vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(
      nodePath.resolve('/non/existent/user/policies'),
    );
    vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
      nodePath.resolve('/non/existent/system/policies'),
    );
    vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true });
  });

  /**
   * Helper to mock a policy file in the filesystem.
   */
  function mockPolicyFile(path: string, content: string) {
    const resolvedPath = nodePath.resolve(path);
    vi.mocked(
      fs.readdir as (path: PathLike) => Promise<string[] | Dirent[]>,
    ).mockImplementation(async (p) => {
      if (nodePath.resolve(p.toString()) === nodePath.dirname(resolvedPath)) {
        return [
          {
            name: nodePath.basename(resolvedPath),
            isFile: () => true,
            isDirectory: () => false,
          } as unknown as Dirent,
        ];
      }
      return (
        await vi.importActual<typeof import('node:fs/promises')>(
          'node:fs/promises',
        )
      ).readdir(p);
    });

    vi.mocked(fs.stat).mockImplementation(async (p) => {
      if (nodePath.resolve(p.toString()) === nodePath.dirname(resolvedPath)) {
        return {
          isDirectory: () => true,
          isFile: () => false,
        } as unknown as Stats;
      }
      if (nodePath.resolve(p.toString()) === resolvedPath) {
        return {
          isDirectory: () => false,
          isFile: () => true,
        } as unknown as Stats;
      }
      return (
        await vi.importActual<typeof import('node:fs/promises')>(
          'node:fs/promises',
        )
      ).stat(p);
    });

    vi.mocked(fs.readFile).mockImplementation(async (p) => {
      if (nodePath.resolve(p.toString()) === resolvedPath) {
        return content;
      }
      return (
        await vi.importActual<typeof import('node:fs/promises')>(
          'node:fs/promises',
        )
      ).readFile(p);
    });
  }

  it('should filter out insecure system policy directories', async () => {
    const systemPolicyDir = '/insecure/system/policies';
    vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(systemPolicyDir);

    vi.mocked(isDirectorySecure).mockImplementation(async (path: string) => {
      if (nodePath.resolve(path) === nodePath.resolve(systemPolicyDir)) {
        return { secure: false, reason: 'Insecure directory' };
      }
      return { secure: true };
    });

    const loadPoliciesSpy = vi
      .spyOn(tomlLoader, 'loadPoliciesFromToml')
      .mockResolvedValue({ rules: [], checkers: [], errors: [] });

    await createPolicyEngineConfig({}, ApprovalMode.DEFAULT, MOCK_DEFAULT_DIR);

    expect(loadPoliciesSpy).toHaveBeenCalled();
    const calledDirs = loadPoliciesSpy.mock.calls[0][0];
    expect(calledDirs).not.toContain(nodePath.resolve(systemPolicyDir));
    expect(calledDirs).toContain(
      nodePath.resolve('/non/existent/user/policies'),
    );
    expect(calledDirs).toContain(MOCK_DEFAULT_DIR);
  });

  it('should NOT filter out insecure supplemental admin policy directories', async () => {
    const adminPolicyDir = nodePath.resolve('/insecure/admin/policies');
    vi.mocked(isDirectorySecure).mockImplementation(async (path: string) => {
      if (nodePath.resolve(path) === adminPolicyDir) {
        return { secure: false, reason: 'Insecure directory' };
      }
      return { secure: true };
    });

    const loadPoliciesSpy = vi
      .spyOn(tomlLoader, 'loadPoliciesFromToml')
      .mockResolvedValue({ rules: [], checkers: [], errors: [] });

    await createPolicyEngineConfig(
      { adminPolicyPaths: [adminPolicyDir] },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    const calledDirs = loadPoliciesSpy.mock.calls[0][0];
    expect(calledDirs).toContain(adminPolicyDir);
    expect(calledDirs).toContain(
      nodePath.resolve('/non/existent/system/policies'),
    );
    expect(calledDirs).toContain(
      nodePath.resolve('/non/existent/user/policies'),
    );
    expect(calledDirs).toContain(MOCK_DEFAULT_DIR);
  });

  it('should return ASK_USER for write tools and ALLOW for read-only tools by default', async () => {
    vi.mocked(
      fs.readdir as (path: PathLike) => Promise<string[]>,
    ).mockResolvedValue([]);

    const config = await createPolicyEngineConfig(
      {},
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    expect(config.defaultDecision).toBe(PolicyDecision.ASK_USER);
    expect(config.rules).toEqual([]);
  });

  it('should allow tools in tools.allowed', async () => {
    vi.mocked(
      fs.readdir as (path: PathLike) => Promise<string[]>,
    ).mockResolvedValue([]);
    const config = await createPolicyEngineConfig(
      { tools: { allowed: ['run_shell_command'] } },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    const rule = config.rules?.find(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBeCloseTo(4.3, 5); // Command line allow
  });

  it('should deny tools in tools.exclude', async () => {
    const config = await createPolicyEngineConfig(
      { tools: { exclude: ['run_shell_command'] } },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    const rule = config.rules?.find(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.DENY,
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBeCloseTo(4.4, 5); // Command line exclude
  });

  it('should allow tools from allowed MCP servers', async () => {
    const config = await createPolicyEngineConfig(
      { mcp: { allowed: ['my-server'] } },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    const rule = config.rules?.find(
      (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBe(4.1); // MCP allowed server
  });

  it('should deny tools from excluded MCP servers', async () => {
    const config = await createPolicyEngineConfig(
      { mcp: { excluded: ['my-server'] } },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    const rule = config.rules?.find(
      (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.DENY,
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBe(4.9); // MCP excluded server
  });

  it('should allow tools from trusted MCP servers', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcpServers: {
          'trusted-server': { trust: true },
          'untrusted-server': { trust: false },
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    const trustedRule = config.rules?.find(
      (r) =>
        r.mcpName === 'trusted-server' && r.decision === PolicyDecision.ALLOW,
    );
    expect(trustedRule).toBeDefined();
    expect(trustedRule?.priority).toBe(4.2); // MCP trusted server

    // Untrusted server should not have an allow rule
    const untrustedRule = config.rules?.find(
      (r) =>
        r.mcpName === 'untrusted-server' && r.decision === PolicyDecision.ALLOW,
    );
    expect(untrustedRule).toBeUndefined();
  });

  it('should NOT automatically allow configured MCP servers in non-interactive mode by default', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcpServers: {
          'server-1': new MCPServerConfig('node', []),
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
      false, // non-interactive
    );

    const rule = config.rules?.find(
      (r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeUndefined();
  });

  it('should automatically allow configured MCP servers in non-interactive mode if opted-in', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcp: { autoAllowInHeadless: true },
        mcpServers: {
          'server-1': new MCPServerConfig('node', []),
          'server-2': new MCPServerConfig('python', []),
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
      false, // non-interactive
    );

    const rule1 = config.rules?.find(
      (r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
    );
    const rule2 = config.rules?.find(
      (r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
    );

    expect(rule1).toBeDefined();
    expect(rule1?.source).toBe('Settings (Headless MCP Auto-Allow)');
    expect(rule2).toBeDefined();
    expect(rule2?.source).toBe('Settings (Headless MCP Auto-Allow)');
  });

  it('should NOT automatically allow configured MCP servers in interactive mode even if opted-in', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcp: { autoAllowInHeadless: true },
        mcpServers: {
          'server-1': new MCPServerConfig('node', []),
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
      true, // interactive
    );

    const rule = config.rules?.find(
      (r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeUndefined();
  });

  it('should NOT duplicate allow rules if an MCP server is already explicitly allowed, wildcard allowed, or trusted', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcp: {
          autoAllowInHeadless: true,
          allowed: ['server-1', '*'],
        },
        mcpServers: {
          'server-1': new MCPServerConfig('node', []),
          'server-2': new MCPServerConfig('node', []),
          'server-3': { trust: true },
          'server-4': new MCPServerConfig('node', []),
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
      false, // non-interactive
    );

    // server-1: already in mcp.allowed
    const rules1 = config.rules?.filter(
      (r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rules1).toHaveLength(1);
    expect(rules1?.[0].source).toBe('Settings (MCP Allowed)');

    // server-2: covered by '*' in mcp.allowed
    // Note: the logic adds a rule for '*' which will match server-2 at runtime,
    // but the loop in headless auto-allow should skip adding a specific rule for server-2.
    const rules2 = config.rules?.filter(
      (r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rules2).toHaveLength(0);

    // server-3: already trusted
    const rules3 = config.rules?.filter(
      (r) => r.mcpName === 'server-3' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rules3).toHaveLength(1);
    expect(rules3?.[0].source).toBe('Settings (MCP Trusted)');

    // server-4: NOT explicitly allowed or trusted, but SHOULD NOT be added because '*' exists in mcp.allowed
    const rules4 = config.rules?.filter(
      (r) => r.mcpName === 'server-4' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rules4).toHaveLength(0);

    // Verify the wildcard rule exists
    const wildcardRule = config.rules?.find(
      (r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
    );
    expect(wildcardRule).toBeDefined();
    expect(wildcardRule?.toolName).toBe('mcp_*');
  });

  it('should use correct tool name pattern for wildcard server in headless auto-allow', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcp: { autoAllowInHeadless: true },
        mcpServers: {
          '*': new MCPServerConfig('node', []),
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
      false, // non-interactive
    );

    const rule = config.rules?.find(
      (r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeDefined();
    expect(rule?.toolName).toBe('mcp_*');
  });

  it('should handle multiple MCP server configurations together', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcp: { allowed: ['allowed-server'], excluded: ['excluded-server'] },
        mcpServers: { 'trusted-server': { trust: true } },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    // Check allowed server
    const allowedRule = config.rules?.find(
      (r) =>
        r.mcpName === 'allowed-server' && r.decision === PolicyDecision.ALLOW,
    );
    expect(allowedRule).toBeDefined();
    expect(allowedRule?.priority).toBe(4.1); // MCP allowed server

    // Check trusted server
    const trustedRule = config.rules?.find(
      (r) =>
        r.mcpName === 'trusted-server' && r.decision === PolicyDecision.ALLOW,
    );
    expect(trustedRule).toBeDefined();
    expect(trustedRule?.priority).toBe(4.2); // MCP trusted server

    // Check excluded server
    const excludedRule = config.rules?.find(
      (r) =>
        r.mcpName === 'excluded-server' && r.decision === PolicyDecision.DENY,
    );
    expect(excludedRule).toBeDefined();
    expect(excludedRule?.priority).toBe(4.9); // MCP excluded server
  });

  it('should allow all tools in YOLO mode', async () => {
    const config = await createPolicyEngineConfig({}, ApprovalMode.YOLO);
    const rule = config.rules?.find(
      (r) => r.decision === PolicyDecision.ALLOW && r.toolName === '*',
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBeCloseTo(1.998, 5);
  });

  it('should allow edit tool in AUTO_EDIT mode', async () => {
    const config = await createPolicyEngineConfig({}, ApprovalMode.AUTO_EDIT);
    const rule = config.rules?.find(
      (r) =>
        r.toolName === 'replace' &&
        r.decision === PolicyDecision.ALLOW &&
        r.modes?.includes(ApprovalMode.AUTO_EDIT),
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBeCloseTo(1.015, 5);
  });

  it('should prioritize exclude over allow', async () => {
    const config = await createPolicyEngineConfig(
      {
        tools: {
          allowed: ['run_shell_command'],
          exclude: ['run_shell_command'],
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    const denyRule = config.rules?.find(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.DENY,
    );
    const allowRule = config.rules?.find(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.ALLOW,
    );
    expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!);
  });

  it('should prioritize specific tool allows over MCP server excludes', async () => {
    const settings: PolicySettings = {
      mcp: { excluded: ['my-server'] },
      tools: { allowed: ['mcp_my-server_specific-tool'] },
    };
    const config = await createPolicyEngineConfig(
      settings,
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    const serverDenyRule = config.rules?.find(
      (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.DENY,
    );
    const toolAllowRule = config.rules?.find(
      (r) =>
        r.toolName === 'mcp_my-server_specific-tool' &&
        r.decision === PolicyDecision.ALLOW,
    );

    expect(serverDenyRule).toBeDefined();
    expect(serverDenyRule?.priority).toBe(4.9); // MCP excluded server
    expect(toolAllowRule).toBeDefined();
    expect(toolAllowRule?.priority).toBeCloseTo(4.3, 5); // Command line allow

    // Server deny (4.9) has higher priority than tool allow (4.3),
    // so server deny wins (this is expected behavior - server-level blocks are security critical)
  });

  it('should handle MCP server allows and tool excludes', async () => {
    const { createPolicyEngineConfig } = await import('./config.js');
    const settings: PolicySettings = {
      mcp: { allowed: ['my-server'] },
      mcpServers: {
        'my-server': {
          trust: true,
        },
      },
      tools: { exclude: ['mcp_my-server_dangerous-tool'] },
    };
    const config = await createPolicyEngineConfig(
      settings,
      ApprovalMode.DEFAULT,
      '/tmp/mock/default/policies',
    );

    const serverAllowRule = config.rules?.find(
      (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.ALLOW,
    );
    const toolDenyRule = config.rules?.find(
      (r) =>
        r.toolName === 'mcp_my-server_dangerous-tool' &&
        r.decision === PolicyDecision.DENY,
    );

    expect(serverAllowRule).toBeDefined();
    expect(toolDenyRule).toBeDefined();
    // Command line exclude (4.4) has higher priority than MCP server trust (4.2)
    // This is the correct behavior - specific exclusions should beat general server trust
    expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!);
  });

  it('should handle complex priority scenarios correctly', async () => {
    mockPolicyFile(
      nodePath.join(MOCK_DEFAULT_DIR, 'default.toml'),
      '[[rule]]\ntoolName = "glob"\ndecision = "allow"\npriority = 50\n',
    );

    const settings: PolicySettings = {
      tools: {
        allowed: ['mcp_trusted-server_tool1', 'other-tool'], // Priority 4.3
        exclude: ['mcp_trusted-server_tool2', 'glob'], // Priority 4.4
      },
      mcp: {
        allowed: ['allowed-server'], // Priority 4.1
        excluded: ['excluded-server'], // Priority 4.9
      },
      mcpServers: {
        'trusted-server': {
          trust: true, // Priority 4.2
        },
      },
    };

    const config = await createPolicyEngineConfig(
      settings,
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    const globDenyRule = config.rules?.find(
      (r) => r.toolName === 'glob' && r.decision === PolicyDecision.DENY,
    );
    const globAllowRule = config.rules?.find(
      (r) => r.toolName === 'glob' && r.decision === PolicyDecision.ALLOW,
    );
    expect(globDenyRule).toBeDefined();
    expect(globAllowRule).toBeDefined();
    // Deny from settings (user tier)
    expect(globDenyRule!.priority).toBeCloseTo(4.4, 5); // Command line exclude
    // Allow from default TOML: 1 + 50/1000 = 1.05
    expect(globAllowRule!.priority).toBeCloseTo(1.05, 5);

    // Verify all priority levels are correct
    const priorities = config.rules
      ?.map((r) => ({
        tool: r.toolName,
        decision: r.decision,
        priority: r.priority,
      }))
      .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));

    // Check that the highest priority items are the excludes (user tier: 4.4 and 4.9)
    const highestPriorityExcludes = priorities?.filter(
      (p) =>
        Math.abs(p.priority! - 4.4) < 0.01 ||
        Math.abs(p.priority! - 4.9) < 0.01,
    );
    expect(
      highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY),
    ).toBe(true);
  });

  it('should handle MCP servers with undefined trust property', async () => {
    const config = await createPolicyEngineConfig(
      {
        mcpServers: {
          'no-trust-property': {},
          'explicit-false': { trust: false },
        },
      },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    // Neither server should have an allow rule
    const noTrustRule = config.rules?.find(
      (r) =>
        r.mcpName === 'no-trust-property' &&
        r.decision === PolicyDecision.ALLOW,
    );
    const explicitFalseRule = config.rules?.find(
      (r) =>
        r.mcpName === 'explicit-false' && r.decision === PolicyDecision.ALLOW,
    );

    expect(noTrustRule).toBeUndefined();
    expect(explicitFalseRule).toBeUndefined();
  });

  it('should have YOLO allow-all rule beat write tool rules in YOLO mode', async () => {
    const config = await createPolicyEngineConfig(
      { tools: { exclude: ['dangerous-tool'] } },
      ApprovalMode.YOLO,
    );

    const wildcardRule = config.rules?.find(
      (r) => r.toolName === '*' && r.decision === PolicyDecision.ALLOW,
    );
    const writeToolRules = config.rules?.filter(
      (r) =>
        ['run_shell_command'].includes(r.toolName || '') &&
        r.decision === PolicyDecision.ASK_USER,
    );

    expect(wildcardRule).toBeDefined();
    writeToolRules?.forEach((writeRule) => {
      expect(wildcardRule!.priority).toBeGreaterThan(writeRule.priority!);
    });
    // Should still have the exclude rule (from settings, user tier)
    const excludeRule = config.rules?.find(
      (r) =>
        r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY,
    );
    expect(excludeRule).toBeDefined();
    expect(excludeRule?.priority).toBeCloseTo(4.4, 5); // Command line exclude
  });

  it('should support argsPattern in policy rules', async () => {
    mockPolicyFile(
      nodePath.join(MOCK_DEFAULT_DIR, 'write.toml'),
      `
  [[rule]]
  toolName = "run_shell_command"
  argsPattern = "\\"command\\":\\"git (status|diff|log)\\""
  decision = "allow"
  priority = 150
  `,
    );

    const config = await createPolicyEngineConfig(
      {},
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    const rule = config.rules?.find(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeDefined();
    // Priority 150 in default tier → 1.150
    expect(rule?.priority).toBeCloseTo(1.15, 5);
    expect(rule?.argsPattern).toBeInstanceOf(RegExp);
    expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true);
    expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false);
  });

  it('should load safety_checker configuration from TOML', async () => {
    mockPolicyFile(
      nodePath.join(MOCK_DEFAULT_DIR, 'safety.toml'),
      `
[[rule]]
toolName = "write_file"
decision = "allow"
priority = 10

[[safety_checker]]
toolName = "write_file"
priority = 10
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
`,
    );

    const config = await createPolicyEngineConfig(
      {},
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    expect(
      config.rules?.some(
        (r) =>
          r.toolName === 'write_file' && r.decision === PolicyDecision.ALLOW,
      ),
    ).toBe(true);
    const checker = config.checkers?.find(
      (c) => c.toolName === 'write_file' && c.checker.type === 'in-process',
    );
    expect(checker?.checker.name).toBe(InProcessCheckerType.ALLOWED_PATH);
  });

  it('should reject invalid in-process checker names', async () => {
    mockPolicyFile(
      nodePath.join(MOCK_DEFAULT_DIR, 'invalid_safety.toml'),
      `
[[rule]]
toolName = "write_file"
decision = "allow"
priority = 10

[[safety_checker]]
toolName = "write_file"
priority = 10
[safety_checker.checker]
type = "in-process"
name = "invalid-name"
`,
    );

    const config = await createPolicyEngineConfig(
      {},
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    expect(
      config.rules?.find((r) => r.toolName === 'write_file'),
    ).toBeUndefined();
  });

  it('should support mcpName in policy rules from TOML', async () => {
    mockPolicyFile(
      nodePath.join(MOCK_DEFAULT_DIR, 'mcp.toml'),
      `
  [[rule]]
  toolName = "my-tool"
  mcpName = "my-server"
  decision = "allow"
  priority = 150
  `,
    );

    const config = await createPolicyEngineConfig(
      {},
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );

    const rule = config.rules?.find(
      (r) =>
        r.toolName === 'mcp_my-server_my-tool' &&
        r.mcpName === 'my-server' &&
        r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBeCloseTo(1.15, 5);
  });

  it('should have default ASK_USER rule for discovered tools', async () => {
    const config = await createPolicyEngineConfig({}, ApprovalMode.DEFAULT);
    const discoveredRule = config.rules?.find(
      (r) =>
        r.toolName === 'discovered_tool_*' &&
        r.decision === PolicyDecision.ASK_USER,
    );
    expect(discoveredRule).toBeDefined();
    expect(discoveredRule?.priority).toBeCloseTo(1.01, 5);
  });

  it('should normalize legacy "ShellTool" alias to "run_shell_command"', async () => {
    vi.mocked(
      fs.readdir as (path: PathLike) => Promise<string[]>,
    ).mockResolvedValue([]);
    const config = await createPolicyEngineConfig(
      { tools: { allowed: ['ShellTool'] } },
      ApprovalMode.DEFAULT,
      MOCK_DEFAULT_DIR,
    );
    const rule = config.rules?.find(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.ALLOW,
    );
    expect(rule).toBeDefined();
    expect(rule?.priority).toBeCloseTo(4.3, 5); // Command line allow

    vi.doUnmock('node:fs/promises');
  });

  it('should allow overriding Plan Mode deny with user policy', async () => {
    const userPolicyDir = '/tmp/gemini-cli-test/user/policies';
    vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPolicyDir);

    mockPolicyFile(
      nodePath.join(userPolicyDir, 'user-plan.toml'),
      `
[[rule]]
toolName = "run_shell_command"
commandPrefix = ["git status", "git diff"]
decision = "allow"
priority = 100
modes = ["plan"]

[[rule]]
toolName = "codebase_investigator"
decision = "allow"
priority = 100
modes = ["plan"]
`,
    );

    const config = await createPolicyEngineConfig(
      {},
      ApprovalMode.PLAN,
      nodePath.join(__dirname, 'policies'),
    );

    const shellRules = config.rules?.filter(
      (r) =>
        r.toolName === 'run_shell_command' &&
        r.decision === PolicyDecision.ALLOW &&
        r.modes?.includes(ApprovalMode.PLAN),
    );
    expect(shellRules?.length).toBeGreaterThan(0);
    shellRules?.forEach((r) => expect(r.priority).toBeCloseTo(4.1, 5));

    const subagentRule = config.rules?.find(
      (r) =>
        r.toolName === 'codebase_investigator' &&
        r.decision === PolicyDecision.ALLOW,
    );
    expect(subagentRule).toBeDefined();
    expect(subagentRule?.priority).toBeCloseTo(4.1, 5);
  });

  it('should deduplicate security warnings when called multiple times', async () => {
    const systemPoliciesDir = nodePath.resolve(
      '/tmp/gemini-cli-test/system/policies',
    );
    vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
      systemPoliciesDir,
    );

    vi.mocked(
      fs.readdir as (path: PathLike) => Promise<string[]>,
    ).mockImplementation(async (path) => {
      if (nodePath.resolve(path.toString()) === systemPoliciesDir) {
        return ['policy.toml'] as string[];
      }
      return [] as string[];
    });

    const feedbackSpy = vi
      .spyOn(coreEvents, 'emitFeedback')
      .mockImplementation(() => {});

    // First call
    await createPolicyEngineConfig(
      { adminPolicyPaths: [nodePath.resolve('/tmp/other/admin/policies')] },
      ApprovalMode.DEFAULT,
    );
    expect(feedbackSpy).toHaveBeenCalledWith(
      'warning',
      expect.stringContaining('Ignoring --admin-policy'),
    );
    const count = feedbackSpy.mock.calls.length;

    // Second call
    await createPolicyEngineConfig(
      { adminPolicyPaths: ['/tmp/other/admin/policies'] },
      ApprovalMode.DEFAULT,
    );
    expect(feedbackSpy.mock.calls.length).toBe(count);

    feedbackSpy.mockRestore();
  });
});

describe('getPolicyDirectories', () => {
  const USER_POLICIES_DIR = '/mock/user/policies';
  const SYSTEM_POLICIES_DIR = '/mock/system/policies';

  beforeEach(() => {
    vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(USER_POLICIES_DIR);
    vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
      SYSTEM_POLICIES_DIR,
    );
  });

  it('should include default user policies directory when policyPaths is undefined', () => {
    const dirs = getPolicyDirectories();
    expect(dirs).toContain(USER_POLICIES_DIR);
  });

  it('should include default user policies directory when policyPaths is an empty array', () => {
    // This is the specific case that regressed
    const dirs = getPolicyDirectories(undefined, []);
    expect(dirs).toContain(USER_POLICIES_DIR);
  });

  it('should replace default user policies directory when policyPaths has entries', () => {
    const customPath = '/custom/policies';
    const dirs = getPolicyDirectories(undefined, [customPath]);
    expect(dirs).toContain(customPath);
    expect(dirs).not.toContain(USER_POLICIES_DIR);
  });

  it('should include all tiers in correct order', () => {
    const defaultDir = '/default/policies';
    const workspaceDir = '/workspace/policies';
    const adminPath = '/admin/extra/policies';
    const userPath = '/user/custom/policies';

    const dirs = getPolicyDirectories(defaultDir, [userPath], workspaceDir, [
      adminPath,
    ]);

    // Order should be Admin -> User -> Workspace -> Default
    // getPolicyDirectories returns them in that order (which is then reversed by the loader)
    expect(dirs[0]).toBe(SYSTEM_POLICIES_DIR);
    expect(dirs[1]).toBe(adminPath);
    expect(dirs[2]).toBe(userPath);
    expect(dirs[3]).toBe(workspaceDir);
    expect(dirs[4]).toBe(defaultDir);
  });
});
