/**
 * MailForge Worker — pollt email_queue und schickt Mails via M365 Graph.
 *
 * Run: npm run worker (dev, hot-reload) oder npm run worker:prod
 *
 * Throttling-Limits (M365):
 *   - 30 Recipients pro /sendMail-Call (M365 hard limit)
 *   - 10.000 Mails/Tag pro Mailbox (wir nehmen 9.500 als Safety-Margin)
 *   - Ca. 30 Mails/Min pro Mailbox (wir nehmen 20/Min)
 *
 * Retry: bei sendMail-Fehlern 3 Versuche mit Backoff (5min/15min/60min), dann 'failed'.
 */

// Umgebung: `npm run worker` nutzt `dotenv -c` → lädt `.env.local` (falls vorhanden) und `.env`.
import {
  pickPendingJobs,
  getJobContext,
  markJobSent,
  markJobFailed,
  incrementJobAttempts,
  finalizeCampaignIfDone,
  incrementVariantSent,
  recordDelivered,
  findCampaignsReadyForPromotion,
  promoteChampion,
  type PickedJob,
} from '@/lib/db/campaign-repo';
import { getWorkspaceById } from '@/lib/db/repositories';
import { adapters } from '@root/adapters.config';
import { applyUtmToHtml, applyUtmToText, hasAnyUtm } from '@/modules/sending/lib/utm';
import { applyTracking } from '@/modules/sending/lib/tracking';
import { runConversionPolling, getConversionPollingSettings } from '@/modules/stats/lib/cumas-poller';
import { runBounceScanning, getBouncePollingSettings } from '@/modules/sending/lib/inbox-scanner';
import { substituteTagsHtml, substituteTagsText } from '@/modules/sending/lib/tag-substitution';
import { buildFooterUrlTags } from '@/modules/sending/lib/footer-urls';

const POLL_INTERVAL_MS = 5_000;
const BATCH_SIZE = 10;
const MIN_GAP_MS = 3_000;         // ~20/Min
const MAX_RETRIES = 3;
const DAILY_BUDGET = 9_500;

let dailyCount = 0;
let dailyResetAt = nextMidnightUtc();

function nextMidnightUtc(): number {
  const d = new Date();
  d.setUTCHours(24, 0, 0, 0);
  return d.getTime();
}

function log(level: 'info' | 'warn' | 'error', msg: string) {
  const ts = new Date().toISOString();
  const line = `[${ts}] [worker] ${level.toUpperCase()} ${msg}`;
  if (level === 'error') console.error(line);
  else if (level === 'warn') console.warn(line);
  else console.log(line);
}

function sleep(ms: number) { return new Promise<void>(r => setTimeout(r, ms)); }

// Tag-Substitution + Escape-Helper sind in src/lib/email/tag-substitution.ts ausgelagert,
// damit Worker und Standalone-Preview denselben Code teilen.

async function processOne(job: PickedJob): Promise<void> {
  if (Date.now() >= dailyResetAt) {
    log('info', 'Daily quota reset');
    dailyCount = 0;
    dailyResetAt = nextMidnightUtc();
  }
  if (dailyCount >= DAILY_BUDGET) {
    log('warn', `Daily budget ${DAILY_BUDGET} reached — postponing job ${job.jobId}`);
    markJobFailed(job.jobId, 'Daily budget reached', true);
    return;
  }

  // Variant-aware Lookup: zieht den richtigen HTML-Snapshot pro Job (A/B-Test)
  const ctx = getJobContext(job.campaignId, job.variantId);
  if (!ctx) {
    log('error', `Campaign ${job.campaignId} (variant ${job.variantId ?? 'none'}) not found`);
    markJobFailed(job.jobId, 'Campaign/Variant data missing', false);
    return;
  }

  const workspace = getWorkspaceById(ctx.workspaceId);
  const hasExplicitSender = !!(ctx.senderEmail && ctx.senderName);
  if (!hasExplicitSender && !workspace?.companyId) {
    markJobFailed(job.jobId, `Workspace has no companyId AND no explicit sender on campaign (workspaceId=${ctx.workspaceId})`, false);
    return;
  }

  // 1. Personalisierung — Reihenfolge: System-Tags → CRM-Spalten → FIRST_NAME (Backwards-Compat).
  //
  // Whitespace-Toleranz: alle Tag-Regexe akzeptieren `{{ KEY }}` mit/ohne Spaces — sonst
  // bricht der intuitive User-Input `{{ id }}` (siehe Plan-Phase-1).
  //
  // URL-Encoding-Hinweis: für reine UUIDs / numerische IDs / alphanumerische CRM-Codes ist
  // escapeHtml ausreichend (kein '&', '+', '%' im Wert). Falls je eine CRM-ID Sonderzeichen
  // enthält, werden Click-gewrappte Links trotzdem korrekt gemacht — der Click-Wrapper baut
  // den `to=`-Param via URLSearchParams, der den Wert erneut URL-encoded. Sonderzeichen-Risiko
  // bleibt nur bei mailto:/tel: ohne Click-Wrap, was untypisch ist.
  const firstName = job.recipientFirstName?.trim() || 'gemeinsam';

  // Login-Direktlink: routed through the active CrmAdapter's optional
  // `resolveLoginLink()` capability. Adapters that don't have a magic-link
  // concept (e.g. HubSpot, generic-REST) simply don't implement it, and the
  // worker substitutes an empty string — which drops the `{{LOGIN_LINK}}`
  // anchor via tag-substitution's "empty href guard".
  const loginLink =
    adapters.crm.resolveLoginLink && workspace
      ? adapters.crm.resolveLoginLink({
          workspaceShopDomain: workspace.shopDomain,
          recipientExternalId: job.recipientCrmId,
          recipientPoolType: job.recipientPoolType,
          recipientId: job.recipientId,
        })
      : '';

  // Footer-System-URLs (Browser-View, Unsubscribe, Impressum, Datenschutz).
  // Diese vier Tags stehen im Render-Template als Platzhalter und werden EXKLUSIV
  // hier zur Send-Zeit aufgelöst — sie sind empfänger- und campaign-spezifisch
  // (Unsub-Token hängt am Recipient, Browser-URL trägt campaignId/variantId).
  // Workspace-Imprint/Privacy sind in der workspaces-Tabelle NOT NULL → kein Leer-Fallback nötig.
  const footerTags = workspace
    ? buildFooterUrlTags({
        campaignId: job.campaignId,
        variantId: job.variantId,
        recipientId: job.recipientId,
        brandImprintUrl: workspace.imprintUrl,
        brandPrivacyUrl: workspace.privacyUrl,
      })
    : { BROWSER_URL: '', UNSUBSCRIBE_URL: '', IMPRINT_URL: '', PRIVACY_URL: '' };

  const SYSTEM_TAGS: Record<string, string> = {
    RECIPIENT_ID: job.recipientId,
    CRM_ID: job.recipientCrmId ?? '',
    EMAIL: job.recipientEmail,
    FIRST_NAME: firstName,
    // CTA-URL-Defense-in-Depth: Pipeline + Render lösen die Tokens normalerweise zur
    // Build-Zeit auf, aber bei Backups/Re-Sends/Pre-Defense-Layer-Campaigns kann ein
    // altes HTML mit unaufgelösten {{CTA_PRIMARY_URL}} durchschlüpfen. Worker substituiert
    // dann mit den im Briefing gespeicherten URLs (siehe getJobContext). Leer → "#".
    CTA_PRIMARY_URL: ctx.ctaPrimaryUrl ?? '#',
    CTA_SECONDARY_URL: ctx.ctaSecondaryUrl ?? '#',
    LOGIN_LINK: loginLink,
    ...footerTags,
  };

  let html = ctx.emailHtml;
  let text = ctx.emailText;

  // Diagnostik: wenn Body {{LOGIN_LINK}} enthält UND wir keinen Link bauen konnten,
  // ist das fast immer ein Setup-Problem (Workspace ohne shopDomain) oder ein
  // Recipient-Sync-Problem (kein crmId aus dem CRM). Job läuft trotzdem durch —
  // wir wollen nicht wegen einer fehlenden ID die ganze Campaign blocken.
  if (!loginLink && /\{\{\s*LOGIN_LINK\s*\}\}/.test(html)) {
    const reason = !workspace?.shopDomain
      ? `workspace "${workspace?.slug ?? ctx.workspaceId}" hat kein shopDomain gesetzt`
      : `recipient ${job.recipientId} hat kein crmId`;
    log('warn', `LOGIN_LINK leer für recipient ${job.recipientEmail}: ${reason}`);
  }

  // 1a. System-Tags zuerst — sind UPPER_SNAKE und kollidieren nicht mit CRM-Spaltennamen
  html = substituteTagsHtml(html, SYSTEM_TAGS);
  text = substituteTagsText(text, SYSTEM_TAGS);

  // 1b. Generische Merge-Tag-Substitution aus customDataJson (alle CRM-Spalten der Datenquelle).
  // System-Platzhalter wie {{HERO_URL}}, {{CTA_PRIMARY_URL}}, {{UNSUBSCRIBE_URL}} sind UPPER_SNAKE
  // und kommen vom CRM nie — also keine Konflikt-Gefahr. Null/undefined → Leerstring.
  // Reserved-Keys = System-Tags: deren Werte gewinnen, customData darf sie nicht überschreiben.
  if (job.customData) {
    const reserved = new Set(Object.keys(SYSTEM_TAGS));
    html = substituteTagsHtml(html, job.customData, { reservedKeys: reserved });
    text = substituteTagsText(text, job.customData, { reservedKeys: reserved });
  }

  // 2. UTM-Auto-Append zur Send-Zeit. utm_content wird per A/B-Variante überschrieben:
  //    - Single-Variant Send → utm_content bleibt wie vom User gesetzt (oder leer)
  //    - Multi-Variant Send → utm_content = base ? `${base}-${label}` : label (z.B. 'A' oder 'hero-A')
  if (ctx.utmDefaults && hasAnyUtm(ctx.utmDefaults)) {
    const baseContent = ctx.utmDefaults.content;
    const variantLabel = ctx.variantLabel;
    const effectiveContent = variantLabel
      ? (baseContent ? `${baseContent}-${variantLabel}` : variantLabel)
      : baseContent;
    const utmForJob = { ...ctx.utmDefaults, content: effectiveContent };
    html = applyUtmToHtml(html, utmForJob);
    text = applyUtmToText(text, utmForJob);
  }

  // 3. Open/Click-Tracking: Pixel + Click-Wrapper. Reihenfolge: NACH UTM, sodass die `to`-URLs
  // im Click-Wrapper bereits UTM-angereichert sind. Nach 302 landet der User auf der UTM-URL.
  const trackingBaseUrl = process.env.MAILFORGE_BASE_URL ?? 'http://localhost:3000';
  html = applyTracking(html, {
    campaignId: job.campaignId,
    variantId: job.variantId,
    recipientId: job.recipientId,
    baseUrl: trackingBaseUrl,
  });

  // Subject-Personalisierung: bisher zog der Worker `ctx.subject` direkt,
  // weil Marketing-Mails ein vom LLM bereits ausgeschriebenes Subject haben.
  // Trigger-Templates dürfen hier {{KEY}}-Tags enthalten (z.B. "Bestellung {{orderNumber}}"),
  // also substituieren wir mit demselben Helper wie für Plain-Text-Body. Subject ist
  // kein HTML, daher kein escapeHtml — `substituteTagsText` setzt Werte 1:1 ein.
  // Für klassische Marketing-Mails ist das ein No-op (kein Tag → kein Replace).
  let subject = substituteTagsText(ctx.subject, SYSTEM_TAGS);
  if (job.customData) {
    const reservedSubjectKeys = new Set(Object.keys(SYSTEM_TAGS));
    subject = substituteTagsText(subject, job.customData, { reservedKeys: reservedSubjectKeys });
  }

  const attempt = incrementJobAttempts(job.jobId);
  try {
    // Sender-Resolution: explicit ctx.senderEmail wins, else workspace default.
    const fromEmail = hasExplicitSender
      ? ctx.senderEmail!
      : (workspace?.senderEmail ?? '');
    const fromName = hasExplicitSender
      ? ctx.senderName!
      : (workspace?.senderName ?? '');
    if (!fromEmail) {
      throw new Error(
        `No sender resolvable for workspace ${workspace?.slug ?? workspace?.id} — set workspaces.sender_email or pass an explicit sender.`,
      );
    }

    const startSend = Date.now();
    const result = await adapters.send.send({
      workspaceId: workspace?.id ?? '',
      from: { email: fromEmail, name: fromName },
      to: [job.recipientEmail],
      subject,
      bodyHtml: html,
      bodyText: text,
    });
    if (!result.ok) {
      throw new Error(result.error ?? `Send failed (${result.errorKind ?? 'unknown'})`);
    }
    const durationMs = Date.now() - startSend;
    dailyCount += 1;
    const internalMessageId = result.messageId ?? `${fromEmail}-${Date.now()}`;
    markJobSent(job.jobId, internalMessageId);
    if (job.variantId) incrementVariantSent(job.variantId);
    // 'delivered'-Event direkt nach Provider-Accept schreiben, damit Stats
    // eine Delivery-Rate-Metrik haben. Best-effort — wirft nie, Send hat bereits geklappt.
    recordDelivered(job.campaignId, job.recipientId, job.variantId, internalMessageId);
    log('info', `✓ Sent to ${job.recipientEmail} from ${fromEmail} via ${adapters.send.name} (${durationMs}ms, attempt ${attempt})`);
  } catch (e) {
    const err = e instanceof Error ? e.message : String(e);
    const willRetry = attempt < MAX_RETRIES;
    log('error', `✗ Failed ${job.recipientEmail} (attempt ${attempt}/${MAX_RETRIES}): ${err.slice(0, 200)}`);
    markJobFailed(job.jobId, err, willRetry);
  } finally {
    finalizeCampaignIfDone(job.campaignId);
  }
}

// Champion-Promotion: alle 5 Minuten checken ob Champion-Test-Campaigns ready sind.
// Separater Tick weil dies langsamer laufen darf als die Send-Loop (Send: ms-Polling, Promotion: min-Polling).
const CHAMPION_TICK_INTERVAL_MS = 5 * 60 * 1000;
let lastChampionTick = 0;

// Conversion-Polling: rate-limited via app_settings.conversion_polling.intervalMinutes.
// Read-und-vergleichen pro Tick, damit Settings-Änderungen ohne Worker-Restart greifen.
let lastConversionPollTick = 0;

// Bounce-Inbox-Scanner: rate-limited via app_settings.bounce_polling.intervalMinutes.
// Gleiches Pattern wie Conversion-Polling — Settings können live geändert werden.
let lastBouncePollTick = 0;

function runChampionPromotionTick(): void {
  if (Date.now() - lastChampionTick < CHAMPION_TICK_INTERVAL_MS) return;
  lastChampionTick = Date.now();
  try {
    const ready = findCampaignsReadyForPromotion();
    if (ready.length === 0) return;
    log('info', `Champion-Promotion: ${ready.length} campaign(s) ready`);
    for (const campaignId of ready) {
      const result = promoteChampion(campaignId);
      if (result.winnerVariantId) {
        log('info', `✓ Promoted campaign ${campaignId} — winner=${result.winnerVariantId}, ${result.promotedJobCount} jobs released`);
      }
    }
  } catch (e) {
    log('error', `Champion-Promotion error: ${e instanceof Error ? e.message : e}`);
  }
}

/**
 * Conversion-Polling-Tick (Phase 3b).
 *
 * Liest die in app_settings.conversion_polling konfigurierte Frequenz und ruft den
 * Cumas-Poller auf wenn das letzte Tick-Run länger als intervalMinutes zurückliegt.
 *
 * Async fire-and-forget: das Polling-Run läuft sequenziell durch alle Quellen,
 * der Worker-Loop wartet währenddessen NICHT — wir starten den Promise und gehen weiter.
 * Race-Condition mit dem nächsten Tick: lastConversionPollTick wird VOR dem await
 * gesetzt, sodass parallele Ticks nicht gleichzeitig pollen.
 */
function runConversionPollingTick(): void {
  const settings = getConversionPollingSettings();
  if (!settings.enabled) return;
  const intervalMs = settings.intervalMinutes * 60 * 1000;
  if (Date.now() - lastConversionPollTick < intervalMs) return;
  lastConversionPollTick = Date.now();

  // Fire-and-forget — wir wollen den Send-Loop nicht blockieren
  void runConversionPolling()
    .then((results) => {
      for (const r of results) {
        if (r.errors.length > 0) {
          log('warn', `Conversion-Polling [${r.source}]: ${r.errors.length} errors — ${r.errors[0]?.slice(0, 120)}`);
        }
        if (r.conversionsRecorded > 0 || r.scanned > 0) {
          log('info', `Conversion-Polling [${r.source}]: scanned=${r.scanned}, matched=${r.matched}, recorded=${r.conversionsRecorded}`);
        }
      }
    })
    .catch((e) => {
      log('error', `Conversion-Polling fatal: ${e instanceof Error ? e.message : e}`);
    });
}

/**
 * Bounce-Inbox-Scanner-Tick (Phase 3/4).
 *
 * Liest die in app_settings.bounce_polling konfigurierte Frequenz und ruft den
 * Inbox-Scanner auf wenn das letzte Tick-Run länger als intervalMinutes zurückliegt.
 *
 * Async fire-and-forget genau wie Conversion-Polling. Race-Condition wird durch
 * Setzen von lastBouncePollTick VOR dem await verhindert.
 *
 * Disabled-Pfad: getBouncePollingSettings() liefert enabled=false als Default →
 * Tick wird sofort no-op und produziert keine Log-Ausgaben.
 */
function runBounceInboxScannerTick(): void {
  const settings = getBouncePollingSettings();
  if (!settings.enabled) return;
  const intervalMs = settings.intervalMinutes * 60 * 1000;
  if (Date.now() - lastBouncePollTick < intervalMs) return;
  lastBouncePollTick = Date.now();

  void runBounceScanning()
    .then(({ results, skipped }) => {
      if (skipped) return;
      for (const r of results) {
        if (r.errors.length > 0) {
          log('warn', `Bounce-Scanner [${r.mailbox}]: ${r.errors.length} errors — ${r.errors[0]?.slice(0, 120)}`);
        }
        if (r.recorded > 0 || r.scanned > 0) {
          log('info', `Bounce-Scanner [${r.mailbox}]: scanned=${r.scanned}, parsed=${r.parsed}, recorded=${r.recorded}`);
        }
      }
    })
    .catch((e) => {
      log('error', `Bounce-Scanner fatal: ${e instanceof Error ? e.message : e}`);
    });
}

async function loop(): Promise<void> {
  while (true) {
    try {
      // Champion-Tick (rate-limited intern via lastChampionTick) — entkoppelt vom Send-Polling
      runChampionPromotionTick();

      // Conversion-Polling-Tick (rate-limited via app_settings.conversion_polling.intervalMinutes)
      // Fire-and-forget — blockiert den Send-Loop nicht
      runConversionPollingTick();

      // Bounce-Inbox-Scanner-Tick (rate-limited via app_settings.bounce_polling.intervalMinutes)
      // Liest NDRs aus den Sender-Postfächern via Microsoft Graph
      runBounceInboxScannerTick();

      const jobs = pickPendingJobs(BATCH_SIZE);
      if (jobs.length === 0) {
        await sleep(POLL_INTERVAL_MS);
        continue;
      }
      log('info', `Picked ${jobs.length} jobs`);
      for (const job of jobs) {
        await processOne(job);
        await sleep(MIN_GAP_MS);
      }
    } catch (e) {
      log('error', `Loop error: ${e instanceof Error ? e.message : e}`);
      await sleep(POLL_INTERVAL_MS);
    }
  }
}

// ─── Bootstrap ───────────────────────────────────────────────────────────────
log('info', 'MailForge Worker starting …');
log('info', `Poll: ${POLL_INTERVAL_MS}ms · Batch: ${BATCH_SIZE} · Min-Gap: ${MIN_GAP_MS}ms · Daily-Budget: ${DAILY_BUDGET}`);

if (!adapters.send.isConfigured()) {
  const hints = [
    'Setze in .env.local (wird vom Worker mitgeladen): M365_TENANT_ID, M365_CLIENT_ID, M365_CLIENT_SECRET',
    'oder SEND_PROVIDER_CONFIG als eine Zeile JSON (gleiches Format wie send_provider_config in /settings).',
    'Wenn die Werte nur in der Settings-UI stehen: APP_ENCRYPTION_KEY und SQLITE_DB_PATH müssen im Worker identisch zu Next.js sein, sonst kann send_provider_config nicht aus der DB gelesen werden.',
  ].join(' ');
  log(
    'error',
    `Send provider "${adapters.send.name}" not configured — ${hints}`,
  );
  process.exit(1);
}
log('info', `Send provider: ${adapters.send.name}`);

process.on('SIGINT', () => { log('info', 'SIGINT — shutting down'); process.exit(0); });
process.on('SIGTERM', () => { log('info', 'SIGTERM — shutting down'); process.exit(0); });

loop().catch(e => { log('error', `Fatal: ${e}`); process.exit(1); });
