/**
 * Permission management for AI coding agents.
 *
 * Provides a canonical permission format (PermissionSet with allow/deny rules)
 * and converters to/from each agent's native format (Claude settings.json,
 * OpenCode opencode.jsonc, Codex config.toml + .rules). Handles discovery,
 * installation, removal, and merging of permission groups stored in
 * ~/.agents/permissions/groups/.
 */
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'yaml';
import * as TOML from 'smol-toml';
import type {
  AgentId,
  PermissionSet,
  InstalledPermission,
  ClaudePermissions,
  OpenCodePermissions,
  CodexPermissions,
} from './types.js';
import { getPermissionsDir, getUserPermissionsDir, ensureAgentsDir } from './state.js';
import { safeJoin } from './paths.js';
import { AGENTS, agentConfigDirName } from './agents.js';
import { updateGeminiSettings } from './gemini-settings.js';

const HOME = os.homedir();

// PERMISSIONS_CAPABLE_AGENTS removed — use `capableAgents('allowlist')`
// from lib/capabilities.ts. The capability matrix on AgentConfig is the
// single source of truth. (Per-agent native format details:
//   antigravity → ~/.gemini/antigravity-cli/settings.json `permissions.{allow,deny}`
//   grok        → ~/.grok/config.toml `[permission].rules`
// the writer in `applyPermissionsToVersion` handles the format dispatch.)

/** Filename used for Codex Starlark deny-rules generated from permission groups. */
export const CODEX_RULES_FILENAME = 'agents-deny.rules';

export type ParsedRules = PermissionSet;

export function containsBroadGrants(rules: ParsedRules): { broad: string[]; reason: string } | null {
  const broad: string[] = [];
  const reasons = new Set<string>();

  for (const perm of rules.allow) {
    if (BLANKET_BASH_FORMS.has(perm)) {
      broad.push(perm);
      reasons.add('allows any bash command and maps Codex to approval_policy=never');
      continue;
    }
    const parsed = parseCanonicalPattern(perm);
    if (!parsed) continue;
    if (parsed.tool === 'bash' && (parsed.pattern === '*' || parsed.pattern === '**')) {
      broad.push(perm);
      reasons.add('allows any bash command and maps Codex to approval_policy=never');
    } else if (parsed.tool === 'bash' && parsed.pattern.startsWith('/') && parsed.pattern.includes('*')) {
      broad.push(perm);
      reasons.add('allows wildcard absolute bash paths');
    } else if ((parsed.tool === 'write' || parsed.tool === 'read') && (parsed.pattern === '*' || parsed.pattern === '**')) {
      broad.push(perm);
      reasons.add(`allows broad ${parsed.tool} filesystem access`);
    }
  }

  for (const dir of rules.additionalDirectories || []) {
    if (dir.startsWith('/') || dir.startsWith('~/') || dir === '~' || dir.split(/[\\/]/).includes('..')) {
      broad.push(`additionalDirectories:${dir}`);
      reasons.add('adds a broad or parent-traversing sandbox directory');
    }
  }

  if (broad.length === 0) return null;
  return { broad, reason: Array.from(reasons).join('; ') };
}

/**
 * Convert canonical deny rules to Codex Starlark .rules format.
 * E.g. "Bash(git reset:*)" -> prefix_rule(pattern=["git", "reset"], decision="forbidden")
 */
export function convertDenyToCodexRules(deny: string[]): string | null {
  const rules: string[] = [];

  for (const perm of deny) {
    const parsed = parseCanonicalPattern(perm);
    if (!parsed || parsed.tool !== 'bash') continue;

    // Pattern format: "command arg1 arg2:*" or "command:*"
    const command = parsed.pattern.replace(/:?\*$/, '').trim();
    if (!command) continue;

    const parts = command.split(/\s+/);
    const patternStr = parts.map(p => JSON.stringify(p)).join(', ');
    rules.push(`prefix_rule(\n    pattern = [${patternStr}],\n    decision = "forbidden",\n)`);
  }

  if (rules.length === 0) return null;

  return `# Auto-generated by agents-cli from deny permission groups.\n# Do not edit manually — re-run "agents use" to regenerate.\n\n${rules.join('\n\n')}\n`;
}

/**
 * Ensure central permissions directory exists.
 */
function ensurePermissionsDir(): void {
  const dir = getUserPermissionsDir();
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
}

/**
 * Parse a permission set from a YAML file.
 */
export function parsePermissionSet(filePath: string): PermissionSet | null {
  if (!fs.existsSync(filePath)) {
    return null;
  }

  try {
    const content = fs.readFileSync(filePath, 'utf-8');
    const parsed = yaml.parse(content);

    if (!parsed || typeof parsed !== 'object') {
      return null;
    }

    return {
      name: parsed.name || path.basename(filePath, path.extname(filePath)),
      description: parsed.description,
      allow: Array.isArray(parsed.allow) ? parsed.allow : [],
      deny: Array.isArray(parsed.deny) ? parsed.deny : [],
      additionalDirectories: Array.isArray(parsed.additionalDirectories) ? parsed.additionalDirectories : undefined,
    };
  } catch {
    return null;
  }
}

/**
 * Discover permission sets from a repository.
 */
export function discoverPermissionsFromRepo(repoPath: string): Array<{ name: string; path: string; set: PermissionSet }> {
  const results: Array<{ name: string; path: string; set: PermissionSet }> = [];

  // Look for permissions in common locations
  const searchPaths = [
    path.join(repoPath, 'permissions'),
    path.join(repoPath, 'agent-permissions'),
    repoPath,
  ];

  for (const searchPath of searchPaths) {
    if (!fs.existsSync(searchPath)) continue;

    try {
      const entries = fs.readdirSync(searchPath, { withFileTypes: true });
      for (const entry of entries) {
        if (!entry.isFile()) continue;
        if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue;

        const filePath = path.join(searchPath, entry.name);
        const set = parsePermissionSet(filePath);
        if (set) {
          results.push({
            name: set.name,
            path: filePath,
            set,
          });
        }
      }
    } catch {
      // Skip inaccessible directories
    }
  }

  return results;
}

/**
 * Permission group info with rule count.
 */
export interface PermissionGroupInfo {
  name: string;        // e.g., "02-node"
  ruleCount: number;   // number of allow rules in this group
  path: string;        // full path to the group file
}

/**
 * Discover permission groups from ~/.agents/permissions/groups/.
 * Returns groups with their rule counts.
 */
export function discoverPermissionGroups(): PermissionGroupInfo[] {
  const seen = new Set<string>();
  const groups: PermissionGroupInfo[] = [];

  // Search user dir first, then system (user wins on name collision)
  for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
    const groupsDir = path.join(baseDir, 'groups');
    if (!fs.existsSync(groupsDir)) continue;

    try {
      const entries = fs.readdirSync(groupsDir, { withFileTypes: true });
      for (const entry of entries) {
        if (!entry.isFile()) continue;
        if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue;

        const name = entry.name.replace(/\.(yaml|yml)$/, '');
        if (seen.has(name)) continue;
        seen.add(name);

        const filePath = path.join(groupsDir, entry.name);
        let ruleCount = 0;
        try {
          const content = fs.readFileSync(filePath, 'utf-8');
          const matches = content.match(/^\s*-\s*"/gm);
          ruleCount = matches ? matches.length : 0;
        } catch { /* Skip files we can't read */ }

        groups.push({ name, ruleCount, path: filePath });
      }
    } catch { /* Skip inaccessible directory */ }
  }

  return groups.sort((a, b) => a.name.localeCompare(b.name));
}

/**
 * Get total rule count across all permission groups.
 */
export function getTotalPermissionRuleCount(): number {
  const groups = discoverPermissionGroups();
  return groups.reduce((sum, g) => sum + g.ruleCount, 0);
}

/**
 * A permission preset recipe — names a preset and lists which groups it composes.
 * Lives at ~/.agents/permissions/presets/<name>.yaml.
 */
export interface PermissionPresetRecipe {
  name: string;
  description?: string;
  includes: string[];
}

/** Env var that selects which set recipe to apply at sync time. */
export const PERMISSION_PRESET_ENV_VAR = 'AGENTS_PERMISSION_PRESET';

/**
 * Read a permission preset recipe by name from ~/.agents/permissions/presets/.
 * Returns null if the recipe file is missing or malformed.
 */
export function readPermissionPresetRecipe(name: string): PermissionPresetRecipe | null {
  const presetsDir = path.join(getPermissionsDir(), 'presets');
  for (const ext of ['.yaml', '.yml']) {
    const filePath = safeJoin(presetsDir, name + ext);
    if (!fs.existsSync(filePath)) continue;
    try {
      const content = fs.readFileSync(filePath, 'utf-8');
      const parsed = yaml.parse(content);
      if (!parsed || typeof parsed !== 'object') return null;
      if (!Array.isArray(parsed.includes)) return null;
      return {
        name: parsed.name || name,
        description: parsed.description,
        includes: parsed.includes.filter((v: unknown): v is string => typeof v === 'string'),
      };
    } catch {
      return null;
    }
  }
  return null;
}

/**
 * Return the active permission preset name from AGENTS_PERMISSION_PRESET env var,
 * or null if unset. Caller decides the default behavior when null.
 */
export function getActivePermissionPresetName(): string | null {
  const v = process.env[PERMISSION_PRESET_ENV_VAR];
  return v && v.trim() ? v.trim() : null;
}

/**
 * Build a PermissionSet from selected groups.
 * Concatenates allow/deny rules from each group.
 *
 * Uses line-by-line regex extraction instead of YAML parsing because
 * permission files often contain unescaped nested quotes that break YAML.
 */
export function buildPermissionsFromGroups(groupNames: string[]): PermissionSet {
  const allAllow: string[] = [];
  const allDeny: string[] = [];

  for (const groupName of groupNames) {
    // Search user dir first, then system dir
    let filePath: string | null = null;
    for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
      const groupsDir = path.join(baseDir, 'groups');
      for (const ext of ['.yaml', '.yml']) {
        const candidate = safeJoin(groupsDir, `${groupName}${ext}`);
        if (fs.existsSync(candidate)) { filePath = candidate; break; }
      }
      if (filePath) break;
    }
    if (!filePath) continue;

    try {
      const content = fs.readFileSync(filePath, 'utf-8');

      // Extract rules using line-by-line regex (more robust than YAML parsing)
      // Matches lines like: - "Bash(git *)" or   - "WebFetch(domain:example.com)"
      // Handles nested quotes that break YAML parsers
      const lines = content.split('\n');
      for (const line of lines) {
        // Match: optional whitespace, dash, whitespace, quote, content, quote
        // Use greedy match to capture everything between first and last quote
        const match = line.match(/^\s*-\s*"(.+)"$/);
        if (match) {
          const rule = match[1];
          // 99-deny group rules go to deny, others to allow
          if (groupName === '99-deny' || groupName.includes('-deny')) {
            allDeny.push(rule);
          } else {
            allAllow.push(rule);
          }
        }
      }
    } catch {
      // Skip files we can't read
    }
  }

  return {
    name: 'built',
    description: `Built from groups: ${groupNames.join(', ')}`,
    allow: allAllow,
    deny: allDeny.length > 0 ? allDeny : undefined,
  };
}

/**
 * List installed permission sets from central storage.
 * User dir takes precedence; system entries are surfaced when user has no
 * same-named override.
 */
export function listInstalledPermissions(): InstalledPermission[] {
  ensureAgentsDir();
  const seen = new Set<string>();
  const results: InstalledPermission[] = [];

  for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
    if (!fs.existsSync(dir)) continue;
    try {
      const entries = fs.readdirSync(dir, { withFileTypes: true });
      for (const entry of entries) {
        if (!entry.isFile()) continue;
        if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue;

        const filePath = path.join(dir, entry.name);
        const set = parsePermissionSet(filePath);
        if (!set) continue;
        if (seen.has(set.name)) continue;
        seen.add(set.name);
        results.push({ name: set.name, path: filePath, set });
      }
    } catch {
      // Skip inaccessible directory
    }
  }

  return results;
}

/**
 * Get a specific permission set by name. Searches user dir first, then system.
 */
function getPermissionSet(name: string): InstalledPermission | null {
  for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
    for (const ext of ['.yml', '.yaml']) {
      const filePath = safeJoin(dir, name + ext);
      if (fs.existsSync(filePath)) {
        const set = parsePermissionSet(filePath);
        if (set) {
          return { name: set.name, path: filePath, set };
        }
      }
    }
  }

  return null;
}

/**
 * Install a permission set to user-level central storage.
 */
export function installPermissionSet(
  sourcePath: string,
  name: string
): { success: boolean; error?: string } {
  ensurePermissionsDir();

  const set = parsePermissionSet(sourcePath);
  if (!set) {
    return { success: false, error: 'Invalid permission file' };
  }

  const targetPath = safeJoin(getUserPermissionsDir(), name + '.yml');

  try {
    fs.copyFileSync(sourcePath, targetPath);
    return { success: true };
  } catch (err) {
    return { success: false, error: (err as Error).message };
  }
}

/**
 * Remove a permission set from user-level central storage. System-shipped
 * sets are intentionally not deletable from user commands.
 */
export function removePermissionSet(name: string): { success: boolean; error?: string } {
  const dir = getUserPermissionsDir();

  for (const ext of ['.yml', '.yaml']) {
    const filePath = safeJoin(dir, name + ext);
    if (fs.existsSync(filePath)) {
      try {
        fs.unlinkSync(filePath);
        return { success: true };
      } catch (err) {
        return { success: false, error: (err as Error).message };
      }
    }
  }

  return { success: false, error: `Permission set '${name}' not found` };
}

// ============================================================================
// Agent-specific converters
// ============================================================================

/**
 * Convert canonical permission set to Claude format.
 * Claude uses: { permissions: { allow: ["Bash(*)", "Read(**)"], deny: [] } }
 */
export function convertToClaudeFormat(set: PermissionSet): ClaudePermissions {
  const permissions: ClaudePermissions['permissions'] = {
    allow: [...set.allow],
    deny: set.deny ? [...set.deny] : [],
  };
  if (set.additionalDirectories?.length) {
    permissions.additionalDirectories = [...set.additionalDirectories];
  }
  return { permissions };
}

/**
 * Parse canonical permission pattern to extract tool and pattern.
 * "Bash(git *)" -> { tool: "bash", pattern: "git *" }
 * "Read(**)" -> { tool: "read", pattern: "**" }
 */
function parseCanonicalPattern(permission: string): { tool: string; pattern: string } | null {
  const match = permission.match(/^(\w+)\((.+)\)$/);
  if (!match) return null;
  return { tool: match[1].toLowerCase(), pattern: match[2] };
}

/** Blanket-Bash canonical forms that mean "allow any bash command". */
const BLANKET_BASH_FORMS = new Set(['Bash', 'Bash(*)', 'Bash(**)']);

/**
 * Convert canonical permission set to Gemini format.
 * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
 * Bash permissions map to run_shell_command(prefix) — the prefix is extracted
 * from the canonical "Bash(cmd:*)" pattern by stripping the trailing ":*".
 * Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
 * Non-Bash tool patterns are skipped; Gemini uses different tool names.
 */
export function convertToGeminiFormat(set: PermissionSet): { tools: { allowed: string[] } } {
  const allowed = new Set<string>();
  for (const perm of set.allow) {
    if (BLANKET_BASH_FORMS.has(perm)) {
      allowed.add('run_shell_command');
      continue;
    }
    const parsed = parseCanonicalPattern(perm);
    if (!parsed || parsed.tool !== 'bash') continue;
    const colonIdx = parsed.pattern.lastIndexOf(':');
    const prefix = colonIdx > 0 ? parsed.pattern.slice(0, colonIdx) : parsed.pattern;
    if (prefix === '*' || prefix === '**') {
      allowed.add('run_shell_command');
    } else {
      allowed.add(`run_shell_command(${prefix})`);
    }
  }
  return { tools: { allowed: Array.from(allowed) } };
}

/**
 * Strip Claude's `:*` subcommand-wildcard suffix and return a space-glob form.
 * "mq:*" -> "mq *", "git status" -> "git status", "*" -> "*".
 * Used by serializers whose native pattern grammar uses ` *` instead of `:*`.
 */
function normalizeBashPattern(pattern: string): string {
  if (pattern === '*' || pattern === '**') return '*';
  if (pattern.endsWith(':*')) return pattern.slice(0, -2) + ' *';
  return pattern;
}

/**
 * Convert canonical permission set to Antigravity format.
 * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
 *   { permissions: { allow: [...], deny: [...] } }
 * where each entry is action-namespaced: command(...), read_file(...),
 * write_file(...), read_url(...), mcp(...).
 * Bash maps to `command`, Read to `read_file`, Write to `write_file`,
 * WebFetch to `read_url`. Other canonical tools are skipped.
 * Note: Antigravity matches `command(npm install)` as an exact string,
 * not a prefix — `Bash(npm:*)` becomes `command(npm *)` which is a glob
 * but Antigravity upstream has a known exact-match bug for some forms.
 */
export function convertToAntigravityFormat(set: PermissionSet): { permissions: { allow: string[]; deny?: string[] } } {
  const allow = serializeAntigravityEntries(set.allow);
  const deny = set.deny ? serializeAntigravityEntries(set.deny) : [];
  return {
    permissions: {
      allow,
      ...(deny.length ? { deny } : {}),
    },
  };
}

function serializeAntigravityEntries(perms: string[]): string[] {
  const out = new Set<string>();
  for (const perm of perms) {
    if (BLANKET_BASH_FORMS.has(perm)) {
      out.add('command(*)');
      continue;
    }
    const parsed = parseCanonicalPattern(perm);
    if (!parsed) continue;
    const action = ANTIGRAVITY_ACTION_BY_TOOL[parsed.tool];
    if (!action) continue;
    if (parsed.tool === 'bash') {
      out.add(`${action}(${normalizeBashPattern(parsed.pattern)})`);
    } else {
      const p = parsed.pattern === '**' ? '*' : parsed.pattern;
      out.add(`${action}(${p})`);
    }
  }
  return Array.from(out);
}

const ANTIGRAVITY_ACTION_BY_TOOL: Record<string, string | undefined> = {
  bash: 'command',
  read: 'read_file',
  write: 'write_file',
  webfetch: 'read_url',
};

/**
 * Convert canonical permission set to Grok format.
 * Grok reads ~/.grok/config.toml with
 *   [permission]
 *   rules = [ { action = "allow", tool = "bash", pattern = "git *" }, ... ]
 * Tool names are lowercase: bash, read, edit, grep, mcptool, webfetch.
 * Canonical Write maps to Grok's `edit` tool.
 */
export function convertToGrokFormat(set: PermissionSet): { permission: { rules: GrokRule[] } } {
  const rules: GrokRule[] = [];
  for (const perm of set.allow) {
    const rule = canonicalToGrokRule(perm, 'allow');
    if (rule) rules.push(rule);
  }
  if (set.deny) {
    for (const perm of set.deny) {
      const rule = canonicalToGrokRule(perm, 'deny');
      if (rule) rules.push(rule);
    }
  }
  return { permission: { rules } };
}

export type GrokRule = { action: 'allow' | 'deny'; tool: string; pattern?: string };

const GROK_TOOL_BY_CANONICAL: Record<string, string | undefined> = {
  bash: 'bash',
  read: 'read',
  write: 'edit',
  grep: 'grep',
  webfetch: 'webfetch',
};

function canonicalToGrokRule(perm: string, action: 'allow' | 'deny'): GrokRule | null {
  if (BLANKET_BASH_FORMS.has(perm)) {
    return { action, tool: 'bash', pattern: '*' };
  }
  const parsed = parseCanonicalPattern(perm);
  if (!parsed) return null;
  const tool = GROK_TOOL_BY_CANONICAL[parsed.tool];
  if (!tool) return null;
  const pattern = parsed.tool === 'bash' ? normalizeBashPattern(parsed.pattern) : parsed.pattern;
  if (pattern === '' || pattern === undefined) {
    return { action, tool };
  }
  return { action, tool, pattern };
}

export type KimiRule = { decision: 'allow' | 'deny'; pattern: string };

/**
 * Parse a canonical permission string preserving the tool's original casing.
 * `parseCanonicalPattern` lowercases the tool name, which is fine for Grok
 * (lowercase tool vocabulary) but wrong for Kimi, whose tool names are
 * capitalized (`Bash`, `Read`, `Grep`). Bare tool names (no parens, e.g.
 * `Read` or an MCP id like `mcp__server__tool`) return `pattern: null`.
 */
function parseCanonicalPreserveCase(perm: string): { tool: string; pattern: string | null } {
  const m = perm.match(/^([\w-]+)\((.*)\)$/);
  if (m) return { tool: m[1], pattern: m[2] };
  return { tool: perm, pattern: null };
}

/**
 * Translate a canonical Bash arg-glob (`cmd:*`) into the Kimi pattern(s) that
 * actually match that command's invocations.
 *
 * Kimi matches Bash arg-globs with picomatch, where `*` does NOT cross `/` and a
 * `**` only globstars when it is its own path segment (`*​/**`, `**​/`). A plain
 * `cmd*` therefore matches `git status -s` but NOT `git push origin feat/x` or
 * `cat dir/file` — any argument containing a slash falls through to a prompt
 * (verified interactively against kimi 0.12.1). We emit TWO patterns so the
 * command auto-approves whether its args contain a slash or not:
 *   - `cmd*`     — no-slash args (and the bare command; `*` is zero-or-more).
 *   - `cmd*​/**` — args with a path: `*` consumes up to the first `/`, then the
 *                 bounded globstar crosses the remaining slashes.
 * "git push:*" -> ["git push*", "git push*​/**"].
 */
function kimiBashPatterns(pattern: string): string[] {
  if (pattern === '*' || pattern === '**') return ['*'];
  if (pattern.endsWith(':*')) {
    const prefix = pattern.slice(0, -2);
    return [`${prefix}*`, `${prefix}*/**`];
  }
  // Exact command (no `:*`, e.g. `env`, `pwd`, `true`) — no path args expected.
  return [pattern];
}

function canonicalToKimiRules(perm: string, decision: 'allow' | 'deny'): KimiRule[] {
  if (BLANKET_BASH_FORMS.has(perm)) {
    return [{ decision, pattern: 'Bash' }];
  }
  const { tool, pattern } = parseCanonicalPreserveCase(perm);
  // Bare tool name (no parens) — name-only match. Covers `Read`, `Grep`, and
  // MCP tool ids, which Kimi can only match by name anyway.
  if (pattern === null) {
    return [{ decision, pattern: tool }];
  }
  if (tool.toLowerCase() === 'bash') {
    return kimiBashPatterns(pattern).map((p) => ({
      decision,
      pattern: p === '*' ? 'Bash' : `Bash(${p})`,
    }));
  }
  // Non-Bash built-ins (Read/Write/Edit/Grep/Glob/WebFetch...) share Kimi's
  // capitalized tool vocabulary, so pass the tool+pattern through. A `**`/`*`
  // glob means "any" — collapse to a name-only rule.
  if (pattern === '*' || pattern === '**') {
    return [{ decision, pattern: tool }];
  }
  return [{ decision, pattern: `${tool}(${pattern})` }];
}

/**
 * Convert a canonical permission set to Kimi Code's `[permission].rules` format.
 * Kimi (`~/.kimi-code/config.toml`) reads rules of the form
 *   [[permission.rules]]
 *   decision = "allow"
 *   pattern  = "Bash(git status*)"
 * Tool names are capitalized and the Bash arg-glob uses a trailing `*` (no
 * Claude `:*` separator). Without this conversion the canonical strings match
 * nothing in Kimi's engine and every tool call falls through to a prompt.
 *
 * Each `:*` Bash rule expands to TWO patterns (`cmd*` and `cmd*​/**`) so the
 * command auto-approves whether or not its arguments contain a slash — see
 * `kimiBashPatterns` for why Kimi's picomatch matcher needs both.
 */
export function convertToKimiFormat(set: PermissionSet): { permission: { rules: KimiRule[] } } {
  const rules: KimiRule[] = [];
  for (const perm of set.allow) {
    rules.push(...canonicalToKimiRules(perm, 'allow'));
  }
  if (set.deny) {
    for (const perm of set.deny) {
      rules.push(...canonicalToKimiRules(perm, 'deny'));
    }
  }
  return { permission: { rules } };
}

/**
 * Convert canonical permission set to OpenCode format.
 * OpenCode uses: { permission: { bash: { "git *": "allow", "rm *": "deny" } } }
 */
export function convertToOpenCodeFormat(set: PermissionSet): OpenCodePermissions {
  const bashPermissions: Record<string, 'allow' | 'deny' | 'ask'> = {};

  // Process allow list
  for (const perm of set.allow) {
    if (BLANKET_BASH_FORMS.has(perm)) {
      // Bare "Bash" has no parens so parseCanonicalPattern returns null;
      // normalize all three blanket forms to "*".
      bashPermissions['*'] = 'allow';
      continue;
    }
    const parsed = parseCanonicalPattern(perm);
    if (parsed && parsed.tool === 'bash') {
      bashPermissions[parsed.pattern] = 'allow';
    }
  }

  // Process deny list
  if (set.deny) {
    for (const perm of set.deny) {
      const parsed = parseCanonicalPattern(perm);
      if (parsed && parsed.tool === 'bash') {
        bashPermissions[parsed.pattern] = 'deny';
      }
    }
  }

  return {
    permission: {
      bash: bashPermissions,
    },
  };
}

/**
 * Convert canonical permission set to Codex format.
 * Codex uses coarse-grained modes, so we infer the best fit.
 */
export function convertToCodexFormat(set: PermissionSet, cwd?: string): CodexPermissions {
  const result: CodexPermissions = {};

  // Check for broad bash permissions -> suggest full-auto.
  // Treat the bare blanket form "Bash" the same as "Bash(*)" / "Bash(**)";
  // parseCanonicalPattern requires parens so "Bash" alone wouldn't match
  // otherwise — the difference determines whether a pod runs unattended
  // (approval_policy: 'never') or stalls on interactive approvals.
  const hasBroadBash = set.allow.some((p) => {
    if (BLANKET_BASH_FORMS.has(p)) return true;
    const parsed = parseCanonicalPattern(p);
    return parsed !== null && parsed.tool === 'bash' && (parsed.pattern === '*' || parsed.pattern === '**');
  });

  if (hasBroadBash) {
    result.approval_policy = 'never';
    result.sandbox_mode = 'workspace-write';
  } else if (set.allow.length > 0) {
    result.approval_policy = 'on-request';
    result.sandbox_mode = 'workspace-write';
  }

  // Check for network/web permissions
  const hasNetwork = set.allow.some((p) => {
    const parsed = parseCanonicalPattern(p);
    return parsed && (parsed.tool === 'websearch' || parsed.tool === 'webfetch');
  });

  if (hasNetwork) {
    result.sandbox_workspace_write = {
      network_access: true,
    };
  }

  return result;
}

// ============================================================================
// Read agent permissions from native configs
// ============================================================================

/**
 * Strip JSON comments for JSONC parsing.
 */
function stripJsonComments(content: string): string {
  let result = '';
  let inString = false;
  let escape = false;
  let i = 0;

  while (i < content.length) {
    const char = content[i];
    const next = content[i + 1];

    if (escape) {
      result += char;
      escape = false;
      i++;
      continue;
    }

    if (char === '\\' && inString) {
      result += char;
      escape = true;
      i++;
      continue;
    }

    if (char === '"') {
      inString = !inString;
      result += char;
      i++;
      continue;
    }

    if (!inString) {
      if (char === '/' && next === '/') {
        while (i < content.length && content[i] !== '\n') {
          i++;
        }
        continue;
      }
      if (char === '/' && next === '*') {
        i += 2;
        while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
          i++;
        }
        i += 2;
        continue;
      }
    }

    result += char;
    i++;
  }

  return result;
}

/**
 * Read Claude's current permissions from settings.json.
 */
function readClaudePermissions(
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  options?: { home?: string }
): ClaudePermissions | null {
  const home = options?.home || HOME;
  const configPath = scope === 'user'
    ? path.join(home, '.claude', 'settings.json')
    : path.join(cwd || process.cwd(), '.claude', 'settings.json');

  if (!fs.existsSync(configPath)) {
    return null;
  }

  try {
    const content = fs.readFileSync(configPath, 'utf-8');
    const config = JSON.parse(content);
    if (config.permissions) {
      return {
        permissions: {
          allow: config.permissions.allow || [],
          deny: config.permissions.deny || [],
        },
      };
    }
    return null;
  } catch {
    return null;
  }
}

/**
 * Read OpenCode's current permissions from opencode.jsonc.
 */
function readOpenCodePermissions(
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  options?: { home?: string }
): OpenCodePermissions | null {
  const home = options?.home || HOME;
  const configPath = scope === 'user'
    ? path.join(home, '.opencode', 'opencode.jsonc')
    : path.join(cwd || process.cwd(), '.opencode', 'opencode.jsonc');

  if (!fs.existsSync(configPath)) {
    return null;
  }

  try {
    const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
    const config = JSON.parse(content);
    if (config.permission) {
      return {
        permission: {
          bash: config.permission.bash || {},
        },
      };
    }
    return null;
  } catch {
    return null;
  }
}

/**
 * Read Codex's current permissions from config.toml.
 */
function readCodexPermissions(
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  options?: { home?: string }
): CodexPermissions | null {
  const home = options?.home || HOME;
  const configPath = scope === 'user'
    ? path.join(home, '.codex', 'config.toml')
    : path.join(cwd || process.cwd(), '.codex', 'config.toml');

  if (!fs.existsSync(configPath)) {
    return null;
  }

  try {
    const content = fs.readFileSync(configPath, 'utf-8');
    const config = TOML.parse(content) as Record<string, unknown>;

    const result: CodexPermissions = {};

    if (config.approval_policy) {
      result.approval_policy = config.approval_policy as CodexPermissions['approval_policy'];
    }
    if (config.sandbox_mode) {
      result.sandbox_mode = config.sandbox_mode as CodexPermissions['sandbox_mode'];
    }
    if (config.sandbox_workspace_write) {
      const sw = config.sandbox_workspace_write as Record<string, unknown>;
      result.sandbox_workspace_write = {
        network_access: sw.network_access as boolean | undefined,
        writable_roots: sw.writable_roots as string[] | undefined,
      };
    }

    return result;
  } catch {
    return null;
  }
}

/**
 * Read agent permissions based on agent ID.
 */
export function readAgentPermissions(
  agentId: AgentId,
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  options?: { home?: string }
): ClaudePermissions | OpenCodePermissions | CodexPermissions | null {
  switch (agentId) {
    case 'claude':
      return readClaudePermissions(scope, cwd, options);
    case 'opencode':
      return readOpenCodePermissions(scope, cwd, options);
    case 'codex':
      return readCodexPermissions(scope, cwd, options);
    default:
      return null;
  }
}

// ============================================================================
// Apply permissions to agents
// ============================================================================

/**
 * Apply a permission set to Claude's settings.json.
 */
export function applyClaudePermissions(
  set: PermissionSet,
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  merge: boolean = true
): { success: boolean; error?: string } {
  const configDir = scope === 'user'
    ? path.join(HOME, '.claude')
    : path.join(cwd || process.cwd(), '.claude');
  const configPath = path.join(configDir, 'settings.json');

  try {
    // Ensure directory exists
    if (!fs.existsSync(configDir)) {
      fs.mkdirSync(configDir, { recursive: true });
    }

    // Read existing config
    let config: Record<string, unknown> = {};
    if (fs.existsSync(configPath)) {
      config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
    }

    const newPermissions = convertToClaudeFormat(set);

    if (merge && config.permissions) {
      const existing = config.permissions as { allow?: string[]; deny?: string[] };
      const mergedAllow = new Set([...(existing.allow || []), ...newPermissions.permissions.allow]);
      const mergedDeny = new Set([...(existing.deny || []), ...newPermissions.permissions.deny]);
      config.permissions = {
        allow: [...mergedAllow],
        deny: [...mergedDeny],
      };
    } else {
      config.permissions = newPermissions.permissions;
    }

    fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
    return { success: true };
  } catch (err) {
    return { success: false, error: (err as Error).message };
  }
}

/**
 * Apply a permission set to OpenCode's opencode.jsonc.
 */
function applyOpenCodePermissions(
  set: PermissionSet,
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  merge: boolean = true
): { success: boolean; error?: string } {
  const configDir = scope === 'user'
    ? path.join(HOME, '.opencode')
    : path.join(cwd || process.cwd(), '.opencode');
  const configPath = path.join(configDir, 'opencode.jsonc');

  try {
    // Ensure directory exists
    if (!fs.existsSync(configDir)) {
      fs.mkdirSync(configDir, { recursive: true });
    }

    // Read existing config
    let config: Record<string, unknown> = {};
    if (fs.existsSync(configPath)) {
      const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
      config = JSON.parse(content);
    }

    const newPermissions = convertToOpenCodeFormat(set);

    if (merge && config.permission) {
      const existing = config.permission as { bash?: Record<string, string> };
      config.permission = {
        ...existing,
        bash: {
          ...(existing.bash || {}),
          ...newPermissions.permission.bash,
        },
      };
    } else {
      config.permission = newPermissions.permission;
    }

    // Write without comments (they'll be lost)
    fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
    return { success: true };
  } catch (err) {
    return { success: false, error: (err as Error).message };
  }
}

/**
 * Apply a permission set to Codex's config.toml.
 */
function applyCodexPermissions(
  set: PermissionSet,
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  merge: boolean = true
): { success: boolean; error?: string } {
  const configDir = scope === 'user'
    ? path.join(HOME, '.codex')
    : path.join(cwd || process.cwd(), '.codex');
  const configPath = path.join(configDir, 'config.toml');

  try {
    // Ensure directory exists
    if (!fs.existsSync(configDir)) {
      fs.mkdirSync(configDir, { recursive: true });
    }

    // Read existing config
    let config: Record<string, unknown> = {};
    if (fs.existsSync(configPath)) {
      const content = fs.readFileSync(configPath, 'utf-8');
      config = TOML.parse(content) as Record<string, unknown>;
    }

    const newPermissions = convertToCodexFormat(set, cwd);

    // Merge or replace
    if (newPermissions.approval_policy) {
      config.approval_policy = newPermissions.approval_policy;
    }
    if (newPermissions.sandbox_mode) {
      config.sandbox_mode = newPermissions.sandbox_mode;
    }
    if (newPermissions.sandbox_workspace_write) {
      const existing = config.sandbox_workspace_write as Record<string, unknown> | undefined;
      config.sandbox_workspace_write = merge
        ? { ...existing, ...newPermissions.sandbox_workspace_write }
        : newPermissions.sandbox_workspace_write;
    }

    fs.writeFileSync(configPath, TOML.stringify(config as any), 'utf-8');

    // Write .rules file for deny permissions
    if (set.deny && set.deny.length > 0) {
      const rulesContent = convertDenyToCodexRules(set.deny);
      if (rulesContent) {
        const rulesDir = path.join(configDir, 'rules');
        fs.mkdirSync(rulesDir, { recursive: true });
        fs.writeFileSync(path.join(rulesDir, CODEX_RULES_FILENAME), rulesContent, 'utf-8');
      }
    }

    return { success: true };
  } catch (err) {
    return { success: false, error: (err as Error).message };
  }
}

/**
 * Apply a permission set to an agent (global config).
 */
function applyPermissionsToAgent(
  agentId: AgentId,
  set: PermissionSet,
  scope: 'user' | 'project' = 'user',
  cwd?: string,
  merge: boolean = true
): { success: boolean; error?: string } {
  switch (agentId) {
    case 'claude':
      return applyClaudePermissions(set, scope, cwd, merge);
    case 'opencode':
      return applyOpenCodePermissions(set, scope, cwd, merge);
    case 'codex':
      return applyCodexPermissions(set, scope, cwd, merge);
    default:
      return { success: false, error: `Agent '${agentId}' does not support permissions` };
  }
}

/**
 * Apply a permission set to a specific version's home directory.
 * This writes to {versionHome}/.{agent}/settings.json (or equivalent).
 */
export function applyPermissionsToVersion(
  agentId: AgentId,
  set: PermissionSet,
  versionHome: string,
  merge: boolean = true
): { success: boolean; error?: string } {
  const configDir = path.join(versionHome, agentConfigDirName(agentId));

  try {
    fs.mkdirSync(configDir, { recursive: true });

    if (agentId === 'claude') {
      const configPath = path.join(configDir, 'settings.json');
      let config: Record<string, unknown> = {};
      if (fs.existsSync(configPath)) {
        config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
      }

      const newPermissions = convertToClaudeFormat(set);

      if (merge && config.permissions) {
        const existing = config.permissions as { allow?: string[]; deny?: string[]; additionalDirectories?: string[] };
        const mergedAllow = new Set([...(existing.allow || []), ...newPermissions.permissions.allow]);
        const mergedDeny = new Set([...(existing.deny || []), ...newPermissions.permissions.deny]);
        const mergedDirs = new Set([...(existing.additionalDirectories || []), ...(newPermissions.permissions.additionalDirectories || [])]);
        const perms: Record<string, unknown> = {
          allow: [...mergedAllow],
          deny: [...mergedDeny],
        };
        if (mergedDirs.size > 0) {
          perms.additionalDirectories = [...mergedDirs];
        }
        config.permissions = perms;
      } else {
        config.permissions = newPermissions.permissions;
      }

      fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
      return { success: true };
    }

    if (agentId === 'opencode') {
      const configPath = path.join(configDir, 'opencode.jsonc');
      let config: Record<string, unknown> = {};
      if (fs.existsSync(configPath)) {
        const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
        config = JSON.parse(content);
      }

      const newPermissions = convertToOpenCodeFormat(set);

      if (merge && config.permission) {
        const existing = config.permission as { bash?: Record<string, string> };
        config.permission = {
          ...existing,
          bash: {
            ...(existing.bash || {}),
            ...newPermissions.permission.bash,
          },
        };
      } else {
        config.permission = newPermissions.permission;
      }

      fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
      return { success: true };
    }

    if (agentId === 'codex') {
      const configPath = path.join(configDir, 'config.toml');
      let config: Record<string, unknown> = {};
      if (fs.existsSync(configPath)) {
        const content = fs.readFileSync(configPath, 'utf-8');
        config = TOML.parse(content) as Record<string, unknown>;
      }

      const newPermissions = convertToCodexFormat(set);

      if (newPermissions.approval_policy) {
        config.approval_policy = newPermissions.approval_policy;
      }
      if (newPermissions.sandbox_mode) {
        config.sandbox_mode = newPermissions.sandbox_mode;
      }
      if (newPermissions.sandbox_workspace_write) {
        const existing = config.sandbox_workspace_write as Record<string, unknown> | undefined;
        config.sandbox_workspace_write = merge
          ? { ...existing, ...newPermissions.sandbox_workspace_write }
          : newPermissions.sandbox_workspace_write;
      }

      fs.writeFileSync(configPath, TOML.stringify(config as any), 'utf-8');

      // Write .rules file for deny permissions
      if (set.deny && set.deny.length > 0) {
        const rulesContent = convertDenyToCodexRules(set.deny);
        if (rulesContent) {
          const rulesDir = path.join(configDir, 'rules');
          fs.mkdirSync(rulesDir, { recursive: true });
          fs.writeFileSync(path.join(rulesDir, CODEX_RULES_FILENAME), rulesContent, 'utf-8');
        }
      }

      return { success: true };
    }

    if (agentId === 'gemini') {
      const geminiPerms = convertToGeminiFormat(set);
      const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
      updateGeminiSettings(settingsPath, (settings) => {
        // Remove stale permissions key written by earlier versions of this serializer.
        delete (settings as Record<string, unknown>).permissions;
        const tools = (typeof settings.tools === 'object' && settings.tools !== null && !Array.isArray(settings.tools))
          ? settings.tools as Record<string, unknown>
          : {};
        if (merge) {
          const existing = Array.isArray(tools.allowed) ? (tools.allowed as string[]) : [];
          tools.allowed = Array.from(new Set([...existing, ...geminiPerms.tools.allowed]));
        } else {
          tools.allowed = geminiPerms.tools.allowed;
        }
        settings.tools = tools;
      });
      return { success: true };
    }

    if (agentId === 'antigravity') {
      const antigravityPerms = convertToAntigravityFormat(set);
      const settingsPath = path.join(versionHome, '.gemini', 'antigravity-cli', 'settings.json');
      updateGeminiSettings(settingsPath, (settings) => {
        const perms = (typeof settings.permissions === 'object' && settings.permissions !== null && !Array.isArray(settings.permissions))
          ? settings.permissions as Record<string, unknown>
          : {};
        if (merge) {
          const existingAllow = Array.isArray(perms.allow) ? (perms.allow as string[]) : [];
          const existingDeny = Array.isArray(perms.deny) ? (perms.deny as string[]) : [];
          perms.allow = Array.from(new Set([...existingAllow, ...antigravityPerms.permissions.allow]));
          const mergedDeny = Array.from(new Set([...existingDeny, ...(antigravityPerms.permissions.deny ?? [])]));
          if (mergedDeny.length) perms.deny = mergedDeny;
          else delete perms.deny;
        } else {
          perms.allow = antigravityPerms.permissions.allow;
          if (antigravityPerms.permissions.deny?.length) perms.deny = antigravityPerms.permissions.deny;
          else delete perms.deny;
        }
        settings.permissions = perms;
      });
      return { success: true };
    }

    if (agentId === 'grok') {
      const grokPerms = convertToGrokFormat(set);
      const configPath = path.join(versionHome, '.grok', 'config.toml');
      let config: Record<string, unknown> = {};
      if (fs.existsSync(configPath)) {
        config = TOML.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
      }
      const existingPermission = (typeof config.permission === 'object' && config.permission !== null && !Array.isArray(config.permission))
        ? config.permission as Record<string, unknown>
        : {};
      if (merge) {
        const existingRules = Array.isArray(existingPermission.rules) ? (existingPermission.rules as GrokRule[]) : [];
        const seen = new Set<string>();
        const dedup: GrokRule[] = [];
        for (const r of [...existingRules, ...grokPerms.permission.rules]) {
          const key = `${r.action}|${r.tool}|${r.pattern ?? ''}`;
          if (seen.has(key)) continue;
          seen.add(key);
          dedup.push(r);
        }
        existingPermission.rules = dedup;
      } else {
        existingPermission.rules = grokPerms.permission.rules;
      }
      config.permission = existingPermission;
      fs.mkdirSync(path.dirname(configPath), { recursive: true });
      fs.writeFileSync(configPath, TOML.stringify(config as any), 'utf-8');
      return { success: true };
    }

    if (agentId === 'kimi') {
      const configPath = path.join(versionHome, '.kimi-code', 'config.toml');
      let config: Record<string, unknown> = {};
      if (fs.existsSync(configPath)) {
        config = TOML.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
      }

      const newRules: Array<{ decision: string; pattern: string }> = convertToKimiFormat(set).permission.rules;

      if (merge) {
        const existingPermission = (typeof config.permission === 'object' && config.permission !== null && !Array.isArray(config.permission))
          ? config.permission as Record<string, unknown>
          : {};
        const existingRules = Array.isArray(existingPermission.rules)
          ? (existingPermission.rules as Array<{ decision?: string; pattern?: string }>)
          : [];
        const seen = new Set<string>();
        const dedup: Array<{ decision: string; pattern: string }> = [];
        for (const r of [...existingRules, ...newRules]) {
          if (!r.decision || !r.pattern) continue;
          const key = `${r.decision}|${r.pattern}`;
          if (seen.has(key)) continue;
          seen.add(key);
          dedup.push({ decision: r.decision, pattern: r.pattern });
        }
        config.permission = { rules: dedup };
      } else {
        config.permission = { rules: newRules };
      }

      fs.mkdirSync(path.dirname(configPath), { recursive: true });
      fs.writeFileSync(configPath, TOML.stringify(config as any), 'utf-8');
      return { success: true };
    }

    return { success: false, error: `Agent '${agentId}' does not support permissions` };
  } catch (err) {
    return { success: false, error: (err as Error).message };
  }
}

// ============================================================================
// Export canonical format from agent
// ============================================================================

/**
 * Convert Claude permissions back to canonical format.
 */
export function claudeToCanonical(perms: ClaudePermissions): PermissionSet {
  const result: PermissionSet = {
    name: 'exported',
    allow: perms.permissions.allow,
    deny: perms.permissions.deny.length > 0 ? perms.permissions.deny : undefined,
  };
  if (perms.permissions.additionalDirectories?.length) {
    result.additionalDirectories = perms.permissions.additionalDirectories;
  }
  return result;
}

/**
 * Convert OpenCode permissions back to canonical format.
 */
export function openCodeToCanonical(perms: OpenCodePermissions): PermissionSet {
  const allow: string[] = [];
  const deny: string[] = [];

  for (const [pattern, action] of Object.entries(perms.permission.bash)) {
    if (action === 'allow') {
      allow.push(`Bash(${pattern})`);
    } else if (action === 'deny') {
      deny.push(`Bash(${pattern})`);
    }
  }

  return {
    name: 'exported',
    allow,
    deny: deny.length > 0 ? deny : undefined,
  };
}

/**
 * Convert Codex permissions back to canonical format (approximation).
 */
export function codexToCanonical(perms: CodexPermissions): PermissionSet {
  const allow: string[] = [];

  if (perms.approval_policy === 'never' || perms.sandbox_mode === 'danger-full-access') {
    allow.push('Bash(*)');
    allow.push('Read(**)');
    allow.push('Write(**)');
    allow.push('Edit(**)');
  } else if (perms.sandbox_mode === 'workspace-write') {
    allow.push('Bash(*)');
    allow.push('Read(**)');
  }

  if (perms.sandbox_workspace_write?.network_access) {
    allow.push('WebSearch(*)');
    allow.push('WebFetch(*)');
  }

  return {
    name: 'exported',
    allow,
  };
}

/**
 * Export agent's current permissions to canonical format.
 */
function exportAgentPermissions(
  agentId: AgentId,
  scope: 'user' | 'project' = 'user',
  cwd?: string
): PermissionSet | null {
  const perms = readAgentPermissions(agentId, scope, cwd);
  if (!perms) return null;

  switch (agentId) {
    case 'claude':
      return claudeToCanonical(perms as ClaudePermissions);
    case 'opencode':
      return openCodeToCanonical(perms as OpenCodePermissions);
    case 'codex':
      return codexToCanonical(perms as CodexPermissions);
    default:
      return null;
  }
}

/**
 * Export permissions from a specific config file path to canonical format.
 * Auto-detects agent type from file path/name.
 */
export function exportPermissionsFromPath(filePath: string): PermissionSet | null {
  if (!fs.existsSync(filePath)) {
    return null;
  }

  const content = fs.readFileSync(filePath, 'utf-8');
  const fileName = path.basename(filePath);
  const parentDir = path.basename(path.dirname(filePath));

  // Detect agent type from path
  let agentId: AgentId | null = null;

  if (fileName === 'settings.json' && parentDir === '.claude') {
    agentId = 'claude';
  } else if (fileName === 'opencode.jsonc' || parentDir === '.opencode') {
    agentId = 'opencode';
  } else if (fileName === 'config.toml' && parentDir === '.codex') {
    agentId = 'codex';
  } else if (filePath.includes('.claude')) {
    agentId = 'claude';
  } else if (filePath.includes('.opencode')) {
    agentId = 'opencode';
  } else if (filePath.includes('.codex')) {
    agentId = 'codex';
  }

  if (!agentId) {
    return null;
  }

  try {
    switch (agentId) {
      case 'claude': {
        const config = JSON.parse(content);
        if (config.permissions) {
          return claudeToCanonical({
            permissions: {
              allow: config.permissions.allow || [],
              deny: config.permissions.deny || [],
            },
          });
        }
        return null;
      }
      case 'opencode': {
        // Strip JSONC comments
        const jsonContent = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
        const config = JSON.parse(jsonContent);
        if (config.permission) {
          return openCodeToCanonical({
            permission: {
              bash: config.permission.bash || {},
            },
          });
        }
        return null;
      }
      case 'codex': {
        const config = TOML.parse(content) as Record<string, unknown>;
        const perms: CodexPermissions = {};
        if (config.approval_policy) {
          perms.approval_policy = config.approval_policy as CodexPermissions['approval_policy'];
        }
        if (config.sandbox_mode) {
          perms.sandbox_mode = config.sandbox_mode as CodexPermissions['sandbox_mode'];
        }
        if (config.sandbox_workspace_write) {
          const sw = config.sandbox_workspace_write as Record<string, unknown>;
          perms.sandbox_workspace_write = {
            network_access: sw.network_access as boolean | undefined,
            writable_roots: sw.writable_roots as string[] | undefined,
          };
        }
        return codexToCanonical(perms);
      }
      default:
        return null;
    }
  } catch {
    return null;
  }
}

/**
 * Save a permission set to central storage.
 */
function savePermissionSet(set: PermissionSet): { success: boolean; error?: string } {
  ensurePermissionsDir();
  const filePath = safeJoin(getUserPermissionsDir(), set.name + '.yml');

  try {
    const content = yaml.stringify({
      name: set.name,
      description: set.description,
      allow: set.allow,
      deny: set.deny,
    });
    fs.writeFileSync(filePath, content, 'utf-8');
    return { success: true };
  } catch (err) {
    return { success: false, error: (err as Error).message };
  }
}

/** Name used for the default permission set in central storage. */
const DEFAULT_PERMISSION_SET_NAME = 'default';

/**
 * Get the default permission set from central storage.
 */
export function getDefaultPermissionSet(): PermissionSet {
  const existing = getPermissionSet(DEFAULT_PERMISSION_SET_NAME);
  if (existing) {
    return existing.set;
  }
  return {
    name: DEFAULT_PERMISSION_SET_NAME,
    description: 'Default permission set',
    allow: [],
    deny: [],
  };
}

/**
 * Compute diff between existing and new permissions.
 * Returns { added, existing, removed } for both allow and deny rules.
 */
export function computePermissionsDiff(
  existing: PermissionSet,
  incoming: PermissionSet
): {
  allow: { added: string[]; existing: string[] };
  deny: { added: string[]; existing: string[] };
} {
  const existingAllowSet = new Set(existing.allow);
  const existingDenySet = new Set(existing.deny || []);

  const allowAdded = incoming.allow.filter((r) => !existingAllowSet.has(r));
  const allowExisting = incoming.allow.filter((r) => existingAllowSet.has(r));

  const incomingDeny = incoming.deny || [];
  const denyAdded = incomingDeny.filter((r) => !existingDenySet.has(r));
  const denyExisting = incomingDeny.filter((r) => existingDenySet.has(r));

  return {
    allow: { added: allowAdded, existing: allowExisting },
    deny: { added: denyAdded, existing: denyExisting },
  };
}

/**
 * Merge incoming permissions into existing, deduplicating.
 */
export function mergePermissionSets(existing: PermissionSet, incoming: PermissionSet): PermissionSet {
  const allowSet = new Set([...existing.allow, ...incoming.allow]);
  const denySet = new Set([...(existing.deny || []), ...(incoming.deny || [])]);
  const dirsSet = new Set([...(existing.additionalDirectories || []), ...(incoming.additionalDirectories || [])]);

  const result: PermissionSet = {
    name: existing.name,
    description: existing.description,
    allow: Array.from(allowSet).sort(),
    deny: Array.from(denySet).sort(),
  };
  if (dirsSet.size > 0) {
    result.additionalDirectories = Array.from(dirsSet).sort();
  }
  return result;
}

/**
 * Save the default permission set.
 */
export function saveDefaultPermissionSet(set: PermissionSet): { success: boolean; error?: string } {
  set.name = DEFAULT_PERMISSION_SET_NAME;
  return savePermissionSet(set);
}
