/**
 * Analyze Command
 *
 * Indexes a repository and stores the knowledge graph in .ontoindex/
 *
 * Delegates core analysis to the shared runFullAnalysis orchestrator.
 * This CLI wrapper handles: heap management, progress bar, SIGINT,
 * skill generation (--skills), summary output, and process.exit().
 */

import path from 'path';
import { execFileSync } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { closeLbug } from '../core/lbug/lbug-adapter.js';
import {
  getStoragePaths,
  getGlobalRegistryPath,
  RegistryNameCollisionError,
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import fs from 'fs/promises';
import { randomUUID } from 'crypto';
import {
  applyLargeRepoSafeAnalyzePreset,
  formatHugeRepoNote,
  formatLargeRepoSafeNote,
  parseLargeRepoSafeAppliedList,
} from './analyze-large-repo-safe.js';
import type { PipelineProfile } from '../core/ingestion/pipeline.js';

const HEAP_MB = 8192;
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
const STACK_KB = 4096;
const STACK_FLAG = `--stack-size=${STACK_KB}`;

interface ErrorWithCode {
  code?: unknown;
}

interface ErrorWithStatus {
  status?: unknown;
}

interface ErrorWithMessage {
  message?: unknown;
}

interface AnalyzeLockRecord {
  pid?: unknown;
  token?: unknown;
  startedAt?: unknown;
}

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function hasProperties(
  value: unknown,
): value is ErrorWithCode & ErrorWithStatus & ErrorWithMessage {
  return (typeof value === 'object' && value !== null) || typeof value === 'function';
}

function getErrorCode(error: unknown): string | undefined {
  if (!hasProperties(error)) return undefined;
  const { code } = error;
  return typeof code === 'string' ? code : undefined;
}

function getErrorStatus(error: unknown): number | undefined {
  if (!hasProperties(error)) return undefined;
  const { status } = error;
  return typeof status === 'number' ? status : undefined;
}

function getErrorMessage(error: unknown): string {
  if (hasProperties(error)) {
    const { message } = error;
    if (message) return String(message);
  }
  return String(error);
}

function parseAnalyzeLockRecord(contents: string): AnalyzeLockRecord | null {
  const parsed: unknown = JSON.parse(contents);
  return isObject(parsed) ? parsed : null;
}

/** Re-exec the process with an 8GB heap and larger stack if we're currently below that. */
function ensureHeap(): boolean {
  const nodeOpts = process.env.NODE_OPTIONS || '';
  if (nodeOpts.includes('--max-old-space-size')) return false;

  const v8Heap = v8.getHeapStatistics().heap_size_limit;
  if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;

  // --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+,
  // so pass it only as a direct CLI argument, not via the environment.
  const cliFlags = [HEAP_FLAG];
  if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);

  try {
    execFileSync(process.execPath, [...cliFlags, ...process.argv.slice(1)], {
      stdio: 'inherit',
      env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
    });
  } catch (e: unknown) {
    process.exitCode = getErrorStatus(e) ?? 1;
  }
  return true;
}

interface AnalyzeLock {
  path: string;
  release: () => Promise<void>;
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

async function removeAnalyzeLockIfOwner(
  lockPath: string,
  expectedPid: number,
  expectedToken?: string,
): Promise<boolean> {
  try {
    const current = parseAnalyzeLockRecord(await fs.readFile(lockPath, 'utf-8'));
    if (current?.pid !== expectedPid) return false;
    if (expectedToken !== undefined && current?.token !== expectedToken) return false;
    if (expectedToken === undefined && current?.token !== undefined) return false;
    await fs.unlink(lockPath);
    return true;
  } catch {
    return false;
  }
}

async function acquireAnalyzeLock(repoPath: string): Promise<AnalyzeLock> {
  const { storagePath } = getStoragePaths(repoPath);
  await fs.mkdir(storagePath, { recursive: true });
  const lockPath = path.join(storagePath, 'analyze.lock');
  const token = randomUUID();

  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      const handle = await fs.open(lockPath, 'wx');
      try {
        await handle.writeFile(
          JSON.stringify(
            {
              pid: process.pid,
              token,
              startedAt: new Date().toISOString(),
              repoPath,
              command: process.argv.join(' '),
            },
            null,
            2,
          ),
          'utf-8',
        );
      } finally {
        await handle.close();
      }
      let released = false;
      return {
        path: lockPath,
        release: async () => {
          if (released) return;
          released = true;
          await removeAnalyzeLockIfOwner(lockPath, process.pid, token);
        },
      };
    } catch (err: unknown) {
      if (getErrorCode(err) !== 'EEXIST') throw err;

      let existing: AnalyzeLockRecord | null = null;
      try {
        existing = parseAnalyzeLockRecord(await fs.readFile(lockPath, 'utf-8'));
      } catch {}

      const pid = typeof existing?.pid === 'number' ? existing.pid : null;
      if (pid !== null && !isProcessAlive(pid)) {
        const staleToken = typeof existing?.token === 'string' ? existing.token : undefined;
        await removeAnalyzeLockIfOwner(lockPath, pid, staleToken);
        continue;
      }

      const owner = pid !== null ? `PID ${pid}` : 'another process';
      const startedAt =
        typeof existing?.startedAt === 'string' ? ` since ${existing.startedAt}` : '';
      throw new Error(
        `OntoIndex analyze is already running for this repo (${owner}${startedAt}). ` +
          `Lock: ${lockPath}`,
      );
    }
  }

  throw new Error(`Could not acquire analyze lock for ${repoPath}`);
}

interface AnalyzeOptions {
  force?: boolean;
  embeddings?: boolean;
  /** Build ANN_NEIGHBOR edges for symbol-neighborhood retrieval (implies embeddings). */
  annNeighbors?: boolean;
  skills?: boolean;
  verbose?: boolean;
  /** Skip AGENTS.md and CLAUDE.md ontoindex block updates. */
  skipAgentsMd?: boolean;
  /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
  noStats?: boolean;
  /** Index the folder even when no .git directory is present. */
  skipGit?: boolean;
  /** Deprecated compatibility flag; aliases to the bounded symbols profile. */
  indexOnly?: boolean;
  /** Apply conservative defaults for bounded large-repo analysis. */
  largeRepoSafe?: boolean;
  /** Run the minimal scan/structure/parse pipeline for symbol lookup only. */
  symbolsOnly?: boolean;
  /** Compose symbols-only, large-repo-safe defaults, and scoped-root guardrails. */
  hugeRepo?: boolean;
  /** Allow --huge-repo at repository root without explicit include paths. */
  allowHugeRoot?: boolean;
  /** Limit analysis to repository-relative file or directory roots. */
  includePath?: string[];
  markdownSidecar?: boolean;
  /**
   * Override the default basename-derived registry `name` with a
   * user-supplied alias (#829). Disambiguates repos whose paths share a
   * basename. Persisted — subsequent re-analyses of the same path without
   * `--name` preserve the alias.
   */
  name?: string;
  /**
   * Allow registration even when another path already uses the same
   * `--name` alias (#829). Intentionally a distinct flag from `--force`
   * because the user may want to coexist under the same name WITHOUT
   * paying the cost of a pipeline re-index. Maps to registerRepo's
   * `allowDuplicateName` option end-to-end.
   */
  allowDuplicateName?: boolean;
}

export function resolveAnalyzePipelineProfile(
  options?: Pick<AnalyzeOptions, 'indexOnly' | 'symbolsOnly' | 'hugeRepo'>,
): PipelineProfile | undefined {
  if (options?.hugeRepo) return 'huge-repo-symbols';
  return options?.symbolsOnly || options?.indexOnly ? 'symbols' : undefined;
}

export function resolveAnalyzeIncludePaths(
  options?: Pick<AnalyzeOptions, 'includePath'>,
): string[] | undefined {
  return options?.includePath && options.includePath.length > 0
    ? [...options.includePath]
    : undefined;
}

export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
  if (ensureHeap()) return;

  const largeRepoSafeApplied =
    (options?.largeRepoSafe || options?.hugeRepo) &&
    process.env.ONTOINDEX_LARGE_REPO_SAFE_APPLIED !== undefined
      ? parseLargeRepoSafeAppliedList(process.env.ONTOINDEX_LARGE_REPO_SAFE_APPLIED)
      : applyLargeRepoSafeAnalyzePreset(options);

  if (options?.verbose) {
    process.env.ONTOINDEX_VERBOSE = '1';
  }

  console.log('\n  OntoIndex Analyzer\n');

  if (options?.hugeRepo) {
    console.log(`  ${formatHugeRepoNote()}\n`);
  } else if (options?.indexOnly && !options.symbolsOnly) {
    console.log('  --index-only is deprecated; using --symbols-only behavior.\n');
  }

  if (options?.largeRepoSafe || options?.hugeRepo) {
    console.log(`  ${formatLargeRepoSafeNote(largeRepoSafeApplied, options)}\n`);
  }

  let repoPath: string;
  if (inputPath) {
    repoPath = path.resolve(inputPath);
  } else {
    const gitRoot = getGitRoot(process.cwd());
    if (!gitRoot) {
      if (!options?.skipGit) {
        console.log(
          '  Not inside a git repository.\n  Tip: pass --skip-git to index any folder without a .git directory.\n',
        );
        process.exitCode = 1;
        return;
      }
      // --skip-git: fall back to cwd as the root
      repoPath = path.resolve(process.cwd());
    } else {
      repoPath = gitRoot;
    }
  }

  // A subdir inside a git repo (no own .git) is valid \u2014 git log/rev-parse still work from it.
  const repoHasGit = hasGitDir(repoPath) || getGitRoot(repoPath) !== null;
  if (!repoHasGit && !options?.skipGit) {
    console.log(
      '  Not a git repository.\n  Tip: pass --skip-git to index any folder without a .git directory.\n',
    );
    process.exitCode = 1;
    return;
  }
  if (!repoHasGit) {
    console.log(
      '  Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n',
    );
  }

  const includePaths = resolveAnalyzeIncludePaths(options);
  if (options?.hugeRepo && !options.allowHugeRoot && !includePaths) {
    console.error(
      '  --huge-repo requires at least one --include-path, or pass --allow-huge-root explicitly.\n',
    );
    process.exitCode = 1;
    return;
  }

  // KuzuDB migration cleanup is handled by runFullAnalysis internally.
  // Note: --skills is handled after runFullAnalysis using the returned pipelineResult.

  if (process.env.ONTOINDEX_NO_GITIGNORE) {
    console.log(
      '  ONTOINDEX_NO_GITIGNORE is set — skipping .gitignore (still reading .ontoindexignore)\n',
    );
  }

  // ── CLI progress bar setup ─────────────────────────────────────────
  const bar = new cliProgress.SingleBar(
    {
      format: '  {bar} {percentage}% | {phase}',
      barCompleteChar: '\u2588',
      barIncompleteChar: '\u2591',
      hideCursor: true,
      barGlue: '',
      autopadding: true,
      clearOnComplete: false,
      stopOnComplete: false,
    },
    cliProgress.Presets.shades_grey,
  );

  bar.start(100, 0, { phase: 'Initializing...' });

  // Graceful SIGINT handling
  let aborted = false;
  let analyzeLock: AnalyzeLock | null = null;
  const sigintHandler = () => {
    if (aborted) process.exit(1);
    aborted = true;
    bar.stop();
    console.log('\n  Interrupted — cleaning up...');
    closeLbug()
      .catch(() => {})
      .then(() => analyzeLock?.release())
      .catch(() => {})
      .finally(() => process.exit(130));
  };
  process.on('SIGINT', sigintHandler);

  // Route console output through bar.log() to prevent progress bar corruption
  const origLog = console.log.bind(console);
  const origWarn = console.warn.bind(console);
  const origError = console.error.bind(console);
  let barCurrentValue = 0;
  const barLog = (...args: unknown[]) => {
    process.stdout.write('\x1b[2K\r');
    origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
    bar.update(barCurrentValue);
  };
  console.log = barLog;
  console.warn = barLog;
  console.error = barLog;

  // Track elapsed time per phase
  let lastPhaseLabel = 'Initializing...';
  let phaseStart = Date.now();

  const updateBar = (value: number, phaseLabel: string) => {
    barCurrentValue = value;
    if (phaseLabel !== lastPhaseLabel) {
      lastPhaseLabel = phaseLabel;
      phaseStart = Date.now();
    }
    const elapsed = Math.round((Date.now() - phaseStart) / 1000);
    const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
    bar.update(value, { phase: display });
  };

  const elapsedTimer = setInterval(() => {
    const elapsed = Math.round((Date.now() - phaseStart) / 1000);
    if (elapsed >= 3) {
      bar.update({ phase: `${lastPhaseLabel} (${elapsed}s)` });
    }
  }, 1000);

  const t0 = Date.now();

  // ── Run shared analysis orchestrator ───────────────────────────────
  try {
    analyzeLock = await acquireAnalyzeLock(repoPath);
    const { runFullAnalysis } = await import('../core/run-analyze.js');
    const profile = resolveAnalyzePipelineProfile(options);
    const result = await runFullAnalysis(
      repoPath,
      {
        // Pipeline re-index — OR'd with --skills because skill generation
        // needs a fresh pipelineResult. Has no bearing on the registry
        // collision guard (see allowDuplicateName below).
        force: options?.force || options?.skills,
        embeddings: options?.embeddings,
        annNeighbors: options?.annNeighbors,
        skipGit: options?.skipGit,
        markdownSidecar: options?.markdownSidecar,
        ...(profile ? { profile } : {}),
        ...(includePaths ? { includePaths } : {}),
        skipAgentsMd: options?.skipAgentsMd,
        noStats: options?.noStats,
        registryName: options?.name,
        // Registry-collision bypass — its own CLI flag, intentionally NOT
        // overloading --force. A user who hits the collision guard should
        // be able to accept the duplicate name without also paying the
        // cost of a full pipeline re-index. See #829 review round 2.
        allowDuplicateName: options?.allowDuplicateName,
      },
      {
        onProgress: (_phase, percent, message) => {
          updateBar(percent, message);
        },
        onLog: barLog,
      },
    );

    if (result.alreadyUpToDate) {
      clearInterval(elapsedTimer);
      process.removeListener('SIGINT', sigintHandler);
      console.log = origLog;
      console.warn = origWarn;
      console.error = origError;
      bar.stop();
      console.log('  Already up to date\n');
      // Safe to return without process.exit(0) — the early-return path in
      // runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
      return;
    }

    // Skill generation (CLI-only, uses pipeline result from analysis)
    if (options?.skills && result.pipelineResult) {
      updateBar(99, 'Generating skill files...');
      try {
        const { generateSkillFiles } = await import('./skill-gen.js');
        const { generateAIContextFiles } = await import('./ai-context.js');
        const skillResult = await generateSkillFiles(
          repoPath,
          result.repoName,
          result.pipelineResult,
        );
        if (skillResult.skills.length > 0) {
          barLog(`  Generated ${skillResult.skills.length} skill files`);
          // Re-generate AI context files now that we have skill info
          const s = result.stats;
          const communityResult = result.pipelineResult?.communityResult;
          let aggregatedClusterCount = 0;
          if (communityResult?.communities) {
            const groups = new Map<string, number>();
            for (const c of communityResult.communities) {
              const label = c.heuristicLabel || c.label || 'Unknown';
              groups.set(label, (groups.get(label) || 0) + c.symbolCount);
            }
            aggregatedClusterCount = Array.from(groups.values()).filter(
              (count: number) => count >= 5,
            ).length;
          }
          const { storagePath: sp } = getStoragePaths(repoPath);
          await generateAIContextFiles(
            repoPath,
            sp,
            result.repoName,
            {
              files: s.files ?? 0,
              nodes: s.nodes ?? 0,
              edges: s.edges ?? 0,
              communities: s.communities,
              clusters: aggregatedClusterCount,
              processes: s.processes,
            },
            skillResult.skills,
            { skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
          );
        }
      } catch {
        /* best-effort */
      }
    }

    const totalTime = ((Date.now() - t0) / 1000).toFixed(1);

    clearInterval(elapsedTimer);
    process.removeListener('SIGINT', sigintHandler);

    console.log = origLog;
    console.warn = origWarn;
    console.error = origError;

    bar.update(100, { phase: 'Done' });
    bar.stop();

    // ── Summary ────────────────────────────────────────────────────
    const s = result.stats;
    console.log(`\n  Repository indexed successfully (${totalTime}s)\n`);
    console.log(
      `  ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`,
    );
    console.log(`  ${repoPath}`);

    try {
      await fs.access(getGlobalRegistryPath());
    } catch {
      console.log('\n  Tip: Run `ontoindex setup` to configure MCP for your editor.');
    }

    console.log('');
  } catch (err: unknown) {
    clearInterval(elapsedTimer);
    process.removeListener('SIGINT', sigintHandler);
    console.log = origLog;
    console.warn = origWarn;
    console.error = origError;
    bar.stop();

    const msg = getErrorMessage(err);

    // Registry name-collision from --name (#829) — surface as an
    // actionable error rather than a generic stack-trace.
    if (err instanceof RegistryNameCollisionError) {
      console.error(`\n  Registry name collision:\n`);
      console.error(`    "${err.registryName}" is already used by "${err.existingPath}".\n`);
      console.error(`  Options:`);
      console.error(`    • Pick a different alias:  ontoindex analyze --name <alias>`);
      console.error(
        `    • Allow the duplicate:     ontoindex analyze --allow-duplicate-name  (leaves "-r ${err.registryName}" ambiguous)`,
      );
      console.error('');
      process.exitCode = 1;
      return;
    }

    console.error(`\n  Analysis failed: ${msg}\n`);

    // Provide helpful guidance for known failure modes
    if (
      msg.includes('Maximum call stack size exceeded') ||
      msg.includes('call stack') ||
      msg.includes('Map maximum size') ||
      msg.includes('Invalid array length') ||
      msg.includes('Invalid string length') ||
      msg.includes('allocation failed') ||
      msg.includes('heap out of memory') ||
      msg.includes('JavaScript heap')
    ) {
      console.error('  This error typically occurs on very large repositories.');
      console.error('  Suggestions:');
      console.error('    1. Add large vendored/generated directories to .ontoindexignore');
      console.error('    2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"');
      console.error('    3. Increase stack size: NODE_OPTIONS="--stack-size=4096"');
      console.error('');
    } else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) {
      // Note: the original arborist "Cannot destructure property 'package' of
      // 'node.target'" crash happens inside npm *before* ontoindex code runs,
      // so it can't be caught here.  This branch handles dependency-resolution
      // errors that surface at runtime (e.g. dynamic require failures).
      console.error('  This looks like an npm dependency resolution issue.');
      console.error('  Suggestions:');
      console.error('    1. Clear the npm cache:    npm cache clean --force');
      console.error('    2. Update npm:             npm install -g npm@latest');
      console.error('    3. Reinstall ontoindex:     npm install -g ontoindex@latest');
      console.error('    4. Or try npx directly:    npx ontoindex@latest analyze');
      console.error('');
    } else if (
      msg.includes('MODULE_NOT_FOUND') ||
      msg.includes('Cannot find module') ||
      msg.includes('ERR_MODULE_NOT_FOUND')
    ) {
      console.error('  A required module could not be loaded. The installation may be corrupt.');
      console.error('  Suggestions:');
      console.error('    1. Reinstall:   npm install -g ontoindex@latest');
      console.error('    2. Clear cache: npm cache clean --force && npx ontoindex@latest analyze');
      console.error('');
    }

    process.exitCode = 1;
    return;
  } finally {
    await analyzeLock?.release();
  }

  // LadybugDB's native module holds open handles that prevent Node from exiting.
  // ONNX Runtime also registers native atexit hooks that segfault on some
  // platforms (#38, #40). Force-exit to ensure clean termination.
  process.exit(0);
};
