/**
 * Lazy background embedding indexer.
 * Finds symbols without embeddings and indexes them in batches.
 */
import type { Store } from '../db/store.js';
import { logger } from '../logger.js';
import type { ProgressState } from '../progress.js';
import { warnIfCloudEmbeddingProvider } from './cloud-warning.js';
import type { EmbeddingService, VectorStore } from './interfaces.js';
import { ProviderMismatchError } from './vector-store.js';

const DEFAULT_BATCH_SIZE = 50;
/** Trip the circuit breaker after this many consecutive batch failures. */
const FAILURE_THRESHOLD = 2;
/** How long to skip embedding work after the breaker trips. */
const COOLDOWN_MS = 10 * 60 * 1000;

/** Construction-time knobs for {@link EmbeddingPipeline}. */
export interface EmbeddingPipelineOptions {
  /**
   * When true (default), provider/model/dim drift between the on-disk index
   * and the active embedding service causes ensureConsistent() to drop the
   * vector store and re-embed under the new config (with a loud warn log).
   * When false, the same drift throws {@link ProviderMismatchError} — useful
   * when the operator wants a hard gate against silent model swaps.
   */
  autoRebuildOnProviderMismatch?: boolean;
}

export class EmbeddingPipeline {
  private consistent = false;
  /** Set while indexUnembedded is running; rapid re-triggers (file watcher) are no-ops. */
  private inFlight = false;
  /** Consecutive batch failures since last success — drives the circuit breaker. */
  private consecutiveFailures = 0;
  /** Wall-clock until which indexUnembedded short-circuits to 0. */
  private disabledUntilMs = 0;
  /** Whether we've already logged the breaker trip (to avoid log spam). */
  private breakerNotified = false;
  /** Resolved auto-rebuild flag — defaults to true for back-compat. */
  private readonly autoRebuild: boolean;

  constructor(
    private store: Store,
    private embeddingService: EmbeddingService,
    private vectorStore: VectorStore,
    private progress?: ProgressState,
    options?: EmbeddingPipelineOptions,
  ) {
    this.autoRebuild = options?.autoRebuildOnProviderMismatch ?? true;
  }

  /**
   * Verify the stored vectors match the current embedding model + dimensionality.
   * On mismatch, drops the vector table and re-stamps the meta. The follow-up
   * indexUnembedded call will repopulate. Idempotent and cached after first run.
   */
  private ensureConsistent(): void {
    if (this.consistent) return;
    const dim = this.embeddingService.dimensions();
    const model = this.embeddingService.modelName();
    const provider = this.embeddingService.providerName?.() ?? 'unknown';
    // Skip for fallback/no-op services — they produce no vectors.
    if (dim === 0) {
      this.consistent = true;
      return;
    }

    // One-shot stderr warning before the first cloud-bound embedding leaves
    // the machine. Suppressed by TRACE_MCP_ACCEPT_CLOUD_EMBEDDINGS=1 and
    // never fires for local providers (ollama, onnx, fallback).
    warnIfCloudEmbeddingProvider(provider);

    const meta = this.vectorStore.getMeta();
    if (!meta) {
      // Post-migration or first run: stamp without reindexing. Any existing
      // vectors are assumed to match the current config (the invariant starts
      // being enforced from this point forward).
      this.vectorStore.setMeta(model, dim, provider);
    } else if (
      meta.model !== model ||
      meta.dim !== dim ||
      // Only enforce provider mismatch when the index has it stamped — legacy
      // indexes without the column are accepted and will be backfilled below.
      (meta.provider !== undefined && meta.provider !== provider)
    ) {
      // P0.1: auto-rebuild is opt-out via ai.autoRebuildOnProviderMismatch.
      // When disabled, raise ProviderMismatchError so the operator notices
      // before the index gets silently rewritten.
      if (!this.autoRebuild) {
        throw new ProviderMismatchError(
          {
            provider: meta.provider ?? 'unknown',
            model: meta.model,
          },
          { provider, model },
        );
      }
      logger.warn(
        { old: meta, new: { model, dim, provider } },
        'Embedding provider/model/dim changed — dropping vector index for reindex',
      );
      this.vectorStore.clear();
      this.vectorStore.setMeta(model, dim, provider);
    } else if (meta.provider === undefined) {
      // Legacy index missing the provider column — backfill in place.
      this.vectorStore.setMeta(model, dim, provider);
    }
    this.consistent = true;
  }

  async indexSymbol(symbolId: number, text: string): Promise<void> {
    this.ensureConsistent();
    const embedding = await this.embeddingService.embed(text);
    if (embedding.length > 0) {
      this.vectorStore.insert(symbolId, embedding);
    }
  }

  /**
   * Find symbols that don't have embeddings yet and embed them in a loop.
   * Reports progress and returns the total number of newly embedded symbols.
   *
   * Self-guarded: skips work when a previous run is still in flight (rapid
   * file-watcher re-triggers) or when the circuit breaker is open (embedding
   * service has been failing for several consecutive batches).
   */
  async indexUnembedded(batchSize = DEFAULT_BATCH_SIZE, signal?: AbortSignal): Promise<number> {
    if (this.inFlight) return 0;
    if (this.disabledUntilMs > Date.now()) return 0;
    if (signal?.aborted) return 0;

    this.inFlight = true;
    try {
      this.ensureConsistent();
      const totalToEmbed = this.store.countUnembeddedSymbols();
      if (totalToEmbed === 0) return 0;

      this.progress?.update('embedding', {
        phase: 'running',
        processed: 0,
        total: totalToEmbed,
        startedAt: Date.now(),
        completedAt: 0,
      });

      let totalIndexed = 0;

      try {
        let batch: number;
        do {
          // Cooperative cancellation between batches — owner disposed.
          if (signal?.aborted) break;
          batch = await this.embedBatch(batchSize, signal);
          totalIndexed += batch;
          if (batch > 0) {
            this.progress?.update('embedding', { processed: totalIndexed });
          }
          // Stop the loop if the breaker tripped during this batch.
          if (this.disabledUntilMs > Date.now()) break;
        } while (batch > 0);

        this.progress?.update('embedding', {
          phase: 'completed',
          processed: totalIndexed,
          completedAt: Date.now(),
        });
      } catch (e) {
        this.progress?.update('embedding', {
          phase: 'error',
          error: e instanceof Error ? e.message : String(e),
        });
        throw e;
      }

      return totalIndexed;
    } finally {
      this.inFlight = false;
    }
  }

  /**
   * Embed a single batch of unembedded symbols.
   * Returns the number of symbols embedded in this batch.
   */
  private async embedBatch(batchSize: number, signal?: AbortSignal): Promise<number> {
    const unembedded = this.store.db
      .prepare(`
      SELECT s.id, s.name, s.fqn, s.kind, s.signature, s.summary
      FROM symbols s
      LEFT JOIN symbol_embeddings se ON se.symbol_id = s.id
      WHERE se.symbol_id IS NULL
      LIMIT ?
    `)
      .all(batchSize) as {
      id: number;
      name: string;
      fqn: string | null;
      kind: string;
      signature: string | null;
      summary: string | null;
    }[];

    if (unembedded.length === 0) return 0;
    if (signal?.aborted) return 0;

    const texts = unembedded.map((s) => buildEmbeddingText(s));
    let indexed = 0;

    try {
      const embeddings = await this.embeddingService.embedBatch(texts, undefined, signal);
      for (let i = 0; i < embeddings.length; i++) {
        if (embeddings[i].length > 0) {
          this.vectorStore.insert(unembedded[i].id, embeddings[i]);
          indexed++;
        }
      }
      // Reset breaker on any successful batch.
      if (this.consecutiveFailures > 0 || this.breakerNotified) {
        if (this.breakerNotified) {
          logger.info('Embedding service recovered — resuming background embedding');
        }
        this.consecutiveFailures = 0;
        this.breakerNotified = false;
      }
    } catch (e) {
      logger.error({ error: e }, 'Embedding batch failed');
      this.consecutiveFailures++;
      if (this.consecutiveFailures >= FAILURE_THRESHOLD && !this.breakerNotified) {
        this.disabledUntilMs = Date.now() + COOLDOWN_MS;
        this.breakerNotified = true;
        logger.warn(
          {
            consecutiveFailures: this.consecutiveFailures,
            cooldownMinutes: COOLDOWN_MS / 60_000,
          },
          'Embedding service unreachable — pausing background embedding',
        );
      } else if (this.breakerNotified) {
        // Already tripped; just extend the cooldown so a still-broken service
        // doesn't get probed the moment the previous window ends.
        this.disabledUntilMs = Date.now() + COOLDOWN_MS;
      }
    }

    logger.debug({ indexed, total: unembedded.length }, 'Indexed unembedded symbols');
    return indexed;
  }

  /**
   * Re-embed all symbols (deletes existing embeddings first).
   * Also re-stamps meta with the current model + dimensionality so the invariant
   * holds going forward. Returns the number of embedded symbols.
   */
  async reindexAll(): Promise<number> {
    this.vectorStore.clear();
    const dim = this.embeddingService.dimensions();
    if (dim > 0) {
      this.vectorStore.setMeta(
        this.embeddingService.modelName(),
        dim,
        this.embeddingService.providerName?.() ?? 'unknown',
      );
    }
    this.consistent = true;
    return this.indexUnembedded(DEFAULT_BATCH_SIZE);
  }
}

function buildEmbeddingText(symbol: {
  name: string;
  fqn: string | null;
  kind: string;
  signature: string | null;
  summary: string | null;
}): string {
  const parts = [symbol.kind, symbol.fqn ?? symbol.name];
  if (symbol.signature) parts.push(symbol.signature);
  if (symbol.summary) parts.push(symbol.summary);
  return parts.join(' ');
}
