/**
 * ADR-121 Phase 3 — quantization tests.
 *
 * Covers:
 *  - int8 round-trip + recall (≥0.98 on unit-normalized vectors — the
 *    Phase 3 acceptance gate)
 *  - SearchableEmbeddingCache + `quantize: 'int8'` end-to-end
 *  - rabitqMemoryReduction math
 *  - RabitqSnapshot graceful error when @ruvector/rabitq-wasm absent
 *
 * Real RaBitQ end-to-end search runs against an actual `.build` index
 * when the optional peer dep is installable; otherwise we assert the
 * named-error contract.
 */

import { describe, it, expect, vi } from 'vitest';
import {
  int8Encode,
  int8Decode,
  measureInt8RoundTripRecall,
  rabitqMemoryReduction,
  RabitqSnapshot,
} from '../quantization.js';
import { createSearchableEmbeddingCache } from '../searchable-embedding-cache.js';

// Same mocking convention as the searchable-cache tests — force the
// linear-scan fallback so the int8 wiring is exercised against a
// deterministic backing.
vi.mock('@ruvector/core', () => ({}));
// And mock @ruvector/rabitq-wasm absent so the Phase 3 "graceful
// error when missing" path is exercised in CI without depending on
// the WASM module loading.
vi.mock('@ruvector/rabitq-wasm', () => ({}));

const DIM = 16;

/** Build a random unit-normalized Float32Array of length `DIM`. */
function randomUnitVector(seed: number): Float32Array {
  // Tiny LCG so tests are deterministic.
  let s = seed >>> 0;
  const next = () => { s = (s * 1664525 + 1013904223) >>> 0; return (s / 0xffffffff) * 2 - 1; };
  const v = new Float32Array(DIM);
  let sumSq = 0;
  for (let i = 0; i < DIM; i++) { v[i] = next(); sumSq += v[i]! * v[i]!; }
  const norm = Math.sqrt(sumSq);
  if (norm === 0) v[0] = 1;
  else for (let i = 0; i < DIM; i++) v[i] = v[i]! / norm;
  return v;
}

describe('int8 quantization', () => {
  it('encodes a unit vector to int8 + scale and round-trips with high recall', () => {
    const v = randomUnitVector(42);
    const enc = int8Encode(v);
    expect(enc.bytes).toBeInstanceOf(Int8Array);
    expect(enc.bytes.length).toBe(DIM);
    expect(enc.scale).toBeGreaterThan(0);
    const back = int8Decode(enc);
    // Cosine similarity between original and round-tripped should be
    // close to 1 (the Phase 3 acceptance: ≥0.98 mean recall).
    let dot = 0, na = 0, nb = 0;
    for (let i = 0; i < DIM; i++) { dot += v[i]! * back[i]!; na += v[i]! * v[i]!; nb += back[i]! * back[i]!; }
    const sim = dot / (Math.sqrt(na) * Math.sqrt(nb));
    expect(sim).toBeGreaterThan(0.98);
  });

  it('handles the all-zero vector without dividing by zero', () => {
    const z = new Float32Array(DIM);
    const enc = int8Encode(z);
    expect(enc.scale).toBe(1);
    expect(Array.from(enc.bytes)).toEqual(Array.from(new Int8Array(DIM)));
    const back = int8Decode(enc);
    expect(Array.from(back)).toEqual(Array.from(new Float32Array(DIM)));
  });

  it('measureInt8RoundTripRecall hits ≥0.98 mean recall across a corpus (acceptance gate)', () => {
    // 100 unit-normalized random vectors — the same shape the
    // benchmark uses on real embeddings.
    const corpus = Array.from({ length: 100 }, (_, i) => randomUnitVector(i + 1));
    const result = measureInt8RoundTripRecall(corpus);
    expect(result.count).toBe(100);
    expect(result.meanRecall).toBeGreaterThanOrEqual(0.98);
    // Bound the variance — no single vector should fall below 0.95.
    expect(result.minRecall).toBeGreaterThanOrEqual(0.95);
  });

  it('measureInt8RoundTripRecall handles empty corpus', () => {
    const result = measureInt8RoundTripRecall([]);
    expect(result.count).toBe(0);
    expect(result.meanRecall).toBe(1);
  });
});

describe('SearchableEmbeddingCache + quantize: "int8"', () => {
  it('round-trips a vector through the cache with int8 storage', async () => {
    const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' });
    const original = randomUnitVector(123);
    await cache.set('key', original);
    const back = await cache.get('key');
    expect(back).toBeInstanceOf(Float32Array);
    expect(back!.length).toBe(DIM);
    // Cosine sim between original and dequantized should be ≥0.98.
    let dot = 0, na = 0, nb = 0;
    for (let i = 0; i < DIM; i++) { dot += original[i]! * back![i]!; na += original[i]! ** 2; nb += back![i]! ** 2; }
    expect(dot / (Math.sqrt(na) * Math.sqrt(nb))).toBeGreaterThan(0.98);
  });

  it('reports the quantize mode + estimated bytes in stats()', async () => {
    const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' });
    await cache.set('a', randomUnitVector(1));
    await cache.set('b', randomUnitVector(2));
    const s = cache.stats();
    expect(s.quantize).toBe('int8');
    expect(s.size).toBe(2);
    // int8 estimated bytes: 2 vectors * (DIM=16 + 4 scale) = 40
    expect(s.estimatedBytes).toBe(2 * (DIM + 4));
  });

  it('default (no quantize) reports estimatedBytes as full float32', async () => {
    const cache = createSearchableEmbeddingCache({ dimension: DIM });
    await cache.set('a', randomUnitVector(1));
    const s = cache.stats();
    expect(s.quantize).toBe('none');
    expect(s.estimatedBytes).toBe(DIM * 4);
  });

  it('delete drops the int8 entry alongside the index entry', async () => {
    const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' });
    await cache.set('a', randomUnitVector(1));
    expect((await cache.delete('a'))).toBe(true);
    expect(await cache.get('a')).toBeUndefined();
    expect(cache.stats().size).toBe(0);
    expect(cache.stats().estimatedBytes).toBe(0);
  });

  it('clear drops all int8 entries', async () => {
    const cache = createSearchableEmbeddingCache({ dimension: DIM, quantize: 'int8' });
    await cache.set('a', randomUnitVector(1));
    await cache.set('b', randomUnitVector(2));
    await cache.clear();
    expect(cache.stats().size).toBe(0);
    expect(cache.stats().estimatedBytes).toBe(0);
  });
});

describe('rabitqMemoryReduction', () => {
  it('computes the theoretical 32× reduction for typical embeddings', () => {
    // 10k vectors × 384 dim
    const r = rabitqMemoryReduction(10_000, 384);
    expect(r.rawBytes).toBe(10_000 * 384 * 4); // 15.36 MB
    expect(r.rabitqBytes).toBe(Math.ceil(10_000 * 384 / 8)); // 480 KB
    // Reduction is N×dim×4 vs ceil(N×dim/8). For N=10k dim=384,
    // that's 15,360,000 / 480,000 = exactly 32.
    expect(r.reductionFactor).toBeCloseTo(32, 1);
  });

  it('handles single-vector case', () => {
    const r = rabitqMemoryReduction(1, 8);
    expect(r.rawBytes).toBe(32);
    expect(r.rabitqBytes).toBe(1); // ceil(8/8) = 1
    expect(r.reductionFactor).toBe(32);
  });

  it('handles empty corpus gracefully', () => {
    const r = rabitqMemoryReduction(0, 384);
    expect(r.rawBytes).toBe(0);
    expect(r.rabitqBytes).toBe(0);
    // 0/1 (the Math.max guard) → 0 reduction factor; not divide-by-zero
    expect(r.reductionFactor).toBe(0);
  });
});

describe('RabitqSnapshot — peer-dep error contract', () => {
  it('build() throws a clear, named error when @ruvector/rabitq-wasm is missing', async () => {
    const entries = Array.from({ length: 5 }, (_, i) => ({
      id: `e${i}`,
      vector: randomUnitVector(i + 1),
    }));
    await expect(
      RabitqSnapshot.build(entries, { dimension: DIM }),
    ).rejects.toThrow(/@ruvector\/rabitq-wasm/);
    // Acceptance: error gives the operator the install command + a
    // fallback path so they're not stuck.
    await expect(
      RabitqSnapshot.build(entries, { dimension: DIM }),
    ).rejects.toThrow(/npm install/);
  });
});
