/**
 * Phase 6 — IMAP bounce poller.
 *
 * Polls the configured IMAP inbox for delivery-status notifications, runs
 * each through `parseBounce()`, and matches the original recipient against
 * a recent `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).
 *
 * The job runs globally (no per-port context). IMAP creds are read from
 * environment variables (`IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` /
 * `IMAP_PASS`) — when any 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).
 *
 * 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.
 */

import { and, desc, eq, gte, 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;

  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.originalRecipient || parsed.bounceClass === 'unknown') {
            skippedNonBounce++;
            continue;
          }

          const lookback = new Date(Date.now() - SEND_MATCH_WINDOW_DAYS * 86_400_000);
          // Most-recent matching send to this recipient; the recipient
          // may have been sent multiple files in the same window — the
          // bounce always refers to the latest.
          const candidates = await db
            .select()
            .from(documentSends)
            .where(
              and(
                eq(documentSends.recipientEmail, parsed.originalRecipient),
                gte(documentSends.sentAt, lookback),
              ),
            )
            .orderBy(desc(documentSends.sentAt))
            .limit(1);
          const 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',
              description: `Your email to ${parsed.originalRecipient} 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, 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.
    }
  }
}
