/**
 * Broker: ctag polling + change detection
 *
 * Tests runPoll in src/broker/poller.ts.
 * Key behavior (D-13): ctag unchanged → no DB write (skip sync entirely)
 * Credentials decrypted via decryptPassword before client creation (T-03-04).
 */

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';

// --- module-level mocks (Vitest hoisting) ---

const mockSyncCalendar = vi.fn().mockResolvedValue(undefined);

vi.mock('../../src/broker/sync.js', () => ({
  syncCalendar: mockSyncCalendar,
}));

// decryptPassword returns a predictable plaintext for any input
vi.mock('../../src/broker/crypto.js', () => ({
  decryptPassword: vi.fn().mockReturnValue('decrypted-app-password'),
}));

// db mock: select() chain returns configurable results
const mockCalendarsSelectResult: Array<{ id: number; url: string; ctag: string | null }> = [];
const mockCredentialsSelectResult: Array<{
  id: number;
  userId: number;
  fastmailEmail: string;
  encryptedPassword: string;
}> = [];

// Each call to db.select() needs to return different chains
// We use a call counter to decide which data to return
let _callCount = 0;

const mockSelectLimit = vi.fn();
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit });
const mockSelectFrom = vi.fn();
const mockSelect = vi.fn().mockImplementation(() => ({ from: mockSelectFrom }));

mockSelectFrom.mockImplementation(() => ({
  // For memberCredentials selects (no .where), resolve directly
  where: mockSelectWhere,
  // Support both: direct await (no where) and .where().limit()
  then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
    _callCount++;
    resolve(mockCredentialsSelectResult);
    return Promise.resolve(mockCredentialsSelectResult);
  },
}));

mockSelectLimit.mockImplementation(() => Promise.resolve(mockCalendarsSelectResult));

vi.mock('../../src/db/client.js', () => ({
  db: { select: mockSelect },
}));

// mockFetchCalendars: controlled per test
const mockFetchCalendars = vi.fn();
const mockCreateFastmailClient = vi.fn().mockResolvedValue({
  fetchCalendars: mockFetchCalendars,
});

vi.mock('../../src/broker/client.js', () => ({
  createFastmailClient: mockCreateFastmailClient,
}));

describe('broker poller — runPoll', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    _callCount = 0;

    // Reset implementations
    mockSyncCalendar.mockResolvedValue(undefined);
    mockCreateFastmailClient.mockResolvedValue({ fetchCalendars: mockFetchCalendars });
    mockSelect.mockImplementation(() => ({ from: mockSelectFrom }));
    mockSelectFrom.mockImplementation(() => ({
      where: mockSelectWhere,
      then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
        resolve(mockCredentialsSelectResult);
        return Promise.resolve(mockCredentialsSelectResult);
      },
    }));
    mockSelectLimit.mockResolvedValue(mockCalendarsSelectResult);

    // Clear arrays
    mockCredentialsSelectResult.length = 0;
    mockCalendarsSelectResult.length = 0;
  });

  it('skips syncCalendar when ctag is unchanged', async () => {
    const { runPoll } = await import('../../src/broker/poller.js');

    // Set up one credential
    mockCredentialsSelectResult.push({
      id: 1,
      userId: 10,
      fastmailEmail: 'lucas@fastmail.com',
      encryptedPassword: 'encrypted-blob',
    });

    // Set up the stored calendar row with ctag 'ctag-v1'
    mockCalendarsSelectResult.push({
      id: 100,
      url: 'https://caldav.fastmail.com/cal/',
      ctag: 'ctag-v1',
    });

    // fetchCalendars returns a calendar with the SAME ctag
    mockFetchCalendars.mockResolvedValue([
      {
        url: 'https://caldav.fastmail.com/cal/',
        displayName: 'Test Calendar',
        ctag: 'ctag-v1', // UNCHANGED
        syncToken: null,
      },
    ]);

    await runPoll();

    expect(mockSyncCalendar).not.toHaveBeenCalled();
  });

  it('calls syncCalendar when ctag changes', async () => {
    const { runPoll } = await import('../../src/broker/poller.js');

    mockCredentialsSelectResult.push({
      id: 1,
      userId: 10,
      fastmailEmail: 'lucas@fastmail.com',
      encryptedPassword: 'encrypted-blob',
    });

    mockCalendarsSelectResult.push({
      id: 100,
      url: 'https://caldav.fastmail.com/cal/',
      ctag: 'ctag-v1',
    });

    mockFetchCalendars.mockResolvedValue([
      {
        url: 'https://caldav.fastmail.com/cal/',
        displayName: 'Test Calendar',
        ctag: 'ctag-v2', // CHANGED
        syncToken: null,
      },
    ]);

    await runPoll();

    expect(mockSyncCalendar).toHaveBeenCalledOnce();
  });

  it('calls syncCalendar when ctag was null (first sync)', async () => {
    const { runPoll } = await import('../../src/broker/poller.js');

    mockCredentialsSelectResult.push({
      id: 1,
      userId: 10,
      fastmailEmail: 'lucas@fastmail.com',
      encryptedPassword: 'encrypted-blob',
    });

    // No stored calendar row yet (empty array → first sync)
    // mockCalendarsSelectResult is empty

    mockFetchCalendars.mockResolvedValue([
      {
        url: 'https://caldav.fastmail.com/cal/',
        displayName: 'My Calendar',
        ctag: 'ctag-v1',
        syncToken: null,
      },
    ]);

    await runPoll();

    expect(mockSyncCalendar).toHaveBeenCalledOnce();
  });

  it('handles decryptPassword failure gracefully without crashing the poller', async () => {
    const { runPoll } = await import('../../src/broker/poller.js');
    const { decryptPassword } = await import('../../src/broker/crypto.js');

    mockCredentialsSelectResult.push({
      id: 1,
      userId: 10,
      fastmailEmail: 'lucas@fastmail.com',
      encryptedPassword: 'corrupted',
    });

    // Make decryptPassword throw for this test
    (decryptPassword as Mock).mockImplementationOnce(() => {
      throw new Error('Decryption failed');
    });

    // runPoll should not throw — it should catch and skip the credential
    await expect(runPoll()).resolves.not.toThrow();
    expect(mockSyncCalendar).not.toHaveBeenCalled();
  });

  it('BUG B: scopes the stored-calendar lookup to (userId, url), not url alone', async () => {
    // Capture the predicate passed to db.select().from(calendars).where(...).
    // The buggy code passed eq(url) only; the fix passes and(eq(userId), eq(url)).
    // We serialize the predicate and assert it references the member's user_id column.
    const capturedWhere: unknown[] = [];
    mockSelectWhere.mockImplementation((pred: unknown) => {
      capturedWhere.push(pred);
      return { limit: mockSelectLimit };
    });

    const { runPoll } = await import('../../src/broker/poller.js');

    mockCredentialsSelectResult.push({
      id: 7,
      userId: 42,
      fastmailEmail: 'lucas@fastmail.com',
      encryptedPassword: 'enc',
    });

    mockFetchCalendars.mockResolvedValue([
      {
        url: 'https://caldav.fastmail.com/cal/',
        displayName: 'Calendar',
        ctag: 'c',
        syncToken: null,
      },
    ]);

    await runPoll();

    expect(capturedWhere.length).toBeGreaterThan(0);
    // A composite and(...) predicate exposes multiple queryChunks; a single eq does not
    // contain a nested SQL referencing the user_id column. Serialize and inspect.
    const pred = capturedWhere[0] as { queryChunks?: unknown[] };
    const serialized = JSON.stringify(pred, (_k, v) =>
      typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
        ? (v as { name?: unknown }).name
        : v,
    );
    expect(serialized).toContain('user_id');
    expect(serialized).toContain('url');
  });

  it('processes all member credentials in a poll cycle', async () => {
    const { runPoll } = await import('../../src/broker/poller.js');

    // Two credentials
    mockCredentialsSelectResult.push(
      { id: 1, userId: 10, fastmailEmail: 'lucas@fastmail.com', encryptedPassword: 'enc1' },
      { id: 2, userId: 20, fastmailEmail: 'wife@icloud.com', encryptedPassword: 'enc2' },
    );

    // Each member's calendar has a different (new) ctag → both trigger sync
    mockFetchCalendars.mockResolvedValue([
      {
        url: 'https://caldav.fastmail.com/cal/',
        displayName: 'Calendar',
        ctag: 'new-ctag',
        syncToken: null,
      },
    ]);

    await runPoll();

    // createFastmailClient called once per credential
    expect(mockCreateFastmailClient).toHaveBeenCalledTimes(2);
    // syncCalendar called once per credential (one calendar each, ctag changed)
    expect(mockSyncCalendar).toHaveBeenCalledTimes(2);
  });
});
