/**
 * Tests for the pgvector embedding pipeline.
 *
 * Verifies:
 *   - upsertEmbedding / bulkUpsertEmbeddings write to the test schema
 *   - semanticSearch returns ranked hits ordered by cosine distance
 *   - searchMessages with mode='hybrid' returns RRF-fused results
 *   - searchMessages with mode='lexical' keeps existing behaviour
 *
 * All repo functions use hardcoded `icloud.` qualifiers, so devices and
 * conversations are seeded into the real `icloud.*` schema and cleaned up
 * in afterAll — same pattern as message-search.repo.test.ts.
 *
 * Requires: QUINN_MACSYNC_DB_URL pointing at a reachable Postgres instance.
 */

import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { randomUUIDv7 } from 'bun';

import { bulkUpsertMessages, searchMessages } from '@/entities/message/repo';
import { upsertEmbedding, bulkUpsertEmbeddings, semanticSearch, getEmbeddingCoverage } from '@/entities/message-embedding/repo';
import { EMBEDDING_MODEL_VERSION } from '@/shared/model-boss';
import { pgRun, pgGet } from '@/shared/db';

import { setupTestDb, teardownTestDb, type TestDb } from './db-harness';

let testDb: TestDb;
const seededDeviceIds: string[] = [];

const EMBED_DIM = 768;

function makeVec(dim: number, bias: number): number[] {
  const v = Array.from({ length: dim }, (_, i) => Math.sin(i * 0.1 + bias));
  const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
  return v.map((x) => x / norm);
}

async function seedDevice(): Promise<string> {
  const id = randomUUIDv7();
  await pgRun(
    testDb.pool,
    `INSERT INTO macsync.devices (id, name, token, platform)
     VALUES ($1, 'embed-test-device', $2, 'macos')`,
    [id, `tok-${id}`],
  );
  seededDeviceIds.push(id);
  return id;
}

async function seedConversation(deviceId: string): Promise<string> {
  const id = randomUUIDv7();
  await pgRun(
    testDb.pool,
    `INSERT INTO macsync.conversations (id, device_id, external_id, display_name, is_group, participants)
     VALUES ($1, $2, $3, 'Embed Test Convo', false, '[]'::jsonb)`,
    [id, deviceId, `ext-${id}`],
  );
  return id;
}

beforeAll(async () => {
  testDb = await setupTestDb();
});

afterAll(async () => {
  if (seededDeviceIds.length > 0) {
    const placeholders = seededDeviceIds.map((_, i) => `$${i + 1}`).join(', ');
    await pgRun(
      testDb.pool,
      `DELETE FROM macsync.messages WHERE device_id IN (${placeholders})`,
      seededDeviceIds,
    );
    await pgRun(
      testDb.pool,
      `DELETE FROM macsync.conversations WHERE device_id IN (${placeholders})`,
      seededDeviceIds,
    );
    await pgRun(
      testDb.pool,
      `DELETE FROM macsync.devices WHERE id IN (${placeholders})`,
      seededDeviceIds,
    );
  }
  await teardownTestDb(testDb);
});

describe('upsertEmbedding', () => {
  it('stores and overwrites an embedding for a message', async () => {
    const deviceId = await seedDevice();
    const convId = await seedConversation(deviceId);
    const externalId = `guid-emb-upsert-${randomUUIDv7()}`;

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId,
        provider: 'imessage',
        fromHandle: '+1555001001',
        body: 'Hello from the embedding test',
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: new Date().toISOString(),
      },
    ]);

    const row = await pgGet<{ id: string }>(
      testDb.pool,
      `SELECT id FROM macsync.messages WHERE external_id = $1`,
      [externalId],
    );
    if (!row) throw new Error('seed message not found');
    const msgId = row.id;

    const vec = makeVec(EMBED_DIM, 0.5);
    await upsertEmbedding(testDb.pool, { messageId: msgId, embedding: vec, modelVersion: EMBEDDING_MODEL_VERSION });

    const emb = await testDb.pool.query(
      `SELECT message_id, model_version FROM macsync.message_embeddings WHERE message_id = $1`,
      [msgId],
    );
    expect(emb.rows).toHaveLength(1);
    expect(emb.rows[0].model_version).toBe(EMBEDDING_MODEL_VERSION);

    // Upsert again with different vector — should not throw, still one row
    const vec2 = makeVec(EMBED_DIM, 1.0);
    await upsertEmbedding(testDb.pool, { messageId: msgId, embedding: vec2, modelVersion: EMBEDDING_MODEL_VERSION });

    const count = await testDb.pool.query<{ n: string }>(
      `SELECT COUNT(*) as n FROM macsync.message_embeddings WHERE message_id = $1`,
      [msgId],
    );
    expect(Number(count.rows[0].n)).toBe(1);
  });
});

describe('bulkUpsertEmbeddings', () => {
  it('upserts multiple embeddings in a single batch', async () => {
    const deviceId = await seedDevice();
    const convId = await seedConversation(deviceId);
    const bodies = ['Alpha message', 'Beta message', 'Gamma message'];
    const externalIds = bodies.map((_, i) => `guid-bulk-${i}-${randomUUIDv7()}`);

    await bulkUpsertMessages(testDb.pool, bodies.map((body, i) => ({
      id: randomUUIDv7(),
      deviceId,
      conversationId: convId,
      externalId: externalIds[i]!,
      provider: 'imessage' as const,
      fromHandle: '+1555002001',
      body,
      isFromMe: false,
      hasAttachments: false,
      attachmentCount: 0,
      sentAt: new Date().toISOString(),
    })));

    const ids = (await testDb.pool.query<{ id: string }>(
      `SELECT id FROM macsync.messages WHERE external_id = ANY($1)`,
      [externalIds],
    )).rows.map((r) => r.id);

    expect(ids.length).toBe(bodies.length);

    const drafts = ids.map((messageId, i) => ({
      messageId,
      embedding: makeVec(EMBED_DIM, i * 0.3),
      modelVersion: EMBEDDING_MODEL_VERSION,
    }));

    await bulkUpsertEmbeddings(testDb.pool, drafts);

    const count = await testDb.pool.query<{ n: string }>(
      `SELECT COUNT(*) as n FROM macsync.message_embeddings WHERE message_id = ANY($1)`,
      [ids],
    );
    expect(Number(count.rows[0].n)).toBe(ids.length);
  });
});

describe('getEmbeddingCoverage', () => {
  it('returns correct covered/total counts', async () => {
    const coverage = await getEmbeddingCoverage(testDb.pool);
    expect(typeof coverage.total).toBe('number');
    expect(typeof coverage.covered).toBe('number');
    expect(coverage.covered).toBeLessThanOrEqual(coverage.total);
  });
});

describe('semanticSearch', () => {
  it('returns hits ordered by cosine similarity', async () => {
    const deviceId = await seedDevice();
    const convId = await seedConversation(deviceId);
    const hikingExtId = `guid-sem-a-${randomUUIDv7()}`;
    const groceryExtId = `guid-sem-b-${randomUUIDv7()}`;

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: hikingExtId,
        provider: 'imessage',
        fromHandle: '+1555003001',
        body: 'I love hiking in the mountains on weekends',
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: new Date().toISOString(),
      },
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: groceryExtId,
        provider: 'imessage',
        fromHandle: '+1555003002',
        body: 'Picked up groceries: milk eggs and bread',
        isFromMe: true,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: new Date().toISOString(),
      },
    ]);

    const hikeId = (await pgGet<{ id: string }>(
      testDb.pool,
      `SELECT id FROM macsync.messages WHERE external_id = $1`,
      [hikingExtId],
    ))?.id;
    const grocId = (await pgGet<{ id: string }>(
      testDb.pool,
      `SELECT id FROM macsync.messages WHERE external_id = $1`,
      [groceryExtId],
    ))?.id;

    if (!hikeId || !grocId) throw new Error('seed messages not found');

    const hikingVec = makeVec(EMBED_DIM, 0.1);
    const groceryVec = makeVec(EMBED_DIM, 1.6);

    await bulkUpsertEmbeddings(testDb.pool, [
      { messageId: hikeId, embedding: hikingVec, modelVersion: EMBEDDING_MODEL_VERSION },
      { messageId: grocId, embedding: groceryVec, modelVersion: EMBEDDING_MODEL_VERSION },
    ]);

    const queryVec = makeVec(EMBED_DIM, 0.12);
    const hits = await semanticSearch(testDb.pool, { embedding: queryVec, limit: 10 });

    expect(hits.length).toBeGreaterThanOrEqual(2);
    const hikePos = hits.findIndex((h) => h.messageId === hikeId);
    const grocPos = hits.findIndex((h) => h.messageId === grocId);
    expect(hikePos).toBeGreaterThanOrEqual(0);
    expect(grocPos).toBeGreaterThanOrEqual(0);
    expect(hikePos).toBeLessThan(grocPos);
  });
});

describe('searchMessages mode=lexical', () => {
  it('falls back to pure FTS — mode field is "lexical"', async () => {
    const deviceId = await seedDevice();
    const convId = await seedConversation(deviceId);
    // Use a UUID-derived token to guarantee uniqueness and avoid English stemmer mangling
    const uniqueToken = `zxqlex${randomUUIDv7().replace(/-/g, '').slice(0, 8)}`;

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-lex-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: '+1555004001',
        body: `${uniqueToken} starlight dances across the velveteen sky`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: new Date().toISOString(),
      },
    ]);

    const result = await searchMessages(testDb.pool, { query: uniqueToken, mode: 'lexical', limit: 5 });
    expect(result.hits.length).toBeGreaterThanOrEqual(1);
    expect(result.hits[0]?.mode).toBe('lexical');
    expect(result.hits[0]?.snippet).toMatch(/«[^»]+»/);
  });
});

describe('searchMessages mode=hybrid', () => {
  it('returns hybrid results with RRF-fused rank and mode=hybrid', async () => {
    const deviceId = await seedDevice();
    const convId = await seedConversation(deviceId);
    const extId = `guid-hyb-${randomUUIDv7()}`;

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: extId,
        provider: 'imessage',
        fromHandle: '+1555005001',
        body: 'Coruscating prisms refract the afternoon light beautifully',
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: new Date().toISOString(),
      },
    ]);

    const msgId = (await pgGet<{ id: string }>(
      testDb.pool,
      `SELECT id FROM macsync.messages WHERE external_id = $1`,
      [extId],
    ))?.id;

    if (!msgId) throw new Error('seed message not found');

    await upsertEmbedding(testDb.pool, {
      messageId: msgId,
      embedding: makeVec(EMBED_DIM, 2.1),
      modelVersion: EMBEDDING_MODEL_VERSION,
    });

    const queryEmbedding = makeVec(EMBED_DIM, 2.1);
    const result = await searchMessages(testDb.pool, {
      query: 'coruscating',
      mode: 'hybrid',
      queryEmbedding,
      limit: 5,
    });

    expect(result.hits.length).toBeGreaterThanOrEqual(1);
    expect(result.hits[0]?.mode).toBe('hybrid');
    expect(typeof result.hits[0]?.rank).toBe('number');
  });
});
