import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { VectorStore, cosineSimilarity, contentHash } from './vector-store.js';
import type { Chunk } from './chunker.js';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

describe('cosineSimilarity', () => {
  it('returns 1 for identical vectors', () => {
    const v = [1, 2, 3];
    expect(cosineSimilarity(v, v)).toBeCloseTo(1.0, 5);
  });

  it('returns 0 for orthogonal vectors', () => {
    expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0.0, 5);
  });

  it('returns -1 for opposite vectors', () => {
    expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1.0, 5);
  });

  it('handles Float32Array inputs', () => {
    const a = new Float32Array([0.5, 0.5]);
    const b = new Float32Array([0.5, 0.5]);
    expect(cosineSimilarity(a, b)).toBeCloseTo(1.0, 4);
  });

  it('throws on dimension mismatch', () => {
    expect(() => cosineSimilarity([1, 2], [1, 2, 3])).toThrow('dimension mismatch');
  });

  it('returns 0 for zero vectors', () => {
    expect(cosineSimilarity([0, 0], [1, 1])).toBe(0);
  });
});

describe('contentHash', () => {
  it('returns consistent hash for same input', () => {
    const hash1 = contentHash('hello world');
    const hash2 = contentHash('hello world');
    expect(hash1).toBe(hash2);
  });

  it('returns different hash for different input', () => {
    expect(contentHash('hello')).not.toBe(contentHash('world'));
  });

  it('returns an 8-character hex string', () => {
    const hash = contentHash('test');
    expect(hash).toMatch(/^[0-9a-f]{8}$/);
  });
});

describe('VectorStore', () => {
  let dir: string;
  let store: VectorStore;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'flynn-vector-test-'));
    store = new VectorStore(join(dir, 'vectors.db'));
  });

  afterEach(() => {
    store.close();
    rmSync(dir, { recursive: true, force: true });
  });

  const makeChunks = (namespace: string, texts: string[]): Chunk[] =>
    texts.map((text, i) => ({
      text,
      namespace,
      startLine: i * 10 + 1,
      endLine: (i + 1) * 10,
    }));

  const makeFakeEmbeddings = (count: number, dims: number = 4): number[][] =>
    Array.from({ length: count }, (_, i) => {
      // Create somewhat distinct embeddings using simple patterns
      const vec = new Array(dims).fill(0);
      vec[i % dims] = 1.0;
      return vec;
    });

  describe('upsertChunks', () => {
    it('inserts chunks and embeddings', () => {
      const chunks = makeChunks('notes', ['chunk one', 'chunk two']);
      const embeddings = makeFakeEmbeddings(2);

      store.upsertChunks(chunks, embeddings, 'hash1');
      expect(store.count()).toBe(2);
    });

    it('replaces existing chunks for same namespace', () => {
      const chunks1 = makeChunks('notes', ['old chunk']);
      const embeddings1 = makeFakeEmbeddings(1);
      store.upsertChunks(chunks1, embeddings1, 'hash1');
      expect(store.count()).toBe(1);

      const chunks2 = makeChunks('notes', ['new chunk 1', 'new chunk 2']);
      const embeddings2 = makeFakeEmbeddings(2);
      store.upsertChunks(chunks2, embeddings2, 'hash2');
      expect(store.count()).toBe(2);
    });

    it('keeps other namespaces intact when upserting', () => {
      store.upsertChunks(
        makeChunks('ns1', ['chunk 1']),
        makeFakeEmbeddings(1),
        'h1',
      );
      store.upsertChunks(
        makeChunks('ns2', ['chunk 2']),
        makeFakeEmbeddings(1),
        'h2',
      );
      expect(store.count()).toBe(2);

      // Upsert ns1 again
      store.upsertChunks(
        makeChunks('ns1', ['new chunk 1']),
        makeFakeEmbeddings(1),
        'h3',
      );
      expect(store.count()).toBe(2); // ns2 chunk still there
    });

    it('throws on length mismatch', () => {
      const chunks = makeChunks('test', ['a', 'b']);
      const embeddings = makeFakeEmbeddings(1);
      expect(() => store.upsertChunks(chunks, embeddings, 'h')).toThrow('mismatch');
    });

    it('does nothing for empty chunks', () => {
      store.upsertChunks([], [], 'h');
      expect(store.count()).toBe(0);
    });
  });

  describe('deleteNamespace', () => {
    it('removes all embeddings for a namespace', () => {
      store.upsertChunks(makeChunks('ns1', ['a']), makeFakeEmbeddings(1), 'h1');
      store.upsertChunks(makeChunks('ns2', ['b']), makeFakeEmbeddings(1), 'h2');
      expect(store.count()).toBe(2);

      store.deleteNamespace('ns1');
      expect(store.count()).toBe(1);
    });
  });

  describe('hasContentHash', () => {
    it('returns true when namespace+hash exists', () => {
      store.upsertChunks(makeChunks('notes', ['a']), makeFakeEmbeddings(1), 'abc123');
      expect(store.hasContentHash('notes', 'abc123')).toBe(true);
    });

    it('returns false for non-matching hash', () => {
      store.upsertChunks(makeChunks('notes', ['a']), makeFakeEmbeddings(1), 'abc123');
      expect(store.hasContentHash('notes', 'different')).toBe(false);
    });

    it('returns false for non-existent namespace', () => {
      expect(store.hasContentHash('nonexistent', 'abc')).toBe(false);
    });
  });

  describe('search', () => {
    it('returns results sorted by cosine similarity', () => {
      // Create embeddings where chunk 1 is close to query and chunk 2 is far
      const chunks = makeChunks('test', ['close match', 'far away']);
      const embeddings = [
        [0.9, 0.1, 0.0, 0.0], // close to query
        [0.0, 0.0, 0.9, 0.1], // far from query
      ];
      store.upsertChunks(chunks, embeddings, 'h');

      const query = [1.0, 0.0, 0.0, 0.0]; // similar to first chunk
      const results = store.search(query, 5);

      expect(results.length).toBe(2);
      expect(results[0].chunkText).toBe('close match');
      expect(results[0].score).toBeGreaterThan(results[1].score);
    });

    it('respects topK limit', () => {
      const chunks = makeChunks('test', ['a', 'b', 'c']);
      const embeddings = makeFakeEmbeddings(3);
      store.upsertChunks(chunks, embeddings, 'h');

      const results = store.search([1, 0, 0, 0], 2);
      expect(results.length).toBe(2);
    });

    it('returns empty array when no embeddings exist', () => {
      const results = store.search([1, 0, 0, 0], 5);
      expect(results).toEqual([]);
    });

    it('includes namespace and line info in results', () => {
      const chunks = makeChunks('sessions/abc', ['test chunk']);
      const embeddings = [[1, 0, 0, 0]];
      store.upsertChunks(chunks, embeddings, 'h');

      const results = store.search([1, 0, 0, 0], 1);
      expect(results[0].namespace).toBe('sessions/abc');
      expect(results[0].startLine).toBe(1);
      expect(results[0].endLine).toBe(10);
    });
  });
});
