/**
 * Unit tests for the search-cache entity.
 *
 * Like the message repo tests, these run against the real `icloud` schema
 * (the repo SQL has hardcoded `icloud.` qualifiers). Each test uses unique
 * UUID-suffixed query strings to avoid collisions. Keys are cleaned up after
 * each test; epoch-bumping tests save and restore the watermark.
 *
 * 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 { Pool } from 'pg';

import { pgGet, pgRun } from '@/shared/db';
import { bulkUpsertMessages } from '@/entities/message/repo';
import {
  buildCacheKey,
  getSearchCache,
  setSearchCache,
  invalidateSearchCache,
  clearSearchCache,
} from '@/entities/search-cache/repo';
import type { MessageSearchHit } from '@/entities/message/types';

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

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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

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

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

function makeHit(
  id: string,
  body: string,
  convId: string,
  attributedBody: string | null = null,
): MessageSearchHit {
  return {
    message: {
      id,
      deviceId: randomUUIDv7(),
      conversationId: convId,
      externalId: `ext-${id}`,
      provider: 'imessage',
      fromHandle: '+15550000000',
      body,
      isFromMe: false,
      bodyDecoded: null,
      hasAttachments: false,
      attachmentCount: 0,
      attachmentMimeTypes: [],
      sentAt: new Date().toISOString(),
      deliveredAt: null,
      readAt: null,
      syncedAt: new Date().toISOString(),
      attributedBody,
      associatedMessageType: null,
      associatedMessageGuid: null,
      expressiveSendStyleId: null,
      balloonBundleId: null,
      isAudioMessage: false,
      replyToGuid: null,
      threadOriginatorGuid: null,
      groupTitle: null,
      service: null,
      senderIdentifier: null,
      senderDisplayName: null,
      senderPhoneNumber: null,
      senderEmail: null,
    },
    snippet: body,
    rank: 0.9,
    mode: 'lexical',
  };
}

/** Save and restore the epoch watermark around a test that bumps it. */
async function withSavedEpoch(pool: Pool, fn: () => Promise<void>): Promise<void> {
  const row = await pgGet<{ last_invalidated_at: string }>(
    pool,
    `SELECT last_invalidated_at FROM macsync.search_cache_epoch WHERE id = 1`,
  );
  const saved = row?.last_invalidated_at ?? '1970-01-01T00:00:00.000Z';
  try {
    await fn();
  } finally {
    await pgRun(
      pool,
      `UPDATE macsync.search_cache_epoch SET last_invalidated_at = $1 WHERE id = 1`,
      [saved],
    );
  }
}

// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------

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);
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('buildCacheKey', () => {
  it('produces the same key for identical inputs', () => {
    const filter = { query: 'jellyfish', mode: 'lexical' as const };
    expect(buildCacheKey(filter)).toBe(buildCacheKey(filter));
  });

  it('normalises query to lowercase and trimmed', () => {
    const a = buildCacheKey({ query: '  Jellyfish  ', mode: 'lexical' });
    const b = buildCacheKey({ query: 'jellyfish', mode: 'lexical' });
    expect(a).toBe(b);
  });

  it('produces different keys for different modes', () => {
    const lex = buildCacheKey({ query: 'pier', mode: 'lexical' });
    const sem = buildCacheKey({ query: 'pier', mode: 'semantic' });
    expect(lex).not.toBe(sem);
  });

  it('produces different keys for different queries', () => {
    const a = buildCacheKey({ query: 'jellyfish', mode: 'hybrid' });
    const b = buildCacheKey({ query: 'pier', mode: 'hybrid' });
    expect(a).not.toBe(b);
  });
});

describe('setSearchCache / getSearchCache', () => {
  it('returns null for a key that has never been stored', async () => {
    const key = buildCacheKey({ query: `nosuchterm-${randomUUIDv7()}`, mode: 'lexical' });
    const result = await getSearchCache(testDb.pool, key);
    expect(result).toBeNull();
  });

  it('round-trips hits and total through JSONB', async () => {
    const { pool } = testDb;
    const convId = randomUUIDv7();
    const uniqueWord = `phosphorescent-cache-${randomUUIDv7().slice(0, 8)}`;
    const hit = makeHit(randomUUIDv7(), `A ${uniqueWord} jellyfish`, convId);
    const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });

    try {
      await setSearchCache(pool, key, { hits: [hit], total: 1 });

      const cached = await getSearchCache(pool, key);
      expect(cached).not.toBeNull();
      expect(cached!.total).toBe(1);
      expect(cached!.hits.length).toBe(1);
      expect(cached!.hits[0]!.message.body).toBe(`A ${uniqueWord} jellyfish`);
    } finally {
      await clearSearchCache(pool, key);
    }
  });

  it('round-trips attributedBody as base64 via JSONB', async () => {
    const { pool } = testDb;
    const convId = randomUUIDv7();
    const rawBase64 = Buffer.from([0x01, 0x02, 0x03, 0xff]).toString('base64');
    const uniqueWord = `attrbody-${randomUUIDv7().slice(0, 8)}`;
    const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBase64);
    const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });

    try {
      await setSearchCache(pool, key, { hits: [hit], total: 1 });

      const cached = await getSearchCache(pool, key);
      expect(cached).not.toBeNull();
      expect(cached!.hits[0]!.message.attributedBody).toBe(rawBase64);
    } finally {
      await clearSearchCache(pool, key);
    }
  });

  it('upserts on duplicate key — updates hits and total', async () => {
    const { pool } = testDb;
    const convId = randomUUIDv7();
    const uniqueWord = `upsert-${randomUUIDv7().slice(0, 8)}`;
    const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });

    try {
      await setSearchCache(pool, key, {
        hits: [makeHit(randomUUIDv7(), `first ${uniqueWord}`, convId)],
        total: 1,
      });

      await setSearchCache(pool, key, {
        hits: [
          makeHit(randomUUIDv7(), `second hit a ${uniqueWord}`, convId),
          makeHit(randomUUIDv7(), `second hit b ${uniqueWord}`, convId),
        ],
        total: 2,
      });

      const cached = await getSearchCache(pool, key);
      expect(cached!.total).toBe(2);
      expect(cached!.hits.length).toBe(2);
    } finally {
      await clearSearchCache(pool, key);
    }
  });
});

describe('invalidateSearchCache (epoch watermark)', () => {
  it('causes getSearchCache to return null for pre-existing entries', async () => {
    const { pool } = testDb;
    const convId = randomUUIDv7();
    const uniqueWord = `invalidation-${randomUUIDv7().slice(0, 8)}`;
    const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });

    await withSavedEpoch(pool, async () => {
      try {
        await setSearchCache(pool, key, {
          hits: [makeHit(randomUUIDv7(), `should be stale ${uniqueWord}`, convId)],
          total: 1,
        });

        expect(await getSearchCache(pool, key)).not.toBeNull();

        // Small sleep so fetched_at < last_invalidated_at when we bump the epoch.
        await new Promise((r) => setTimeout(r, 10));
        await invalidateSearchCache(pool);

        expect(await getSearchCache(pool, key)).toBeNull();
      } finally {
        await clearSearchCache(pool, key);
      }
    });
  });

  it('entries written after invalidation are served normally', async () => {
    const { pool } = testDb;
    const convId = randomUUIDv7();
    const uniqueWord = `post-inv-${randomUUIDv7().slice(0, 8)}`;
    const key = buildCacheKey({ query: uniqueWord, mode: 'hybrid' });

    await withSavedEpoch(pool, async () => {
      try {
        await invalidateSearchCache(pool);

        await setSearchCache(pool, key, {
          hits: [makeHit(randomUUIDv7(), `fresh hit ${uniqueWord}`, convId)],
          total: 1,
        });

        const cached = await getSearchCache(pool, key);
        expect(cached).not.toBeNull();
        expect(cached!.hits[0]!.message.body).toContain(uniqueWord);
      } finally {
        await clearSearchCache(pool, key);
      }
    });
  });
});

describe('bulkUpsertMessages triggers cache invalidation', () => {
  it('cache miss after bulkUpsertMessages', async () => {
    const { pool } = testDb;
    const deviceId = await seedDevice(pool);
    const convId = await seedConversation(pool, deviceId);
    const uniqueWord = `xylotherion-${randomUUIDv7().slice(0, 8)}`;
    const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });

    await withSavedEpoch(pool, async () => {
      try {
        // Seed cache to simulate a prior search.
        await setSearchCache(pool, key, {
          hits: [makeHit(randomUUIDv7(), `stale ${uniqueWord}`, randomUUIDv7())],
          total: 1,
        });
        expect(await getSearchCache(pool, key)).not.toBeNull();

        // New messages arriving should bump the epoch (fire-and-forget inside bulkUpsert).
        await bulkUpsertMessages(pool, [
          {
            id: randomUUIDv7(),
            deviceId,
            conversationId: convId,
            externalId: `guid-inv-${randomUUIDv7()}`,
            provider: 'imessage',
            fromHandle: '+15550009999',
            body: `new message about ${uniqueWord}`,
            isFromMe: false,
            hasAttachments: false,
            attachmentCount: 0,
            sentAt: new Date().toISOString(),
          },
        ]);

        // Give the fire-and-forget invalidation time to settle.
        await new Promise((r) => setTimeout(r, 50));

        expect(await getSearchCache(pool, key)).toBeNull();
      } finally {
        await clearSearchCache(pool, key);
      }
    });
  });
});

describe('clearSearchCache', () => {
  it('removes a specific cache entry by key', async () => {
    const { pool } = testDb;
    const convId = randomUUIDv7();
    const uniqueWord = `clear-key-${randomUUIDv7().slice(0, 8)}`;
    const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });

    await setSearchCache(pool, key, {
      hits: [makeHit(randomUUIDv7(), `to be cleared ${uniqueWord}`, convId)],
      total: 1,
    });

    await clearSearchCache(pool, key);

    expect(await getSearchCache(pool, key)).toBeNull();
  });
});
