/**
 * search.ts — hybrid retrieval inside the runtime.
 *
 * Wraps the same `public.hybrid_search_chunks` RPC the web uses, with the
 * query embedding fetched from the embed-service. Detectives that need
 * grounding (Holmes, Dupin, …) call this to assemble a chunk shortlist
 * before reasoning.
 *
 * Default `rerank: never` — the runtime is latency-sensitive (it runs in the
 * agentic loop) and the RPC's RRF order is good enough for the head.
 */
import { env } from "./env";
import { query } from "./pg";

export interface SearchHit {
  chunk_pk: number;
  doc_id: string;
  chunk_id: string;
  page: number;
  type: string;
  bbox: { x: number; y: number; w: number; h: number } | null;
  content_en: string | null;
  content_pt: string | null;
  classification: string | null;
  score: number;
  bm25_rank: number | null;
  dense_rank: number | null;
}

async function embedQuery(q: string): Promise<number[]> {
  const r = await fetch(`${env.EMBED_SERVICE_URL}/embed`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ texts: [q] }),
    signal: AbortSignal.timeout(60_000),
  });
  if (!r.ok) {
    const t = await r.text();
    throw new Error(`embed-service HTTP ${r.status}: ${t.slice(0, 200)}`);
  }
  const data = await r.json() as { embeddings: number[][] };
  const vec = data.embeddings?.[0];
  if (!Array.isArray(vec)) throw new Error("embed-service returned no vector");
  return vec;
}

function toVectorLiteral(vec: number[]): string {
  return "[" + vec.join(",") + "]";
}

export interface HybridSearchOpts {
  query: string;
  lang?: "pt" | "en";
  doc_id?: string | null;
  type?: string | null;
  classification?: string | null;
  ufo_only?: boolean;
  recall_k?: number;
  top_k?: number;
  /**
   * Max cosine distance for dense matches. Lower = stricter (less noise),
   * higher = better recall. The web's `/api/search/hybrid` uses 0.40 for
   * the chat (precision matters). The runtime defaults to 0.55: Holmes
   * needs RECALL so the model has chunks to reason over; it filters out
   * the irrelevant ones in the prose.
   */
  max_dense_dist?: number;
}

export async function hybridSearch(opts: HybridSearchOpts): Promise<SearchHit[]> {
  const {
    query: q,
    lang = "pt",
    doc_id = null,
    type = null,
    classification = null,
    ufo_only = false,
    recall_k = 100,
    top_k = 20,
    max_dense_dist = 0.55,
  } = opts;
  if (!q.trim()) return [];
  const vec = await embedQuery(q);
  const rows = await query<SearchHit>(
    `SELECT * FROM public.hybrid_search_chunks($1, $2::vector, $3, $4, $5, $6, $7, $8, 60, $9)`,
    [q, toVectorLiteral(vec), lang, doc_id, type, classification, ufo_only, recall_k, max_dense_dist],
  );
  return rows.slice(0, top_k);
}
