/**
 * phishing-eml-service.ts — parseAndStoreMessage orchestration tests.
 *
 * postgresClient.query, getAutotaskClient (Autotask factory), and the B2
 * client's isB2Configured/presignUpload are all mocked (following
 * pax8-sync-service.test.ts / notify.test.ts's mocking discipline) — no real
 * Postgres, Autotask, or B2 credentials/network are used. All `.eml` content
 * is imported from eml-parser.fixtures.ts (synthetic only, per this
 * milestone's Out of Scope constraint — no real customer email).
 */

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

// Mock postgresClient BEFORE importing the module under test.
const queryMock = vi.fn();
vi.mock('./postgres-client', () => ({
  postgresClient: {
    query: (...args: unknown[]) => queryMock(...args),
  },
}));

// Mock the Autotask factory so no real credentials/network are required.
const getAttachmentsMock = vi.fn();
const getAttachmentContentMock = vi.fn();
vi.mock('./autotask-factory', () => ({
  getAutotaskClient: () => ({
    getAttachments: (...args: unknown[]) => getAttachmentsMock(...args),
    getAttachmentContent: (...args: unknown[]) => getAttachmentContentMock(...args),
  }),
}));

// Mock the B2 client — isB2Configured/presignUpload are stubbed per-test.
const isB2ConfiguredMock = vi.fn();
const presignUploadMock = vi.fn();
vi.mock('./b2/client', () => ({
  isB2Configured: () => isB2ConfiguredMock(),
  presignUpload: (...args: unknown[]) => presignUploadMock(...args),
  EML_OBJECT_KEY_REGEX: /^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/,
}));

// Import AFTER the mocks are declared so vi.mock hoisting takes effect.
import { parseAndStoreMessage } from './phishing-eml-service';
import {
  RFC_EML_TIER_ATTACHMENTS,
  NO_EML_ATTACHMENTS,
  RICH_MULTIPART_EML,
} from './eml-parser.fixtures';

const REPORT_ID = 'report-abc-123';
const TICKET_ID = 999;
const SELECTED_ATTACHMENT_ID = RFC_EML_TIER_ATTACHMENTS[0].id; // rfc.eml, tier 1
// The exact URL embedded in RICH_MULTIPART_EML's text+html body (16-01 fixture).
const MESSAGE_BODY_URL = 'http://evil-example.test/verify';

function selectedAttachmentWithContent() {
  return {
    ...RFC_EML_TIER_ATTACHMENTS[0],
    data: RICH_MULTIPART_EML.toString('base64'),
  };
}

/** Extracts [sql, params] tuples for calls whose SQL contains `needle`. */
function callsContaining(needle: string): Array<[string, unknown[]]> {
  return queryMock.mock.calls
    .filter(([sql]) => String(sql).includes(needle))
    .map(([sql, params]) => [String(sql), (params as unknown[]) ?? []]);
}

describe('parseAndStoreMessage', () => {
  const originalFetch = global.fetch;

  beforeEach(() => {
    queryMock.mockReset();
    getAttachmentsMock.mockReset();
    getAttachmentContentMock.mockReset();
    isB2ConfiguredMock.mockReset();
    presignUploadMock.mockReset();

    // Default: any INSERT ... RETURNING resolves with a single row. The new
    // leading `SELECT id FROM messages WHERE report_id` idempotency check
    // (Defect 3, 260717-a19) must resolve empty by default so existing
    // happy-path tests aren't short-circuited as 'already-parsed'.
    queryMock.mockImplementation((sql: string) => {
      if (String(sql).includes('FROM messages WHERE report_id')) {
        return Promise.resolve({ rows: [], rowCount: 0 });
      }
      return Promise.resolve({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 });
    });

    global.fetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));
  });

  afterEach(() => {
    global.fetch = originalFetch;
    vi.restoreAllMocks();
  });

  it('lists attachments, selects the original message, parses it, and writes one messages row plus indicators (happy path)', async () => {
    getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS);
    getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent());
    isB2ConfiguredMock.mockReturnValue(false);

    const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    expect(result).toEqual({ stored: true, messageId: 'message-uuid-1' });
    expect(getAttachmentsMock).toHaveBeenCalledWith('Tickets', TICKET_ID);
    expect(getAttachmentContentMock).toHaveBeenCalledWith('Tickets', TICKET_ID, SELECTED_ATTACHMENT_ID);

    const messageInserts = callsContaining('INSERT INTO messages');
    expect(messageInserts).toHaveLength(1);

    const [, messageParams] = messageInserts[0];
    const headersPayload = JSON.parse(messageParams[2] as string);
    expect(headersPayload.authResults).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' });

    const indicatorInserts = callsContaining('INSERT INTO indicators');
    expect(indicatorInserts.length).toBeGreaterThan(0);
  });

  it('returns { stored: false } and never inserts a messages row when no .eml attachment is found', async () => {
    getAttachmentsMock.mockResolvedValue(NO_EML_ATTACHMENTS);

    const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    expect(result).toEqual({ stored: false, reason: 'no-eml-attachment' });
    expect(getAttachmentContentMock).not.toHaveBeenCalled();
    expect(callsContaining('INSERT INTO messages')).toHaveLength(0);
  });

  it('persists with raw_ref null and never calls presignUpload/PUT when B2 is unconfigured', async () => {
    getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS);
    getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent());
    isB2ConfiguredMock.mockReturnValue(false);

    await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    expect(presignUploadMock).not.toHaveBeenCalled();
    expect(global.fetch).not.toHaveBeenCalled();

    const [, messageParams] = callsContaining('INSERT INTO messages')[0];
    expect(messageParams[6]).toBeNull(); // raw_ref column
  });

  it('sets raw_ref to phishing/{reportId}/{attachmentId}.eml and PUTs exactly once when B2 is configured', async () => {
    getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS);
    getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent());
    isB2ConfiguredMock.mockReturnValue(true);
    presignUploadMock.mockReturnValue('https://s3.example.test/presigned-put-url');

    await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    expect(presignUploadMock).toHaveBeenCalledTimes(1);
    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(global.fetch).toHaveBeenCalledWith(
      'https://s3.example.test/presigned-put-url',
      expect.objectContaining({ method: 'PUT' })
    );

    const [, messageParams] = callsContaining('INSERT INTO messages')[0];
    expect(messageParams[6]).toBe(`phishing/${REPORT_ID}/${SELECTED_ATTACHMENT_ID}.eml`);
  });

  it('never calls fetch with any URL extracted from the message body (no-network invariant)', async () => {
    getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS);
    getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent());
    isB2ConfiguredMock.mockReturnValue(true);
    presignUploadMock.mockReturnValue('https://s3.example.test/presigned-put-url');

    await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>;
    for (const call of fetchMock.mock.calls) {
      expect(call[0]).not.toBe(MESSAGE_BODY_URL);
    }
    // The only fetch call made is the mocked B2 presigned PUT target.
    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(fetchMock).toHaveBeenCalledWith('https://s3.example.test/presigned-put-url', expect.anything());
  });

  it('writes an attachment_hash indicator whose metadata carries filename/contentType/size/related', async () => {
    getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS);
    getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent());
    isB2ConfiguredMock.mockReturnValue(false);

    await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    const attachmentHashInserts = callsContaining('INSERT INTO indicators').filter(
      ([, params]) => params[1] === 'attachment_hash'
    );
    expect(attachmentHashInserts).toHaveLength(1);

    const [, params] = attachmentHashInserts[0];
    const metadata = JSON.parse(params[3] as string);
    expect(metadata).toEqual(
      expect.objectContaining({
        filename: 'invoice.pdf',
        contentType: 'application/pdf',
        size: expect.any(Number),
        related: false,
      })
    );
  });

  it('returns { stored: false, reason: "already-parsed" } and does no work when a messages row already exists for the report (Defect 3, 260717-a19)', async () => {
    queryMock.mockImplementation((sql: string) => {
      if (String(sql).includes('FROM messages WHERE report_id')) {
        return Promise.resolve({ rows: [{ id: 'existing-message-uuid' }], rowCount: 1 });
      }
      return Promise.resolve({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 });
    });

    const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID });

    expect(result).toEqual({ stored: false, reason: 'already-parsed' });
    expect(getAttachmentsMock).not.toHaveBeenCalled();
    expect(getAttachmentContentMock).not.toHaveBeenCalled();
    expect(callsContaining('INSERT INTO messages')).toHaveLength(0);
  });
});
