/**
 * Phase 6 - IMAP bounce poller.
 *
 * Polls the configured IMAP inbox for delivery-status notifications, runs
 * each through `parseBounce()`, and matches the bounce back to the exact
 * originating `document_sends` row. When matched, updates the send row's
 * bounce_* columns and fires an `email_bounced` notification to the rep
 * who originated the send (hard/soft only - out-of-office is logged but
 * not surfaced as an actionable alert).
 *
 * ── Matching strategy (audit M8) ─────────────────────────────────────────
 * The job runs globally against a SINGLE env-configured IMAP inbox shared
 * by every port (`IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS`) -
 * there is no per-port IMAP config today. A naive "most recent send to this
 * recipient address within N days" match is cross-tenant unsafe: if two
 * ports both emailed `victim@x.com`, the bounce would be pinned to whichever
 * sent most recently, mutating the wrong port's row and leaking the bounce
 * reason into the wrong tenant's notification feed. A forged NDR (the body
 * is attacker-controllable) could likewise mark an arbitrary cross-port send
 * bounced.
 *
 * To avoid that we match in two tiers:
 *   1. **By Message-ID (precise, port-unambiguous).** Every outbound send
 *      stores the SMTP `Message-ID` on `document_sends.message_id`. A
 *      conformant NDR echoes that id back via `In-Reply-To` / `References`
 *      or in its returned-headers block; `parseBounce` surfaces them as
 *      `originalMessageIds`. An exact id match identifies one specific send
 *      row (and therefore one specific port) with no guessing.
 *   2. **By recipient + time window (fallback, single-port only).** Used
 *      only when no Message-ID could be extracted (older/odd NDR shapes).
 *      If the recipient+window query returns sends spanning MORE THAN ONE
 *      port, we DO NOT guess - the message is skipped/flagged rather than
 *      mis-attributed. In the common single-tenant deployment every match is
 *      the same port, so this fallback still works there.
 *
 * LIMITATION: with one global inbox and a recipient-only fallback we cannot
 * attribute a bounce that (a) carries no usable Message-ID AND (b) matches
 * sends from more than one port. Those are logged (`skippedAmbiguous`) and
 * left untouched. Wiring per-port IMAP (a `getSalesImapConfig(portId)` +
 * `eq(documentSends.portId, portId)` scope) would remove the limitation
 * entirely; tracked alongside audit M8.
 *
 * The `originalRecipient` value is parsed from the untrusted inbound body
 * and is already validated against the RFC5322 regex inside `parseBounce`,
 * so by the time it reaches the query/notification here it is a
 * syntactically valid address (audit L16a).
 *
 * State (last-run timestamp) is persisted to `system_settings` under
 * `bounce_poller_state` with `port_id = NULL`, so concurrent worker
 * instances see the same checkpoint. On first run the lookback is 24 h.
 * When any IMAP_* env var is missing the poll is a no-op so the worker
 * boots happily in dev. Run cadence is set in `src/lib/queue/scheduler.ts`
 * (every 15 minutes).
 */

import { and, desc, eq, gte, inArray, isNull } from 'drizzle-orm';

import { db } from '@/lib/db';
import { documentSends } from '@/lib/db/schema/brochures';
import { systemSettings } from '@/lib/db/schema/system';
import { logger } from '@/lib/logger';
import { parseBounce } from '@/lib/email/bounce-parser';
import { createNotification } from '@/lib/services/notifications.service';

const STATE_KEY = 'bounce_poller_state';
const FIRST_RUN_LOOKBACK_HOURS = 24;
/** How far back to look for the originating document_sends row. Any send
 *  whose bounce arrives after this window won't be matched - the SMTP
 *  protocol guarantees NDRs typically arrive within minutes / hours, so
 *  7 days is generous. */
const SEND_MATCH_WINDOW_DAYS = 7;

interface PollerState {
  lastRunAt: string;
}

async function loadPollerState(): Promise<PollerState | null> {
  const row = await db.query.systemSettings.findFirst({
    where: and(eq(systemSettings.key, STATE_KEY), isNull(systemSettings.portId)),
  });
  if (!row) return null;
  const value = row.value as PollerState | null;
  return value && typeof value === 'object' && 'lastRunAt' in value ? value : null;
}

async function savePollerState(state: PollerState): Promise<void> {
  await db
    .insert(systemSettings)
    .values({
      key: STATE_KEY,
      value: state as unknown as Record<string, unknown>,
      portId: null,
      updatedBy: 'system',
    })
    .onConflictDoUpdate({
      target: [systemSettings.key, systemSettings.portId],
      set: {
        value: state as unknown as Record<string, unknown>,
        updatedBy: 'system',
        updatedAt: new Date(),
      },
    });
}

export async function processImapBouncePoll(): Promise<void> {
  const host = process.env.IMAP_HOST;
  const portStr = process.env.IMAP_PORT;
  const user = process.env.IMAP_USER;
  // Strip whitespace from the password defensively. Google Workspace App
  // Passwords are 16 characters formatted as 4 groups of 4 separated by
  // spaces (`abcd efgh ijkl mnop`); pasted as-is, Gmail's IMAP server
  // rejects them with an auth error in some configurations. Per
  // https://support.google.com/mail/answer/185833, the password must be
  // entered as the unbroken 16-character string. Stripping here means a
  // copy-paste with the visual spaces preserved still works.
  const pass = process.env.IMAP_PASS?.replace(/\s+/g, '');
  if (!host || !portStr || !user || !pass) {
    logger.debug('IMAP bounce poll skipped - IMAP_* env not configured');
    return;
  }
  const port = Number.parseInt(portStr, 10);
  if (!Number.isFinite(port)) {
    logger.warn({ portStr }, 'IMAP bounce poll skipped - IMAP_PORT not numeric');
    return;
  }

  const state = await loadPollerState();
  const since = state?.lastRunAt
    ? new Date(state.lastRunAt)
    : new Date(Date.now() - FIRST_RUN_LOOKBACK_HOURS * 60 * 60 * 1000);
  // Capture run start BEFORE network calls so the next poll's `since`
  // covers anything that arrived while we were processing.
  const runStartedAt = new Date();

  const imapflowModule = await import('imapflow');
  const ImapFlow = imapflowModule.ImapFlow;

  const client = new ImapFlow({
    host,
    port,
    secure: port === 993,
    auth: { user, pass },
    logger: false,
    // Mirror email-threads.service.ts: bound any single network step so a
    // hung server can't stall the maintenance worker.
    socketTimeout: 60_000,
    greetingTimeout: 30_000,
    connectionTimeout: 30_000,
  });

  let scanned = 0;
  let matched = 0;
  let skippedNoMatch = 0;
  let skippedNonBounce = 0;
  let skippedAmbiguous = 0;

  try {
    await client.connect();
    await client.mailboxOpen('INBOX');

    const searchResult = await client.search({ since });
    const uids: number[] = searchResult === false ? [] : searchResult;

    if (uids.length === 0) {
      logger.debug({ since: since.toISOString() }, 'IMAP bounce poll: nothing new');
    } else {
      for await (const message of client.fetch(uids, { source: true })) {
        scanned++;
        try {
          if (!message.source) continue;
          const parsed = await parseBounce(message.source);
          if (parsed.bounceClass === 'unknown') {
            skippedNonBounce++;
            continue;
          }

          const lookback = new Date(Date.now() - SEND_MATCH_WINDOW_DAYS * 86_400_000);

          // ── Tier 1: exact Message-ID match (port-unambiguous). ──────────
          // The NDR echoed back one or more original Message-IDs; each maps
          // to at most one send row, so a hit identifies the exact
          // originating send (and its port) with zero guessing. This works
          // even cross-tenant on the shared inbox.
          let target: typeof documentSends.$inferSelect | undefined;
          if (parsed.originalMessageIds.length > 0) {
            const byMessageId = await db
              .select()
              .from(documentSends)
              .where(inArray(documentSends.messageId, parsed.originalMessageIds))
              .orderBy(desc(documentSends.sentAt))
              .limit(2);
            if (byMessageId.length > 0) {
              // Message-IDs are unique per send; >1 hit would mean a stored
              // collision - still safe to take the most recent, but log it.
              if (byMessageId.length > 1) {
                logger.warn(
                  { messageIds: parsed.originalMessageIds, uid: message.uid },
                  'IMAP bounce: multiple sends share a Message-ID; using most recent',
                );
              }
              target = byMessageId[0];
            }
          }

          // ── Tier 2: recipient + window fallback (single-port only). ─────
          // Only when no Message-ID could be matched. We MUST NOT guess
          // across tenants on the shared inbox, so we refuse to attribute
          // when candidate sends span more than one port (audit M8).
          if (!target) {
            if (!parsed.originalRecipient) {
              skippedNoMatch++;
              continue;
            }
            // Pull a small set (not just the latest) so we can detect a
            // cross-port collision before committing to any one row.
            const candidates = await db
              .select()
              .from(documentSends)
              .where(
                and(
                  eq(documentSends.recipientEmail, parsed.originalRecipient),
                  gte(documentSends.sentAt, lookback),
                ),
              )
              .orderBy(desc(documentSends.sentAt))
              .limit(10);
            if (candidates.length === 0) {
              skippedNoMatch++;
              continue;
            }
            const distinctPorts = new Set(candidates.map((c) => c.portId));
            if (distinctPorts.size > 1) {
              // Ambiguous: this recipient was emailed by multiple ports in
              // the window and we have no Message-ID to disambiguate. Skip
              // rather than mis-attribute to the wrong tenant.
              skippedAmbiguous++;
              logger.warn(
                {
                  recipient: parsed.originalRecipient,
                  ports: [...distinctPorts],
                  uid: message.uid,
                },
                'IMAP bounce: recipient matched sends across multiple ports with no Message-ID; skipping to avoid cross-tenant misattribution',
              );
              continue;
            }
            // Single port: the most-recent send is the bounce target.
            target = candidates[0];
          }

          if (!target) {
            skippedNoMatch++;
            continue;
          }

          // Idempotency: a NDR can re-deliver to our mailbox if SMTP
          // retries; only update + notify once per send row.
          if (target.bounceDetectedAt) continue;

          await db
            .update(documentSends)
            .set({
              bounceStatus: parsed.bounceClass,
              bounceReason: parsed.reason,
              bounceDetectedAt: new Date(),
            })
            .where(eq(documentSends.id, target.id));
          matched++;

          // Skip OOO - informational, not actionable. Hard/soft notify
          // the original sender so they can re-send or escalate.
          if (
            target.sentByUserId &&
            (parsed.bounceClass === 'hard' || parsed.bounceClass === 'soft')
          ) {
            await createNotification({
              portId: target.portId,
              userId: target.sentByUserId,
              type: 'email_bounced',
              title: 'Email bounced',
              // Use the trusted stored recipient from the matched send row,
              // not the parsed-from-untrusted-body value (which can be null
              // on a Message-ID-only match and is attacker-influenced).
              description: `Your email to ${target.recipientEmail} bounced - ${parsed.reason}`,
              link: target.interestId ? `/interests/${target.interestId}` : undefined,
              entityType: 'document_send',
              entityId: target.id,
              dedupeKey: `bounce:${target.id}`,
            });
          }
        } catch (err) {
          logger.warn({ err, uid: message.uid }, 'IMAP bounce: failed to process message');
        }
      }
    }

    await savePollerState({ lastRunAt: runStartedAt.toISOString() });
    logger.info(
      {
        scanned,
        matched,
        skippedNoMatch,
        skippedNonBounce,
        skippedAmbiguous,
        sinceISO: since.toISOString(),
      },
      'IMAP bounce poll complete',
    );
  } finally {
    try {
      await client.logout();
    } catch {
      // Logout failures are non-fatal - the connection will be torn down
      // by the timeout settings above.
    }
  }
}
