/**
 * Gen-UI training-chunk library.
 *
 * Loads the harvester's per-chunk JSON records + aggregate index from
 * `packages/a2ui/corpus/chunks/` into in-memory Maps for fast retrieval.
 *
 * Used by:
 *   - packages/a2ui/mcp/server.js — exposes search_chunks / get_chunk / lookup_chunk tools
 *   - packages/a2ui/compose/strategies/zettel/chunk-synthesizer.js — Phase C.2
 *
 * Spec: docs/specs/genui-chunk-marker.md (Draft v0.1.0).
 */

import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CHUNKS_DIR = join(__dirname, '..', 'chunks');

let _state = null;

function load() {
  if (_state) return _state;

  const indexPath = join(CHUNKS_DIR, '_index.json');
  if (!existsSync(indexPath)) {
    _state = { index: null, chunks: new Map(), byPrimary: new Map(), byKind: new Map() };
    return _state;
  }

  const index = JSON.parse(readFileSync(indexPath, 'utf8'));
  const chunks = new Map();      // name → record
  const byPrimary = new Map();   // tagName → Set<name>
  const byKind = new Map();      // kind → Set<name>

  for (const file of readdirSync(CHUNKS_DIR)) {
    if (file === '_index.json' || !file.endsWith('.json')) continue;
    const rec = JSON.parse(readFileSync(join(CHUNKS_DIR, file), 'utf8'));
    const name = rec.name;
    chunks.set(name, rec);

    const primary = rec.primary || rec.instances?.[0]?.primary || 'unknown';
    if (!byPrimary.has(primary)) byPrimary.set(primary, new Set());
    byPrimary.get(primary).add(name);

    const kind = rec.kind || 'block';
    if (!byKind.has(kind)) byKind.set(kind, new Set());
    byKind.get(kind).add(name);
  }

  _state = { index, chunks, byPrimary, byKind };
  return _state;
}

export function getChunk(name) {
  const { chunks } = load();
  return chunks.get(name) || null;
}

export function getAllChunks() {
  const { chunks } = load();
  return [...chunks.values()];
}

export function getChunkIndex() {
  const { index } = load();
  return index;
}

export function lookupChunksByPrimary(componentName) {
  const { byPrimary, chunks } = load();
  const names = byPrimary.get(componentName);
  if (!names) return [];
  return [...names].map((n) => chunks.get(n)).filter(Boolean);
}

export function listChunksByKind(kind) {
  const { byKind, chunks } = load();
  const names = byKind.get(kind);
  if (!names) return [];
  return [...names].map((n) => chunks.get(n)).filter(Boolean);
}

/**
 * Keyword scoring over chunk metadata. Used as the floor when embeddings
 * are unavailable AND blended into the final score when they are.
 */
function keywordScore(name, rec, query) {
  const q = query.toLowerCase();
  const tokens = q.split(/[\s\-_]+/).filter(Boolean);
  const haystack = [
    name,
    rec.primary || '',
    ...(rec.slots || []).map((s) => s.name || ''),
    ...(rec.instances?.[0]?.slots || []).map((s) => s.name || ''),
    Object.values(rec.attrs || {}).join(' '),
  ].join(' ').toLowerCase();

  const nameWords = name.toLowerCase().split(/[-_]+/);
  let score = 0;
  // Boost when the chunk's first word matches any query token (e.g. "dashboard" → dashboard-*)
  if (tokens.some((tok) => nameWords[0] === tok)) score += 10;
  if (haystack.includes(q)) score += 5;
  for (const tok of tokens) {
    if (nameWords.includes(tok)) score += 3;    // whole-word match (e.g. pane ≠ panel)
    else if (name.toLowerCase().includes(tok)) score += 1; // substring fallback
    if (haystack.includes(tok)) score += 1;
  }
  return score;
}

/**
 * Search chunks. Two-mode:
 *   - Sync (default): keyword-only scoring; no API calls.
 *   - Async (`searchChunksAsync`): blends semantic embedding cosine score
 *     (when chunk-embeddings.json + an OPENAI/VOYAGE key are available)
 *     with keyword score; falls back to keyword-only on any failure.
 *
 * The sync path is preserved for callers that need a synchronous result
 * (e.g. compose_from_chunks's retrieval-first tier doesn't want to await
 * a network round trip just to short-circuit on a strong keyword hit).
 */
export function searchChunks(query, { kind, limit = 20 } = {}) {
  const { chunks } = load();
  if (!query || typeof query !== 'string') return [];

  const candidates = [];
  for (const [name, rec] of chunks) {
    if (kind && rec.kind !== kind) continue;
    const score = keywordScore(name, rec, query);
    if (score > 0) candidates.push({ name, score, kind: rec.kind, primary: rec.primary });
  }
  candidates.sort((a, b) => b.score - a.score);
  return candidates.slice(0, limit);
}

/**
 * Embedding-blended search. Returns the top `limit` chunks ranked by
 * (keyword + 5×cosine) when embeddings are available; falls back to
 * keyword-only when not. Scoring weights are tuned so a perfect cosine
 * match (1.0) contributes ~5 to the score, comparable to a name-prefix hit.
 */
export async function searchChunksAsync(query, { kind, limit = 20 } = {}) {
  const { chunks } = load();
  if (!query || typeof query !== 'string') return [];

  let cosineByName = null;
  // Env-var override: A2UI_DISABLE_EMBEDDINGS=1 forces keyword-only mode.
  // Used by `npm run eval:diff -- --engine chunk-zettel-fast` and any
  // diagnostic that needs to confirm the keyword path is independently
  // healthy (i.e. retrieval works even when embeddings are stale or
  // missing). Default: embeddings enabled when available.
  const embeddingsDisabled = process.env.A2UI_DISABLE_EMBEDDINGS === '1';
  if (!embeddingsDisabled) {
    try {
      const { scoreAll, available } = await import('../../retrieval/embedding/chunk-embedding-retriever.js');
      if (await available()) {
        cosineByName = await scoreAll(query);
      }
    } catch { /* embeddings unavailable — keyword-only */ }
  }

  const candidates = [];
  for (const [name, rec] of chunks) {
    if (kind && rec.kind !== kind) continue;
    const kw = keywordScore(name, rec, query);
    const cos = cosineByName?.get(name) || 0;
    const blended = kw + cos * 5;
    if (blended > 0) {
      candidates.push({
        name,
        score: blended,
        keywordScore: kw,
        cosineScore: cos,
        kind: rec.kind,
        primary: rec.primary,
      });
    }
  }
  candidates.sort((a, b) => b.score - a.score);
  return candidates.slice(0, limit);
}
