/**
 * Unit tests for `generateContactSummary`.
 *
 * Seeds messages via real repo functions against `icloud.*` tables (same
 * pattern as message-search.repo.test.ts). The LLM chat callable is always
 * stubbed — no model-boss calls are made.
 *
 * 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 { Pool } from 'pg';

import { bulkUpsertMessages } from '@/entities/message/repo';
import { pgRun } from '@/shared/db';
import { generateContactSummary } from '@/features/contact-summary';
import type { ChatFn, ContactInput } from '@/features/contact-summary';
import type { ContactSummaryData } from '@/entities/contact';

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

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

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

function uq(): string {
  return randomUUIDv7().replace(/-/g, '').slice(0, 8);
}

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

async function seedConversation(pool: Pool, deviceId: string, handle: 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, 'Summary Test Convo', false, $4::jsonb)`,
    [id, deviceId, `ext-summary-${id}`, JSON.stringify([handle])],
  );
  return id;
}

function makeContact(
  deviceId: string,
  handle: string,
  overrides: Partial<ContactInput> = {},
): ContactInput {
  return {
    id: randomUUIDv7(),
    deviceId,
    handle,
    displayName: 'Test Contact',
    phoneNumbers: [],
    emails: [],
    ...overrides,
  };
}

const STUB_SUMMARY: ContactSummaryData = {
  mostRecently: 'Discussed the weekend plans.',
  overallSummary: 'Close friend who messages frequently about social events.',
  recap: 'Best friend from university.',
};

function makeChatStub(returnValue: ContactSummaryData = STUB_SUMMARY): {
  stub: ChatFn;
  calls: Array<{ messages: Parameters<ChatFn>[0]; opts: Parameters<ChatFn>[1] }>;
} {
  const calls: Array<{ messages: Parameters<ChatFn>[0]; opts: Parameters<ChatFn>[1] }> = [];
  const stub: ChatFn = async (messages, opts) => {
    calls.push({ messages, opts });
    return returnValue;
  };
  return { stub, calls };
}

// ---------------------------------------------------------------------------
// 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('generateContactSummary', () => {
  it('returns all-null summary when no messages exist for the contact', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const contact = makeContact(deviceId, `+1555${uq().slice(0, 7)}`);
    const { stub, calls } = makeChatStub();

    const result = await generateContactSummary(testDb.pool, contact, stub);

    expect(result).toEqual({ mostRecently: null, overallSummary: null, recap: null });
    expect(calls.length).toBe(0); // chat must NOT be called when there are no messages
  });

  it('calls chat with system + user messages when messages exist', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const handle = `+1555${uq().slice(0, 7)}`;
    const convId = await seedConversation(testDb.pool, deviceId, handle);
    const now = new Date().toISOString();

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-sum-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: handle,
        body: `Hey, want to grab coffee on ${uq()}?`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const contact = makeContact(deviceId, handle);
    const { stub, calls } = makeChatStub();

    await generateContactSummary(testDb.pool, contact, stub);

    expect(calls.length).toBe(1);
    const [call] = calls;
    expect(call!.messages[0]!.role).toBe('system');
    expect(call!.messages[1]!.role).toBe('user');
    // User message must contain the contact name and the message body
    expect(call!.messages[1]!.content).toContain('Test Contact');
    expect(call!.messages[1]!.content).toContain('coffee');
  });

  it('returns the stub summary result as-is', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const handle = `+1555${uq().slice(0, 7)}`;
    const convId = await seedConversation(testDb.pool, deviceId, handle);
    const now = new Date().toISOString();

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-ret-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: handle,
        body: `Message for return value test ${uq()}`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const contact = makeContact(deviceId, handle);
    const { stub } = makeChatStub(STUB_SUMMARY);

    const result = await generateContactSummary(testDb.pool, contact, stub);

    expect(result).toEqual(STUB_SUMMARY);
  });

  it('deduplicates handles across handle, phoneNumbers, and emails', async () => {
    const deviceId = await seedDevice(testDb.pool);
    // Same value appears as both handle and phoneNumbers[0]
    const handle = `+1555${uq().slice(0, 7)}`;
    const convId = await seedConversation(testDb.pool, deviceId, handle);
    const now = new Date().toISOString();

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-dedup-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: handle,
        body: `Dedup test message ${uq()}`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const contact = makeContact(deviceId, handle, {
      phoneNumbers: [handle], // duplicate of handle
    });
    const { stub, calls } = makeChatStub();

    // Should not throw (no duplicate placeholders in SQL)
    await generateContactSummary(testDb.pool, contact, stub);

    expect(calls.length).toBe(1);
  });

  it('matches messages by email handle', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const email = `user${uq()}@example.com`;
    const convId = await seedConversation(testDb.pool, deviceId, email);
    const now = new Date().toISOString();

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-email-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: email,
        body: `iMessage via email ${uq()}`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const contact = makeContact(deviceId, `+1555${uq().slice(0, 7)}`, {
      emails: [email], // handle doesn't match but email does
    });
    const { stub, calls } = makeChatStub();

    await generateContactSummary(testDb.pool, contact, stub);

    expect(calls.length).toBe(1);
    expect(calls[0]!.messages[1]!.content).toContain('iMessage via email');
  });

  it('includes both sent and received messages in the transcript', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const handle = `+1555${uq().slice(0, 7)}`;
    const convId = await seedConversation(testDb.pool, deviceId, handle);
    const now = new Date().toISOString();
    const uniqueOutgoing = `outgoing${uq()}`;

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-recv-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: handle,
        body: `Incoming message ${uq()}`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-sent-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: '',
        body: uniqueOutgoing,
        isFromMe: true,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const contact = makeContact(deviceId, handle);
    const { stub, calls } = makeChatStub();

    await generateContactSummary(testDb.pool, contact, stub);

    const userContent = calls[0]!.messages[1]!.content;
    // Outgoing message appears via participant match, labelled "Me"
    expect(userContent).toContain(uniqueOutgoing);
    expect(userContent).toContain('Me:');
  });

  it('wraps chat errors in a descriptive error', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const handle = `+1555${uq().slice(0, 7)}`;
    const convId = await seedConversation(testDb.pool, deviceId, handle);
    const now = new Date().toISOString();

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-err-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: handle,
        body: `Error test ${uq()}`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const failingChat: ChatFn = async () => {
      throw new Error('model-boss timeout');
    };
    const contact = makeContact(deviceId, handle);

    await expect(generateContactSummary(testDb.pool, contact, failingChat)).rejects.toThrow(
      /model-boss call failed/,
    );
  });

  it('empty-string fields from chat are coerced to null', async () => {
    const deviceId = await seedDevice(testDb.pool);
    const handle = `+1555${uq().slice(0, 7)}`;
    const convId = await seedConversation(testDb.pool, deviceId, handle);
    const now = new Date().toISOString();

    await bulkUpsertMessages(testDb.pool, [
      {
        id: randomUUIDv7(),
        deviceId,
        conversationId: convId,
        externalId: `guid-empty-${randomUUIDv7()}`,
        provider: 'imessage',
        fromHandle: handle,
        body: `Empty string test ${uq()}`,
        isFromMe: false,
        hasAttachments: false,
        attachmentCount: 0,
        sentAt: now,
      },
    ]);

    const emptySummary: ContactSummaryData = {
      mostRecently: '',
      overallSummary: '',
      recap: '',
    };
    const { stub } = makeChatStub(emptySummary);
    const contact = makeContact(deviceId, handle);

    const result = await generateContactSummary(testDb.pool, contact, stub);

    // The stub returns value directly, bypassing parseSummary.
    // Verify the coercion path by capturing the parse fn passed to chat.
    let capturedParse: ((raw: unknown) => ContactSummaryData) | undefined;
    const capturingStub: ChatFn = async (messages, opts) => {
      capturedParse = opts.parse;
      return opts.parse({ mostRecently: '', overallSummary: '', recap: '' });
    };

    const coercedResult = await generateContactSummary(testDb.pool, contact, capturingStub);

    expect(coercedResult).toEqual({ mostRecently: null, overallSummary: null, recap: null });
    expect(result).toEqual(emptySummary); // plain stub returns what it's given

    // Verify parse directly
    if (capturedParse) {
      expect(capturedParse({ mostRecently: 'x', overallSummary: 'y', recap: 'z' })).toEqual({
        mostRecently: 'x',
        overallSummary: 'y',
        recap: 'z',
      });
      expect(() => capturedParse!({ missing: true })).toThrow();
    }
  });
});
