/**
 * Mimecast blast-radius lookup abstraction (Phase 17).
 *
 * Given a reported message's identity (Message-ID, sender, recipient,
 * subject, date window), returns normalized delivery data — matched/
 * delivered/held/rejected/clicked counts and per-recipient status — when
 * Mimecast is configured, or a clean `status: 'unavailable'` signal (never a
 * throw) when it isn't or when the underlying lookup fails unexpectedly.
 *
 * Three load-bearing facts:
 *
 * (a) EPHEMERAL (D-03): this lookup is a pure function call — nothing is
 *     persisted here. If Phase 19's classifier wants to keep a result, it
 *     writes to its own `classifications.reasons` JSONB column; that is
 *     Phase 19's concern, not this module's.
 *
 * (b) `clicked` IS BEST-EFFORT (D-02): it is derived from getThreatEvents()'s
 *     analysis[] subtype containing a click-like value. Mimecast's real
 *     click-tracking data (TTP URL Protect logs) lives in a separate,
 *     currently-unwrapped `/api/ttp/url/get-logs` endpoint (see
 *     17-RESEARCH.md Pitfall 2) — this module does not call it. A value of
 *     `clicked: 0` means "no click-type threat event found in the events
 *     this module can see," NOT "confirmed zero clicks."
 *
 * (c) PER-TENANT RESOLUTION (D-05, formerly a known gap): this module now
 *     accepts an optional `options.client` — a pre-built MimecastClient for a
 *     specific company's `mimecast_tenants` row (via
 *     getMimecastClientForTenant()). Resolution of WHICH tenant to use is the
 *     caller's responsibility (the campaign detail route looks up
 *     reports.company_id -> mimecast_tenants); this module simply uses
 *     whatever client it is given, or falls back to the single global
 *     env-var-configured getMimecastClient() when no client is injected.
 */

import {
  isMimecastConfigured,
  getMimecastClient,
  type MimecastClient,
  type MimecastDeliveredMessage,
  type MimecastHeldMessage,
} from './mimecast-client';
import { getCachedData, setCachedData } from './redis-client';

export interface BlastRadiusInput {
  /** Optional Mimecast Message-ID — used only for supplementary body/header
   * evidence via getMessageInfo(); it never gates or replaces the fan-out. */
  messageId?: string;
  /** Reported message's sender address. Required — see T-17-01: never allow
   * a date-range-only fan-out query. */
  sender: string;
  /** Reporter/reported-to recipient address. Required — see T-17-01. */
  recipient: string;
  /** Reported message's subject. Required — see T-17-01. */
  subject: string;
  /** Date window to search within. Required — see T-17-01. */
  dateWindow: { start: Date; end: Date };
}

export type BlastRadiusResult =
  | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }
  | {
      status: 'ok';
      matched: number;
      delivered: number;
      held: number;
      rejected: number;
      clicked: number;
      perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>;
      source: 'fan-out';
    };

/**
 * Best-effort delivered/rejected split. The exact string values Mimecast
 * returns in MimecastDeliveredMessage.status have NOT been confirmed against
 * a live/sandbox tenant (17-RESEARCH.md A3 / Open Question #2) — only the
 * TypeScript field name and type (`string`) are confirmed from the client
 * code. Treat unrecognized status strings conservatively as NOT rejected
 * (i.e. delivered) rather than hardcoding a guessed enum as if it were
 * confirmed. Log raw values at debug level so a first real-tenant run can
 * validate/refine this heuristic.
 */
function isRejectedStatus(status: string): boolean {
  return /reject/i.test(status);
}

/** Best-effort click-type detection (D-02) — see module doc-comment (b). */
function isClickEvent(analysis: string[] | undefined): boolean {
  return (analysis ?? []).some((a) => /click/i.test(a));
}

/**
 * Sender-relevance guard: true when the domain of `a` exactly matches the
 * domain of `b`, or one is a proper subdomain of the other — case-insensitive,
 * never a bare substring match. Mirrors the comparison logic in
 * campaign-classifier's `domainMatchesAllowlist` (but does not reuse it — that
 * helper is allowlist-specific).
 */
function domainsMatch(a: string, b: string): boolean {
  const domainOf = (address: string): string => {
    const at = address.lastIndexOf('@');
    if (at === -1 || at === address.length - 1) return '';
    return address.slice(at + 1).toLowerCase();
  };
  const x = domainOf(a);
  const y = domainOf(b);
  if (!x || !y) return false;
  return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
}

export async function getBlastRadius(
  input: BlastRadiusInput,
  options?: { client?: MimecastClient; cacheScope?: string }
): Promise<BlastRadiusResult> {
  // An injected tenant client is self-sufficient (it carries its own
  // credentials) — only fall back to the global env-configured client (and
  // its isMimecastConfigured() gate) when no client was injected.
  const client = options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null);
  if (!client) {
    return { status: 'unavailable', reason: 'not_configured' };
  }

  const scope = options?.cacheScope ?? 'global';
  const cacheKey = input.messageId
    ? `mimecast:blast-radius:${scope}:msgid:${input.messageId}`
    : `mimecast:blast-radius:${scope}:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`;

  const cached = await getCachedData<BlastRadiusResult>(cacheKey);
  if (cached) return cached;

  try {
    // Supplementary only — body/header evidence, never gates the fan-out
    // (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts).
    if (input.messageId) {
      await client.getMessageInfo(input.messageId);
    }

    const startStr = input.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000');
    const endStr = input.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000');

    // D-01 (corrected): the fan-out runs unconditionally — it is the only
    // source of the matched/delivered/held/rejected/clicked counts.
    //
    // Tenant-wide fan-out (T-17-01): deliberately NOT scoped by a single
    // recipient. searchDeliveredMessages is queried by sender+subject+
    // date-window only, so it returns every delivered/rejected message
    // matching this campaign across the whole tenant — the true blast
    // radius, not just the reporter's mailbox. getHeldMessages is queried
    // by date-window only (it has no server-side sender filter) and relies
    // entirely on the domainsMatch() post-filter below to stay scoped to
    // this campaign's sender.
    const [deliveredResult, heldResult, threatsResult] = await Promise.all([
      client.searchDeliveredMessages({
        from: input.sender,
        subject: input.subject,
        start: startStr,
        end: endStr,
      }),
      client.getHeldMessages({ start: startStr, end: endStr }),
      client.getThreatEvents(),
    ]);

    // Bug 1 defense-in-depth: searchDeliveredMessages() swallows its own
    // errors internally (returns { messages: [], error } rather than
    // throwing) — e.g. a future end-date rejected by Mimecast as
    // err_track_and_trace_invalid_end_date. Without this check, that
    // swallowed error would silently present as a confident zero-count 'ok'.
    // Surface it via the existing outer catch instead.
    if (deliveredResult.error) {
      throw new Error(deliveredResult.error);
    }

    const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? [];
    const heldRows: MimecastHeldMessage[] = heldResult.messages ?? [];
    // Even within the date window, unrelated held messages (different
    // senders that happen to land in the same window) must not count toward
    // held/matched or override a recipient's delivered status. Filter to
    // rows whose sender domain plausibly matches the campaign's sender.
    const relevantHeldRows = heldRows.filter((h) => domainsMatch(h.from, input.sender));
    if (heldRows.length !== relevantHeldRows.length) {
      console.debug(
        '[MIMECAST-BLAST-RADIUS] held rows filtered as unrelated sender:',
        heldRows.length - relevantHeldRows.length
      );
    }

    const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status)));
    if (rawStatuses.length > 0) {
      console.debug('[MIMECAST-BLAST-RADIUS] raw delivered-message status values seen:', rawStatuses);
    }

    const rejectedRows = deliveredRows.filter((m) => isRejectedStatus(m.status));
    const nonRejectedDeliveredRows = deliveredRows.filter((m) => !isRejectedStatus(m.status));

    const delivered = nonRejectedDeliveredRows.length;
    const rejected = rejectedRows.length;
    const held = relevantHeldRows.length;
    // matched = delivered + held; a rejected recipient was never actually
    // delivered, so rejected is NOT included in matched.
    const matched = delivered + held;

    const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length;

    // Per-recipient merge: group by the single-string `to` field. Since the
    // fan-out above is tenant-wide (not scoped to one recipient), this Map
    // naturally accumulates one entry per distinct `to` value seen across all
    // delivered/held/rejected rows — the true multi-recipient blast radius,
    // not just the reporter. Delivered (non-rejected) rows → 'delivered',
    // rejected rows → 'rejected', held rows → 'held' (held overwrites a
    // same-recipient delivered entry — a held message for a recipient is the
    // more actionable signal). Ensure the originally-reported recipient is
    // always represented, even if neither result set returned a row for them
    // ('unknown').
    const perRecipientMap = new Map<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
    for (const row of nonRejectedDeliveredRows) {
      if (row.to) perRecipientMap.set(row.to, 'delivered');
    }
    for (const row of rejectedRows) {
      if (row.to) perRecipientMap.set(row.to, 'rejected');
    }
    for (const row of relevantHeldRows) {
      if (row.to) perRecipientMap.set(row.to, 'held');
    }
    if (!perRecipientMap.has(input.recipient)) {
      perRecipientMap.set(input.recipient, 'unknown');
    }

    const perRecipient = Array.from(perRecipientMap.entries()).map(([recipient, status]) => ({
      recipient,
      status,
    }));

    const result: BlastRadiusResult = {
      status: 'ok',
      matched,
      delivered,
      held,
      rejected,
      clicked,
      perRecipient,
      source: 'fan-out',
    };

    await setCachedData(cacheKey, result, 300);
    return result;
  } catch (err) {
    // T-17-02: log err.message ONLY, never full Mimecast response bodies
    // (which may contain other recipients' subjects/content).
    const message = err instanceof Error ? err.message : String(err);
    console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message);
    return { status: 'unavailable', reason: 'lookup_failed', error: message };
  }
}
