/**
 * Route integration tests for `GET /my/messages/search`.
 *
 * Spins up the full Hono app (with real DB via the test schema), makes HTTP
 * requests, and asserts response shapes and status codes.
 *
 * 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 type { Hono } from 'hono';
import { Pool } from 'pg';

import { bulkUpsertMessages } from '@/entities/message/repo';
import { pgRun } from '@/shared/db';
import { loadConfig } from '@/app/config';

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

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface SearchResponse {
  query: string;
  mode: string;
  limit: number;
  offset: number;
  total: number;
  searchDate: string;
  results: Array<{
    message: { id: string; body: string; conversationId: string; sentAt: string };
    snippet: string;
    rank: number;
    mode: string;
  }>;
}

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

let testDb: TestDb;
let app: Hono;
let serviceToken: string;
let seededDeviceId: string;

async function seedDevice(pool: Pool): Promise<string> {
  const id = randomUUIDv7();
  await pgRun(
    pool,
    `INSERT INTO macsync.devices (id, name, token, platform)
     VALUES ($1, 'route-test-device', $2, 'macos')`,
    [id, `tok-route-${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, 'Route Test Convo', false, '[]'::jsonb)`,
    [id, deviceId, `ext-route-${id}`],
  );
  return id;
}

async function get(path: string, token?: string): Promise<Response> {
  const headers: Record<string, string> = {};
  if (token) headers['Authorization'] = `Bearer ${token}`;
  return app.fetch(new Request(`http://localhost${path}`, { headers }));
}

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

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

  const { createApp } = await import('@/app/server');
  const built = await createApp();
  app = built.app;

  const config = loadConfig();
  serviceToken = config.SERVICE_TOKEN;

  const { pool } = testDb;
  seededDeviceId = await seedDevice(pool);
  const convId = await seedConversation(pool, seededDeviceId);
  const now = new Date().toISOString();

  await bulkUpsertMessages(pool, [
    {
      id: randomUUIDv7(),
      deviceId: seededDeviceId,
      conversationId: convId,
      externalId: `guid-route-a-${randomUUIDv7()}`,
      provider: 'imessage',
      fromHandle: '+1555001001',
      body: 'Phosphorescent jellyfish spotted near the pier at midnight',
      isFromMe: false,
      hasAttachments: false,
      attachmentCount: 0,
      sentAt: now,
    },
    {
      id: randomUUIDv7(),
      deviceId: seededDeviceId,
      conversationId: convId,
      externalId: `guid-route-b-${randomUUIDv7()}`,
      provider: 'imessage',
      fromHandle: '+1555001002',
      body: 'The pier was completely empty in the early morning light',
      isFromMe: true,
      hasAttachments: false,
      attachmentCount: 0,
      sentAt: now,
    },
  ]);
});

afterAll(async () => {
  if (seededDeviceId) {
    await pgRun(testDb.pool, `DELETE FROM macsync.messages WHERE device_id = $1`, [seededDeviceId]);
    await pgRun(testDb.pool, `DELETE FROM macsync.conversations WHERE device_id = $1`, [seededDeviceId]);
    await pgRun(testDb.pool, `DELETE FROM macsync.devices WHERE id = $1`, [seededDeviceId]);
  }
  await teardownTestDb(testDb);
});

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

describe('GET /my/messages/search', () => {
  it('returns 200 with correct result shape for a valid query', async () => {
    const res = await get('/my/messages/search?q=phosphorescent&mode=lexical', serviceToken);
    expect(res.status).toBe(200);

    const body = (await res.json()) as SearchResponse;
    expect(typeof body.query).toBe('string');
    expect(typeof body.limit).toBe('number');
    expect(typeof body.total).toBe('number');
    expect(typeof body.offset).toBe('number');
    expect(typeof body.searchDate).toBe('string');
    expect(Array.isArray(body.results)).toBe(true);

    if (body.results.length > 0) {
      const hit = body.results[0]!;
      expect(typeof hit.message.id).toBe('string');
      expect(typeof hit.message.body).toBe('string');
      expect(typeof hit.snippet).toBe('string');
      expect(typeof hit.rank).toBe('number');
      expect(typeof hit.mode).toBe('string');
    }
  });

  it('searchDate is a valid ISO timestamp', async () => {
    const res = await get('/my/messages/search?q=jellyfish&mode=lexical', serviceToken);
    expect(res.status).toBe(200);
    const body = (await res.json()) as SearchResponse;
    expect(new Date(body.searchDate).getTime()).toBeGreaterThan(0);
  });

  it('total reflects the true match count regardless of limit', async () => {
    const resAll = await get('/my/messages/search?q=pier&limit=100&mode=lexical', serviceToken);
    const resOne = await get('/my/messages/search?q=pier&limit=1&mode=lexical', serviceToken);
    expect(resAll.status).toBe(200);
    expect(resOne.status).toBe(200);

    const bodyAll = (await resAll.json()) as SearchResponse;
    const bodyOne = (await resOne.json()) as SearchResponse;

    expect(bodyAll.total).toBe(bodyOne.total);
    expect(bodyOne.results.length).toBeLessThanOrEqual(1);
  });

  it('offset is reflected in the response envelope', async () => {
    const res = await get('/my/messages/search?q=pier&offset=1&limit=10&mode=lexical', serviceToken);
    expect(res.status).toBe(200);
    const body = (await res.json()) as SearchResponse;
    expect(body.offset).toBe(1);
  });

  it('reflects the query string in the response envelope', async () => {
    const res = await get('/my/messages/search?q=jellyfish&mode=lexical', serviceToken);
    expect(res.status).toBe(200);
    const body = (await res.json()) as SearchResponse;
    expect(body.query).toBe('jellyfish');
  });

  it('returns 400 for an empty q parameter', async () => {
    const res = await get('/my/messages/search?q=', serviceToken);
    expect(res.status).toBe(400);
  });

  it('returns 400 for a whitespace-only q parameter', async () => {
    const res = await get('/my/messages/search?q=%20%20%20', serviceToken);
    expect(res.status).toBe(400);
  });

  it('returns 400 for a q parameter exceeding 256 characters', async () => {
    const longQ = 'a'.repeat(300);
    const res = await get(`/my/messages/search?q=${encodeURIComponent(longQ)}`, serviceToken);
    expect(res.status).toBe(400);
  });

  it('returns 400 when q is missing entirely', async () => {
    const res = await get('/my/messages/search', serviceToken);
    expect(res.status).toBe(400);
  });

  it('returns 401 when Authorization header is absent', async () => {
    const res = await get('/my/messages/search?q=jellyfish');
    expect(res.status).toBe(401);
  });

  it('returns 401 for an invalid Bearer token', async () => {
    const res = await get('/my/messages/search?q=jellyfish', 'not-the-real-token');
    expect(res.status).toBe(401);
  });

  it('honours the limit query parameter', async () => {
    const res = await get('/my/messages/search?q=pier&limit=1&mode=lexical', serviceToken);
    expect(res.status).toBe(200);
    const body = (await res.json()) as SearchResponse;
    expect(body.limit).toBe(1);
    expect(body.results.length).toBeLessThanOrEqual(1);
  });

  it('second identical request is served faster (server cache hit)', async () => {
    const word = `celestinite${randomUUIDv7().slice(0, 6)}`;
    const url = `/my/messages/search?q=${word}&mode=lexical`;

    const t1 = performance.now();
    await get(url, serviceToken);
    const cold = performance.now() - t1;

    const t2 = performance.now();
    await get(url, serviceToken);
    const warm = performance.now() - t2;

    // Warm hit should be materially faster — allow generous headroom for CI jitter.
    // We only assert it's not slower than the cold miss by an unreasonable margin.
    expect(warm).toBeLessThan(cold + 200);
  });
});
