/**
 * Graph builder - builds VaultIndex from parsed notes
 *
 * Performance features:
 * - Parallel parsing with concurrency limit
 * - Progress reporting for large vaults
 * - Timeout protection
 * - SQLite index caching for fast startup
 */

import type { VaultNote, VaultIndex, Backlink, OutLink } from './types.js';
import type { IndexState } from './types.js';
import type { VaultFile } from './vault.js';
import { scanVault } from './vault.js';
import type { StateDb, VaultIndexCacheData } from '@velvetmonkey/vault-core';
import {
  loadVaultIndexCache,
  getVaultIndexCacheInfo,
  saveVaultIndexCache,
} from '@velvetmonkey/vault-core';
import { parseNote } from './parser.js';
import { levenshteinDistance } from '../shared/levenshtein.js';
import { serverLog } from '../shared/serverLog.js';
import { canonicalPath, getModuleCaseInsensitive } from './caseSensitivity.js';
import { embedTextCached, findSemanticallySimilarEntities, hasEntityEmbeddingsIndex } from './embeddings.js';
import { getActiveScopeOrNull } from '../../vault-scope.js';

/** Default timeout for vault indexing (5 minutes) */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;

/** Concurrency limit for parallel file parsing */
const PARSE_CONCURRENCY = 50;

/** Progress reporting interval (every N files) */
const PROGRESS_INTERVAL = 100;

// =============================================================================
// Index State Tracking (for async startup)
// =============================================================================

/** Possible states of the vault index */
export type { IndexState } from './types.js';

/** Current state of the index */
let indexState: IndexState = 'building';

/** Progress of index building */
let indexProgress = { parsed: 0, total: 0 };

/** Error if index building failed */
let indexError: Error | null = null;

/** Get the current index state.
 * Reads from VaultScope if available, but falls through to module-level
 * if the scope is stale (still 'building' while module-level is 'ready').
 * This handles the case where the scope was created before the index finished building.
 */
export function getIndexState(): IndexState {
  const scope = getActiveScopeOrNull();
  if (scope) {
    // Module-level 'ready' always wins over stale scope 'building'
    if (scope.indexState === 'building' && indexState === 'ready') return 'ready';
    return scope.indexState;
  }
  return indexState;
}

/** Get the current index build progress */
export function getIndexProgress(): { parsed: number; total: number } {
  return { ...indexProgress };
}

/** Get the index error (reads from VaultScope if available) */
export function getIndexError(): Error | null {
  const scope = getActiveScopeOrNull();
  return scope ? scope.indexError : indexError;
}

/** Set the index state (called by index.ts during startup) */
export function setIndexState(state: IndexState): void {
  indexState = state;
}

/** Set the index error (called by index.ts on failure) */
export function setIndexError(error: Error | null): void {
  indexError = error;
}

/** Update index progress (called during build) */
function updateIndexProgress(parsed: number, total: number): void {
  indexProgress = { parsed, total };
}

/**
 * Normalize a link target for matching
 * - Lowercase for case-insensitive matching
 * - Remove .md extension if present
 */
export function normalizeTarget(target: string): string {
  return target.toLowerCase().replace(/\.md$/, '');
}

/**
 * Normalize a note path to a matchable key
 * - Remove .md extension
 * - Lowercase
 */
function normalizeNotePath(path: string): string {
  return path.toLowerCase().replace(/\.md$/, '');
}

/**
 * Get the title from a path (filename without extension)
 */
function getTitleFromPath(path: string): string {
  return path.replace(/\.md$/, '').split('/').pop() || path;
}

/** Options for building the vault index */
export interface BuildOptions {
  /** Timeout in milliseconds (default: 5 minutes) */
  timeoutMs?: number;
  /** Callback for progress updates */
  onProgress?: (parsed: number, total: number) => void;
}

/**
 * Build the complete vault index with timeout protection
 */
export async function buildVaultIndex(
  vaultPath: string,
  options: BuildOptions = {}
): Promise<VaultIndex> {
  const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;

  console.error(`Scanning vault: ${vaultPath}`);
  const startTime = Date.now();

  // Create timeout promise
  const timeoutPromise = new Promise<never>((_, reject) => {
    setTimeout(() => {
      reject(new Error(`Vault indexing timed out after ${timeoutMs / 1000}s`));
    }, timeoutMs);
  });

  // Run indexing with timeout
  return Promise.race([
    buildVaultIndexInternal(vaultPath, startTime, onProgress),
    timeoutPromise,
  ]);
}

/**
 * Internal implementation of vault index building
 */
async function buildVaultIndexInternal(
  vaultPath: string,
  startTime: number,
  onProgress?: (parsed: number, total: number) => void
): Promise<VaultIndex> {
  // Scan for files
  const files = await scanVault(vaultPath);
  console.error(`Found ${files.length} markdown files`);

  // Initialize progress tracking
  updateIndexProgress(0, files.length);

  // Parse all notes with concurrency control
  const notes = new Map<string, VaultNote>();
  const parseErrors: Array<{ path: string; reason: string }> = [];
  let parsedCount = 0;

  // Process files in batches for controlled concurrency
  for (let i = 0; i < files.length; i += PARSE_CONCURRENCY) {
    const batch = files.slice(i, i + PARSE_CONCURRENCY);

    const results = await Promise.allSettled(
      batch.map(async (file) => {
        const note = await parseNote(file);
        return { file, note };
      })
    );

    for (let j = 0; j < results.length; j++) {
      const result = results[j];
      if (result.status === 'fulfilled') {
        // Keyed by on-disk path. Dedup of case variants on case-insensitive
        // filesystems happens in `addNoteToIndex` / the post-scan canonical
        // dedup pass below — scanVault returns one path per physical file,
        // so the common case is already dedup-free.
        notes.set(result.value.note.path, result.value.note);
      } else {
        const filePath = batch[j]?.path ?? '<unknown>';
        const reason = result.reason instanceof Error
          ? result.reason.message : String(result.reason);
        parseErrors.push({ path: filePath, reason });
      }
      parsedCount++;
    }

    // Update progress tracking (always)
    updateIndexProgress(parsedCount, files.length);

    // Progress reporting to console (at intervals)
    if (parsedCount % PROGRESS_INTERVAL === 0 || parsedCount === files.length) {
      const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
      console.error(`Parsed ${parsedCount}/${files.length} files (${elapsed}s)`);
      onProgress?.(parsedCount, files.length);
    }

    // Yield to event loop so MCP transport can process handshake mid-build
    await new Promise(resolve => setImmediate(resolve));
  }

  if (parseErrors.length > 0) {
    const msg = `Failed to parse ${parseErrors.length} file(s):`;
    console.error(msg);
    serverLog('index', msg, 'error');
    for (const err of parseErrors) {
      const detail = `  - ${err.path}: ${err.reason}`;
      console.error(detail);
      serverLog('index', detail, 'error');
    }
  }

  // Build entities map (for resolving link targets)
  // Maps: normalized title/alias -> path
  const entities = new Map<string, string>();

  for (const note of notes.values()) {
    // Entity values hold the on-disk `note.path` so consumers surfacing them as
    // display paths (suggest_wikilinks) keep the true casing. Lookups against
    // `index.notes` must go through `getIndexedNote` / `canonicalPath` to
    // collapse case variants on case-insensitive volumes.
    const normalizedTitle = normalizeTarget(note.title);
    if (!entities.has(normalizedTitle)) {
      entities.set(normalizedTitle, note.path);
    }

    const normalizedPath = normalizeNotePath(note.path);
    entities.set(normalizedPath, note.path);

    for (const alias of note.aliases) {
      const normalizedAlias = normalizeTarget(alias);
      if (!entities.has(normalizedAlias)) {
        entities.set(normalizedAlias, note.path);
      }
    }
  }

  // Build backlinks index
  // Maps: normalized target -> backlinks from sources
  const backlinks = new Map<string, Backlink[]>();

  for (const note of notes.values()) {
    for (const link of note.outlinks) {
      const normalizedTarget = normalizeTarget(link.target);

      // Resolve the target to an actual note path if possible
      const targetPath = entities.get(normalizedTarget);
      const key = targetPath ? normalizeNotePath(targetPath) : normalizedTarget;

      if (!backlinks.has(key)) {
        backlinks.set(key, []);
      }

      backlinks.get(key)!.push({
        // Source holds on-disk case so backlink trace output matches the real
        // file name. Reverse lookups via `index.notes` must go through
        // `getIndexedNote` / `canonicalPath` on case-insensitive volumes.
        source: note.path,
        line: link.line,
        // Context will be loaded on-demand to save memory
      });
    }
  }

  // Build tags index
  const tags = new Map<string, Set<string>>();

  for (const note of notes.values()) {
    for (const tag of note.tags) {
      if (!tags.has(tag)) {
        tags.set(tag, new Set());
      }
      // `notes` collapses by canonical key, so at most one note per tag holds
      // the canonical path — use `note.path` directly (on-disk case).
      tags.get(tag)!.add(note.path);
    }
  }

  // Build canonical→on-disk alias map. On case-sensitive filesystems this is
  // effectively an identity map over notes keys. On case-insensitive volumes
  // it lets `getIndexedNote` resolve mixed-case inputs in O(1). scanVault
  // returns one path per physical file, so no dedup is needed here.
  const canonicalAlias = new Map<string, string>();
  for (const onDiskPath of notes.keys()) {
    canonicalAlias.set(canonicalPath(onDiskPath), onDiskPath);
  }

  console.error(`Index built: ${notes.size} notes, ${entities.size} entities, ${backlinks.size} link targets, ${tags.size} tags`);

  return {
    notes,
    backlinks,
    entities,
    tags,
    canonicalAlias,
    builtAt: new Date(),
  };
}

/**
 * Resolve a link target to a note path
 * Returns undefined if the target doesn't match any note
 */
export function resolveTarget(index: VaultIndex, target: string): string | undefined {
  const normalized = normalizeTarget(target);
  return index.entities.get(normalized);
}

/**
 * Get backlinks for a note
 */
export function getBacklinksForNote(index: VaultIndex, notePath: string): Backlink[] {
  const normalized = normalizeNotePath(notePath);
  return index.backlinks.get(normalized) || [];
}

/**
 * Look up a note from the index by path with case-insensitive-filesystem
 * awareness. Pass user-supplied or externally-sourced paths through this
 * helper so `Flywheel.md` and `flywheel.md` resolve to the same entry on
 * Windows/macOS default volumes.
 */
export function getIndexedNote(index: VaultIndex, notePath: string): VaultNote | undefined {
  // Fast path: exact match (covers Linux and any case that matches on-disk).
  const direct = index.notes.get(notePath);
  if (direct) return direct;
  // Fall back to canonical alias for mixed-case inputs on case-insensitive FS.
  const aliased = index.canonicalAlias?.get(canonicalPath(notePath));
  return aliased ? index.notes.get(aliased) : undefined;
}

/**
 * Case-insensitive-aware existence check for a note path.
 */
export function hasIndexedNote(index: VaultIndex, notePath: string): boolean {
  if (index.notes.has(notePath)) return true;
  const aliased = index.canonicalAlias?.get(canonicalPath(notePath));
  return aliased !== undefined && index.notes.has(aliased);
}

/**
 * Get forward links (outlinks) for a note with resolution info
 */
export function getForwardLinksForNote(
  index: VaultIndex,
  notePath: string
): Array<{ target: string; alias?: string; line: number; resolvedPath?: string; exists: boolean }> {
  const note = getIndexedNote(index, notePath);
  if (!note) return [];

  return note.outlinks.map((link) => {
    const resolvedPath = resolveTarget(index, link.target);
    return {
      target: link.target,
      alias: link.alias,
      line: link.line,
      resolvedPath,
      exists: resolvedPath !== undefined,
    };
  });
}

/**
 * Find orphan notes (notes with no backlinks)
 */
export function findOrphanNotes(
  index: VaultIndex,
  folder?: string
): Array<{ path: string; title: string; modified: Date }> {
  const orphans: Array<{ path: string; title: string; modified: Date }> = [];

  for (const note of index.notes.values()) {
    // Filter by folder if specified
    if (folder && !note.path.startsWith(folder)) {
      continue;
    }

    // Check if this note has any backlinks
    const backlinks = getBacklinksForNote(index, note.path);
    if (backlinks.length === 0) {
      orphans.push({
        path: note.path,
        title: note.title,
        modified: note.modified,
      });
    }
  }

  // Sort by modified date (most recent first)
  return orphans.sort((a, b) => b.modified.getTime() - a.modified.getTime());
}

/**
 * Find hub notes (highly connected notes)
 */
export function findHubNotes(
  index: VaultIndex,
  minLinks: number = 5
): Array<{
  path: string;
  title: string;
  backlink_count: number;
  forward_link_count: number;
  total_connections: number;
}> {
  const hubs: Array<{
    path: string;
    title: string;
    backlink_count: number;
    forward_link_count: number;
    total_connections: number;
  }> = [];

  for (const note of index.notes.values()) {
    const backlinkCount = getBacklinksForNote(index, note.path).length;
    const forwardLinkCount = note.outlinks.length;
    const totalConnections = backlinkCount + forwardLinkCount;

    if (totalConnections >= minLinks) {
      hubs.push({
        path: note.path,
        title: note.title,
        backlink_count: backlinkCount,
        forward_link_count: forwardLinkCount,
        total_connections: totalConnections,
      });
    }
  }

  // Sort by total connections (highest first)
  return hubs.sort((a, b) => b.total_connections - a.total_connections);
}

// Re-export levenshteinDistance from shared module for backward compatibility
export { levenshteinDistance } from '../shared/levenshtein.js';

/**
 * Find a similar entity in the index (for detecting typos/broken links)
 * Returns the path of the best matching note, or undefined if no close match
 *
 * Strict thresholds to minimize false positives:
 * - Length ≤ 3: skip (acronyms too ambiguous)
 * - Length 4-5: max distance 1
 * - Length 6-10: max distance 1
 * - Length > 10: max distance 2
 *
 * Optimizations:
 * - Skip entities where length difference > maxDist (impossible to match)
 * - Early termination on distance=1 match (can't do better for typos)
 */
export function findSimilarEntity(
  index: VaultIndex,
  target: string
): { path: string; entity: string; distance: number } | undefined {
  const normalized = normalizeTarget(target);
  const normalizedLen = normalized.length;

  // Skip very short strings - acronyms like EDP, DDL are too ambiguous
  if (normalizedLen <= 3) {
    return undefined;
  }

  // Strict thresholds to reduce false positives
  const maxDist = normalizedLen <= 10 ? 1 : 2;

  let bestMatch: { path: string; entity: string; distance: number } | undefined;

  for (const [entity, path] of index.entities) {
    // Optimization 1: Skip if length difference exceeds maxDist
    // (Levenshtein distance >= |len(a) - len(b)|)
    const lenDiff = Math.abs(entity.length - normalizedLen);
    if (lenDiff > maxDist) {
      continue;
    }

    const dist = levenshteinDistance(normalized, entity);
    if (dist > 0 && dist <= maxDist) {
      if (!bestMatch || dist < bestMatch.distance) {
        bestMatch = { path, entity, distance: dist };

        // Optimization 2: Early termination on distance=1
        // (distance=0 means exact match, which resolveTarget would have found)
        if (dist === 1) {
          return bestMatch;
        }
      }
    }
  }

  return bestMatch;
}

/**
 * Find a similar entity with semantic fallback.
 *
 * First tries the fast synchronous prefix/contains/Levenshtein approach.
 * If that fails and entity embeddings are available, falls back to
 * semantic similarity search (threshold ~0.7).
 *
 * This helps resolve broken links like [[deployment automation]] -> [[CI/CD]]
 * where the names are conceptually similar but textually different.
 */
export async function findSimilarEntityWithSemantic(
  index: VaultIndex,
  target: string
): Promise<{ path: string; entity: string; distance: number; semantic?: boolean } | undefined> {
  // Try fast synchronous match first
  const levenshteinMatch = findSimilarEntity(index, target);
  if (levenshteinMatch) return levenshteinMatch;

  // Semantic fallback
  try {
    if (!hasEntityEmbeddingsIndex()) return undefined;

    const targetEmbedding = await embedTextCached(target);
    const matches = findSemanticallySimilarEntities(targetEmbedding, 1);

    if (matches.length > 0 && matches[0].similarity >= 0.7) {
      const entityName = matches[0].entityName;
      // Resolve entity name to a path in the index
      const entityPath = index.entities.get(entityName.toLowerCase());
      if (entityPath) {
        return {
          path: entityPath,
          entity: entityName,
          distance: Math.round((1 - matches[0].similarity) * 100), // pseudo-distance
          semantic: true,
        };
      }
    }
  } catch {
    // Semantic search failure never blocks link resolution
  }

  return undefined;
}

// =============================================================================
// Index Caching (for fast startup)
// =============================================================================

/**
 * Serialize VaultIndex to cacheable format
 */
export function serializeVaultIndex(index: VaultIndex): VaultIndexCacheData {
  return {
    notes: Array.from(index.notes.values()).map(note => ({
      path: note.path,
      title: note.title,
      aliases: note.aliases,
      frontmatter: note.frontmatter,
      outlinks: note.outlinks,
      tags: note.tags,
      modified: note.modified.getTime(),
      created: note.created?.getTime(),
    })),
    backlinks: Array.from(index.backlinks.entries()).map(([key, value]) => [key, value]),
    entities: Array.from(index.entities.entries()),
    tags: Array.from(index.tags.entries()).map(([tag, paths]) => [tag, Array.from(paths)]),
    builtAt: index.builtAt.getTime(),
  };
}

/**
 * Deserialize VaultIndex from cached format
 */
export function deserializeVaultIndex(data: VaultIndexCacheData): VaultIndex {
  // Notes keyed by on-disk path. A legacy cache persisted with two case
  // variants of the same physical file (pre-P42-Issue-1 bug) collapses to
  // the first-seen entry on case-insensitive filesystems — we can't know
  // which was "correct," and the scanner will rewrite this on next full
  // rebuild anyway.
  const ci = getModuleCaseInsensitive();
  const notes = new Map<string, VaultNote>();
  const canonicalAlias = new Map<string, string>();
  for (const note of data.notes) {
    const canonKey = canonicalPath(note.path, ci);
    const existingOnDisk = canonicalAlias.get(canonKey);
    if (existingOnDisk) {
      // Duplicate physical file — keep the first-seen entry.
      continue;
    }
    notes.set(note.path, {
      path: note.path,
      title: note.title,
      aliases: note.aliases,
      frontmatter: note.frontmatter,
      outlinks: note.outlinks as OutLink[],
      tags: note.tags,
      modified: new Date(note.modified),
      created: note.created ? new Date(note.created) : undefined,
    });
    canonicalAlias.set(canonKey, note.path);
  }

  const backlinks = new Map<string, Backlink[]>();
  for (const [key, value] of data.backlinks) {
    // Backlink keys were already lowercased via normalizeNotePath when built,
    // but a cache persisted on a different FS may carry mixed case — lowercase
    // defensively so `getBacklinksForNote` lookups hit the right bucket.
    const canonKey = key.toLowerCase();
    const existing = backlinks.get(canonKey);
    if (existing) {
      existing.push(...value);
    } else {
      backlinks.set(canonKey, [...value]);
    }
  }

  const entities = new Map<string, string>();
  for (const [key, value] of data.entities) {
    entities.set(key, value);
  }

  const tags = new Map<string, Set<string>>();
  for (const [tag, paths] of data.tags) {
    tags.set(tag, new Set(paths));
  }

  return {
    notes,
    backlinks,
    entities,
    tags,
    canonicalAlias,
    builtAt: new Date(data.builtAt),
  };
}

/**
 * Load VaultIndex from cache if valid, otherwise return null
 *
 * @param stateDb - State database
 * @param scannedFileCount - Number of files found by scanVault (may be higher than parsed notes)
 * @param maxAgeMs - Maximum cache age in milliseconds (default: 24 hours)
 * @param tolerancePercent - Allowed mismatch between cached notes and scanned files (default: 5%)
 * @param newestFileMtime - Newest file modification time from scanVault (invalidates cache if files changed since build)
 */
export function loadVaultIndexFromCache(
  stateDb: StateDb,
  scannedFileCount: number,
  maxAgeMs: number = 24 * 60 * 60 * 1000,
  tolerancePercent: number = 5,
  newestFileMtime?: Date
): VaultIndex | null {
  const info = getVaultIndexCacheInfo(stateDb);
  if (!info) {
    console.error('[Flywheel] No cached index found');
    return null;
  }

  // Validate cache - allow tolerance for parse failures
  // (cached noteCount is parsed notes, scannedFileCount includes unparseable files)
  const minExpected = Math.floor(scannedFileCount * (1 - tolerancePercent / 100));
  const maxExpected = Math.ceil(scannedFileCount * (1 + tolerancePercent / 100));
  if (info.noteCount < minExpected || info.noteCount > maxExpected) {
    console.error(`[Flywheel] Cache invalid: note count mismatch (${info.noteCount} cached vs ${scannedFileCount} scanned, tolerance ${tolerancePercent}%)`);
    return null;
  }

  const age = Date.now() - info.builtAt.getTime();
  if (age > maxAgeMs) {
    console.error(`[Flywheel] Cache invalid: too old (${Math.round(age / 1000 / 60)} minutes)`);
    return null;
  }

  // Reject cache if any file was modified after the cache was built
  if (newestFileMtime && newestFileMtime.getTime() > info.builtAt.getTime()) {
    console.error(`[Flywheel] Cache invalid: files modified since cache was built (newest file: ${newestFileMtime.toISOString()}, cache built: ${info.builtAt.toISOString()})`);
    return null;
  }

  // Load the cache
  const data = loadVaultIndexCache(stateDb);
  if (!data) {
    console.error('[Flywheel] Failed to load cached index data');
    return null;
  }

  console.error(`[Flywheel] Loaded index from cache (${info.noteCount} notes, built ${Math.round(age / 1000)}s ago)`);
  return deserializeVaultIndex(data);
}

/**
 * Save VaultIndex to cache
 */
export function saveVaultIndexToCache(stateDb: StateDb, index: VaultIndex): void {
  const data = serializeVaultIndex(index);
  saveVaultIndexCache(stateDb, data);
  console.error(`[Flywheel] Saved index to cache (${index.notes.size} notes)`);
}
