// SPDX-License-Identifier: MIT
/**
 * Installation Service
 *
 * Handles dev-suite installation, uninstallation, and server preparation.
 */

import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { getLogger } from '../utils/logger.js';
import { resolveProjectPath, PathValidationError } from '../utils/utilities.js';
import { execSync, execFileSync } from 'child_process';
import type { InstallConfig, InstallManifest } from '../types.js';
import type { TrackedFile, ExtendedManifest, StackInfo } from '../types/index.js';
import { AgentsService } from './agents.service.js';
import { HooksService } from './hooks.service.js';
import {
  validatePathWithinBase,
  validateEntryName,
  validateAgentId,
  validateSkillPath,
  getDevSuiteDir,
  calculateFileHashFromPath,
  copyDirSync,
  findAgentFile,
  parseAgentSkills,
  parseAgentSkillsStructured,
  flattenSkillName,
  toInstalledAgentContent,
  getServerEnvVars,
  updateClaudeMd,
  cleanClaudeMdSection,
  generatePathScopedRules,
  removePathScopedRules,
} from './installation/index.js';

const logger = getLogger('InstallationService');

// Constants
const TIMEOUTS = {
  NPM_INSTALL: 120000,
  COMMAND_DEFAULT: 60000,
};

export class InstallationService {
  private agentsService = new AgentsService();
  private hooksService = new HooksService();

  /**
   * Prepare (build) MCP servers
   */
  async prepareServers(servers: string[]): Promise<{ prepared: string[]; failed: string[] }> {
    const devSuiteDir = getDevSuiteDir();
    const mcpRoot = path.join(devSuiteDir, 'mcp-servers');
    const prepared: string[] = [];
    const failed: string[] = [];

    // Skip npm install if all requested servers are already built (e.g. Electron packaged app)
    const allBuilt = servers.length === 0 || servers.every(name =>
      fs.existsSync(path.join(mcpRoot, name, 'dist', 'index.js'))
    );

    if (!allBuilt && !fs.existsSync(path.join(mcpRoot, 'node_modules'))) {
      try {
        execSync('npm ci', { cwd: mcpRoot, stdio: 'pipe', timeout: TIMEOUTS.NPM_INSTALL });
      } catch (error: unknown) {
        logger.warn('npm ci failed, falling back to npm install', {
          error,
          context: { mcpRoot }
        });
        try {
          execSync('npm install', { cwd: mcpRoot, stdio: 'pipe', timeout: TIMEOUTS.NPM_INSTALL });
        } catch (e) {
          throw new Error(`Failed to install MCP dependencies: ${e}`);
        }
      }
    }

    for (const serverName of servers) {
      // SECURITY: Validate serverName before interpolating into shell command
      if (!validateEntryName(serverName)) {
        logger.warn('Invalid server name in prepareMcpServers', { context: { serverName } });
        failed.push(serverName);
        continue;
      }

      const serverDir = path.join(mcpRoot, serverName);
      const distPath = path.join(serverDir, 'dist', 'index.js');

      if (!fs.existsSync(serverDir)) {
        failed.push(serverName);
        continue;
      }

      // Already built
      if (fs.existsSync(distPath)) {
        prepared.push(serverName);
        continue;
      }

      try {
        execFileSync('npm', ['run', 'build', `--workspace=${serverName}`], {
          cwd: mcpRoot,
          stdio: 'pipe',
          timeout: TIMEOUTS.COMMAND_DEFAULT,
          shell: process.platform === 'win32',
        });

        if (fs.existsSync(distPath)) {
          prepared.push(serverName);
        } else {
          failed.push(serverName);
        }
      } catch (error: unknown) {
        logger.warn('Failed to build MCP server', {
          error,
          context: { serverName }
        });
        failed.push(serverName);
      }
    }

    return { prepared, failed };
  }

  /**
   * Install dev-suite into a project
   */
  async install(config: InstallConfig): Promise<InstallManifest> {
    let { projectPath } = config;
    if (projectPath.includes('..')) throw new PathValidationError('Path traversal not allowed');
    projectPath = resolveProjectPath(projectPath);
    if (!path.isAbsolute(projectPath)) throw new PathValidationError('Path must be rooted');
    const { agents, mcpServers: requestedMcpServers, envVars, rules = [], detectedStack } = config;
    const devSuiteDir = getDevSuiteDir();

    // Auto-include MCP servers marked `isDefault: true` in their metadata
    // (dev-suite built-in capabilities, currently `skill-loader`). Skipped
    // when the caller explicitly asks for `skillLoadingMode='eager'` —
    // that's the escape hatch for environments without DEV_SUITE_ROOT
    // (CI, containers). Eager mode copies all skills locally so the MCP
    // runtime fallback isn't needed.
    const mcpServers = [...requestedMcpServers];
    const allowAutoInclude = config.skillLoadingMode !== 'eager';
    if (allowAutoInclude) {
      const allServers = await this.agentsService.getMcpServers();
      for (const meta of allServers) {
        if (meta.isDefault && !mcpServers.includes(meta.name)) {
          mcpServers.push(meta.name);
          logger.info('Auto-included default MCP server', { context: { name: meta.name } });
        }
      }
    }

    // Lazy is the default whenever `skill-loader` is in the install set
    // (now automatic via `isDefault`). Explicit `'eager'` still wins.
    let skillLoadingMode: 'eager' | 'lazy' = config.skillLoadingMode ?? 'eager';
    if (config.skillLoadingMode === undefined && mcpServers.includes('skill-loader')) {
      skillLoadingMode = 'lazy';
    }

    // Create extended manifest with hash tracking for upgrade system
    const extendedManifest: ExtendedManifest = {
      version: '1.0.0',
      installedAt: new Date().toISOString(),
      projectPath,
      detectedStack: detectedStack ? this.normalizeStackInfo(detectedStack) : undefined,
      agents: [],
      mcpServers: [],
      features: {},
      files: [],
      upgradeHistory: [],
    };

    // Legacy manifest for backward compatibility
    const manifest: InstallManifest = {
      version: '1.0.0',
      installedAt: new Date().toISOString(),
      projectPath,
      agents: [],
      mcpServers: [],
      rules: [],
      files: [],
    };

    // Create directories
    const claudeDir = path.join(projectPath, '.claude');
    const agentsDir = path.join(claudeDir, 'agents');
    const skillsDir = path.join(claudeDir, 'skills');
    const mcpServersDir = path.join(projectPath, '.mcp-servers');

    fs.mkdirSync(agentsDir, { recursive: true });
    fs.mkdirSync(skillsDir, { recursive: true });
    fs.mkdirSync(mcpServersDir, { recursive: true });

    // Clean stale skill folders left over from previous installs (eager
    // mode nested folders, lazy mode flat names from a previous agent set).
    // Without this, re-installing accumulates skill descriptions in
    // `.claude/skills/` and re-triggers Claude Code's
    // `skillListingBudgetFraction` warning. We treat any direct child of
    // `.claude/skills/` containing a `SKILL.md` (anywhere in its tree) as
    // dev-suite-managed and remove it. `_README.md` and unrelated user files
    // are preserved.
    this.cleanStaleSkills(skillsDir);

    // dev-suite typically installs 30-60 core skills across selected agents,
    // which exceeds Claude Code's default `skillListingBudgetFraction = 0.01`
    // (~20 descriptions). We raise it to 0.05 (~100 descriptions) so the
    // *"N descriptions dropped"* warning doesn't fire. The bump costs ~10K
    // tokens per session — trivial vs the ~200K context window. User's
    // explicit override (any pre-existing value in `.claude/settings.json`)
    // is preserved.
    this.ensureSkillBudget(claudeDir, manifest, extendedManifest, projectPath);

    // Install agents
    // In lazy mode we split skills in two buckets:
    //   - agent-scoped skills (referenced by selected agents) → installed
    //     natively under `.claude/skills/<flat-name>/SKILL.md` so Claude Code
    //     auto-loads their description at boot (~250 char/skill).
    //   - all other ~620 skills → reachable on-demand via the `skill-loader`
    //     MCP server (no boot cost).
    const lazySkillPaths = new Map<string, string>(); // skillPath → description (NON-preloaded)
    const preloadedSkillPaths = new Set<string>();    // original path of natively installed skills
    const usedFlatNames = new Map<string, string>();  // flatName → originalSkillPath (collision detection)
    const skillPathToFlat = new Map<string, string>(); // skillPath → final flat dir name (shared across agents)

    for (const agentId of agents) {
      if (skillLoadingMode === 'lazy') {
        const installed = this.installAgentLazy(
          agentId, projectPath, devSuiteDir, manifest, extendedManifest, lazySkillPaths, preloadedSkillPaths, usedFlatNames, skillPathToFlat
        );
        if (installed) {
          manifest.agents.push(agentId);
          extendedManifest.agents.push(agentId);
        }
      } else {
        const installed = this.installAgent(
          agentId, projectPath, devSuiteDir, manifest, extendedManifest, usedFlatNames, preloadedSkillPaths, skillPathToFlat
        );
        if (installed) {
          manifest.agents.push(agentId);
          extendedManifest.agents.push(agentId);
        }
      }
    }

    // In lazy mode: write a small README at .claude/skills/_README.md that
    // documents the dual discovery model (native preload + MCP fallback).
    // We use _README.md rather than index.md so Claude Code's auto-discovery
    // doesn't try to interpret it as a skill folder.
    if (skillLoadingMode === 'lazy') {
      this.writeSkillIndex(lazySkillPaths, preloadedSkillPaths, skillsDir, devSuiteDir, projectPath, manifest, extendedManifest);
    }

    // Install MCP servers
    const mcpConfig: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {};

    for (const serverName of mcpServers) {
      const installed = this.installMcpServer(serverName, projectPath, devSuiteDir, manifest, extendedManifest);
      if (installed) {
        manifest.mcpServers.push(serverName);
        extendedManifest.mcpServers.push(serverName);
        mcpConfig[serverName] = {
          command: 'node',
          args: [path.join(projectPath, '.mcp-servers', serverName, 'dist', 'index.js')],
          env: getServerEnvVars(serverName, envVars, devSuiteDir),
        };
      }
    }

    // In lazy mode: add the skill-loader MCP server entry to .mcp.json so
    // Claude Code can fetch skill bodies at runtime. Point to the LOCAL copy
    // of the server in <project>/.mcp-servers/skill-loader/ (which the
    // install step already populated with node_modules + bundled skills) —
    // NOT to the dev-suite source, so the project stays portable.
    //
    // The skill-loader self-resolves its skills directory from
    // <packageDir>/skills/ (auto-bundled at build time). DEV_SUITE_ROOT is
    // injected ONLY when the user explicitly provided one (development
    // override against a live dev-suite source).
    if (skillLoadingMode === 'lazy') {
      const skillLoaderDist = path.join(projectPath, '.mcp-servers', 'skill-loader', 'dist', 'index.js');
      const userOverride = envVars?.DEV_SUITE_ROOT?.trim();
      mcpConfig['skill-loader'] = {
        command: 'node',
        args: [skillLoaderDist],
        env: userOverride ? { DEV_SUITE_ROOT: userOverride } : {},
      };
      logger.info('Lazy skill loading enabled — skill-loader MCP server added to .mcp.json', {
        context: { skillLoaderDist, devSuiteRootOverride: userOverride ?? null },
      });
    }

    // Install rules → copy to [projectPath]/.claude/rules/
    if (rules.length > 0) {
      const rulesDestDir = path.join(claudeDir, 'rules');
      fs.mkdirSync(rulesDestDir, { recursive: true });
      const { RulesService } = await import('./rules.service.js');
      const rulesService = new RulesService();
      for (const ruleId of rules) {
        const src = rulesService.findRuleFile(ruleId);
        if (src) {
          const dest = path.join(rulesDestDir, `${ruleId}.md`);
          fs.copyFileSync(src, dest);
          manifest.rules.push(ruleId);
          manifest.files.push({ path: `.claude/rules/${ruleId}.md`, type: 'config', source: src });
        }
      }
    }

    // Write .mcp.json
    const mcpJsonPath = path.join(projectPath, '.mcp.json');
    fs.writeFileSync(mcpJsonPath, JSON.stringify({ mcpServers: mcpConfig }, null, 2));
    manifest.files.push({ path: '.mcp.json', type: 'config', source: 'generated' });
    this.trackFile(extendedManifest, projectPath, '.mcp.json', 'config');

    // Write .dev-suite.json
    const devSuiteConfig = {
      version: manifest.version,
      installedAt: manifest.installedAt,
      agents: { enabled: manifest.agents },
      mcpServers: { enabled: manifest.mcpServers },
      rules: { enabled: manifest.rules },
    };
    const devSuiteJsonPath = path.join(projectPath, '.dev-suite.json');
    fs.writeFileSync(devSuiteJsonPath, JSON.stringify(devSuiteConfig, null, 2));
    manifest.files.push({ path: '.dev-suite.json', type: 'config', source: 'generated' });
    this.trackFile(extendedManifest, projectPath, '.dev-suite.json', 'config');

    // Write extended manifest (used by upgrade system)
    const manifestPath = path.join(projectPath, '.dev-suite-manifest.json');
    fs.writeFileSync(manifestPath, JSON.stringify(extendedManifest, null, 2));

    // Configure integration-validator hook if stack detected
    let validatorHookConfigured = false;
    if (detectedStack) {
      const hookResult = this.hooksService.configureIntegrationValidatorHook(projectPath, detectedStack);
      validatorHookConfigured = hookResult.configured;
      if (hookResult.configured) {
        manifest.files.push({ path: '.claude/settings.json', type: 'config', source: 'generated' });
        this.trackFile(extendedManifest, projectPath, '.claude/settings.json', 'config');
        // Track the integration-validator-hook feature as applied
        extendedManifest.features['integration-validator-hook'] = {
          version: '1.0.0',
          appliedAt: new Date().toISOString(),
        };
        logger.info('Integration validator hook configured', { context: { projectPath } });
      }
    }

    // Record catalog snapshot for new-component detection
    const allAgents = await this.agentsService.getAgents();
    const allMcpServers = await this.agentsService.getMcpServers();
    extendedManifest.availableAtInstall = {
      agents: allAgents.map(a => a.id),
      mcpServers: allMcpServers.map(s => s.name),
    };

    // Generate path-scoped rule files (.claude/rules/{category}.md)
    const installedAgents = allAgents.filter(a => manifest.agents.includes(a.id));
    const ruleFiles = generatePathScopedRules(installedAgents, projectPath);
    extendedManifest.installedRuleFiles = ruleFiles;

    // Re-write extended manifest with catalog snapshot + rule file tracking
    fs.writeFileSync(manifestPath, JSON.stringify(extendedManifest, null, 2));

    // Update CLAUDE.md — always-on agents inline, path-scoped ones cross-referenced
    updateClaudeMd(projectPath, installedAgents, detectedStack, validatorHookConfigured);

    return manifest;
  }

  /**
   * Uninstall dev-suite from a project
   */
  async uninstall(projectPath: string): Promise<{ removed: string[]; errors: string[] }> {
    if (projectPath.includes('..')) throw new PathValidationError('Path traversal not allowed');
    projectPath = resolveProjectPath(projectPath);
    if (!path.isAbsolute(projectPath)) throw new PathValidationError('Path must be rooted');
    const removed: string[] = [];
    const errors: string[] = [];

    // Read manifest (may be either InstallManifest or ExtendedManifest on disk)
    const manifestPath = path.join(projectPath, '.dev-suite-manifest.json');
    let manifest: (InstallManifest & { installedRuleFiles?: string[] }) | null = null;

    if (fs.existsSync(manifestPath)) {
      try {
        manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as InstallManifest & { installedRuleFiles?: string[] };
      } catch (error: unknown) {
        logger.warn('Failed to read installation manifest', {
          error,
          context: { manifestPath }
        });
        errors.push('Failed to read manifest');
      }
    }

    // Remove tracked files
    if (manifest?.files) {
      for (const file of manifest.files) {
        // Handle both string format and object format {path: string}
        const filePath = typeof file === 'string' ? file : file.path;
        if (!filePath) continue;

        const fullPath = path.join(projectPath, filePath);
        if (fs.existsSync(fullPath)) {
          try {
            fs.unlinkSync(fullPath);
            removed.push(filePath);
          } catch (e) {
            errors.push(`Failed to remove ${filePath}: ${e}`);
          }
        }
      }
    }

    // Remove path-scoped rule files tracked by dev-suite
    const ruleFiles = manifest?.installedRuleFiles ?? [];
    if (ruleFiles.length > 0) {
      const ruleResult = removePathScopedRules(projectPath, ruleFiles);
      removed.push(...ruleResult.removed);
      errors.push(...ruleResult.errors);
    }

    // Remove directories
    const dirsToRemove = ['.mcp-servers', '.claude/agents', '.claude/skills', '.claude/commands', '.kb-cache'];
    for (const dir of dirsToRemove) {
      const dirPath = path.join(projectPath, dir);
      if (fs.existsSync(dirPath)) {
        try {
          fs.rmSync(dirPath, { recursive: true, force: true });
          removed.push(dir);
        } catch (e) {
          errors.push(`Failed to remove ${dir}: ${e}`);
        }
      }
    }

    // Remove .claude if empty
    const claudeDir = path.join(projectPath, '.claude');
    if (fs.existsSync(claudeDir)) {
      try {
        const contents = fs.readdirSync(claudeDir);
        if (contents.length === 0) {
          fs.rmdirSync(claudeDir);
          removed.push('.claude');
        }
      } catch (error: unknown) {
        logger.warn('Failed to remove empty .claude directory', {
          error,
          context: { claudeDir }
        });
      }
    }

    // Remove config files
    const configFiles = ['.dev-suite.json', '.dev-suite-manifest.json', '.mcp.json'];
    for (const file of configFiles) {
      const filePath = path.join(projectPath, file);
      if (fs.existsSync(filePath)) {
        try {
          fs.unlinkSync(filePath);
          removed.push(file);
        } catch (e) {
          errors.push(`Failed to remove ${file}: ${e}`);
        }
      }
    }

    // Clean CLAUDE.md
    cleanClaudeMdSection(projectPath);
    removed.push('CLAUDE.md (dev-suite section)');

    return { removed, errors };
  }

  /**
   * Get installation status
   */
  async getStatus(projectPath: string): Promise<{ installed: boolean; manifest?: InstallManifest }> {
    if (projectPath.includes('..')) throw new PathValidationError('Path traversal not allowed');
    projectPath = resolveProjectPath(projectPath);
    if (!path.isAbsolute(projectPath)) throw new PathValidationError('Path must be rooted');
    const manifestPath = path.join(projectPath, '.dev-suite-manifest.json');

    if (!fs.existsSync(manifestPath)) {
      return { installed: false };
    }

    try {
      const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as InstallManifest;
      return { installed: true, manifest };
    } catch (error: unknown) {
      logger.warn('Failed to read installation manifest', {
        error,
        context: { projectPath, manifestPath }
      });
      return { installed: false };
    }
  }

  // ========== Private methods ==========

  /**
   * Track a file with hash in the extended manifest
   */
  private trackFile(
    extendedManifest: ExtendedManifest,
    projectPath: string,
    relativePath: string,
    type: TrackedFile['type'],
    source?: string
  ): void {
    const fullPath = path.join(projectPath, relativePath);
    const hash = calculateFileHashFromPath(fullPath);

    if (hash) {
      extendedManifest.files.push({
        path: relativePath,
        hash,
        type,
        source,
      });
    }
  }

  /**
   * Normalize detected stack to StackInfo format
   */
  private normalizeStackInfo(detectedStack: {
    projectType?: string;
    frontend?: { framework?: string; metaFramework?: string; runtime?: string; version?: string };
    backend?: { framework?: string; metaFramework?: string; runtime?: string; version?: string };
    database?: { dbType?: string; orm?: string; version?: string };
    testing?: { unit?: string; e2e?: string };
    isMonorepo?: boolean;
    confidence?: number;
  }): StackInfo {
    return {
      projectType: (detectedStack.projectType as StackInfo['projectType']) || 'unknown',
      frontend: detectedStack.frontend ? {
        framework: detectedStack.frontend.framework || '',
        meta_framework: detectedStack.frontend.metaFramework,
        runtime: detectedStack.frontend.runtime,
      } : undefined,
      backend: detectedStack.backend ? {
        framework: detectedStack.backend.framework || '',
        meta_framework: detectedStack.backend.metaFramework,
        runtime: detectedStack.backend.runtime,
      } : undefined,
      database: detectedStack.database ? {
        db_type: detectedStack.database.dbType || '',
        orm: detectedStack.database.orm,
      } : undefined,
      testing: detectedStack.testing,
    };
  }

  /**
   * Ensure `.claude/settings.json` raises `skillListingBudgetFraction` to a
   * value that fits the dev-suite's typical core-skill count (30-60 across
   * selected agents). Default Claude Code budget is 1% (~20 descriptions),
   * which causes the *"N descriptions dropped"* warning on heavy installs.
   *
   * Behaviour:
   * - File missing → create with `{ skillListingBudgetFraction: 0.05 }`
   * - File present, key missing → merge in `0.05` while preserving everything
   *   else (hooks, env, permissions)
   * - File present, key already set → leave alone (respect user override —
   *   even when it's lower than 0.05, the user knows their context budget
   *   better than we do)
   */
  private ensureSkillBudget(
    claudeDir: string,
    manifest: InstallManifest,
    extendedManifest: ExtendedManifest | undefined,
    projectPath: string,
  ): void {
    const TARGET_BUDGET = 0.05;
    const settingsPath = path.join(claudeDir, 'settings.json');
    let settings: Record<string, unknown> = {};
    let alreadySet = false;

    if (fs.existsSync(settingsPath)) {
      try {
        const raw = fs.readFileSync(settingsPath, 'utf-8');
        settings = JSON.parse(raw);
        if (Object.prototype.hasOwnProperty.call(settings, 'skillListingBudgetFraction')) {
          alreadySet = true;
        }
      } catch (error: unknown) {
        logger.warn('Failed to parse existing .claude/settings.json — overwriting with safe defaults', {
          error,
          context: { settingsPath },
        });
        settings = {};
      }
    }

    if (alreadySet) {
      logger.info('Preserving user-set skillListingBudgetFraction', {
        context: { value: settings.skillListingBudgetFraction },
      });
      return;
    }

    settings.skillListingBudgetFraction = TARGET_BUDGET;
    try {
      if (!fs.existsSync(claudeDir)) {
        fs.mkdirSync(claudeDir, { recursive: true });
      }
      fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
      manifest.files.push({ path: '.claude/settings.json', type: 'config', source: 'generated' });
      if (extendedManifest) {
        this.trackFile(extendedManifest, projectPath, '.claude/settings.json', 'config');
      }
      logger.info('Set skillListingBudgetFraction in .claude/settings.json', {
        context: { settingsPath, value: TARGET_BUDGET },
      });
    } catch (error: unknown) {
      logger.warn('Failed to write .claude/settings.json — skill budget not raised', {
        error,
        context: { settingsPath },
      });
    }
  }

  /**
   * Remove dev-suite-managed skill folders from `.claude/skills/` so a
   * re-install starts from a clean slate. Any direct child of `skillsDir`
   * that contains a `SKILL.md` anywhere in its tree is considered managed.
   *
   * Files at the top level (e.g. `_README.md`) are preserved. Unrelated
   * folders that don't contain a `SKILL.md` (rare, but possible if the user
   * keeps custom artifacts here) are also preserved.
   */
  private cleanStaleSkills(skillsDir: string): void {
    if (!fs.existsSync(skillsDir)) return;

    const containsSkillMd = (dir: string): boolean => {
      try {
        for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
          if (entry.isFile() && entry.name === 'SKILL.md') return true;
          if (entry.isDirectory() && containsSkillMd(path.join(dir, entry.name))) return true;
        }
      } catch {
        // unreadable — treat as no match
      }
      return false;
    };

    let removed = 0;
    for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
      if (!entry.isDirectory()) continue;
      // Never touch the reserved `custom/` folder — it holds user-authored
      // skills (custom-agents.service), even though they contain SKILL.md.
      if (entry.name === 'custom' || !validateEntryName(entry.name)) continue;
      // SECURITY: validatePathWithinBase returns a path verified to stay inside
      // skillsDir (rejects traversal/symlink escape) — use the returned value.
      const fullPath = validatePathWithinBase(path.join(skillsDir, entry.name), skillsDir, false);
      if (containsSkillMd(fullPath)) {
        try {
          fs.rmSync(fullPath, { recursive: true, force: true });
          removed++;
        } catch (error: unknown) {
          logger.warn('Failed to remove stale skill folder', {
            error,
            context: { folder: fullPath },
          });
        }
      }
    }

    if (removed > 0) {
      logger.info('Cleaned stale skill folders before re-install', {
        context: { skillsDir, removed },
      });
    }
  }

  /**
   * Install one skill as a FLAT top-level dir under `.claude/skills/<flat>/`
   * (the only shape Claude Code resolves by name), with cross-agent collision
   * handling. Returns the final flat dir name (so the agent's `skills:` list can
   * reference it), or null if the skill is invalid/missing. Idempotent: a skill
   * already installed by another agent returns its recorded flat name.
   */
  private installSkillFlat(
    skillPath: string,
    devSuiteDir: string,
    projectPath: string,
    manifest: InstallManifest,
    extendedManifest: ExtendedManifest | undefined,
    usedFlatNames: Map<string, string>,
    preloadedSkillPaths: Set<string>,
    skillPathToFlat: Map<string, string>
  ): string | null {
    const already = skillPathToFlat.get(skillPath);
    if (already) return already;

    if (!validateSkillPath(skillPath)) {
      logger.warn('Invalid skill path - potential path traversal', { context: { skillPath } });
      return null;
    }
    const skillsSource = path.join(devSuiteDir, 'skills');
    // validatePathWithinBase returns the validated path — use the returned value
    // as the sanitized path into every fs sink (recognized path-injection barrier).
    let safeSrc: string;
    try {
      safeSrc = validatePathWithinBase(path.join(skillsSource, skillPath), skillsSource, false);
    } catch {
      logger.warn('Skill path validation failed', { context: { skillPath } });
      return null;
    }
    if (!fs.existsSync(safeSrc)) return null;

    let flatName = flattenSkillName(skillPath);
    if (!flatName) {
      logger.warn('Skill flatten produced empty name', { context: { skillPath } });
      return null;
    }
    // Collision: a different skillPath already claimed this flat name.
    const claimedBy = usedFlatNames.get(flatName);
    if (claimedBy && claimedBy !== skillPath) {
      const suffix = crypto.createHash('sha1').update(skillPath).digest('hex').slice(0, 6);
      const max = 64 - suffix.length - 1;
      flatName = `${flatName.slice(0, max).replace(/-+$/, '')}-${suffix}`;
      logger.warn('Flat skill name collision — applied hash suffix', {
        context: { skillPath, claimedBy, flatName },
      });
    }

    const skillsDestRoot = path.join(projectPath, '.claude', 'skills');
    let safeDest: string;
    try {
      safeDest = validatePathWithinBase(path.join(skillsDestRoot, flatName), skillsDestRoot, false);
    } catch {
      logger.warn('Flattened skill path failed validation', { context: { skillPath, flatName } });
      return null;
    }

    if (!fs.existsSync(safeDest)) {
      copyDirSync(safeSrc, safeDest);
      manifest.files.push({ path: `.claude/skills/${flatName}`, type: 'skill', source: safeSrc });
      if (extendedManifest) {
        this.trackFile(extendedManifest, projectPath, `.claude/skills/${flatName}`, 'skill', safeSrc);
      }
    }
    usedFlatNames.set(flatName, skillPath);
    preloadedSkillPaths.add(skillPath);
    skillPathToFlat.set(skillPath, flatName);
    return flatName;
  }

  private installAgent(
    agentId: string,
    projectPath: string,
    devSuiteDir: string,
    manifest: InstallManifest,
    extendedManifest: ExtendedManifest | undefined,
    usedFlatNames: Map<string, string>,
    preloadedSkillPaths: Set<string>,
    skillPathToFlat: Map<string, string>
  ): boolean {
    // SECURITY: Validate agentId
    if (!validateAgentId(agentId)) {
      logger.warn('Invalid agent ID - potential path traversal', { context: { agentId } });
      return false;
    }
    // SECURITY: Path traversal check for projectPath
    if (projectPath.includes('..')) {
      logger.warn('Invalid projectPath - path traversal detected', { context: { projectPath } });
      return false;
    }

    const agentFile = findAgentFile(path.join(devSuiteDir, 'agents'), agentId + '.md');
    if (!agentFile) return false;

    try {
      // SECURITY: Validate paths. validatePathWithinBase returns the validated
      // path — use the returned value as the sanitized destination for the agent
      // file write (recognized path-injection barrier).
      validatePathWithinBase(agentFile, path.join(devSuiteDir, 'agents'), false);
      const destPath = validatePathWithinBase(
        path.join(projectPath, '.claude', 'agents', agentId + '.md'),
        projectPath,
        false
      );

      // Eager mode: copy ALL of the agent's skills as FLAT top-level dirs
      // (bundle-expanded by the shared parser), then write the agent file with
      // Claude-Code-native frontmatter so tool restrictions + skill preload
      // actually take effect (see toInstalledAgentContent / Option R).
      const agentContent = fs.readFileSync(agentFile, 'utf-8');
      const skills = parseAgentSkills(agentContent, agentId);
      const installedFlat: string[] = [];
      for (const skillPath of skills) {
        const flat = this.installSkillFlat(
          skillPath, devSuiteDir, projectPath, manifest, extendedManifest,
          usedFlatNames, preloadedSkillPaths, skillPathToFlat
        );
        if (flat && !installedFlat.includes(flat)) installedFlat.push(flat);
      }

      const installedContent = toInstalledAgentContent(agentContent, {
        installedSkillFlatNames: installedFlat,
        grantSkillTool: true,
      });
      fs.writeFileSync(destPath, installedContent, 'utf-8');
      manifest.files.push({ path: `.claude/agents/${agentId}.md`, type: 'agent', source: agentFile });
      // Track with hash for upgrade system (hash reflects the installed file)
      if (extendedManifest) {
        this.trackFile(extendedManifest, projectPath, `.claude/agents/${agentId}.md`, 'agent', agentFile);
      }

      return true;
    } catch (error: unknown) {
      logger.warn('Failed to install agent', {
        error,
        context: { agentId, projectPath }
      });
      return false;
    }
  }

  /**
   * Lazy variant of installAgent.
   *
   * Copies the agent .md file AND preloads ONLY the agent's `core_skills:`
   * (or, for unmigrated agents that still use legacy `skills:`, the full
   * list — same as before) under `.claude/skills/<flat-name>/SKILL.md`.
   * Claude Code's native progressive disclosure loads their description at
   * boot (Level 1) and the body on demand (Level 2).
   *
   * `extended_skills:` are NOT preloaded — they are reachable via the
   * `skill-loader` MCP server (`list_skills`, `load_skill`), which reads
   * DEV_SUITE_ROOT directly at runtime. This keeps the Level 1 budget
   * (~1% of context, `skillListingBudgetFraction`) under control even
   * when many agents are installed.
   */
  private installAgentLazy(
    agentId: string,
    projectPath: string,
    devSuiteDir: string,
    manifest: InstallManifest,
    extendedManifest: ExtendedManifest | undefined,
    lazySkillPaths: Map<string, string>,
    preloadedSkillPaths: Set<string>,
    usedFlatNames: Map<string, string>,
    skillPathToFlat: Map<string, string>
  ): boolean {
    // SECURITY: Validate agentId
    if (!validateAgentId(agentId)) {
      logger.warn('Invalid agent ID - potential path traversal', { context: { agentId } });
      return false;
    }
    if (projectPath.includes('..')) {
      logger.warn('Invalid projectPath - path traversal detected', { context: { projectPath } });
      return false;
    }

    const agentFile = findAgentFile(path.join(devSuiteDir, 'agents'), agentId + '.md');
    if (!agentFile) return false;

    try {
      // SECURITY: Validate paths. validatePathWithinBase returns the validated
      // path — use the returned value as the sanitized destination for the agent
      // file write (recognized path-injection barrier).
      validatePathWithinBase(agentFile, path.join(devSuiteDir, 'agents'), false);
      const destPath = validatePathWithinBase(
        path.join(projectPath, '.claude', 'agents', agentId + '.md'),
        projectPath,
        false
      );

      // Lazy mode: preload only the agent's `core_skills` (or, for unmigrated
      // agents that still use legacy `skills:`, the cap-limited core) as FLAT
      // dirs. `extended_skills:` are NOT copied — they stay reachable via the
      // `skill-loader` MCP at runtime. Then write the agent file with
      // Claude-Code-native frontmatter (tools/mcpServers/skills + skill-loader +
      // Skill) so tool restrictions and skill preload actually take effect.
      const agentContent = fs.readFileSync(agentFile, 'utf-8');
      const { core: coreSkills } = parseAgentSkillsStructured(agentContent, agentId);
      const installedFlat: string[] = [];
      for (const skillPath of coreSkills) {
        const flat = this.installSkillFlat(
          skillPath, devSuiteDir, projectPath, manifest, extendedManifest,
          usedFlatNames, preloadedSkillPaths, skillPathToFlat
        );
        if (flat) {
          if (!installedFlat.includes(flat)) installedFlat.push(flat);
          // Now preloaded natively — drop from the MCP-side index if present.
          lazySkillPaths.delete(skillPath);
        }
      }

      const installedContent = toInstalledAgentContent(agentContent, {
        installedSkillFlatNames: installedFlat,
        extraMcpServers: ['skill-loader'],
        grantSkillTool: true,
      });
      fs.writeFileSync(destPath, installedContent, 'utf-8');
      manifest.files.push({ path: `.claude/agents/${agentId}.md`, type: 'agent', source: agentFile });
      // Track with hash for upgrade system (hash reflects the installed file).
      if (extendedManifest) {
        this.trackFile(extendedManifest, projectPath, `.claude/agents/${agentId}.md`, 'agent', agentFile);
      }

      return true;
    } catch (error: unknown) {
      logger.warn('Failed to install agent (lazy)', {
        error,
        context: { agentId, projectPath }
      });
      return false;
    }
  }

  /**
   * Write `.claude/skills/_README.md` — a short note describing the dual
   * discovery model in lazy mode:
   *
   *   1. The skills referenced by selected agents are installed natively as
   *      `.claude/skills/<flat-name>/SKILL.md` (Claude Code auto-loads their
   *      description at boot, body on demand).
   *   2. All other skills are reachable on demand via the `skill-loader` MCP
   *      server tools `list_skills` and `load_skill`.
   *
   * The filename starts with `_` to keep Claude Code's auto-discovery from
   * mistaking it for a skill folder.
   */
  private writeSkillIndex(
    _lazySkillPaths: Map<string, string>,
    preloadedSkillPaths: Set<string>,
    skillsDir: string,
    devSuiteDir: string,
    projectPath: string,
    manifest: InstallManifest,
    extendedManifest: ExtendedManifest | undefined
  ): void {
    const lines: string[] = [
      '# Skills (lazy mode)',
      '',
      'This project uses dev-suite **tiered skill loading** to keep Claude',
      'Code\'s skill description budget (`skillListingBudgetFraction`,',
      '~1% of context) under control even with many agents installed.',
      '',
      '- **Core skills** — declared as `core_skills:` in each agent\'s',
      '  frontmatter (or `skills:` for unmigrated agents). Installed as',
      '  native Claude Code skills under `.claude/skills/<name>/SKILL.md`.',
      '  Claude Code auto-discovers them at boot: only the YAML description',
      '  is loaded; the body is fetched on demand when the skill is invoked.',
      '- **Extended skills** — declared as `extended_skills:` in each',
      '  agent\'s frontmatter, plus the rest of the dev-suite catalog. NOT',
      '  preloaded. Reachable on demand via the `skill-loader` MCP server:',
      '  - `mcp__skill-loader__list_skills` to discover available skills',
      '    (pass `groupByCategory: true` for a compact summary)',
      '  - `mcp__skill-loader__load_skill({ skill_path: "<path>" })` to',
      '    fetch a full SKILL.md body when needed',
      '',
      `## Natively preloaded core skills (${preloadedSkillPaths.size})`,
      '',
    ];

    const sortedPreloaded = [...preloadedSkillPaths].sort();
    for (const skillPath of sortedPreloaded) {
      lines.push(`- \`${skillPath}\``);
    }
    lines.push('');

    lines.push(
      '> **Runtime requirement**: the `skill-loader` MCP server reads',
      `> \`DEV_SUITE_ROOT\` (set to \`${devSuiteDir}\` in \`.mcp.json\`) to`,
      '> resolve non-preloaded skill bodies on demand.'
    );
    lines.push('');

    const readmePath = path.join(skillsDir, '_README.md');
    fs.writeFileSync(readmePath, lines.join('\n'));

    manifest.files.push({ path: '.claude/skills/_README.md', type: 'skill', source: 'generated' });
    if (extendedManifest) {
      this.trackFile(extendedManifest, projectPath, '.claude/skills/_README.md', 'skill');
    }

    logger.info('Lazy skills README written', {
      context: { preloadedCount: preloadedSkillPaths.size, readmePath },
    });
  }

  private installMcpServer(
    serverName: string,
    projectPath: string,
    devSuiteDir: string,
    manifest: InstallManifest,
    extendedManifest?: ExtendedManifest
  ): boolean {
    // SECURITY: Validate serverName doesn't contain path traversal
    if (!validateEntryName(serverName)) {
      logger.warn('Invalid server name - potential path traversal', { context: { serverName } });
      return false;
    }
    // SECURITY: Path traversal check for projectPath
    if (projectPath.includes('..')) {
      logger.warn('Invalid projectPath - path traversal detected', { context: { projectPath } });
      return false;
    }

    const serverSource = path.join(devSuiteDir, 'mcp-servers', serverName);
    const serverDest = path.join(projectPath, '.mcp-servers', serverName);

    // SECURITY: Validate paths stay within expected directories
    try {
      validatePathWithinBase(serverSource, path.join(devSuiteDir, 'mcp-servers'), false);
      validatePathWithinBase(serverDest, projectPath, false);
    } catch (error: unknown) {
      logger.warn('Path validation failed for MCP server installation', { error, context: { serverName } });
      return false;
    }

    if (!fs.existsSync(serverSource)) return false;
    if (!fs.existsSync(path.join(serverSource, 'dist', 'index.js'))) return false;

    try {
      copyDirSync(serverSource, serverDest);
      manifest.files.push({ path: `.mcp-servers/${serverName}`, type: 'mcp-server', source: serverSource });

      // Track main server file with hash
      if (extendedManifest) {
        const indexPath = `.mcp-servers/${serverName}/dist/index.js`;
        this.trackFile(extendedManifest, projectPath, indexPath, 'mcp-server', serverSource);
      }

      // NO npm install here — by design. Each server's `dist/index.js` is a
      // SELF-CONTAINED esbuild bundle (see mcp-servers/scripts/bundle.mjs):
      // every third-party dependency is inlined at dev-suite build time, and
      // the bundler fails the build if any non-builtin dep is left external
      // (except known optional native add-ons the libraries degrade without).
      // So the copied server needs nothing but Node to run. This removes the
      // previous runtime dependency on network + a resolvable `npm` — the
      // exact step that failed silently inside the packaged Electron app and
      // left servers crashing with ERR_MODULE_NOT_FOUND. New/updated
      // components that pull in new deps get them bundled at build time, so
      // install / reinstall / upgrade never touch npm for MCP servers.

      return true;
    } catch (error: unknown) {
      logger.error('Failed to install MCP server', {
        error,
        context: { serverName, projectPath, serverDest: path.join(projectPath, '.mcp-servers', serverName) }
      });
      return false;
    }
  }
}
