import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";

import {
  buildLocalProvider,
  LOCAL_PROVIDER_ID,
  REMOTE_PROVIDER_ID,
  RemoteEmbeddingProvider,
  resolveEmbeddingConfig,
  type EmbeddingProvider,
  type ProviderKey,
} from "@/lib/embeddings";
import { buildLsaCorpus, projectQuery, type LsaModel } from "@/lib/lsa";
import { getProductDocuments, getProducts, getProductsFingerprint } from "@/lib/products";
import { normalizeWhitespace } from "@/lib/text";

/**
 * The vector index.
 *
 * Product embeddings are generated once, persisted under `/data`, and loaded into
 * memory as plain arrays. A search request only reads that in-memory index and
 * embeds the query — it never re-embeds the catalogue.
 *
 * The index is cached on `globalThis` so Next.js dev hot reloads do not re-run
 * the pipeline on every recompile.
 */

export const VECTOR_INDEX_FORMAT = "apnabazzar-product-vectors";
export const VECTOR_INDEX_VERSION = 1;

const DATA_DIR = path.join(process.cwd(), "data");
const QUERY_CACHE_LIMIT = 256;
const VECTOR_PRECISION = 6;

export interface PersistedVectorIndex {
  format: typeof VECTOR_INDEX_FORMAT;
  version: number;
  provider: ProviderKey;
  dimensions: number;
  /** Fingerprint of the catalogue these vectors were built from. */
  sourceHash: string;
  documentCount: number;
  generatedAt: string;
  /** Vocabulary of the LSA model. Empty for providers that need no vocabulary. */
  terms: string[];
  idf: number[];
  termVectors: number[][];
  documents: { id: number; vector: number[] }[];
}

export type QueryEmbedder = (text: string) => Promise<number[]>;

export interface VectorIndex {
  provider: ProviderKey;
  dimensions: number;
  documents: { id: number; vector: number[] }[];
  /** productId -> position in `documents`, for O(1) lookup. */
  positions: Map<number, number>;
  /** Where the vectors came from, surfaced in the API meta. */
  source: "file" | "memory";
  /** True when a configured remote provider failed and the offline index took over. */
  fallback: boolean;
  embedQuery: QueryEmbedder;
}

function round(value: number): number {
  return Number(value.toFixed(VECTOR_PRECISION));
}

function vectorsFilePath(provider: ProviderKey): string {
  const suffix = provider.id === LOCAL_PROVIDER_ID ? "" : `.${provider.id}`;
  return path.join(DATA_DIR, `product-vectors${suffix}.json`);
}

function isUsableIndexFile(
  value: unknown,
  provider: ProviderKey,
  sourceHash: string,
): value is PersistedVectorIndex {
  if (typeof value !== "object" || value === null) return false;
  const file = value as Partial<PersistedVectorIndex>;
  return (
    file.format === VECTOR_INDEX_FORMAT &&
    file.version === VECTOR_INDEX_VERSION &&
    file.provider?.id === provider.id &&
    file.provider?.model === provider.model &&
    file.sourceHash === sourceHash &&
    Array.isArray(file.documents) &&
    file.documents.length > 0
  );
}

async function readIndexFile(
  provider: ProviderKey,
  sourceHash: string,
): Promise<PersistedVectorIndex | null> {
  try {
    const parsed = JSON.parse(await readFile(vectorsFilePath(provider), "utf8")) as unknown;
    return isUsableIndexFile(parsed, provider, sourceHash) ? parsed : null;
  } catch {
    return null;
  }
}

async function writeIndexFile(file: PersistedVectorIndex): Promise<void> {
  await writeFile(vectorsFilePath(file.provider), `${JSON.stringify(file, null, 2)}\n`, "utf8");
}

/** Build a fresh index for `provider` from the catalogue. One embedding pass. */
export async function buildVectorIndex(provider: EmbeddingProvider): Promise<PersistedVectorIndex> {
  const products = await getProducts();
  const documents = getProductDocuments(products);
  const sourceHash = await getProductsFingerprint();

  const vectors = await provider.embedMany(documents.map((document) => document.text));

  if (vectors.length !== documents.length) {
    throw new Error(
      `Embedding provider returned ${vectors.length} vectors for ${documents.length} products`,
    );
  }

  const first = vectors[0] ?? [];
  const dimensions = first.length;
  if (dimensions === 0 || first.some((value) => !Number.isFinite(value))) {
    throw new Error("Embedding provider returned an invalid vector");
  }

  // The LSA model is needed to fold *new* text into the same space, so it is
  // persisted next to the document vectors for the offline provider.
  const lsaModel = localModelOf(provider) ?? buildLsaCorpus(documents.map((d) => d.text)).model;

  return {
    format: VECTOR_INDEX_FORMAT,
    version: VECTOR_INDEX_VERSION,
    provider: { id: provider.id, model: provider.model },
    dimensions,
    sourceHash,
    documentCount: documents.length,
    generatedAt: new Date().toISOString(),
    terms: lsaModel.terms,
    idf: lsaModel.idf.map(round),
    termVectors: lsaModel.termVectors.map((row) => row.map(round)),
    documents: documents.map((document, index) => ({
      id: document.id,
      vector: vectors[index].map(round),
    })),
  };
}

function localModelOf(provider: EmbeddingProvider): LsaModel | null {
  if (provider.id !== LOCAL_PROVIDER_ID) return null;
  return (provider as { lsaModel?: LsaModel }).lsaModel ?? null;
}

/** Regenerate and persist an index. Backs `npm run build:vectors`. */
export async function regenerateVectorIndex(providerId?: string): Promise<PersistedVectorIndex> {
  const config = resolveEmbeddingConfig();
  const useRemote = providerId ? providerId === REMOTE_PROVIDER_ID : config.apiKey !== null;

  if (useRemote && config.apiKey === null) {
    throw new Error("EMBEDDING_API_KEY is required to build remote vectors");
  }

  const provider = await createProvider(useRemote ? REMOTE_PROVIDER_ID : LOCAL_PROVIDER_ID);
  const file = await buildVectorIndex(provider);
  await writeIndexFile(file);
  return file;
}

async function createProvider(providerId: string): Promise<EmbeddingProvider> {
  if (providerId === REMOTE_PROVIDER_ID) {
    const config = resolveEmbeddingConfig();
    if (!config.apiKey) throw new Error("EMBEDDING_API_KEY is not configured");
    return new RemoteEmbeddingProvider(
      {
        apiKey: config.apiKey,
        model: config.model,
        baseUrl: config.baseUrl,
        batchSize: config.batchSize,
        timeoutMs: config.timeoutMs,
      },
      1536,
    );
  }

  const products = await getProducts();
  const documents = getProductDocuments(products);
  return buildLocalProvider(documents.map((document) => document.text));
}

/** Re-create the persisted LSA model so queries project into the same space. */
function queryEmbedderFromFile(file: PersistedVectorIndex): QueryEmbedder {
  if (file.provider.id !== LOCAL_PROVIDER_ID || file.termVectors.length === 0) {
    throw new Error(`Provider ${file.provider.id} cannot embed queries offline`);
  }
  const model: LsaModel = {
    terms: file.terms,
    idf: file.idf,
    termVectors: file.termVectors,
    dimensions: file.dimensions,
    singularValues: [],
  };
  return async (text) => projectQuery(model, text);
}

const warned = new Set<string>();
function warnOnce(message: string): void {
  if (warned.has(message)) return;
  warned.add(message);
  console.warn(`[vector-search] ${message}`);
}

function toIndex(
  file: PersistedVectorIndex,
  source: "file" | "memory",
  embedQuery: QueryEmbedder,
  fallback = false,
): VectorIndex {
  return {
    provider: file.provider,
    dimensions: file.dimensions,
    documents: file.documents,
    positions: new Map(file.documents.map((document, position) => [document.id, position])),
    source,
    fallback,
    embedQuery,
  };
}

async function resolveIndex(): Promise<VectorIndex> {
  const config = resolveEmbeddingConfig();
  const sourceHash = await getProductsFingerprint();

  if (config.apiKey !== null) {
    const providerKey: ProviderKey = { id: REMOTE_PROVIDER_ID, model: config.model };
    try {
      return await loadOrBuild(providerKey, sourceHash, REMOTE_PROVIDER_ID);
    } catch (error) {
      warnOnce(
        `Remote embedding provider unavailable (${
          error instanceof Error ? error.message : "unknown error"
        }). Falling back to the offline LSA index.`,
      );
      const local = await loadOrBuild(
        { id: LOCAL_PROVIDER_ID, model: "lsa-tfidf" },
        sourceHash,
        LOCAL_PROVIDER_ID,
      );
      return { ...local, fallback: true };
    }
  }

  return loadOrBuild({ id: LOCAL_PROVIDER_ID, model: "lsa-tfidf" }, sourceHash, LOCAL_PROVIDER_ID);
}

async function loadOrBuild(
  providerKey: ProviderKey,
  sourceHash: string,
  providerId: string,
): Promise<VectorIndex> {
  const cached = await readIndexFile(providerKey, sourceHash);
  if (cached) {
    if (providerId === REMOTE_PROVIDER_ID) {
      // Document vectors are cached, but query vectors still need the provider.
      const provider = await createProvider(REMOTE_PROVIDER_ID);
      return toIndex(cached, "file", (text) => provider.embed(text));
    }
    return toIndex(cached, "file", queryEmbedderFromFile(cached));
  }

  const provider = await createProvider(providerId);
  const file = await buildVectorIndex(provider);
  const embedQuery =
    providerId === REMOTE_PROVIDER_ID ? provider.embed.bind(provider) : queryEmbedderFromFile(file);

  try {
    await writeIndexFile(file);
  } catch (error) {
    // A read-only filesystem must not break search: memory is enough.
    warnOnce(
      `Could not persist the vector index (${
        error instanceof Error ? error.message : "unknown error"
      }). Using an in-memory index.`,
    );
    return toIndex(file, "memory", embedQuery);
  }

  return toIndex(file, "file", embedQuery);
}

interface IndexCache {
  index: Promise<VectorIndex>;
  queries: Map<string, number[]>;
}

const CACHE_KEY = Symbol.for("apnabazzar.vectorIndexCache");
type GlobalWithCache = typeof globalThis & { [CACHE_KEY]?: IndexCache };

function cache(): IndexCache {
  const store = globalThis as GlobalWithCache;
  if (!store[CACHE_KEY]) {
    store[CACHE_KEY] = { index: resolveIndex(), queries: new Map() };
  }
  return store[CACHE_KEY];
}

/** The process-wide vector index, built at most once per process. */
export function getVectorIndex(): Promise<VectorIndex> {
  return cache().index;
}

/** Embed a query, memoised per (provider, text) for the lifetime of the process. */
export async function getQueryVector(index: VectorIndex, text: string): Promise<number[]> {
  const normalized = normalizeWhitespace(text).toLowerCase();
  if (normalized.length === 0) return new Array<number>(index.dimensions).fill(0);

  const key = `${index.provider.id}|${index.provider.model}|${normalized}`;
  const store = cache().queries;
  const hit = store.get(key);
  if (hit) return hit;

  const vector = await index.embedQuery(normalized);
  if (store.size >= QUERY_CACHE_LIMIT) {
    const oldest = store.keys().next();
    if (!oldest.done) store.delete(oldest.value);
  }
  store.set(key, vector);
  return vector;
}
