/**
 * Search service — hybrid FTS5 + vector search.
 *
 * Data flow:
 * 1. Compute query embedding via Rust (fastembed)
 * 2. Fetch all block embeddings from SQLite
 * 3. Rank via Rust cosine similarity
 * 4. Run FTS5 keyword query via plugin-sql
 * 5. Merge both result sets via RRF (k=60)
 * 6. Enrich with page titles
 *
 * The service takes its dependencies explicitly (db, invoke) rather than
 * importing them directly, making it testable with a mock database.
 *
 * TODO: scalability — the vector path loads ALL embeddings into memory
 * on every search. At 100k+ blocks, move the full-table scan into Rust
 * memory and search there directly instead of shipping every vector
 * through IPC.
 */
import type Database from "@tauri-apps/plugin-sql"

const RRF_K = 60

export interface SearchResult {
  blockId: string
  pagePath: string
  pageTitle: string
  content: string
  excerpt: string
  score: number
  source: "vector" | "keyword" | "hybrid"
}

export class SearchService {
  constructor(
    private db: Database,
    _workspacePath: string,
    private invoke: (
      cmd: string,
      args?: Record<string, unknown>,
    ) => Promise<unknown>,
  ) {}

  /**
   * Execute a hybrid search query (FTS5 + optional vector).
   *
   * Always runs FTS5 keyword search. Optionally runs vector (semantic) search
   * if the embedding model is loaded. Results are merged via Reciprocal Rank
   * Fusion (RRF) with k=60.
   *
   * Vector search degrades gracefully — if `compute_embedding` or
   * `rank_by_similarity` throws, results fall back to keyword-only.
   *
   * Results are enriched with block content, page paths, and page titles.
   * Each result includes an excerpt with ~60 chars of context on either
   * side of the first matching term.
   *
   * @param query — User's search string. Empty/whitespace returns [].
   * @returns Up to 20 results sorted by RRF score descending.
   */
  async search(query: string): Promise<SearchResult[]> {
    if (!query.trim()) return []

    const ftsQuery = query
      .trim()
      .split(/\s+/)
      .map((w) => `"${w.replace(/"/g, "")}"*`)
      .join(" ")

    // 1. Always run FTS5 keyword search
    const ftsRows = await this.db.select<{ block_id: string }[]>(
      `SELECT b.id as block_id
       FROM blocks_fts f
       JOIN blocks b ON b.rowid = f.rowid
       WHERE blocks_fts MATCH ?
       ORDER BY rank
       LIMIT 20`,
      [ftsQuery],
    )

    // 2. Optionally run vector search (gracefully degrade if model unavailable)
    let ranked: { block_id: string; score: number }[] = []
    try {
      const queryVec = (await this.invoke("compute_embedding", {
        text: query.trim(),
      })) as number[]
      if (queryVec && queryVec.length > 0) {
        const embRows = await this.db.select<
          { id: string; embedding: ArrayBuffer }[]
        >("SELECT id, embedding FROM blocks WHERE embedding IS NOT NULL")
        const candidates: [string, number[]][] = embRows
          .filter((r) => r.embedding)
          .map((r) => [r.id, decodeEmbedding(r.embedding)])
        if (candidates.length > 0) {
          ranked = (await this.invoke("rank_by_similarity", {
            query: queryVec,
            candidates,
          })) as { block_id: string; score: number }[]
        }
      }
    } catch {
      // Vector search unavailable — fall back to keyword only
    }

    // 3. Merge via RRF
    const rankMap = new Map<string, { annRank?: number; ftsRank?: number }>()

    ranked.forEach((r, i) => {
      const entry = rankMap.get(r.block_id) ?? {}
      entry.annRank = i + 1
      rankMap.set(r.block_id, entry)
    })

    ftsRows.forEach((r, i) => {
      const entry = rankMap.get(r.block_id) ?? {}
      entry.ftsRank = i + 1
      rankMap.set(r.block_id, entry)
    })

    const merged = [...rankMap.entries()]
      .map(([blockId, ranks]) => {
        let score = 0
        const sources: string[] = []
        if (ranks.annRank) {
          score += 1 / (RRF_K + ranks.annRank)
          sources.push("vector")
        }
        if (ranks.ftsRank) {
          score += 1 / (RRF_K + ranks.ftsRank)
          sources.push("keyword")
        }
        const source = (
          sources.length === 2 ? "hybrid" : (sources[0] ?? "keyword")
        ) as SearchResult["source"]
        return { blockId, score, source }
      })
      .sort((a, b) => b.score - a.score)
      .slice(0, 20)

    if (merged.length === 0) return []

    // 4. Enrich — fetch block content and page titles for all merged IDs
    const allIds = merged.map((r) => r.blockId)
    const placeholders = allIds.map(() => "?").join(",")
    const contentRows = await this.db.select<
      { id: string; content: string; page_path: string }[]
    >(
      `SELECT id, content, page_path FROM blocks WHERE id IN (${placeholders})`,
      allIds,
    )
    const contentMap = new Map(contentRows.map((r) => [r.id, r.content]))
    const pagePathMap = new Map(contentRows.map((r) => [r.id, r.page_path]))
    const uniquePaths = [...new Set(contentRows.map((r) => r.page_path))]

    const titleMap = new Map<string, string>()
    if (uniquePaths.length > 0) {
      const pathPlaceholders = uniquePaths.map(() => "?").join(",")
      const titleRows = await this.db.select<{ path: string; title: string }[]>(
        `SELECT path, title FROM pages WHERE path IN (${pathPlaceholders})`,
        uniquePaths,
      )
      for (const t of titleRows) {
        titleMap.set(
          t.path,
          t.title || t.path.split("/").pop()?.replace(/\.md$/, "") || t.path,
        )
      }
    }

    return merged.map(({ blockId, score, source }) => {
      const content = contentMap.get(blockId) ?? ""
      const pagePath = pagePathMap.get(blockId) ?? ""
      const pageTitle =
        titleMap.get(pagePath) ??
        pagePath.split("/").pop()?.replace(/\.md$/, "") ??
        pagePath
      return {
        blockId,
        pagePath,
        pageTitle,
        content,
        excerpt: buildExcerpt(content, query),
        score,
        source,
      }
    })
  }
}

function decodeEmbedding(blob: ArrayBuffer): number[] {
  return Array.from(new Float32Array(blob))
}

function buildExcerpt(content: string, query: string): string {
  const lowerContent = content.toLowerCase()
  const terms = query.split(/\s+/)
  const pos = terms
    .map((t) => lowerContent.indexOf(t.toLowerCase()))
    .filter((p) => p >= 0)
    .sort((a, b) => a - b)[0]

  if (pos === undefined)
    return content.slice(0, 120) + (content.length > 120 ? "…" : "")

  const start = Math.max(0, pos - 60)
  const end = Math.min(content.length, pos + 60)
  const excerpt = content.slice(start, end)

  let result = ""
  if (start > 0) result += "…"
  result += excerpt
  if (end < content.length) result += "…"
  return result
}
