// SPDX-License-Identifier: MIT
/**
 * Reinstall Service (erase-and-replace)
 *
 * Transactional reinstall/sync for existing dev-suite installations.
 *
 * Strategy: scoped erase + replace.
 *  - The manifest (`.dev-suite-manifest.json`) is the source of truth for what
 *    dev-suite OWNS on disk; `.dev-suite.json` is the source of truth for what
 *    to reinstall (the selection).
 *  - Only manifest-tracked MANAGED files are erased (agents, mcp-server dirs,
 *    rule files). Skills are cleaned + rebuilt by `install()` itself
 *    (`cleanStaleSkills`). Shared files (`CLAUDE.md`, `.claude/settings.json`,
 *    `.mcp.json`) are merged/regenerated by `install()` — never hard-erased.
 *  - User-owned content (`.claude/agents/custom/`, `.claude/skills/custom/`,
 *    files not in the manifest) is never touched.
 *  - Locally modified managed files are surfaced in preview; the user opts out
 *    per file (`keep`) to preserve their edits.
 *  - The whole erase+replace is wrapped in backup + rollback-on-error.
 *
 * This intentionally does NOT reuse `InstallationService.uninstall()`, which
 * blanket-deletes `.claude/agents` and `.claude/skills` (recursive) and would
 * destroy `custom/`.
 */

import * as fs from 'fs';
import * as path from 'path';
import { getLogger } from '../utils/logger.js';
import { resolveProjectPath, PathValidationError } from '../utils/utilities.js';
import { readJsonSync } from '../utils/fs-utils.js';
import { InstallationService } from './installation.service.js';
import {
  DEV_SUITE_VERSION,
  loadManifest,
  saveManifest,
  calculateFileHashFromPath,
} from './upgrade/index.js';
import {
  copyDirSync,
  removePathScopedRules,
  validatePathWithinBase,
  validateEntryName,
} from './installation/index.js';
import type {
  InstallConfig,
  DetectionResult,
  FrameworkInfo,
} from '../types.js';
import type {
  ExtendedManifest,
  TrackedFile,
  StackInfo,
  ReinstallPreviewResult,
  ReinstallExecuteRequest,
  ReinstallExecuteResult,
  ReinstallModifiedFile,
  ReinstallHistoryEntry,
  ReinstallFileResolution,
} from '../types/index.js';

const logger = getLogger('ReinstallService');

const BACKUP_DIR_PREFIX = '.dev-suite-backup-';

/** Selection of components to reinstall. */
interface Selection {
  agents: string[];
  mcpServers: string[];
  rules: string[];
}

export class ReinstallService {
  private installationService = new InstallationService();

  /**
   * Preview a reinstall (read-only, repeatable).
   */
  async previewReinstall(projectPath: string): Promise<ReinstallPreviewResult> {
    projectPath = this.guardPath(projectPath);
    const manifest = loadManifest(projectPath);

    if (!manifest) {
      return {
        hasValidManifest: false,
        reason: 'No dev-suite manifest found. Is dev-suite installed in this project?',
        selection: { agents: [], mcpServers: [], rules: [] },
        modifiedManagedFiles: [],
        orphansToRemove: [],
        filesToReplace: [],
        skillDirsToRebuild: 0,
        requiresIntervention: false,
      };
    }

    const selection = this.resolveSelection(projectPath, manifest);
    const files = manifest.files ?? [];

    const modifiedManagedFiles: ReinstallModifiedFile[] = [];
    const filesToReplace: string[] = [];
    const orphansToRemove: string[] = [];

    for (const file of files) {
      const category = this.classify(file);
      if (category === 'shared' || category === 'skip') continue;

      // Skill dirs aren't tracked with a usable hash (a directory hashes to
      // null), so they never appear as manifest 'skill' entries; they are
      // counted from disk below and always rebuilt by install() — no opt-out.
      if (category === 'skill') continue;

      filesToReplace.push(file.path);

      // Orphan detection
      if (this.isOrphan(file, selection)) {
        orphansToRemove.push(file.path);
      }

      // Drift detection (only meaningful for files that still exist + have a hash)
      const fullPath = this.safe(projectPath, file.path);
      const currentHash = calculateFileHashFromPath(fullPath);
      if (currentHash && file.hash && currentHash !== file.hash) {
        modifiedManagedFiles.push({
          path: file.path,
          type: file.type,
          manifestHash: file.hash,
          currentHash,
        });
      }
    }

    return {
      hasValidManifest: true,
      selection,
      modifiedManagedFiles,
      orphansToRemove,
      filesToReplace,
      skillDirsToRebuild: this.countManagedSkillDirs(projectPath),
      requiresIntervention: modifiedManagedFiles.length > 0,
    };
  }

  /**
   * Execute a reinstall: backup → erase (scoped) → replace → verify, with
   * rollback on any failure.
   */
  async executeReinstall(request: ReinstallExecuteRequest): Promise<ReinstallExecuteResult> {
    const projectPath = this.guardPath(request.projectPath);
    const resolutions = request.resolutions ?? {};
    const shouldBackup = request.createBackup !== false;

    const manifest = loadManifest(projectPath);
    if (!manifest) {
      return this.fail('No dev-suite manifest found. Is dev-suite installed in this project?');
    }

    const selection = this.resolveSelection(projectPath, manifest);
    const files = manifest.files ?? [];

    // Recover install parameters BEFORE erasing anything (they live in files we
    // are about to regenerate).
    const skillLoadingMode = this.recoverSkillLoadingMode(projectPath);
    const envVars = this.recoverEnvVars(projectPath);
    const detectedStack = this.stackInfoToDetectionResult(manifest.detectedStack);

    // Carry over prior history (install() rewrites the manifest from scratch).
    const priorReinstallHistory = manifest.reinstallHistory ?? [];

    // Determine kept (opt-out) files and snapshot their content.
    const keptFiles: string[] = [];
    const keptSnapshots = new Map<string, Buffer>();
    for (const [relPath, decision] of Object.entries(resolutions)) {
      if ((decision as ReinstallFileResolution) !== 'keep') continue;
      const full = this.safe(projectPath, relPath);
      if (fs.existsSync(full)) {
        keptSnapshots.set(relPath, fs.readFileSync(full));
        keptFiles.push(relPath);
      }
    }

    // ---- Backup ----
    let backupDir: string | undefined;
    if (shouldBackup) {
      try {
        backupDir = this.createReinstallBackup(projectPath, files);
      } catch (error) {
        logger.error('Backup failed; aborting reinstall', { error });
        return this.fail(
          `Backup failed, reinstall aborted: ${error instanceof Error ? error.message : String(error)}`
        );
      }
    }

    const orphansRemoved: string[] = [];

    try {
      // ---- ERASE (scoped) ----
      orphansRemoved.push(...this.erase(projectPath, manifest, selection, new Set(keptFiles)));

      // ---- REPLACE ----
      const config: InstallConfig = {
        projectPath,
        agents: selection.agents,
        mcpServers: selection.mcpServers,
        rules: selection.rules,
        envVars,
        detectedStack,
        skillLoadingMode,
      };
      await this.installationService.install(config);

      // ---- Restore kept files over the canonical versions ----
      const newManifest = loadManifest(projectPath);
      if (!newManifest) {
        throw new Error('Manifest missing after install — install did not complete');
      }
      for (const relPath of keptFiles) {
        const snapshot = keptSnapshots.get(relPath);
        if (!snapshot) continue;
        const full = this.safe(projectPath, relPath);
        fs.mkdirSync(path.dirname(full), { recursive: true });
        fs.writeFileSync(full, snapshot);
        // Re-track the user's hash so future previews don't flag it forever.
        const keptHash = calculateFileHashFromPath(full);
        const entry = newManifest.files.find(f => f.path === relPath);
        if (entry && keptHash) entry.hash = keptHash;
      }

      // ---- VERIFY ----
      const verifyWarnings = this.verify(projectPath, newManifest);

      // ---- History ----
      const historyEntry: ReinstallHistoryEntry = {
        timestamp: new Date().toISOString(),
        devSuiteVersion: DEV_SUITE_VERSION,
        agentsReinstalled: selection.agents,
        mcpReinstalled: selection.mcpServers,
        orphansRemoved,
        keptFiles,
        backupDir,
      };
      newManifest.reinstallHistory = [...priorReinstallHistory, historyEntry];
      saveManifest(projectPath, newManifest);

      logger.info('Reinstall complete', {
        context: {
          projectPath,
          agents: selection.agents.length,
          mcp: selection.mcpServers.length,
          orphansRemoved: orphansRemoved.length,
          keptFiles: keptFiles.length,
          warnings: verifyWarnings.length,
        },
      });

      return {
        success: true,
        backupDir,
        agentsReinstalled: selection.agents,
        mcpReinstalled: selection.mcpServers,
        orphansRemoved,
        keptFiles,
        verifyWarnings,
        newManifest,
      };
    } catch (error) {
      logger.error('Reinstall failed; attempting rollback', { error });
      let rolledBack = false;
      if (backupDir) {
        try {
          this.rollback(projectPath, backupDir, selection, files);
          rolledBack = true;
        } catch (rbError) {
          logger.error('Rollback failed — project may be in a partial state', {
            error: rbError,
            context: { backupDir },
          });
        }
      }
      return {
        success: false,
        error: error instanceof Error ? error.message : String(error),
        rolledBack,
        backupDir,
        agentsReinstalled: [],
        mcpReinstalled: [],
        orphansRemoved: [],
        keptFiles: [],
        verifyWarnings: [],
      };
    }
  }

  /**
   * Get reinstall history for a project.
   */
  async getReinstallHistory(projectPath: string): Promise<ReinstallHistoryEntry[]> {
    projectPath = this.guardPath(projectPath);
    const manifest = loadManifest(projectPath);
    return manifest?.reinstallHistory ?? [];
  }

  // ========== Private helpers ==========

  private guardPath(projectPath: string): string {
    if (projectPath.includes('..')) throw new PathValidationError('Path traversal not allowed');
    const resolved = resolveProjectPath(projectPath);
    if (!path.isAbsolute(resolved)) throw new PathValidationError('Path must be rooted');
    return resolved;
  }

  /**
   * SECURITY: build a path from `base` + segments and verify it stays within
   * `base` (rejects `..`, resolves symlinks, prefix-checks). Returns the
   * sanitized absolute path to use at the fs sink. Every fs operation in this
   * service goes through here so a malicious manifest/`.dev-suite.json` cannot
   * read, write, or delete outside the project (or backup) root.
   */
  private safe(base: string, ...segments: string[]): string {
    return validatePathWithinBase(path.join(base, ...segments), base);
  }

  private fail(error: string): ReinstallExecuteResult {
    return {
      success: false,
      error,
      agentsReinstalled: [],
      mcpReinstalled: [],
      orphansRemoved: [],
      keptFiles: [],
      verifyWarnings: [],
    };
  }

  /**
   * Resolve the selection from `.dev-suite.json`, falling back to the manifest
   * if it is missing/corrupt.
   */
  private resolveSelection(projectPath: string, manifest: ExtendedManifest): Selection {
    const devSuiteJson = readJsonSync<{
      agents?: { enabled?: string[] };
      mcpServers?: { enabled?: string[] };
      rules?: { enabled?: string[] };
    }>(this.safe(projectPath, '.dev-suite.json'));

    return {
      agents: devSuiteJson?.agents?.enabled ?? manifest.agents ?? [],
      mcpServers: devSuiteJson?.mcpServers?.enabled ?? manifest.mcpServers ?? [],
      rules: devSuiteJson?.rules?.enabled ?? [],
    };
  }

  /**
   * Classify a tracked file into how the reinstall treats it.
   */
  private classify(file: TrackedFile): 'managed-file' | 'mcp-server' | 'skill' | 'shared' | 'skip' {
    if (file.type === 'skill') return 'skill';
    if (file.type === 'mcp-server') return 'mcp-server';
    if (file.type === 'agent') return 'managed-file';
    if (file.type === 'config') {
      // Rule files are managed; settings/mcp/dev-suite config are shared/regenerated.
      if (file.path.startsWith('.claude/rules/')) return 'managed-file';
      return 'shared';
    }
    // 'generated' (e.g. _README.md) is regenerated by install() — skip erase.
    return 'skip';
  }

  private componentName(file: TrackedFile): string | null {
    if (file.type === 'agent') {
      // .claude/agents/<id>.md
      return path.basename(file.path, '.md');
    }
    if (file.type === 'mcp-server') {
      // .mcp-servers/<name>/...
      const parts = file.path.split(/[\\/]/);
      const idx = parts.indexOf('.mcp-servers');
      const name = idx >= 0 ? parts[idx + 1] : undefined;
      return name ?? null;
    }
    return null;
  }

  /**
   * Count dev-suite-managed skill directories on disk under `.claude/skills/`
   * — direct children containing a `SKILL.md` (anywhere in their tree),
   * excluding the reserved user `custom/` folder. Mirrors `cleanStaleSkills`
   * (installation.service), which is what actually rebuilds them, so this is
   * the true "will be rebuilt" count. Skill dirs aren't tracked in the manifest
   * (a directory hashes to null), so they can't be counted from `files`.
   */
  private countManagedSkillDirs(projectPath: string): number {
    const skillsDir = this.safe(projectPath, '.claude', 'skills');
    if (!fs.existsSync(skillsDir)) return 0;

    // Every descendant must stay within skillsDir (validated against it).
    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() &&
            validateEntryName(entry.name) &&
            containsSkillMd(validatePathWithinBase(path.join(dir, entry.name), skillsDir))
          ) {
            return true;
          }
        }
      } catch {
        // unreadable — treat as no match
      }
      return false;
    };

    let count = 0;
    try {
      for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
        if (!entry.isDirectory() || entry.name === 'custom' || !validateEntryName(entry.name)) continue;
        if (containsSkillMd(this.safe(skillsDir, entry.name))) count++;
      }
    } catch {
      return 0;
    }
    return count;
  }

  private isOrphan(file: TrackedFile, selection: Selection): boolean {
    const name = this.componentName(file);
    if (file.type === 'agent') return name !== null && !selection.agents.includes(name);
    if (file.type === 'mcp-server') return name !== null && !selection.mcpServers.includes(name);
    return false;
  }

  /**
   * Scoped erase of managed files. NEVER touches `custom/`, shared files, or
   * untracked user content. Returns the relative paths of orphans removed.
   */
  private erase(
    projectPath: string,
    manifest: ExtendedManifest,
    selection: Selection,
    keptPaths: Set<string>,
  ): string[] {
    const orphansRemoved: string[] = [];
    const erasedMcpDirs = new Set<string>();
    const files = manifest.files ?? [];

    for (const file of files) {
      // Hard safety net: never delete anything under a `custom/` segment.
      if (/(^|[\\/])custom[\\/]/.test(file.path)) continue;
      if (keptPaths.has(file.path)) continue;

      const category = this.classify(file);
      const isOrphan = this.isOrphan(file, selection);

      if (category === 'managed-file') {
        // agent files + .claude/rules/*.md copies
        const full = this.safe(projectPath, file.path);
        if (fs.existsSync(full)) {
          fs.unlinkSync(full);
          if (isOrphan) orphansRemoved.push(file.path);
        }
      } else if (category === 'mcp-server') {
        const name = this.componentName(file);
        if (!name || !validateEntryName(name) || erasedMcpDirs.has(name)) continue;
        erasedMcpDirs.add(name);
        const serverDir = this.safe(projectPath, '.mcp-servers', name);
        if (fs.existsSync(serverDir)) {
          fs.rmSync(serverDir, { recursive: true, force: true });
          if (isOrphan) orphansRemoved.push(`.mcp-servers/${name}`);
        }
      }
      // 'skill' dirs are cleaned + rebuilt by install()'s cleanStaleSkills.
      // 'shared'/'skip' files are left for install() to merge/regenerate.
    }

    // Remove sentinel-guarded path-scoped rule files (only ours).
    const ruleFiles = manifest.installedRuleFiles ?? [];
    if (ruleFiles.length > 0) {
      removePathScopedRules(projectPath, ruleFiles);
    }

    return orphansRemoved;
  }

  /**
   * Post-install verification. Returns non-fatal warnings; throws on fatal
   * problems (which trigger rollback).
   */
  private verify(projectPath: string, manifest: ExtendedManifest): string[] {
    const warnings: string[] = [];

    // Every tracked file must exist.
    for (const file of manifest.files ?? []) {
      const full = this.safe(projectPath, file.path);
      if (!fs.existsSync(full)) {
        throw new Error(`Verification failed: tracked file missing after install: ${file.path}`);
      }
    }

    // .mcp.json must be valid JSON with absolute, existing server entry args.
    const mcpJsonPath = this.safe(projectPath, '.mcp.json');
    if (fs.existsSync(mcpJsonPath)) {
      let parsed: { mcpServers?: Record<string, { args?: string[] }> };
      try {
        parsed = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'));
      } catch (e) {
        throw new Error(`Verification failed: .mcp.json is not valid JSON: ${e instanceof Error ? e.message : e}`);
      }
      for (const [name, entry] of Object.entries(parsed.mcpServers ?? {})) {
        const scriptArg = entry.args?.find(a => a.endsWith('.js') || a.endsWith('.cjs') || a.endsWith('.mjs'));
        if (scriptArg && !path.isAbsolute(scriptArg)) {
          throw new Error(`Verification failed: .mcp.json server "${name}" uses a non-absolute path: ${scriptArg}`);
        }
        if (scriptArg && !fs.existsSync(scriptArg)) {
          warnings.push(`MCP server "${name}" script not found on disk: ${scriptArg}`);
        }
      }
    }

    return warnings;
  }

  // ---- Backup / rollback ----

  /**
   * Back up everything the reinstall could mutate. Copies the whole `.claude`
   * tree (copyDirSync skips node_modules/.git), the top-level config files, and
   * each managed `.mcp-servers/<name>` dir (sans node_modules) into a timestamped
   * backup dir. Returns the absolute backup dir path.
   */
  private createReinstallBackup(projectPath: string, files: TrackedFile[]): string {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const backupDir = this.safe(projectPath, `${BACKUP_DIR_PREFIX}${timestamp}`);
    fs.mkdirSync(backupDir, { recursive: true });

    // .claude tree (agents, skills, rules, settings, commands, custom/)
    const claudeDir = this.safe(projectPath, '.claude');
    if (fs.existsSync(claudeDir)) {
      copyDirSync(claudeDir, this.safe(backupDir, '.claude'));
    }

    // Top-level config + CLAUDE.md
    for (const f of ['.mcp.json', '.dev-suite.json', '.dev-suite-manifest.json', 'CLAUDE.md']) {
      const src = this.safe(projectPath, f);
      if (fs.existsSync(src)) {
        fs.copyFileSync(src, this.safe(backupDir, f));
      }
    }

    // Managed MCP server dirs (node_modules skipped by copyDirSync)
    const mcpNames = new Set<string>();
    for (const file of files) {
      if (file.type === 'mcp-server') {
        const name = this.componentName(file);
        if (name && validateEntryName(name)) mcpNames.add(name);
      }
    }
    for (const name of mcpNames) {
      const src = this.safe(projectPath, '.mcp-servers', name);
      if (fs.existsSync(src)) {
        copyDirSync(src, this.safe(backupDir, '.mcp-servers', name));
      }
    }

    logger.info('Created reinstall backup', { context: { backupDir } });
    return backupDir;
  }

  /**
   * Restore project state from a backup created by createReinstallBackup.
   * Deletes managed surfaces (removing any partial files the failed install
   * created) then copies the backup tree back.
   */
  private rollback(projectPath: string, backupDir: string, selection: Selection, files: TrackedFile[]): void {
    // 1. Remove managed surfaces created/mutated during the attempt.
    fs.rmSync(this.safe(projectPath, '.claude'), { recursive: true, force: true });
    for (const f of ['.mcp.json', '.dev-suite.json', '.dev-suite-manifest.json', 'CLAUDE.md']) {
      const p = this.safe(projectPath, f);
      if (fs.existsSync(p)) fs.rmSync(p, { force: true });
    }
    const mcpNames = new Set<string>(selection.mcpServers);
    for (const file of files) {
      if (file.type === 'mcp-server') {
        const name = this.componentName(file);
        if (name) mcpNames.add(name);
      }
    }
    for (const name of mcpNames) {
      if (!validateEntryName(name)) continue;
      const dir = this.safe(projectPath, '.mcp-servers', name);
      if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
    }

    // 2. Copy the backup tree back.
    for (const entry of fs.readdirSync(backupDir, { withFileTypes: true })) {
      if (!validateEntryName(entry.name)) continue;
      const src = this.safe(backupDir, entry.name);
      const dest = this.safe(projectPath, entry.name);
      if (entry.isDirectory()) {
        copyDirSync(src, dest);
      } else {
        fs.copyFileSync(src, dest);
      }
    }
    logger.info('Rolled back reinstall from backup', { context: { backupDir } });
  }

  // ---- Install-parameter recovery ----

  private recoverSkillLoadingMode(projectPath: string): 'eager' | 'lazy' {
    const mcp = readJsonSync<{ mcpServers?: Record<string, unknown> }>(
      this.safe(projectPath, '.mcp.json')
    );
    return mcp?.mcpServers && Object.prototype.hasOwnProperty.call(mcp.mcpServers, 'skill-loader')
      ? 'lazy'
      : 'eager';
  }

  private recoverEnvVars(projectPath: string): Record<string, string> {
    const mcp = readJsonSync<{ mcpServers?: Record<string, { env?: Record<string, string> }> }>(
      this.safe(projectPath, '.mcp.json')
    );
    const out: Record<string, string> = {};
    for (const entry of Object.values(mcp?.mcpServers ?? {})) {
      for (const [k, v] of Object.entries(entry.env ?? {})) {
        if (typeof v === 'string') out[k] = v;
      }
    }
    return out;
  }

  /**
   * Convert the manifest's snake_case StackInfo back into the camelCase
   * DetectionResult shape that InstallConfig expects.
   */
  private stackInfoToDetectionResult(stack?: StackInfo): DetectionResult | undefined {
    if (!stack) return undefined;
    const fw = (f?: StackInfo['frontend']): FrameworkInfo | undefined =>
      f ? { framework: f.framework, metaFramework: f.meta_framework, runtime: f.runtime } : undefined;
    return {
      projectType: stack.projectType,
      frontend: fw(stack.frontend),
      backend: fw(stack.backend),
      database: stack.database ? { dbType: stack.database.db_type, orm: stack.database.orm } : undefined,
      testing: stack.testing,
      isMonorepo: false,
      confidence: 1,
    };
  }
}
