/**
 * ADR-121 Phase 8 — AnnRouter tests.
 *
 * Two layers:
 *   1. `decideBacking()` — pure-function routing rules. Tested
 *      against the documented table without instantiating anything.
 *   2. `AnnRouter.create` + `search` — full lifecycle for the small/
 *      mutable case (HNSW path). The RaBitQ + DiskANN paths are
 *      exercised in CI smoke against the real native backends.
 */

import { describe, it, expect, vi } from 'vitest';

// Force the linear-scan fallback for HNSW so tests don't depend on the
// monorepo's pinned @ruvector/core. Also mock diskann absent so the
// HNSW fallback path triggers for the persistent-routing test.
vi.mock('@ruvector/core', () => ({}));
vi.mock('@ruvector/diskann', () => ({}));

import { AnnRouter, decideBacking } from '../ann-router.js';

const DIM = 8;

function vec(values: number[]): Float32Array {
  if (values.length !== DIM) throw new Error('test bug');
  return new Float32Array(values);
}

describe('decideBacking — routing rules (pure)', () => {
  it('persistent=true → diskann (size irrelevant)', () => {
    const d = decideBacking({ corpusSize: 10, dimension: 384, persistent: true });
    expect(d.backing).toBe('diskann');
    expect(d.reason).toMatch(/persistent/);
  });

  it('corpusSize ≥ 1M → diskann', () => {
    const d = decideBacking({ corpusSize: 1_000_000, dimension: 384 });
    expect(d.backing).toBe('diskann');
    expect(d.reason).toMatch(/1M/);
  });

  it('corpusSize ≥ 100k AND !mutable → rabitq', () => {
    const d = decideBacking({ corpusSize: 250_000, dimension: 384 });
    expect(d.backing).toBe('rabitq');
    expect(d.reason).toMatch(/RaBitQ/);
  });

  it('corpusSize ≥ 100k AND mutable=true → hnsw (RaBitQ is batch-only)', () => {
    const d = decideBacking({ corpusSize: 250_000, dimension: 384, mutable: true });
    expect(d.backing).toBe('hnsw');
    expect(d.reason).toMatch(/mutable=true/);
  });

  it('default (small) → hnsw', () => {
    const d = decideBacking({ corpusSize: 100, dimension: 384 });
    expect(d.backing).toBe('hnsw');
    expect(d.reason).toMatch(/small/);
  });

  it('reason is always a non-empty string', () => {
    for (const wl of [
      { corpusSize: 10, dimension: 8 },
      { corpusSize: 10_000_000, dimension: 8 },
      { corpusSize: 500_000, dimension: 8 },
      { corpusSize: 10, dimension: 8, persistent: true },
    ]) {
      const d = decideBacking(wl as any);
      expect(typeof d.reason).toBe('string');
      expect(d.reason.length).toBeGreaterThan(0);
    }
  });
});

describe('AnnRouter.create — peer-dep degradation', () => {
  it('persistent=true throws when storagePath missing', async () => {
    await expect(
      AnnRouter.create({ corpusSize: 10, dimension: DIM, persistent: true }),
    ).rejects.toThrow(/storagePath/);
  });

  it('persistent=true degrades to HNSW when DiskANN unavailable', async () => {
    const r = await AnnRouter.create({ corpusSize: 10, dimension: DIM, persistent: true, storagePath: '/tmp/nope' });
    expect(r.decision.backing).toBe('hnsw');
    expect(r.decision.degraded).toBe(true);
    expect(r.decision.degradationReason).toMatch(/diskann/i);
  });

  it('corpusSize ≥ 1M degrades to HNSW when DiskANN unavailable', async () => {
    const r = await AnnRouter.create({ corpusSize: 2_000_000, dimension: DIM });
    expect(r.decision.backing).toBe('hnsw');
    expect(r.decision.degraded).toBe(true);
  });

  it('small workload → HNSW directly (no degradation)', async () => {
    const r = await AnnRouter.create({ corpusSize: 100, dimension: DIM });
    expect(r.decision.backing).toBe('hnsw');
    expect(r.decision.degraded).toBe(false);
  });
});

describe('AnnRouter — HNSW end-to-end lifecycle', () => {
  it('add → search returns hits with score + id', async () => {
    const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
    await r.add([
      { id: 'a', vector: vec([1, 0, 0, 0, 0, 0, 0, 0]) },
      { id: 'b', vector: vec([0, 1, 0, 0, 0, 0, 0, 0]) },
      { id: 'c', vector: vec([0, 0, 1, 0, 0, 0, 0, 0]) },
    ]);
    await r.build(); // no-op for HNSW
    const hits = await r.search(vec([1, 0, 0, 0, 0, 0, 0, 0]), 2);
    expect(hits.length).toBe(2);
    expect(hits[0]!.id).toBe('a');
    expect(hits[0]!.score).toBeGreaterThan(0.99); // cosine sim ≈ 1
  });

  it('count() reports HNSW cache size', async () => {
    const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
    await r.add([
      { id: 'a', vector: vec([1, 0, 0, 0, 0, 0, 0, 0]) },
      { id: 'b', vector: vec([0, 1, 0, 0, 0, 0, 0, 0]) },
    ]);
    expect(r.count()).toBe(2);
  });

  it('save() returns false for HNSW backing (in-memory only)', async () => {
    const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
    expect(r.save()).toBe(false);
  });

  it('search before add returns empty array', async () => {
    const r = await AnnRouter.create({ corpusSize: 5, dimension: DIM });
    const hits = await r.search(vec([1, 0, 0, 0, 0, 0, 0, 0]), 3);
    expect(hits).toEqual([]);
  });
});

describe('AnnRouter — decision shape', () => {
  it('decision exposes backing + reason + degraded fields', async () => {
    const r = await AnnRouter.create({ corpusSize: 50, dimension: DIM });
    expect(r.decision).toMatchObject({
      backing: 'hnsw',
      reason: expect.any(String),
      degraded: false,
    });
  });
});
