/**
 * mimecast-blast-radius.ts — getBlastRadius() orchestration tests.
 *
 * Mocks './mimecast-client' and './redis-client' entirely (following
 * phishing-eml-service.test.ts's vi.mock() factory-mocking discipline) — no
 * real Mimecast network calls or Redis connection are used. Synthetic inline
 * fixtures matching MimecastDeliveredMessage/MimecastHeldMessage/
 * MimecastThreatEvent shapes are used directly; no fixture file needed.
 */

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

const isMimecastConfiguredMock = vi.fn();
const getMessageInfoMock = vi.fn();
const searchDeliveredMessagesMock = vi.fn();
const getHeldMessagesMock = vi.fn();
const getThreatEventsMock = vi.fn();
const getMimecastClientMock = vi.fn(() => ({
  getMessageInfo: getMessageInfoMock,
  searchDeliveredMessages: searchDeliveredMessagesMock,
  getHeldMessages: getHeldMessagesMock,
  getThreatEvents: getThreatEventsMock,
}));

vi.mock('./mimecast-client', () => ({
  isMimecastConfigured: () => isMimecastConfiguredMock(),
  getMimecastClient: () => getMimecastClientMock(),
}));

const getCachedDataMock = vi.fn();
const setCachedDataMock = vi.fn();
vi.mock('./redis-client', () => ({
  getCachedData: (...args: unknown[]) => getCachedDataMock(...args),
  setCachedData: (...args: unknown[]) => setCachedDataMock(...args),
}));

// Import AFTER the mocks are declared so vi.mock hoisting takes effect.
import { getBlastRadius, type BlastRadiusInput } from './mimecast-blast-radius';

const BASE_INPUT: BlastRadiusInput = {
  sender: 'attacker@evil.test',
  recipient: 'reporter@wulfconsulting.test',
  subject: 'Urgent: verify your account',
  dateWindow: {
    start: new Date('2026-07-14T00:00:00.000Z'),
    end: new Date('2026-07-15T00:00:00.000Z'),
  },
};

describe('getBlastRadius', () => {
  beforeEach(() => {
    isMimecastConfiguredMock.mockReset().mockReturnValue(true);
    getMimecastClientMock.mockClear();
    getMessageInfoMock.mockReset().mockResolvedValue(null);
    searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] });
    getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 });
    getThreatEventsMock.mockReset().mockResolvedValue({ items: [], nextCursor: null });
    getCachedDataMock.mockReset().mockResolvedValue(null);
    setCachedDataMock.mockReset().mockResolvedValue(undefined);
  });

  it('returns unavailable/not_configured synchronously and never calls getMimecastClient when unconfigured', async () => {
    isMimecastConfiguredMock.mockReturnValue(false);

    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual({ status: 'unavailable', reason: 'not_configured' });
    expect(getMimecastClientMock).not.toHaveBeenCalled();
    expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
    expect(getHeldMessagesMock).not.toHaveBeenCalled();
    expect(getThreatEventsMock).not.toHaveBeenCalled();
  });

  it('returns the cached result on a cache hit without calling any MimecastClient method', async () => {
    const cachedResult = {
      status: 'ok' as const,
      matched: 1,
      delivered: 1,
      held: 0,
      rejected: 0,
      clicked: 0,
      perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' as const }],
      source: 'fan-out' as const,
    };
    getCachedDataMock.mockResolvedValue(cachedResult);

    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual(cachedResult);
    expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
    expect(getHeldMessagesMock).not.toHaveBeenCalled();
    expect(getThreatEventsMock).not.toHaveBeenCalled();
    expect(setCachedDataMock).not.toHaveBeenCalled();
  });

  it('merges delivered/held/threat-event fixtures into a normalized ok result and caches it once', async () => {
    searchDeliveredMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'd1',
          status: 'Delivered',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: BASE_INPUT.recipient,
          toDisplay: 'Reporter',
          received: '2026-07-14T12:00:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
        {
          id: 'd2',
          status: 'Rejected',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: 'other-recipient@wulfconsulting.test',
          toDisplay: 'Other',
          received: '2026-07-14T12:05:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
      ],
    });
    getHeldMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'h1',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromDisplay: 'Attacker',
          to: 'held-recipient@wulfconsulting.test',
          toDisplay: 'Held',
          dateReceived: '2026-07-14T13:00:00+0000',
          reason: 'spam',
          reasonCode: 'SPAM',
          policyInfo: '',
          route: 'inbound',
          hasAttachments: false,
          size: 100,
        },
      ],
      totalCount: 1,
    });
    getThreatEventsMock.mockResolvedValue({
      items: [
        {
          id: 't1',
          eventType: 'click',
          analysis: ['click'],
          source: [],
          direction: [],
          status: [],
        },
      ],
      nextCursor: null,
    });

    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual({
      status: 'ok',
      matched: 2, // 1 non-rejected delivered + 1 held
      delivered: 1,
      held: 1,
      rejected: 1,
      clicked: 1,
      perRecipient: expect.arrayContaining([
        { recipient: BASE_INPUT.recipient, status: 'delivered' },
        { recipient: 'other-recipient@wulfconsulting.test', status: 'rejected' },
        { recipient: 'held-recipient@wulfconsulting.test', status: 'held' },
      ]),
      source: 'fan-out',
    });
    expect(setCachedDataMock).toHaveBeenCalledTimes(1);
    expect(setCachedDataMock).toHaveBeenCalledWith(expect.any(String), result, 300);
  });

  it('clicked is 0 when no click-type analysis value is present', async () => {
    getThreatEventsMock.mockResolvedValue({
      items: [{ id: 't1', eventType: 'malware', analysis: ['malware'], source: [], direction: [], status: [] }],
      nextCursor: null,
    });

    const result = await getBlastRadius(BASE_INPUT);

    expect(result.status).toBe('ok');
    if (result.status === 'ok') {
      expect(result.clicked).toBe(0);
    }
  });

  it('degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects', async () => {
    getHeldMessagesMock.mockRejectedValue(new Error('Mimecast API timeout'));

    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual({
      status: 'unavailable',
      reason: 'lookup_failed',
      error: 'Mimecast API timeout',
    });
    expect(setCachedDataMock).not.toHaveBeenCalled();
  });

  it('a recipient with no delivered/held rows at all is reported as unknown', async () => {
    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual({
      status: 'ok',
      matched: 0,
      delivered: 0,
      held: 0,
      rejected: 0,
      clicked: 0,
      perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'unknown' }],
      source: 'fan-out',
    });
  });

  it('uses an injected tenant client via options.client and never calls getMimecastClient', async () => {
    const tenantGetMessageInfoMock = vi.fn().mockResolvedValue(null);
    const tenantSearchDeliveredMessagesMock = vi.fn().mockResolvedValue({ messages: [] });
    const tenantGetHeldMessagesMock = vi.fn().mockResolvedValue({ messages: [], totalCount: 0 });
    const tenantGetThreatEventsMock = vi.fn().mockResolvedValue({ items: [], nextCursor: null });
    const fakeTenantClient = {
      getMessageInfo: tenantGetMessageInfoMock,
      searchDeliveredMessages: tenantSearchDeliveredMessagesMock,
      getHeldMessages: tenantGetHeldMessagesMock,
      getThreatEvents: tenantGetThreatEventsMock,
    };

    const result = await getBlastRadius(BASE_INPUT, { client: fakeTenantClient as any, cacheScope: 'company-123' });

    expect(result.status).toBe('ok');
    expect(getMimecastClientMock).not.toHaveBeenCalled();
    expect(tenantSearchDeliveredMessagesMock).toHaveBeenCalledTimes(1);
    expect(tenantGetHeldMessagesMock).toHaveBeenCalledTimes(1);
    expect(tenantGetThreatEventsMock).toHaveBeenCalledTimes(1);
    // Global client's mocked methods must not have been touched.
    expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
  });

  it('runs the fan-out with an injected tenant client even when isMimecastConfigured() is false', async () => {
    isMimecastConfiguredMock.mockReturnValue(false);
    const tenantSearchDeliveredMessagesMock = vi.fn().mockResolvedValue({ messages: [] });
    const tenantGetHeldMessagesMock = vi.fn().mockResolvedValue({ messages: [], totalCount: 0 });
    const tenantGetThreatEventsMock = vi.fn().mockResolvedValue({ items: [], nextCursor: null });
    const fakeTenantClient = {
      getMessageInfo: vi.fn().mockResolvedValue(null),
      searchDeliveredMessages: tenantSearchDeliveredMessagesMock,
      getHeldMessages: tenantGetHeldMessagesMock,
      getThreatEvents: tenantGetThreatEventsMock,
    };

    const result = await getBlastRadius(BASE_INPUT, { client: fakeTenantClient as any });

    expect(result.status).toBe('ok');
    expect(getMimecastClientMock).not.toHaveBeenCalled();
    expect(tenantSearchDeliveredMessagesMock).toHaveBeenCalledTimes(1);
  });

  it('degrades to unavailable/lookup_failed when searchDeliveredMessages swallows an error internally (Bug 1 defense-in-depth)', async () => {
    searchDeliveredMessagesMock.mockResolvedValue({
      messages: [],
      error: 'err_track_and_trace_invalid_end_date',
    });

    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual({
      status: 'unavailable',
      reason: 'lookup_failed',
      error: 'err_track_and_trace_invalid_end_date',
    });
    expect(setCachedDataMock).not.toHaveBeenCalled();
  });

  it('calls getHeldMessages with the SAME start/end window passed to searchDeliveredMessages', async () => {
    await getBlastRadius(BASE_INPUT);

    const expectedStart = BASE_INPUT.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000');
    const expectedEnd = BASE_INPUT.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000');

    expect(searchDeliveredMessagesMock).toHaveBeenCalledWith(
      expect.objectContaining({ start: expectedStart, end: expectedEnd })
    );
    const deliveredArg = searchDeliveredMessagesMock.mock.calls[0][0];
    expect(deliveredArg).not.toHaveProperty('to');

    expect(getHeldMessagesMock).toHaveBeenCalledWith({
      start: expectedStart,
      end: expectedEnd,
    });
  });

  it('returns every distinct recipient from a multi-recipient delivered result (true blast radius)', async () => {
    searchDeliveredMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'd1',
          status: 'Delivered',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: BASE_INPUT.recipient,
          toDisplay: 'Reporter',
          received: '2026-07-14T12:00:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
        {
          id: 'd2',
          status: 'Delivered',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: 'coworker-a@wulfconsulting.test',
          toDisplay: 'Coworker A',
          received: '2026-07-14T12:05:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
        {
          id: 'd3',
          status: 'Delivered',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: 'coworker-b@wulfconsulting.test',
          toDisplay: 'Coworker B',
          received: '2026-07-14T12:10:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
      ],
    });

    const result = await getBlastRadius(BASE_INPUT);

    expect(result.status).toBe('ok');
    if (result.status === 'ok') {
      expect(result.delivered).toBe(3);
      expect(result.matched).toBe(3);
      expect(result.perRecipient).toEqual(
        expect.arrayContaining([
          { recipient: BASE_INPUT.recipient, status: 'delivered' },
          { recipient: 'coworker-a@wulfconsulting.test', status: 'delivered' },
          { recipient: 'coworker-b@wulfconsulting.test', status: 'delivered' },
        ])
      );
    }
  });

  it('excludes an in-window held row from an UNRELATED sender domain from held/matched and perRecipient', async () => {
    searchDeliveredMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'd1',
          status: 'Delivered',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: BASE_INPUT.recipient,
          toDisplay: 'Reporter',
          received: '2026-07-14T12:00:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
      ],
    });
    getHeldMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'h1',
          subject: 'Weekly promo',
          from: 'promo@paulfredrick.test', // unrelated domain vs. evil.test
          fromDisplay: 'Paul Fredrick',
          to: BASE_INPUT.recipient,
          toDisplay: 'Reporter',
          dateReceived: '2026-07-14T13:00:00+0000',
          reason: 'spam',
          reasonCode: 'SPAM',
          policyInfo: '',
          route: 'inbound',
          hasAttachments: false,
          size: 100,
        },
      ],
      totalCount: 1,
    });

    const result = await getBlastRadius(BASE_INPUT);

    expect(result).toEqual({
      status: 'ok',
      matched: 1,
      delivered: 1,
      held: 0,
      rejected: 0,
      clicked: 0,
      perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' }],
      source: 'fan-out',
    });
  });

  it('still counts and overrides when a held row sender domain MATCHES input.sender', async () => {
    searchDeliveredMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'd1',
          status: 'Delivered',
          subject: BASE_INPUT.subject,
          from: BASE_INPUT.sender,
          fromEnv: BASE_INPUT.sender,
          to: BASE_INPUT.recipient,
          toDisplay: 'Reporter',
          received: '2026-07-14T12:00:00+0000',
          senderIP: '1.2.3.4',
          spamScore: 0,
          detectionLevel: 'none',
          attachments: false,
          route: 'inbound',
          info: '',
        },
      ],
    });
    getHeldMessagesMock.mockResolvedValue({
      messages: [
        {
          id: 'h1',
          subject: BASE_INPUT.subject,
          from: 'queue@evil.test', // same domain as BASE_INPUT.sender (evil.test)
          fromDisplay: 'Attacker Queue',
          to: BASE_INPUT.recipient,
          toDisplay: 'Reporter',
          dateReceived: '2026-07-14T13:00:00+0000',
          reason: 'spam',
          reasonCode: 'SPAM',
          policyInfo: '',
          route: 'inbound',
          hasAttachments: false,
          size: 100,
        },
      ],
      totalCount: 1,
    });

    const result = await getBlastRadius(BASE_INPUT);

    expect(result.status).toBe('ok');
    if (result.status === 'ok') {
      expect(result.held).toBe(1);
      expect(result.delivered).toBe(1);
      expect(result.matched).toBe(2); // 1 non-rejected delivered + 1 held (counts are independent of the perRecipient override)
      expect(result.perRecipient).toEqual([{ recipient: BASE_INPUT.recipient, status: 'held' }]);
    }
  });
});
