// Cross-lecture search orchestrator (Phase 1). Embeds the query on-device,
// pulls semantic candidates from the vector store, re-ranks them lexically, and
// fuses the two signals with Reciprocal Rank Fusion. Returns segment-level hits
// (with the matching signal tagged) that the UI can jump to. 100% on-device.
//
// IMPORTANT: relative imports only inside src/lib (vitest has no '@/*' alias).

import { getRepo } from '../db';
import type { VectorHit } from '../db/repo';
import { getEmbeddingEngine } from '../embedding';
import { lexicalRank } from './lexical';
import { rrf } from './rrf';
import type { SearchHit, SearchOptions } from './types';

/** How many semantic candidates to pull before re-ranking/fusing. */
const CANDIDATE_LIMIT = 50;
/** Default number of hits returned to the caller. */
const DEFAULT_LIMIT = 30;

/**
 * Search the user's lectures for `query`, returning fused semantic + lexical
 * hits at segment granularity (best first). Empty/whitespace queries return [].
 */
export async function searchLectures(
  query: string,
  opts?: SearchOptions,
): Promise<SearchHit[]> {
  const q = query.trim();
  if (q.length === 0) return [];

  // 1) Embed the query on-device (lazy-load the model on first use).
  const engine = getEmbeddingEngine();
  if (!engine.isLoaded()) await engine.loadModel();
  const [vec] = await engine.embed([q], 'query');
  if (!vec) return [];

  // 2) Semantic candidates from the vector store (cosine, top CANDIDATE_LIMIT).
  const cand = await getRepo().searchVectors(vec, {
    courseId: opts?.courseId,
    limit: CANDIDATE_LIMIT,
  });
  if (cand.length === 0) return [];

  // O(1) candidate lookup by segmentId for the fusion map-back step.
  const bySegment = new Map<string, VectorHit>();
  for (const c of cand) bySegment.set(c.segmentId, c);

  // 3) Two ranked id lists over the SAME candidate set.
  //    - semantic: candidates are already cosine-desc from the repo.
  const semanticIds = cand.map((c) => c.segmentId);
  const semanticSet = new Set(semanticIds);
  //    - lexical: keyword re-rank of just these candidates' texts.
  const lexicalRanked = lexicalRank(
    cand.map((c) => ({ id: c.segmentId, text: c.text })),
    q,
  );
  const lexicalIds = lexicalRanked.map((r) => r.id);
  const lexicalSet = new Set(lexicalIds);

  // 4) Fuse the two orderings.
  const fused = rrf([semanticIds, lexicalIds]);

  // 5) Map fused ids back to their VectorHit and tag the matching signal.
  const limit = opts?.limit ?? DEFAULT_LIMIT;
  const hits: SearchHit[] = [];
  for (const { id } of fused) {
    const hit = bySegment.get(id);
    if (!hit) continue;
    const inSemantic = semanticSet.has(id);
    const inLexical = lexicalSet.has(id);
    const via: SearchHit['via'] = inSemantic && inLexical ? 'both' : inLexical ? 'lexical' : 'semantic';
    hits.push({ ...hit, via });
    if (hits.length >= limit) break;
  }

  return hits;
}
