/**
 * Integration tests for conversation persistence and context resume (M1-008).
 *
 * Verifies the full flow end-to-end using in-memory mocks:
 * 1. User messages are persisted when sent via ChatGateway.
 * 2. Assistant responses are persisted with metadata on agent:end.
 * 3. Conversation history is loaded and injected into context on session resume.
 * 4. The search endpoint returns matching messages.
 */

import { BadRequestException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
import { ConversationsController } from '../conversations/conversations.controller.js';
import type { Message } from '@mosaicstack/brain';

// ---------------------------------------------------------------------------
// Shared test data
// ---------------------------------------------------------------------------

const USER_ID = 'user-test-001';
const CONV_ID = 'conv-test-001';

function makeConversation(overrides?: Record<string, unknown>) {
  return {
    id: CONV_ID,
    userId: USER_ID,
    title: null,
    projectId: null,
    archived: false,
    createdAt: new Date('2026-01-01T00:00:00Z'),
    updatedAt: new Date('2026-01-01T00:00:00Z'),
    ...overrides,
  };
}

function makeMessage(
  role: 'user' | 'assistant' | 'system',
  content: string,
  overrides?: Record<string, unknown>,
) {
  return {
    id: `msg-${role}-${Math.random().toString(36).slice(2)}`,
    conversationId: CONV_ID,
    role,
    content,
    metadata: null,
    createdAt: new Date('2026-01-01T00:01:00Z'),
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Helper: build a mock ConversationsRepo
// ---------------------------------------------------------------------------

function createMockBrain(options?: {
  conversation?: ReturnType<typeof makeConversation> | undefined;
  messages?: ReturnType<typeof makeMessage>[];
  searchResults?: Array<{
    messageId: string;
    conversationId: string;
    conversationTitle: string | null;
    role: 'user' | 'assistant' | 'system';
    content: string;
    createdAt: Date;
  }>;
}) {
  const conversation = options?.conversation;
  const messages = options?.messages ?? [];
  const searchResults = options?.searchResults ?? [];

  return {
    conversations: {
      findAll: vi.fn().mockResolvedValue(conversation ? [conversation] : []),
      findById: vi.fn().mockResolvedValue(conversation),
      create: vi.fn().mockResolvedValue(conversation ?? makeConversation()),
      update: vi.fn().mockResolvedValue(conversation),
      remove: vi.fn().mockResolvedValue(true),
      findMessages: vi.fn().mockResolvedValue(messages),
      addMessage: vi.fn().mockImplementation((data: unknown) => {
        const d = data as {
          conversationId: string;
          role: 'user' | 'assistant' | 'system';
          content: string;
          metadata?: Record<string, unknown>;
        };
        return Promise.resolve(makeMessage(d.role, d.content, { metadata: d.metadata ?? null }));
      }),
      searchMessages: vi.fn().mockResolvedValue(searchResults),
    },
  };
}

// ---------------------------------------------------------------------------
// 1. ConversationsRepo: addMessage persists user message
// ---------------------------------------------------------------------------

describe('ConversationsRepo.addMessage — user message persistence', () => {
  it('persists a user message and returns the saved record', async () => {
    const brain = createMockBrain({ conversation: makeConversation() });

    const result = await brain.conversations.addMessage(
      {
        conversationId: CONV_ID,
        role: 'user',
        content: 'Hello, agent!',
        metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
      },
      USER_ID,
    );

    expect(brain.conversations.addMessage).toHaveBeenCalledOnce();
    expect(result).toBeDefined();
    expect(result!.role).toBe('user');
    expect(result!.content).toBe('Hello, agent!');
    expect(result!.conversationId).toBe(CONV_ID);
  });

  it('returns undefined when conversation does not belong to the user', async () => {
    // Simulate the repo enforcement: ownership mismatch returns undefined
    const brain = createMockBrain({ conversation: undefined });
    brain.conversations.addMessage = vi.fn().mockResolvedValue(undefined);

    const result = await brain.conversations.addMessage(
      { conversationId: CONV_ID, role: 'user', content: 'Hello' },
      'other-user',
    );

    expect(result).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// 2. ConversationsRepo.addMessage — assistant response with metadata
// ---------------------------------------------------------------------------

describe('ConversationsRepo.addMessage — assistant response metadata', () => {
  it('persists assistant message with model, provider, tokens and toolCalls metadata', async () => {
    const assistantMetadata = {
      timestamp: '2026-01-01T00:02:00.000Z',
      model: 'claude-3-5-sonnet-20241022',
      provider: 'anthropic',
      toolCalls: [
        {
          toolCallId: 'tc-001',
          toolName: 'read_file',
          args: { path: '/foo/bar.ts' },
          isError: false,
        },
      ],
      tokenUsage: {
        input: 1000,
        output: 250,
        cacheRead: 0,
        cacheWrite: 0,
        total: 1250,
      },
    };

    const brain = createMockBrain({ conversation: makeConversation() });

    const result = await brain.conversations.addMessage(
      {
        conversationId: CONV_ID,
        role: 'assistant',
        content: 'Here is the file content you requested.',
        metadata: assistantMetadata,
      },
      USER_ID,
    );

    expect(result).toBeDefined();
    expect(result!.role).toBe('assistant');
    expect(result!.content).toBe('Here is the file content you requested.');
    expect(result!.metadata).toMatchObject({
      model: 'claude-3-5-sonnet-20241022',
      provider: 'anthropic',
      tokenUsage: { input: 1000, output: 250, total: 1250 },
    });
    expect((result!.metadata as Record<string, unknown>)['toolCalls']).toHaveLength(1);
    expect(
      (
        (result!.metadata as Record<string, unknown>)['toolCalls'] as Array<Record<string, unknown>>
      )[0]!['toolName'],
    ).toBe('read_file');
  });
});

// ---------------------------------------------------------------------------
// 3. ChatGateway.loadConversationHistory — session resume loads history
// ---------------------------------------------------------------------------

describe('Conversation resume — history loading', () => {
  it('maps DB messages to ConversationHistoryMessage shape', () => {
    // Simulate what ChatGateway.loadConversationHistory does:
    // convert DB Message rows to ConversationHistoryMessage for context injection.
    const dbMessages = [
      makeMessage('user', 'What is the capital of France?', {
        createdAt: new Date('2026-01-01T00:01:00Z'),
      }),
      makeMessage('assistant', 'The capital of France is Paris.', {
        createdAt: new Date('2026-01-01T00:01:05Z'),
      }),
      makeMessage('user', 'And Germany?', { createdAt: new Date('2026-01-01T00:02:00Z') }),
      makeMessage('assistant', 'The capital of Germany is Berlin.', {
        createdAt: new Date('2026-01-01T00:02:05Z'),
      }),
    ];

    // Replicate the mapping logic from ChatGateway
    const history: ConversationHistoryMessage[] = dbMessages.map((msg) => ({
      role: msg.role as 'user' | 'assistant' | 'system',
      content: msg.content,
      createdAt: msg.createdAt,
    }));

    expect(history).toHaveLength(4);
    expect(history[0]).toEqual({
      role: 'user',
      content: 'What is the capital of France?',
      createdAt: new Date('2026-01-01T00:01:00Z'),
    });
    expect(history[1]).toEqual({
      role: 'assistant',
      content: 'The capital of France is Paris.',
      createdAt: new Date('2026-01-01T00:01:05Z'),
    });
    expect(history[2]!.role).toBe('user');
    expect(history[3]!.role).toBe('assistant');
  });

  it('returns empty array when conversation has no messages', async () => {
    const brain = createMockBrain({ conversation: makeConversation(), messages: [] });

    const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
    expect(messages).toHaveLength(0);

    // Gateway produces empty history → no context injection
    const history: ConversationHistoryMessage[] = (messages as Message[]).map((msg) => ({
      role: msg.role as 'user' | 'assistant' | 'system',
      content: msg.content,
      createdAt: msg.createdAt,
    }));
    expect(history).toHaveLength(0);
  });

  it('returns empty array when conversation does not belong to the user', async () => {
    const brain = createMockBrain({ conversation: undefined });
    brain.conversations.findMessages = vi.fn().mockResolvedValue([]);

    const messages = await brain.conversations.findMessages(CONV_ID, 'other-user');
    expect(messages).toHaveLength(0);
  });

  it('preserves message order (ascending by createdAt)', async () => {
    const ordered = [
      makeMessage('user', 'First', { createdAt: new Date('2026-01-01T00:01:00Z') }),
      makeMessage('assistant', 'Second', { createdAt: new Date('2026-01-01T00:01:05Z') }),
      makeMessage('user', 'Third', { createdAt: new Date('2026-01-01T00:02:00Z') }),
    ];
    const brain = createMockBrain({ conversation: makeConversation(), messages: ordered });

    const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
    expect(messages[0]!.content).toBe('First');
    expect(messages[1]!.content).toBe('Second');
    expect(messages[2]!.content).toBe('Third');
  });
});

// ---------------------------------------------------------------------------
// 4. AgentService.buildHistoryPromptSection — context injection format
// ---------------------------------------------------------------------------

describe('AgentService — buildHistoryPromptSection (context injection)', () => {
  /**
   * Replicate the private method logic to test it in isolation.
   * The real method lives in AgentService but is private; we mirror the
   * exact logic here so the test is independent of the service's constructor.
   */
  function buildHistoryPromptSection(
    history: ConversationHistoryMessage[],
    contextWindow: number,
    _sessionId: string,
  ): string {
    const TOKEN_BUDGET = Math.floor(contextWindow * 0.8);
    const HISTORY_HEADER = '## Conversation History (resumed session)\n\n';

    const formatMessage = (msg: ConversationHistoryMessage): string => {
      const roleLabel =
        msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
      return `**${roleLabel}:** ${msg.content}`;
    };

    const estimateTokens = (text: string) => Math.ceil(text.length / 4);

    const formatted = history.map((msg) => formatMessage(msg));
    const fullHistory = formatted.join('\n\n');
    const fullTokens = estimateTokens(HISTORY_HEADER + fullHistory);

    if (fullTokens <= TOKEN_BUDGET) {
      return HISTORY_HEADER + fullHistory;
    }

    // History exceeds budget — summarize oldest messages, keep recent verbatim
    const SUMMARY_RESERVE = Math.floor(TOKEN_BUDGET * 0.2);
    const verbatimBudget = TOKEN_BUDGET - SUMMARY_RESERVE;

    let verbatimTokens = 0;
    let verbatimCutIndex = history.length;
    for (let i = history.length - 1; i >= 0; i--) {
      const t = estimateTokens(formatted[i]!);
      if (verbatimTokens + t > verbatimBudget) break;
      verbatimTokens += t;
      verbatimCutIndex = i;
    }

    const summarizedMessages = history.slice(0, verbatimCutIndex);
    const verbatimMessages = history.slice(verbatimCutIndex);

    let summaryText = '';
    if (summarizedMessages.length > 0) {
      const topics = summarizedMessages
        .filter((m) => m.role === 'user')
        .map((m) => m.content.slice(0, 120).replace(/\n/g, ' '))
        .join('; ');
      summaryText =
        `**Previous conversation summary** (${summarizedMessages.length} messages omitted for brevity):\n` +
        `Topics discussed: ${topics || '(no user messages in summarized portion)'}`;
    }

    const verbatimSection = verbatimMessages.map((m) => formatMessage(m)).join('\n\n');

    const parts: string[] = [HISTORY_HEADER];
    if (summaryText) parts.push(summaryText);
    if (verbatimSection) parts.push(verbatimSection);

    return parts.join('\n\n');
  }

  it('includes header and all messages when history fits within context budget', () => {
    const history: ConversationHistoryMessage[] = [
      { role: 'user', content: 'Hello', createdAt: new Date() },
      { role: 'assistant', content: 'Hi there!', createdAt: new Date() },
    ];

    const result = buildHistoryPromptSection(history, 8192, 'session-1');

    expect(result).toContain('## Conversation History (resumed session)');
    expect(result).toContain('**User:** Hello');
    expect(result).toContain('**Assistant:** Hi there!');
  });

  it('labels roles correctly (user, assistant, system)', () => {
    const history: ConversationHistoryMessage[] = [
      { role: 'system', content: 'You are helpful.', createdAt: new Date() },
      { role: 'user', content: 'Ping', createdAt: new Date() },
      { role: 'assistant', content: 'Pong', createdAt: new Date() },
    ];

    const result = buildHistoryPromptSection(history, 8192, 'session-2');

    expect(result).toContain('**System:** You are helpful.');
    expect(result).toContain('**User:** Ping');
    expect(result).toContain('**Assistant:** Pong');
  });

  it('summarizes old messages when history exceeds 80% of context window', () => {
    // Create enough messages to exceed a tiny context window budget
    const longContent = 'A'.repeat(200);
    const history: ConversationHistoryMessage[] = Array.from({ length: 20 }, (_, i) => ({
      role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant',
      content: `${longContent} message ${i}`,
      createdAt: new Date(),
    }));

    // Use a small context window so history definitely exceeds 80%
    const result = buildHistoryPromptSection(history, 512, 'session-3');

    // Should contain the summary prefix
    expect(result).toContain('messages omitted for brevity');
    expect(result).toContain('Topics discussed:');
  });

  it('returns only header for empty history', () => {
    const result = buildHistoryPromptSection([], 8192, 'session-4');
    // With empty history, the full history join is '' and the section is just the header
    expect(result).toContain('## Conversation History (resumed session)');
  });
});

// ---------------------------------------------------------------------------
// 5. ConversationsController.search — GET /api/conversations/search
// ---------------------------------------------------------------------------

describe('ConversationsController — search endpoint', () => {
  let brain: ReturnType<typeof createMockBrain>;
  let controller: ConversationsController;

  beforeEach(() => {
    const searchResults = [
      {
        messageId: 'msg-001',
        conversationId: CONV_ID,
        conversationTitle: 'Test Chat',
        role: 'user' as const,
        content: 'What is the capital of France?',
        createdAt: new Date('2026-01-01T00:01:00Z'),
      },
      {
        messageId: 'msg-002',
        conversationId: CONV_ID,
        conversationTitle: 'Test Chat',
        role: 'assistant' as const,
        content: 'The capital of France is Paris.',
        createdAt: new Date('2026-01-01T00:01:05Z'),
      },
    ];
    brain = createMockBrain({ searchResults });
    controller = new ConversationsController(brain as never);
  });

  it('returns matching messages for a valid search query', async () => {
    const results = await controller.search({ q: 'France' }, { id: USER_ID });

    expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'France', 20, 0);
    expect(results).toHaveLength(2);
    expect(results[0]).toMatchObject({
      messageId: 'msg-001',
      role: 'user',
      content: 'What is the capital of France?',
    });
    expect(results[1]).toMatchObject({
      messageId: 'msg-002',
      role: 'assistant',
      content: 'The capital of France is Paris.',
    });
  });

  it('uses custom limit and offset when provided', async () => {
    await controller.search({ q: 'Paris', limit: 5, offset: 10 }, { id: USER_ID });

    expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'Paris', 5, 10);
  });

  it('throws BadRequestException when query is empty', async () => {
    await expect(controller.search({ q: '' }, { id: USER_ID })).rejects.toBeInstanceOf(
      BadRequestException,
    );
    await expect(controller.search({ q: '   ' }, { id: USER_ID })).rejects.toBeInstanceOf(
      BadRequestException,
    );
  });

  it('trims whitespace from query before passing to repo', async () => {
    await controller.search({ q: '  Berlin  ' }, { id: USER_ID });

    expect(brain.conversations.searchMessages).toHaveBeenCalledWith(
      USER_ID,
      'Berlin',
      expect.any(Number),
      expect.any(Number),
    );
  });

  it('returns empty array when no messages match', async () => {
    brain.conversations.searchMessages = vi.fn().mockResolvedValue([]);

    const results = await controller.search({ q: 'xyzzy-no-match' }, { id: USER_ID });

    expect(results).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// 6. ConversationsController — messages CRUD
// ---------------------------------------------------------------------------

describe('ConversationsController — message CRUD', () => {
  it('listMessages returns 404 when conversation is not owned by user', async () => {
    const brain = createMockBrain({ conversation: undefined });
    const controller = new ConversationsController(brain as never);

    await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
      NotFoundException,
    );
  });

  it('listMessages returns the messages for an owned conversation', async () => {
    const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
    const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
    const controller = new ConversationsController(brain as never);

    const result = await controller.listMessages(CONV_ID, { id: USER_ID });

    expect(result).toHaveLength(2);
    expect(result[0]!.role).toBe('user');
    expect(result[1]!.role).toBe('assistant');
  });

  it('addMessage returns the persisted message', async () => {
    const brain = createMockBrain({ conversation: makeConversation() });
    const controller = new ConversationsController(brain as never);

    const result = await controller.addMessage(
      CONV_ID,
      { role: 'user', content: 'Persisted content' },
      { id: USER_ID },
    );

    expect(result).toBeDefined();
    expect(result.role).toBe('user');
    expect(result.content).toBe('Persisted content');
  });
});

// ---------------------------------------------------------------------------
// 7. End-to-end persistence flow simulation
// ---------------------------------------------------------------------------

describe('End-to-end persistence flow', () => {
  it('simulates a full conversation: persist user message → persist assistant response → resume with history', async () => {
    // ── Step 1: Conversation is created ────────────────────────────────────
    const brain = createMockBrain({ conversation: makeConversation() });

    await brain.conversations.create({ id: CONV_ID, userId: USER_ID });
    expect(brain.conversations.create).toHaveBeenCalledOnce();

    // ── Step 2: User message is persisted ──────────────────────────────────
    const userMsg = await brain.conversations.addMessage(
      {
        conversationId: CONV_ID,
        role: 'user',
        content: 'Explain monads in simple terms.',
        metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
      },
      USER_ID,
    );

    expect(userMsg).toBeDefined();
    expect(userMsg!.role).toBe('user');

    // ── Step 3: Assistant response is persisted with metadata ───────────────
    const assistantMeta = {
      timestamp: '2026-01-01T00:01:10.000Z',
      model: 'claude-3-5-sonnet-20241022',
      provider: 'anthropic',
      toolCalls: [],
      tokenUsage: { input: 500, output: 120, cacheRead: 0, cacheWrite: 0, total: 620 },
    };

    const assistantMsg = await brain.conversations.addMessage(
      {
        conversationId: CONV_ID,
        role: 'assistant',
        content: 'A monad is a design pattern that wraps values in a context...',
        metadata: assistantMeta,
      },
      USER_ID,
    );

    expect(assistantMsg).toBeDefined();
    expect(assistantMsg!.role).toBe('assistant');

    // ── Step 4: On session resume, history is loaded ────────────────────────
    const storedMessages = [
      makeMessage('user', 'Explain monads in simple terms.', {
        createdAt: new Date('2026-01-01T00:01:00Z'),
        metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
      }),
      makeMessage('assistant', 'A monad is a design pattern that wraps values in a context...', {
        createdAt: new Date('2026-01-01T00:01:10Z'),
        metadata: assistantMeta,
      }),
    ];

    brain.conversations.findMessages = vi.fn().mockResolvedValue(storedMessages);

    const dbMessages = await brain.conversations.findMessages(CONV_ID, USER_ID);
    expect(dbMessages).toHaveLength(2);

    // ── Step 5: History is mapped for context injection ─────────────────────
    const history: ConversationHistoryMessage[] = (dbMessages as Message[]).map((msg) => ({
      role: msg.role as 'user' | 'assistant' | 'system',
      content: msg.content,
      createdAt: msg.createdAt,
    }));

    expect(history[0]).toMatchObject({
      role: 'user',
      content: 'Explain monads in simple terms.',
    });
    expect(history[1]).toMatchObject({
      role: 'assistant',
      content: 'A monad is a design pattern that wraps values in a context...',
    });

    // ── Step 6: History roles are valid for injection ───────────────────────
    for (const msg of history) {
      expect(['user', 'assistant', 'system']).toContain(msg.role);
      expect(typeof msg.content).toBe('string');
      expect(msg.createdAt).toBeInstanceOf(Date);
    }
  });
});
