import { QdrantClient } from '@qdrant/js-client-rest';
import { glob } from 'glob';
import fs from 'fs';
import path from 'path';
import { filterFilesByGitignore, detectLanguageByPath } from '@paparats/shared';
import { v7 as uuidv7 } from 'uuid';
import PQueue from 'p-queue';
import { Chunker } from './chunker.js';
import { resolveTags } from './metadata.js';
import { extractGitMetadata } from './git-metadata.js';
import type { MetadataStore } from './metadata-db.js';
import type { CachedEmbeddingProvider } from './embeddings.js';
import type { TreeSitterManager } from './tree-sitter-parser.js';
import { extractSymbolsForChunks } from './ast-symbol-extractor.js';
import { resolveAstLanguage } from './ast-language.js';
import type { SymbolExtractionResult } from './ast-symbol-extractor.js';
import { buildSymbolEdges } from './symbol-graph.js';
import { chunkByAst } from './ast-chunker.js';
import type { ChunkResult, ProjectConfig, IndexerStats } from './types.js';
import type { Telemetry } from './telemetry/facade.js';
import type { MetricsRegistry } from './metrics.js';
import { NoOpMetrics } from './metrics.js';

// ── Collection name helpers ──────────────────────────────────────────────────

const COLLECTION_PREFIX = 'paparats_';

/** Map a logical group name to a Qdrant collection name */
export function toCollectionName(group: string): string {
  return `${COLLECTION_PREFIX}${group}`;
}

/** Map a Qdrant collection name back to a logical group name */
export function fromCollectionName(collection: string): string {
  return collection.startsWith(COLLECTION_PREFIX)
    ? collection.slice(COLLECTION_PREFIX.length)
    : collection;
}

/**
 * Create a QdrantClient with correct port handling for HTTPS URLs.
 * The JS client defaults to port 6333 when no port is in the URL,
 * which breaks Qdrant Cloud (HTTPS on port 443).
 */
export function createQdrantClient(opts: {
  url: string;
  apiKey?: string;
  timeout?: number;
}): QdrantClient {
  const parsed = new URL(opts.url);
  const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 6333;
  return new QdrantClient({
    url: opts.url,
    apiKey: opts.apiKey,
    port,
    checkCompatibility: false,
    timeout: opts.timeout ?? 30_000,
  });
}

// ── Chunk ID helpers ────────────────────────────────────────────────────────

/**
 * Build a stable, deterministic chunk_id from its components.
 * Format: {group}//{project}//{file}//{startLine}-{endLine}//{hash}
 */
export function buildChunkId(
  group: string,
  project: string,
  file: string,
  startLine: number,
  endLine: number,
  hash: string
): string {
  return `${group}//${project}//${file}//${startLine}-${endLine}//${hash}`;
}

/**
 * Parse a chunk_id back into its components.
 * Returns null if the format is invalid.
 */
export function parseChunkId(chunkId: string): {
  group: string;
  project: string;
  file: string;
  startLine: number;
  endLine: number;
  hash: string;
} | null {
  const parts = chunkId.split('//');
  if (parts.length !== 5) return null;

  const [group, project, file, lineRange, hash] = parts;
  if (!group || !project || !file || !lineRange || !hash) return null;

  const lineParts = lineRange.split('-');
  if (lineParts.length !== 2) return null;

  const startLine = parseInt(lineParts[0]!, 10);
  const endLine = parseInt(lineParts[1]!, 10);

  if (isNaN(startLine) || isNaN(endLine)) return null;

  return { group, project, file, startLine, endLine, hash };
}

export interface IndexerConfig {
  qdrantUrl: string;
  /** Qdrant API key for authenticated access (e.g. Qdrant Cloud) */
  qdrantApiKey?: string;
  embeddingProvider: CachedEmbeddingProvider;
  dimensions: number;
  /** Embedding provider id stamped on new collections (default: 'unknown') */
  embeddingProviderId?: string;
  /** Embedding model id stamped on new collections (default: provider.model) */
  embeddingModelId?: string;
  /** Qdrant request timeout in milliseconds (default: 30000) */
  timeout?: number;
  /** Optional Qdrant client for testing (skips creating from qdrantUrl) */
  qdrantClient?: QdrantClient;
  /** Optional metadata store for git history enrichment */
  metadataStore?: MetadataStore;
  /** Optional tree-sitter manager for AST-based symbol extraction */
  treeSitter?: TreeSitterManager;
  /** Optional telemetry façade for analytics + tracing */
  telemetry?: Telemetry;
  /** Optional Prometheus metrics registry. NoOp by default. */
  metrics?: MetricsRegistry;
}

/** Metadata stamped on each Qdrant collection so we can detect provider
 *  changes that would silently break search (different model/dimensions). */
export interface CollectionMeta {
  provider: string;
  model: string;
  dimensions: number;
  createdAt: number;
  /** Schema version of this metadata record (in case we change fields later). */
  metaVersion: number;
}

/** Deterministic UUID for the per-collection metadata sentinel point. */
const META_SENTINEL_ID = '00000000-0000-7000-8000-000000005ea7';
const META_SCHEMA_VERSION = 1;

/** Thrown when the current embedding provider doesn't match what stamped the
 *  Qdrant collection. Indexing or searching against a mismatched collection
 *  would return garbage, so we fail loudly with a remediation path. */
export class CollectionMetaMismatchError extends Error {
  readonly collection: string;
  readonly existing: CollectionMeta;
  readonly current: { provider: string; model: string; dimensions: number };

  constructor(
    collection: string,
    existing: CollectionMeta,
    current: { provider: string; model: string; dimensions: number }
  ) {
    super(
      `Embedding mismatch for collection ${collection}: ` +
        `indexed with ${existing.provider}/${existing.model} (${existing.dimensions}d), ` +
        `but current process is ${current.provider}/${current.model} (${current.dimensions}d). ` +
        `Drop the collection and reindex, or run with matching EMBEDDING_PROVIDER/EMBEDDING_MODEL.`
    );
    this.name = 'CollectionMetaMismatchError';
    this.collection = collection;
    this.existing = existing;
    this.current = current;
  }
}

/** Qdrant timeout in milliseconds (default: 30s) */
const QDRANT_TIMEOUT_MS = 30_000;
const QDRANT_MAX_RETRIES = 3;

export class Indexer {
  private qdrant: QdrantClient;
  private chunkers = new Map<string, Chunker>();
  private provider: CachedEmbeddingProvider;
  private dimensions: number;
  private providerId: string;
  private modelId: string;
  private metadataStore: MetadataStore | null;
  private treeSitter: TreeSitterManager | null;
  private telemetry: Telemetry | null;
  private metrics: MetricsRegistry;
  stats: IndexerStats;

  constructor(config: IndexerConfig) {
    this.qdrant =
      config.qdrantClient ??
      createQdrantClient({
        url: config.qdrantUrl,
        apiKey: config.qdrantApiKey,
        timeout: config.timeout ?? QDRANT_TIMEOUT_MS,
      });
    this.provider = config.embeddingProvider;
    this.dimensions = config.dimensions;
    this.providerId = config.embeddingProviderId ?? 'unknown';
    this.modelId = config.embeddingModelId ?? config.embeddingProvider.model;
    this.metadataStore = config.metadataStore ?? null;
    this.treeSitter = config.treeSitter ?? null;
    this.telemetry = config.telemetry ?? null;
    this.metrics = config.metrics ?? new NoOpMetrics();
    this.stats = { files: 0, chunks: 0, cached: 0, errors: 0, skipped: 0 };
  }

  /** Expose Qdrant client for git-metadata enrichment */
  get qdrantClient(): QdrantClient {
    return this.qdrant;
  }

  /** Map logical group name to Qdrant collection name (paparats_ prefix) */
  col(group: string): string {
    return toCollectionName(group);
  }

  private getChunker(project: ProjectConfig): Chunker {
    const key = `${project.indexing.chunkSize}:${project.indexing.overlap}`;
    if (!this.chunkers.has(key)) {
      this.chunkers.set(
        key,
        new Chunker({
          chunkSize: project.indexing.chunkSize,
          overlap: project.indexing.overlap,
        })
      );
    }
    return this.chunkers.get(key)!;
  }

  private async retryQdrant<T>(fn: () => Promise<T>, retries = QDRANT_MAX_RETRIES): Promise<T> {
    let lastError: Error | null = null;
    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        return await fn();
      } catch (err) {
        lastError = err as Error;
        if (attempt < retries - 1) {
          const delay = 1000 * 2 ** attempt;
          await new Promise((resolve) => setTimeout(resolve, delay));
          console.warn(
            `[indexer] Qdrant operation failed, retrying (${attempt + 1}/${retries})...`
          );
        }
      }
    }
    throw new Error(`Qdrant failed after ${retries} retries: ${lastError?.message}`);
  }

  /** Fetch existing chunk hashes for a file from Qdrant */
  private async getFileChunkHashes(
    groupName: string,
    projectName: string,
    relPath: string
  ): Promise<Set<string>> {
    try {
      const hashes = new Set<string>();
      let offset: string | number | undefined = undefined;

      for (;;) {
        const result = await this.qdrant.scroll(this.col(groupName), {
          filter: {
            must: [
              { key: 'project', match: { value: projectName } },
              { key: 'file', match: { value: relPath } },
            ],
          },
          with_payload: { include: ['hash'] },
          with_vector: false,
          limit: 1000,
          ...(offset !== undefined ? { offset } : {}),
        });

        for (const point of result.points) {
          const hash = (point.payload as Record<string, unknown> | null)?.['hash'];
          if (typeof hash === 'string') {
            hashes.add(hash);
          }
        }

        if (!result.next_page_offset) break;
        offset = result.next_page_offset as string | number;
      }

      return hashes;
    } catch (err) {
      console.warn(
        `[indexer] Failed to get chunk hashes for ${projectName}/${relPath}: ${(err as Error).message}`
      );
      return new Set<string>();
    }
  }

  /** Get all unique file paths currently indexed for a project in Qdrant */
  private async getIndexedFilePaths(groupName: string, projectName: string): Promise<Set<string>> {
    const files = new Set<string>();
    let offset: string | number | undefined = undefined;

    try {
      for (;;) {
        const result = await this.qdrant.scroll(this.col(groupName), {
          filter: {
            must: [{ key: 'project', match: { value: projectName } }],
          },
          with_payload: { include: ['file'] },
          with_vector: false,
          limit: 1000,
          ...(offset !== undefined ? { offset } : {}),
        });

        for (const point of result.points) {
          const file = (point.payload as Record<string, unknown> | null)?.['file'];
          if (typeof file === 'string') {
            files.add(file);
          }
        }

        if (!result.next_page_offset) break;
        offset = result.next_page_offset as string | number;
      }
    } catch (err) {
      console.warn(
        `[indexer] Failed to get indexed file paths for ${projectName}: ${(err as Error).message}`
      );
    }

    return files;
  }

  /** Remove Qdrant chunks and metadata for files that are no longer present */
  private async cleanupOrphanedChunks(
    groupName: string,
    projectName: string,
    currentFilePaths: Set<string>
  ): Promise<void> {
    try {
      const indexedPaths = await this.getIndexedFilePaths(groupName, projectName);
      const orphaned = Array.from(indexedPaths).filter((p) => !currentFilePaths.has(p));

      if (orphaned.length > 0) {
        for (const relPath of orphaned) {
          await this.retryQdrant(() =>
            this.qdrant.delete(this.col(groupName), {
              filter: {
                must: [
                  { key: 'project', match: { value: projectName } },
                  { key: 'file', match: { value: relPath } },
                ],
              },
              wait: true,
            })
          );
          this.metadataStore?.deleteByFile(groupName, projectName, relPath);
        }
        console.log(
          `  [indexer] Removed ${orphaned.length} orphaned file(s): ${orphaned.join(', ')}`
        );
      }
    } catch (err) {
      console.warn(`  [indexer] Orphan cleanup failed (non-fatal): ${(err as Error).message}`);
    }
  }

  /**
   * Drop chunks and SQLite metadata for `projectName` from any group OTHER
   * than `keepGroup`. Called before indexing so a project that was renamed
   * out of a group (or moved between groups) doesn't leave duplicate
   * chunks behind in the old collection — those would surface in searches
   * with a stale `chunk_id`, while symbol-graph edges live under the new
   * key, breaking find_usages.
   */
  private async evictProjectFromOtherGroups(keepGroup: string, projectName: string): Promise<void> {
    let groups: Record<string, number>;
    try {
      groups = await this.listGroups();
    } catch (err) {
      console.warn(
        `  [indexer] Stale-group cleanup skipped (listGroups failed): ${(err as Error).message}`
      );
      return;
    }
    for (const otherGroup of Object.keys(groups)) {
      if (otherGroup === keepGroup) continue;
      try {
        const probe = await this.retryQdrant(() =>
          this.qdrant.scroll(this.col(otherGroup), {
            filter: { must: [{ key: 'project', match: { value: projectName } }] },
            with_payload: false,
            with_vector: false,
            limit: 1,
          })
        );
        if (probe.points.length === 0) continue;
        console.log(
          `  [indexer] Evicting stale chunks for "${projectName}" from group "${otherGroup}" (now lives in "${keepGroup}")`
        );
        await this.deleteProjectChunks(otherGroup, projectName);
        this.metadataStore?.deleteByProject(otherGroup, projectName);
      } catch (err) {
        console.warn(
          `  [indexer] Stale-group eviction failed for ${otherGroup}/${projectName} (non-fatal): ${(err as Error).message}`
        );
      }
    }
  }

  private hashSetsEqual(a: Set<string>, b: Set<string>): boolean {
    if (a.size !== b.size) return false;
    for (const h of a) {
      if (!b.has(h)) return false;
    }
    return true;
  }

  /**
   * Parse once, chunk by AST (with regex fallback), and extract symbols from the same tree.
   * Returns chunks and optional symbol results.
   */
  private async chunkFile(
    content: string,
    language: string,
    project: ProjectConfig,
    fileContext?: { groupName: string; file: string }
  ): Promise<{ chunks: ChunkResult[]; symbolResults: SymbolExtractionResult[] | null }> {
    const reportError = (
      errorClass:
        | 'ast_parse_failed'
        | 'ast_chunk_zero'
        | 'regex_fallback'
        | 'read_error'
        | 'binary'
        | 'other',
      message: string | null
    ): void => {
      if (!this.telemetry || !fileContext) return;
      this.telemetry.recordChunkingError({
        ts: Date.now(),
        runId: null,
        groupName: fileContext.groupName,
        projectName: project.name,
        file: fileContext.file,
        language,
        errorClass,
        message,
      });
    };

    // Try AST-based chunking + symbol extraction (single parse)
    if (this.treeSitter) {
      try {
        // Pick TSX grammar for .tsx/.jsx so JSX-based identifier usages aren't lost
        const astLanguage = fileContext ? resolveAstLanguage(language, fileContext.file) : language;
        const parsed = await this.treeSitter.parseFile(content, astLanguage);
        if (parsed) {
          try {
            const astConfig = {
              chunkSize: project.indexing.chunkSize,
              maxChunkSize: project.indexing.chunkSize * 3,
            };
            let chunks = chunkByAst(parsed.tree, content, astConfig);

            if (chunks.length === 0) {
              reportError('ast_chunk_zero', null);
              // AST chunking produced 0 chunks — fall back to regex
              const chunker = this.getChunker(project);
              chunks = chunker.chunk(content, language);
            }

            // Extract symbols from the same tree (no re-parse)
            const symbolResults = extractSymbolsForChunks(
              parsed.tree,
              parsed.language,
              chunks.map((c) => ({ startLine: c.startLine, endLine: c.endLine })),
              astLanguage
            );
            return { chunks, symbolResults };
          } catch (err) {
            console.warn(
              `[indexer] AST chunking/symbol extraction failed, falling back to regex: ${(err as Error).message}`
            );
            reportError('regex_fallback', (err as Error).message);
            // Fall back to regex chunker, no symbols (tree may be in bad state)
            const chunker = this.getChunker(project);
            const chunks = chunker.chunk(content, language);
            return { chunks, symbolResults: null };
          } finally {
            parsed.tree.delete();
          }
        }
      } catch (err) {
        console.warn(`[indexer] Tree-sitter parse failed (non-fatal): ${(err as Error).message}`);
        reportError('ast_parse_failed', (err as Error).message);
      }
    }

    // No tree-sitter available — regex chunker only, no symbols
    const chunker = this.getChunker(project);
    const chunks = chunker.chunk(content, language);
    return { chunks, symbolResults: null };
  }

  /** Build Qdrant point payloads from chunks, embeddings, and symbol results */
  private buildPointPayloads(
    chunks: ChunkResult[],
    embeddings: number[][],
    groupName: string,
    projectName: string,
    relPath: string,
    language: string,
    tags: string[],
    service: string,
    boundedContext: string | null,
    symbolResults: SymbolExtractionResult[] | null
  ): Array<{ id: string; vector: number[]; payload: Record<string, unknown> }> {
    return chunks.map((chunk, i) => ({
      id: uuidv7(),
      vector: embeddings[i]!,
      payload: {
        project: projectName,
        file: relPath,
        language,
        startLine: chunk.startLine,
        endLine: chunk.endLine,
        content: chunk.content,
        hash: chunk.hash,
        chunk_id: buildChunkId(
          groupName,
          projectName,
          relPath,
          chunk.startLine,
          chunk.endLine,
          chunk.hash
        ),
        symbol_name: symbolResults?.[i]?.defined_symbols?.[0]?.name ?? null,
        kind: symbolResults?.[i]?.defined_symbols?.[0]?.kind ?? null,
        service,
        bounded_context: boundedContext,
        tags,
        defines_symbols: symbolResults?.[i]?.defines_symbols ?? [],
        uses_symbols: symbolResults?.[i]?.uses_symbols ?? [],
      },
    }));
  }

  /** Ensure group collection exists in Qdrant and that its embedding metadata
   *  matches the current provider — mismatches mean a silent broken search,
   *  so we surface them loudly. */
  async ensureCollection(groupName: string): Promise<void> {
    try {
      await this.qdrant.getCollection(this.col(groupName));
      await this.validateCollectionMeta(groupName);
      return;
    } catch (err) {
      if (err instanceof CollectionMetaMismatchError) throw err;
      // Collection doesn't exist, try to create
    }

    try {
      await this.retryQdrant(() =>
        this.qdrant.createCollection(this.col(groupName), {
          vectors: {
            size: this.dimensions,
            distance: 'Cosine',
          },
        })
      );
      await this.writeCollectionMeta(groupName);
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'project',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'file',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'chunk_id',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'kind',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'tags',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'last_commit_at',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'ticket_keys',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'language',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'defines_symbols',
          field_schema: 'keyword',
          wait: true,
        })
      );
      await this.retryQdrant(() =>
        this.qdrant.createPayloadIndex(this.col(groupName), {
          field_name: 'uses_symbols',
          field_schema: 'keyword',
          wait: true,
        })
      );
    } catch (err) {
      // If creation failed, check if another process created it
      try {
        await this.qdrant.getCollection(this.col(groupName));
        // Collection exists now, that's OK
      } catch {
        throw err;
      }
    }
  }

  /** Write the embedding metadata sentinel point. Stored as a zero-vector
   *  point at a known UUID so search can exclude it via the `__meta` flag. */
  private async writeCollectionMeta(groupName: string): Promise<void> {
    const meta: CollectionMeta = {
      provider: this.providerId,
      model: this.modelId,
      dimensions: this.dimensions,
      createdAt: Date.now(),
      metaVersion: META_SCHEMA_VERSION,
    };
    await this.retryQdrant(() =>
      this.qdrant.upsert(this.col(groupName), {
        wait: true,
        points: [
          {
            id: META_SENTINEL_ID,
            // Zero vector; never returned because all real searches exclude
            // __meta=true via filter.must_not.
            vector: new Array(this.dimensions).fill(0),
            payload: { __meta: true, ...meta },
          },
        ],
      })
    );
  }

  /** Read the sentinel metadata. Returns null when missing — older
   *  collections from before this feature won't have it. */
  async readCollectionMeta(groupName: string): Promise<CollectionMeta | null> {
    try {
      const result = await this.qdrant.retrieve(this.col(groupName), {
        ids: [META_SENTINEL_ID],
        with_payload: true,
        with_vector: false,
      });
      const point = result[0];
      if (!point || !point.payload) return null;
      const p = point.payload as Record<string, unknown>;
      if (p['__meta'] !== true) return null;
      const provider = typeof p['provider'] === 'string' ? p['provider'] : null;
      const model = typeof p['model'] === 'string' ? p['model'] : null;
      const dimensions = typeof p['dimensions'] === 'number' ? p['dimensions'] : null;
      const createdAt = typeof p['createdAt'] === 'number' ? p['createdAt'] : 0;
      const metaVersion = typeof p['metaVersion'] === 'number' ? p['metaVersion'] : 0;
      if (!provider || !model || dimensions === null) return null;
      return { provider, model, dimensions, createdAt, metaVersion };
    } catch {
      return null;
    }
  }

  /** Throw CollectionMetaMismatchError if the existing collection's recorded
   *  provider/model/dimensions don't match what we're about to write into it.
   *  Backfills the sentinel for legacy collections without metadata. */
  private async validateCollectionMeta(groupName: string): Promise<void> {
    const existing = await this.readCollectionMeta(groupName);
    if (!existing) {
      // Legacy collection (predates sentinel) — backfill, assuming the
      // running provider matches what indexed it. If that assumption is
      // wrong, search results will be garbage either way; the warning gives
      // the operator a chance to spot it.
      console.warn(
        `[indexer] Collection ${this.col(groupName)} has no embedding metadata; ` +
          `stamping current provider (${this.providerId}/${this.modelId}/${this.dimensions}d). ` +
          `If it was previously indexed with a different provider, drop the collection and reindex.`
      );
      try {
        await this.writeCollectionMeta(groupName);
      } catch (err) {
        console.warn(`[indexer] Failed to backfill metadata: ${(err as Error).message}`);
      }
      return;
    }
    if (
      existing.provider !== this.providerId ||
      existing.model !== this.modelId ||
      existing.dimensions !== this.dimensions
    ) {
      throw new CollectionMetaMismatchError(this.col(groupName), existing, {
        provider: this.providerId,
        model: this.modelId,
        dimensions: this.dimensions,
      });
    }
  }

  /** Index a single file into its group collection */
  async indexFile(groupName: string, project: ProjectConfig, filePath: string): Promise<number> {
    const relPath = path.relative(project.path, filePath);

    let content: string;
    try {
      const buffer = await fs.promises.readFile(filePath);
      if (buffer.includes(0)) return 0; // Binary file
      content = buffer.toString('utf8');
      if (content.includes('\uFFFD')) return 0; // Invalid UTF-8
    } catch (err) {
      console.error(`  Failed to read ${relPath}: ${(err as Error).message}`);
      return 0;
    }

    if (!content.trim()) return 0;

    const language = detectLanguageByPath(relPath, content) ?? project.languages[0] ?? 'generic';
    const { chunks, symbolResults } = await this.chunkFile(content, language, project, {
      groupName,
      file: relPath,
    });
    if (chunks.length === 0) return 0;

    // Compare chunk hashes to skip unchanged files
    const newHashes = new Set(chunks.map((c) => c.hash));
    const existingHashes = await this.getFileChunkHashes(groupName, project.name, relPath);
    if (this.hashSetsEqual(newHashes, existingHashes)) {
      this.stats.skipped++;
      return 0;
    }

    // Delete old chunks if file was previously indexed
    if (existingHashes.size > 0) {
      await this.retryQdrant(() =>
        this.qdrant.delete(this.col(groupName), {
          filter: {
            must: [
              { key: 'project', match: { value: project.name } },
              { key: 'file', match: { value: relPath } },
            ],
          },
          wait: true,
        })
      );
    }

    const contents = chunks.map((c) => c.content);
    const embeddings = await this.provider.embedBatchPassage(contents);

    if (embeddings.length !== chunks.length) {
      throw new Error(
        `Embedding mismatch: got ${embeddings.length} embeddings for ${chunks.length} chunks in ${relPath}`
      );
    }

    const tags = resolveTags(project.metadata, relPath);

    const points = this.buildPointPayloads(
      chunks,
      embeddings,
      groupName,
      project.name,
      relPath,
      language,
      tags,
      project.metadata.service,
      project.metadata.bounded_context,
      symbolResults
    );

    const batchSize = project.indexing.batchSize;
    for (let i = 0; i < points.length; i += batchSize) {
      const batch = points.slice(i, i + batchSize);
      await this.retryQdrant(() =>
        this.qdrant.upsert(this.col(groupName), { points: batch, wait: true })
      );
    }

    this.telemetry?.upsertFile({
      groupName,
      projectName: project.name,
      file: relPath,
      language,
      totalLines: content.split('\n').length,
      totalBytes: Buffer.byteLength(content, 'utf8'),
      indexedAt: Date.now(),
    });

    this.metrics.incIndexFilesTotal(groupName, 1);
    if (points.length > 0) this.metrics.incIndexChunksTotal(groupName, points.length);

    return points.length;
  }

  /** Index all files in a project into its group collection */
  async indexProject(project: ProjectConfig): Promise<number> {
    const groupName = project.group;

    if (!fs.existsSync(project.path)) {
      console.error(`  Project path not found: ${project.path}`);
      return 0;
    }

    await this.ensureCollection(groupName);
    await this.evictProjectFromOtherGroups(groupName, project.name);

    const fileSet = new Set<string>();
    for (const pattern of project.patterns) {
      const found = await glob(pattern, {
        cwd: project.path,
        absolute: true,
        ignore: project.exclude,
        nodir: true,
      });
      found.forEach((f) => fileSet.add(f));
    }
    let files = Array.from(fileSet);
    if (project.indexing.respectGitignore) {
      files = filterFilesByGitignore(files, project.path);
    }
    console.log(`  ${files.length} files found`);

    const queue = new PQueue({ concurrency: project.indexing.concurrency });
    let totalChunks = 0;
    let processed = 0;
    const skippedBefore = this.stats.skipped;

    const tasks = files.map((file) =>
      queue.add(async () => {
        try {
          const n = await this.indexFile(groupName, project, file);
          totalChunks += n;
          processed++;
          this.stats.files++;
          this.stats.chunks += n;

          if (processed % 10 === 0 || processed === files.length) {
            const pct = Math.round((processed / files.length) * 100);
            console.log(`  [${processed}/${files.length}] ${pct}% — ${totalChunks} chunks`);
            // Yield to the event loop so /health and /metrics handlers can run
            // between CPU-heavy tree-sitter parses.
            await new Promise<void>((resolve) => setImmediate(resolve));
          }
        } catch (err) {
          this.stats.errors++;
          this.metrics.incIndexErrorsTotal(groupName, 1);
          const rel = path.relative(project.path, file);
          console.error(`  Error indexing ${rel}:`);
          console.error(`    ${(err as Error).message}`);
          if (process.env.DEBUG) {
            console.error((err as Error).stack);
          }
        }
      })
    );

    await Promise.all(tasks);
    this.stats.cached = this.provider.cacheHits; // Update once after all tasks complete

    const skippedThisProject = this.stats.skipped - skippedBefore;
    if (skippedThisProject > 0) {
      console.log(`  [indexer] Skipped ${skippedThisProject}/${files.length} files (unchanged)`);
    }

    // Clean up orphaned chunks (files deleted from disk but still in Qdrant)
    const currentRelPaths = new Set(files.map((f) => path.relative(project.path, f)));
    await this.cleanupOrphanedChunks(groupName, project.name, currentRelPaths);

    // Post-indexing: git metadata + symbol graph (single Qdrant scan for both)
    const needsGit = this.metadataStore && project.metadata.git.enabled && totalChunks > 0;
    const needsSymbols = this.metadataStore && this.treeSitter && totalChunks > 0;

    if (needsGit || needsSymbols) {
      try {
        const { chunksByFile, chunkSymbols } = await this.collectProjectChunksForPostIndex(
          groupName,
          project.name,
          !!needsGit,
          !!needsSymbols
        );

        // Git metadata extraction
        if (needsGit && chunksByFile.size > 0) {
          try {
            const result = await extractGitMetadata({
              projectPath: project.path,
              group: groupName,
              project: project.name,
              maxCommitsPerFile: project.metadata.git.maxCommitsPerFile,
              ticketPatterns: project.metadata.git.ticketPatterns,
              metadataStore: this.metadataStore!,
              qdrantClient: this.qdrant,
              chunksByFile,
            });
            console.log(
              `  [indexer] Git metadata: ${result.filesProcessed} files, ${result.commitsStored} commits, ${result.ticketsStored} tickets`
            );
          } catch (err) {
            console.warn(
              `  [indexer] Git metadata extraction failed (non-fatal): ${(err as Error).message}`
            );
          }
        }

        // Symbol graph building
        if (needsSymbols && chunkSymbols.length > 0) {
          try {
            const edges = buildSymbolEdges(chunkSymbols);
            this.metadataStore!.deleteEdgesByProject(groupName, project.name);
            if (edges.length > 0) {
              this.metadataStore!.upsertSymbolEdges(edges);
            }
            console.log(
              `  [indexer] Symbol graph: ${chunkSymbols.length} chunks, ${edges.length} edges`
            );
          } catch (err) {
            console.warn(
              `  [indexer] Symbol graph building failed (non-fatal): ${(err as Error).message}`
            );
          }
        }
      } catch (err) {
        console.warn(
          `  [indexer] Post-index data collection failed (non-fatal): ${(err as Error).message}`
        );
      }
    }

    return totalChunks;
  }

  /**
   * Single Qdrant scan for post-index data (git metadata + symbol graph).
   * Collects all needed fields in one pass instead of two separate scans.
   */
  private async collectProjectChunksForPostIndex(
    groupName: string,
    projectName: string,
    needsGit: boolean,
    needsSymbols: boolean
  ): Promise<{
    chunksByFile: Map<string, Array<{ chunk_id: string; startLine: number; endLine: number }>>;
    chunkSymbols: Array<{ chunk_id: string; defines_symbols: string[]; uses_symbols: string[] }>;
  }> {
    const chunksByFile = new Map<
      string,
      Array<{ chunk_id: string; startLine: number; endLine: number }>
    >();
    const chunkSymbols: Array<{
      chunk_id: string;
      defines_symbols: string[];
      uses_symbols: string[];
    }> = [];

    // Request all fields needed by both consumers
    const fields: string[] = ['chunk_id'];
    if (needsGit) fields.push('file', 'startLine', 'endLine');
    if (needsSymbols) fields.push('defines_symbols', 'uses_symbols');

    let offset: string | number | undefined = undefined;

    for (;;) {
      const page = await this.retryQdrant(() =>
        this.qdrant.scroll(this.col(groupName), {
          filter: {
            must: [{ key: 'project', match: { value: projectName } }],
          },
          with_payload: { include: fields },
          with_vector: false,
          limit: 100,
          ...(offset !== undefined ? { offset } : {}),
        })
      );

      for (const point of page.points) {
        const payload = point.payload as Record<string, unknown> | null;
        if (!payload) continue;
        const chunkId = payload['chunk_id'];
        if (typeof chunkId !== 'string') continue;

        // Populate git metadata map
        if (needsGit) {
          const file = payload['file'] as string | undefined;
          const startLine = payload['startLine'] as number | undefined;
          const endLine = payload['endLine'] as number | undefined;
          if (file && startLine !== undefined && endLine !== undefined) {
            const chunks = chunksByFile.get(file) ?? [];
            chunks.push({ chunk_id: chunkId, startLine, endLine });
            chunksByFile.set(file, chunks);
          }
        }

        // Populate symbol data
        if (needsSymbols) {
          const defs = payload['defines_symbols'];
          const uses = payload['uses_symbols'];
          chunkSymbols.push({
            chunk_id: chunkId,
            defines_symbols: Array.isArray(defs) ? (defs as string[]) : [],
            uses_symbols: Array.isArray(uses) ? (uses as string[]) : [],
          });
        }
      }

      if (!page.next_page_offset) break;
      offset = page.next_page_offset as string | number;
    }

    return { chunksByFile, chunkSymbols };
  }

  /** Index multiple projects (all in the same or different groups) */
  async indexAll(projects: ProjectConfig[]): Promise<void> {
    console.log('Starting full index...\n');
    const start = Date.now();

    for (const project of projects) {
      console.log(`[${project.group}/${project.name}]`);
      const n = await this.indexProject(project);
      console.log(`  Done: ${n} chunks\n`);
    }

    this.stats.cached = this.provider.cacheHits;
    const elapsed = ((Date.now() - start) / 1000).toFixed(1);
    const cacheStats = this.provider.getCacheStats();
    console.log(`Indexing complete in ${elapsed}s`);
    console.log(
      `  Files: ${this.stats.files}, Chunks: ${this.stats.chunks}, Cached: ${this.stats.cached}, Skipped: ${this.stats.skipped}, Errors: ${this.stats.errors}`
    );
    console.log(
      `  Cache: ${cacheStats.size}/${cacheStats.maxSize} entries, hit rate ${(cacheStats.hitRate * 100).toFixed(1)}%`
    );
  }

  /** Update a single file (delete old chunks + re-index) */
  async updateFile(groupName: string, project: ProjectConfig, filePath: string): Promise<void> {
    const relPath = path.relative(project.path, filePath);

    try {
      await this.retryQdrant(() =>
        this.qdrant.delete(this.col(groupName), {
          filter: {
            must: [
              { key: 'project', match: { value: project.name } },
              { key: 'file', match: { value: relPath } },
            ],
          },
          wait: true,
        })
      );
    } catch (err) {
      console.warn(
        `[indexer] Could not delete old chunks for ${relPath}: ${(err as Error).message}`
      );
    }

    if (fs.existsSync(filePath)) {
      const n = await this.indexFile(groupName, project, filePath);
      console.log(`[indexer] Updated ${groupName}/${project.name}/${relPath} (${n} chunks)`);
    } else {
      console.log(`[indexer] Deleted ${groupName}/${project.name}/${relPath}`);
    }
  }

  /** Remove all chunks for a file */
  async deleteFile(groupName: string, project: ProjectConfig, filePath: string): Promise<void> {
    const relPath = path.relative(project.path, filePath);

    try {
      await this.retryQdrant(() =>
        this.qdrant.delete(this.col(groupName), {
          filter: {
            must: [
              { key: 'project', match: { value: project.name } },
              { key: 'file', match: { value: relPath } },
            ],
          },
          wait: true,
        })
      );
      console.log(`[indexer] Removed ${groupName}/${project.name}/${relPath}`);
    } catch {
      // ignore
    }
  }

  /** Content-based: index files from in-memory content (no filesystem access) */
  async indexFilesContent(
    project: ProjectConfig,
    files: Array<{ path: string; content: string; language?: string }>
  ): Promise<number> {
    const groupName = project.group;
    await this.ensureCollection(groupName);

    const defaultLang = project.languages[0] ?? 'generic';
    let totalChunks = 0;

    const queue = new PQueue({ concurrency: project.indexing.concurrency });
    let processed = 0;
    const yieldIfDue = async (): Promise<void> => {
      processed++;
      if (processed % 10 === 0 || processed === files.length) {
        // Yield to the event loop so /health and /metrics handlers can run
        // between CPU-heavy tree-sitter parses.
        await new Promise<void>((resolve) => setImmediate(resolve));
      }
    };
    const tasks = files.map((file) =>
      queue.add(async () => {
        const { path: relPath, content, language } = file;
        const lang = language ?? detectLanguageByPath(relPath, content) ?? defaultLang;

        if (!content.trim()) {
          await yieldIfDue();
          return;
        }

        const { chunks, symbolResults } = await this.chunkFile(content, lang, project, {
          groupName,
          file: relPath,
        });
        if (chunks.length === 0) {
          await yieldIfDue();
          return;
        }

        // Compare chunk hashes to skip unchanged files
        const newHashes = new Set(chunks.map((c) => c.hash));
        const existingHashes = await this.getFileChunkHashes(groupName, project.name, relPath);
        if (this.hashSetsEqual(newHashes, existingHashes)) {
          this.stats.skipped++;
          await yieldIfDue();
          return;
        }

        // Delete old chunks if file was previously indexed
        if (existingHashes.size > 0) {
          await this.retryQdrant(() =>
            this.qdrant.delete(this.col(groupName), {
              filter: {
                must: [
                  { key: 'project', match: { value: project.name } },
                  { key: 'file', match: { value: relPath } },
                ],
              },
              wait: true,
            })
          );
        }

        const contents = chunks.map((c) => c.content);
        const embeddings = await this.provider.embedBatchPassage(contents);

        if (embeddings.length !== chunks.length) {
          throw new Error(
            `Embedding mismatch: got ${embeddings.length} embeddings for ${chunks.length} chunks in ${relPath}`
          );
        }

        const tags = resolveTags(project.metadata, relPath);

        const points = this.buildPointPayloads(
          chunks,
          embeddings,
          groupName,
          project.name,
          relPath,
          lang,
          tags,
          project.metadata.service,
          project.metadata.bounded_context,
          symbolResults
        );

        const batchSize = project.indexing.batchSize;
        for (let i = 0; i < points.length; i += batchSize) {
          const batch = points.slice(i, i + batchSize);
          await this.retryQdrant(() =>
            this.qdrant.upsert(this.col(groupName), { points: batch, wait: true })
          );
        }

        this.telemetry?.upsertFile({
          groupName,
          projectName: project.name,
          file: relPath,
          language: lang,
          totalLines: content.split('\n').length,
          totalBytes: Buffer.byteLength(content, 'utf8'),
          indexedAt: Date.now(),
        });

        totalChunks += points.length;
        this.stats.files++;
        this.stats.chunks += points.length;
        this.metrics.incIndexFilesTotal(groupName, 1);
        if (points.length > 0) this.metrics.incIndexChunksTotal(groupName, points.length);
        await yieldIfDue();
      })
    );

    await Promise.all(tasks);
    this.stats.cached = this.provider.cacheHits;

    return totalChunks;
  }

  /** Content-based: update file by deleting old chunks and indexing new content */
  async updateFileContent(
    groupName: string,
    projectName: string,
    relPath: string,
    content: string,
    language: string,
    project: ProjectConfig
  ): Promise<number> {
    try {
      await this.retryQdrant(() =>
        this.qdrant.delete(this.col(groupName), {
          filter: {
            must: [
              { key: 'project', match: { value: projectName } },
              { key: 'file', match: { value: relPath } },
            ],
          },
          wait: true,
        })
      );
    } catch (err) {
      console.warn(
        `[indexer] Could not delete old chunks for ${relPath}: ${(err as Error).message}`
      );
    }

    if (!content.trim()) {
      console.log(`[indexer] Updated ${groupName}/${projectName}/${relPath} (0 chunks, empty)`);
      return 0;
    }

    const { chunks, symbolResults } = await this.chunkFile(content, language, project, {
      groupName,
      file: relPath,
    });
    if (chunks.length === 0) {
      console.log(`[indexer] Updated ${groupName}/${projectName}/${relPath} (0 chunks)`);
      return 0;
    }

    const contents = chunks.map((c) => c.content);
    const embeddings = await this.provider.embedBatchPassage(contents);

    if (embeddings.length !== chunks.length) {
      throw new Error(
        `Embedding mismatch: got ${embeddings.length} embeddings for ${chunks.length} chunks in ${relPath}`
      );
    }

    const tags = resolveTags(project.metadata, relPath);

    const points = this.buildPointPayloads(
      chunks,
      embeddings,
      groupName,
      projectName,
      relPath,
      language,
      tags,
      project.metadata.service,
      project.metadata.bounded_context,
      symbolResults
    );

    const batchSize = project.indexing.batchSize;
    for (let i = 0; i < points.length; i += batchSize) {
      const batch = points.slice(i, i + batchSize);
      await this.retryQdrant(() =>
        this.qdrant.upsert(this.col(groupName), { points: batch, wait: true })
      );
    }

    this.telemetry?.upsertFile({
      groupName,
      projectName,
      file: relPath,
      language,
      totalLines: content.split('\n').length,
      totalBytes: Buffer.byteLength(content, 'utf8'),
      indexedAt: Date.now(),
    });

    console.log(
      `[indexer] Updated ${groupName}/${projectName}/${relPath} (${points.length} chunks)`
    );

    this.metrics.incIndexFilesTotal(groupName, 1);
    if (points.length > 0) this.metrics.incIndexChunksTotal(groupName, points.length);

    return points.length;
  }

  /** Content-based: delete all chunks for a project (used when force re-index) */
  async deleteProjectChunks(groupName: string, projectName: string): Promise<void> {
    try {
      await this.retryQdrant(() =>
        this.qdrant.delete(this.col(groupName), {
          filter: {
            must: [{ key: 'project', match: { value: projectName } }],
          },
          wait: true,
        })
      );
      console.log(`[indexer] Removed all chunks for ${groupName}/${projectName}`);
    } catch {
      // ignore (collection may not exist)
    }
  }

  /** Content-based: delete chunks by group, project, and relative path (no filesystem) */
  async deleteFileByPath(groupName: string, projectName: string, relPath: string): Promise<void> {
    try {
      await this.retryQdrant(() =>
        this.qdrant.delete(this.col(groupName), {
          filter: {
            must: [
              { key: 'project', match: { value: projectName } },
              { key: 'file', match: { value: relPath } },
            ],
          },
          wait: true,
        })
      );
      console.log(`[indexer] Removed ${groupName}/${projectName}/${relPath}`);
    } catch {
      // ignore
    }
  }

  /** Delete entire group collection and re-index all its projects */
  async reindexGroup(groupName: string, projects: ProjectConfig[]): Promise<number> {
    try {
      await this.qdrant.deleteCollection(this.col(groupName));
    } catch {
      // may not exist
    }

    let total = 0;
    for (const project of projects) {
      total += await this.indexProject(project);
    }
    return total;
  }

  /** Get collection stats for a group. Subtracts the metadata sentinel point
   *  (one per collection since v0.8.0) so the reported count reflects real
   *  chunks, not implementation detail. */
  async getGroupStats(groupName: string): Promise<{ points: number; status: string }> {
    try {
      const info = await this.qdrant.getCollection(this.col(groupName));
      const raw = info.points_count ?? 0;
      const hasSentinel = (await this.readCollectionMeta(groupName)) !== null;
      const points = hasSentinel ? Math.max(0, raw - 1) : raw;
      return { points, status: String(info.status) };
    } catch {
      return { points: 0, status: 'not_indexed' };
    }
  }

  /** Retrieve a chunk by its chunk_id (Qdrant payload filter on keyword index) */
  async getChunkById(chunkId: string): Promise<Record<string, unknown> | null> {
    const parsed = parseChunkId(chunkId);
    if (!parsed) return null;

    try {
      const result = await this.retryQdrant(() =>
        this.qdrant.scroll(this.col(parsed.group), {
          filter: {
            must: [{ key: 'chunk_id', match: { value: chunkId } }],
          },
          with_payload: true,
          with_vector: false,
          limit: 1,
        })
      );

      const point = result.points[0];
      if (!point) return null;
      return point.payload as Record<string, unknown>;
    } catch (err) {
      console.error(`[indexer] Failed to get chunk by ID ${chunkId}:`, err);
      return null;
    }
  }

  /** Get adjacent chunks in the same file, ordered by startLine */
  async getAdjacentChunks(
    groupName: string,
    project: string,
    file: string,
    startLine: number,
    endLine: number,
    radiusLines: number
  ): Promise<Array<Record<string, unknown>>> {
    const minLine = Math.max(0, startLine - radiusLines);
    const maxLine = endLine + radiusLines;

    try {
      const allPoints: Array<Record<string, unknown>> = [];
      let offset: string | number | undefined = undefined;

      for (;;) {
        const result = await this.retryQdrant(() =>
          this.qdrant.scroll(this.col(groupName), {
            filter: {
              must: [
                { key: 'project', match: { value: project } },
                { key: 'file', match: { value: file } },
                { key: 'startLine', range: { lte: maxLine } },
                { key: 'endLine', range: { gte: minLine } },
              ],
            },
            with_payload: true,
            with_vector: false,
            limit: 100,
            ...(offset !== undefined ? { offset } : {}),
          })
        );

        for (const p of result.points) {
          allPoints.push(p.payload as Record<string, unknown>);
        }

        if (!result.next_page_offset) break;
        offset = result.next_page_offset as string | number;
      }

      return allPoints.sort((a, b) => (a['startLine'] as number) - (b['startLine'] as number));
    } catch (err) {
      console.error(
        `[indexer] Failed to get adjacent chunks for ${groupName}/${project}/${file}:`,
        err
      );
      return [];
    }
  }

  /** List projects in a group using Qdrant facet on the `project` keyword field */
  async listProjectsInGroup(
    groupName: string
  ): Promise<Array<{ name: string; chunks: number; languages: string[] }>> {
    try {
      const projectFacet = await this.qdrant.facet(this.col(groupName), {
        key: 'project',
        limit: 1000, // groups rarely exceed a handful of projects
        exact: true,
      });

      const projects = await Promise.all(
        projectFacet.hits.map(async (hit) => {
          const projectName = String(hit.value);
          const chunks = hit.count;

          let languages: string[] = [];
          try {
            const langFacet = await this.qdrant.facet(this.col(groupName), {
              key: 'language',
              limit: 50, // bounded by supported language count
              exact: true,
              filter: {
                must: [{ key: 'project', match: { value: projectName } }],
              },
            });
            languages = langFacet.hits.map((h) => String(h.value));
          } catch {
            // language index may not exist on older collections
          }

          return { name: projectName, chunks, languages };
        })
      );

      return projects;
    } catch (err) {
      console.error(`[indexer] Failed to list projects in group ${groupName}:`, err);
      return [];
    }
  }

  /** List all paparats collections (groups), returning logical group names.
   *  Subtracts the metadata sentinel point (one per collection since v0.8.0)
   *  so reported counts reflect real chunks. */
  async listGroups(): Promise<Record<string, number>> {
    const collections = await this.qdrant.getCollections();
    const paparatsCollections = collections.collections.filter((col) =>
      col.name.startsWith(COLLECTION_PREFIX)
    );
    const infos = await Promise.all(
      paparatsCollections.map((col) => this.qdrant.getCollection(col.name))
    );
    const sentinels = await Promise.all(
      paparatsCollections.map((col) => this.readCollectionMeta(fromCollectionName(col.name)))
    );
    const result: Record<string, number> = {};
    paparatsCollections.forEach((col, i) => {
      const raw = infos[i]!.points_count ?? 0;
      const hasSentinel = sentinels[i] !== null;
      result[fromCollectionName(col.name)] = hasSentinel ? Math.max(0, raw - 1) : raw;
    });
    return result;
  }
}
