import { Worker, type Job } from 'bullmq';
import { env } from '@/lib/env';
import { createHmac } from 'node:crypto';
import { lookup } from 'node:dns/promises';

import type { ConnectionOptions } from 'bullmq';
import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
import { QUEUE_CONFIGS } from '@/lib/queue';
import { isLocalOrPrivateHost } from '@/lib/validators/webhooks';

/**
 * Resolve the webhook hostname and reject if any returned address is in a
 * disallowed range. Defends against DNS rebinding where the validator-time
 * resolution returned a public address but dispatch-time resolution
 * returns a private one.
 *
 * L29 — residual TOCTOU / DNS-rebind window: this function resolves the host
 * and validates every returned IP, but the subsequent `fetch` does its OWN
 * independent DNS resolution. An attacker controlling an authoritative server
 * with a very short TTL could return a public IP here and a private IP to
 * `fetch` microseconds later (classic DNS rebinding). Fully closing this
 * requires PINNING the validated IP — i.e. connecting by the exact address we
 * checked while preserving Host header + TLS SNI. That needs a custom undici
 * dispatcher (`undici.Agent({ connect: { lookup } })`); `undici` is not a
 * direct dependency and `node:undici` is not an importable built-in here, and
 * rewriting delivery onto `node:https` would discard the redirect/SSRF
 * handling built around `fetch`. So we do NOT yet pin. What we DO tighten:
 *   - the resolved/validated addresses are returned to the caller so a future
 *     pin can connect to one of them without re-resolving;
 *   - the check runs as LATE as possible (immediately before `fetch`), making
 *     the rebind window as narrow as the time between this lookup and fetch's;
 *   - the redirect SSRF path (the easy route to the same target) is already
 *     closed via `redirect: 'manual'` (audit H1).
 * The remaining gap is a short-TTL rebind race; tracked as audit L29.
 */
async function resolveAndCheckHost(
  rawUrl: string,
): Promise<{ ok: true; addresses: string[] } | { ok: false; reason: string }> {
  if (isLocalOrPrivateHost(rawUrl)) {
    return { ok: false, reason: 'webhook URL host blocked by static check' };
  }
  let host: string;
  try {
    host = new URL(rawUrl).hostname;
  } catch {
    return { ok: false, reason: 'invalid URL' };
  }
  try {
    const addresses = await lookup(host, { all: true });
    for (const a of addresses) {
      // Reuse the validator's literal-address checks on each resolved IP.
      if (isLocalOrPrivateHost(`https://${a.family === 6 ? `[${a.address}]` : a.address}`)) {
        return { ok: false, reason: `resolved address ${a.address} is in a blocked range` };
      }
    }
    return { ok: true, addresses: addresses.map((a) => a.address) };
  } catch (err) {
    return {
      ok: false,
      reason: `DNS resolution failed: ${err instanceof Error ? err.message : 'unknown'}`,
    };
  }
}

// ─── Job Payload ─────────────────────────────────────────────────────────────

interface WebhookDeliverPayload {
  webhookId: string;
  portId: string;
  event: string;
  deliveryId: string;
  payload: Record<string, unknown>;
}

// ─── Worker ──────────────────────────────────────────────────────────────────

export const webhooksWorker = new Worker(
  'webhooks',
  async (job: Job) => {
    logger.info({ jobId: job.id, jobName: job.name }, 'Processing webhooks job');

    if (job.name !== 'deliver') {
      logger.warn({ jobName: job.name }, 'Unknown webhooks job');
      return;
    }

    const { webhookId, portId, event, deliveryId, payload } = job.data as WebhookDeliverPayload;

    const { db } = await import('@/lib/db');
    const { webhooks, webhookDeliveries } = await import('@/lib/db/schema/system');
    const { userProfiles, userPortRoles } = await import('@/lib/db/schema/users');
    const { decrypt } = await import('@/lib/utils/encryption');
    const { createNotification } = await import('@/lib/services/notifications.service');
    const { eq, and } = await import('drizzle-orm');

    // 1. Fetch webhook - skip if deleted
    const webhook = await db.query.webhooks.findFirst({
      where: eq(webhooks.id, webhookId),
    });

    if (!webhook) {
      logger.info({ webhookId }, 'Webhook deleted - skipping delivery');
      await db.delete(webhookDeliveries).where(eq(webhookDeliveries.id, deliveryId));
      return;
    }

    // Safety net: when EMAIL_REDIRECT_TO is set (dev / staging / migration
    // dry-run), short-circuit webhook delivery so we don't accidentally
    // ping a user-configured production endpoint with synthetic events.
    // Records the delivery as `dead_letter` with a clear reason so the
    // attempt is still visible in the deliveries listing.
    if (process.env.EMAIL_REDIRECT_TO) {
      logger.info(
        { webhookId, deliveryId, url: webhook.url },
        'Webhook delivery skipped (EMAIL_REDIRECT_TO is set - outbound comms are paused)',
      );
      await db
        .update(webhookDeliveries)
        .set({
          status: 'dead_letter',
          responseStatus: null,
          responseBody: 'Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused.',
          deliveredAt: new Date(),
        })
        .where(eq(webhookDeliveries.id, deliveryId));
      return;
    }

    // 2. Decrypt secret
    let secret: string;
    try {
      secret = webhook.secret ? decrypt(webhook.secret) : '';
    } catch (err) {
      logger.error({ webhookId, err }, 'Failed to decrypt webhook secret');
      throw err; // Let BullMQ retry
    }

    // outbound-webhook-auditor C3: NULL secret means a DB tamper / a
    // future migration mistake - every create path generates one. Hard-
    // fail to dead_letter so compliant receivers don't silently accept
    // an empty signature.
    if (!secret) {
      const { db: dbInner } = await import('@/lib/db');
      const { webhookDeliveries } = await import('@/lib/db/schema/system');
      const { eq } = await import('drizzle-orm');
      await dbInner
        .update(webhookDeliveries)
        .set({
          status: 'dead_letter',
          responseStatus: null,
          responseBody: 'Skipped: webhook has no signing secret (missing_signing_secret).',
          deliveredAt: new Date(),
        })
        .where(eq(webhookDeliveries.id, deliveryId));
      logger.error({ webhookId, deliveryId }, 'Webhook has no signing secret; dead-lettered');
      return;
    }

    // 3. Build final payload
    const timestampIso = new Date().toISOString();
    const finalPayload = {
      id: deliveryId,
      event,
      timestamp: timestampIso,
      port_id: portId,
      data: payload,
    };

    const bodyString = JSON.stringify(finalPayload);

    // 4. Sign with HMAC-SHA256 over `${ts}.${body}` (Stripe-style)
    //
    // outbound-webhook-auditor C1: signing only the body lets a captured
    // request be replayed verbatim with a still-valid signature.
    // Including the timestamp in the signed string, surfaced separately
    // as X-Webhook-Timestamp, means receivers can reject anything older
    // than a freshness window (≤ 5 min). Documented receiver-side
    // dedup key is X-Webhook-Delivery (already sent).
    const signedPayload = `${timestampIso}.${bodyString}`;
    const signature = `sha256=${createHmac('sha256', secret).update(signedPayload).digest('hex')}`;

    const attempt = (job.attemptsMade ?? 0) + 1;

    // 5. POST to webhook URL with 10s timeout
    let responseStatus: number | null = null;
    let responseBody: string | null = null;
    let success = false;

    // outbound-webhook-auditor M2: re-assert https:// at dispatch time.
    // The validator runs on create/update, but a bad DB edit could let
    // an http:// URL through; the worker is the last line of defence.
    if (!webhook.url.toLowerCase().startsWith('https://')) {
      const { db: dbInner } = await import('@/lib/db');
      const { webhookDeliveries } = await import('@/lib/db/schema/system');
      const { eq } = await import('drizzle-orm');
      await dbInner
        .update(webhookDeliveries)
        .set({
          status: 'dead_letter',
          responseStatus: null,
          responseBody: 'Blocked: webhook URL is not https.',
          deliveredAt: new Date(),
        })
        .where(eq(webhookDeliveries.id, deliveryId));
      logger.warn(
        { webhookId, deliveryId, url: webhook.url },
        'Webhook dispatch blocked: non-https URL',
      );
      return;
    }

    // SSRF gate: re-resolve the hostname at dispatch time and reject if it
    // points anywhere internal. The validator already filtered literal
    // hostnames at create/update time, but DNS rebinding could swap the
    // answer between then and now.
    const hostCheck = await resolveAndCheckHost(webhook.url);
    if (!hostCheck.ok) {
      logger.warn(
        { webhookId, deliveryId, url: webhook.url, reason: hostCheck.reason },
        'Webhook dispatch blocked by SSRF guard',
      );
      // Persist the failure so the deliveries listing reflects it.
      const { db: dbInner } = await import('@/lib/db');
      const { webhookDeliveries } = await import('@/lib/db/schema/system');
      const { eq } = await import('drizzle-orm');
      await dbInner
        .update(webhookDeliveries)
        .set({
          status: 'dead_letter',
          responseStatus: null,
          responseBody: `Blocked: ${hostCheck.reason}`,
          deliveredAt: new Date(),
        })
        .where(eq(webhookDeliveries.id, deliveryId));
      return;
    }

    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 10_000);

      // L29: `fetch` re-resolves `webhook.url`'s hostname independently of the
      // `resolveAndCheckHost` check just above, so a short-TTL DNS-rebind race
      // remains (validated public IP here, private IP at connect). We do not
      // pin `hostCheck.addresses` because that needs a custom undici dispatcher
      // that isn't importable in this runtime — see resolveAndCheckHost for the
      // full rationale and the mitigations that are in place. Residual gap is a
      // narrow rebind window; the easier redirect route is closed below.
      const response = await fetch(webhook.url, {
        method: 'POST',
        // SSRF guard (audit H1): never follow redirects. resolveAndCheckHost
        // validated the configured host, but a 3xx Location could point at
        // cloud-metadata / RFC1918 with no re-validation. With 'manual' the
        // redirect is returned, not followed, and we refuse to read its body.
        redirect: 'manual',
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': 'PortNimara-Webhook/1.0',
          'X-Webhook-Id': webhookId,
          'X-Webhook-Event': event,
          'X-Webhook-Signature': signature,
          'X-Webhook-Timestamp': timestampIso,
          'X-Webhook-Delivery': deliveryId,
        },
        body: bodyString,
        signal: controller.signal,
      });

      clearTimeout(timeoutId);
      responseStatus = response.status;
      if (response.status >= 300 && response.status < 400) {
        // Redirect not followed — treat as a delivery failure and do NOT
        // read/expose the (un-validated) redirected response.
        responseBody = `Blocked: redirect (${response.status} -> ${
          response.headers.get('location') ?? 'unknown'
        }) not followed (SSRF guard)`;
        success = false;
      } else {
        // Read up to 1KB of response body
        const rawBody = await response.text();
        responseBody = rawBody.slice(0, 1024);
        success = response.status >= 200 && response.status < 300;
      }
    } catch (err) {
      // Network error or timeout
      logger.warn({ webhookId, deliveryId, err }, 'Webhook delivery network error');
      responseStatus = null;
      responseBody = err instanceof Error ? err.message.slice(0, 1024) : String(err).slice(0, 1024);
      success = false;
    }

    const maxAttempts = QUEUE_CONFIGS.webhooks.maxAttempts;
    const isFinalAttempt = attempt >= maxAttempts;

    const { createAuditLog } = await import('@/lib/audit');

    if (success) {
      // 6a. Record success
      await db
        .update(webhookDeliveries)
        .set({
          status: 'success',
          responseStatus,
          responseBody,
          attempt,
          deliveredAt: new Date(),
        })
        .where(eq(webhookDeliveries.id, deliveryId));

      logger.info({ webhookId, deliveryId, event }, 'Webhook delivered successfully');

      void createAuditLog({
        userId: null,
        portId,
        action: 'webhook_delivered',
        entityType: 'webhook_delivery',
        entityId: deliveryId,
        metadata: { webhookId, event, responseStatus, attempt },
        source: 'webhook',
        severity: 'info',
      });
    } else if (!success && isFinalAttempt) {
      // 6b. Final failure → dead_letter + system alert
      await db
        .update(webhookDeliveries)
        .set({
          status: 'dead_letter',
          responseStatus,
          responseBody,
          attempt,
        })
        .where(eq(webhookDeliveries.id, deliveryId));

      logger.error(
        { webhookId, deliveryId, event, attempt },
        'Webhook delivery permanently failed - dead_letter',
      );

      void createAuditLog({
        userId: null,
        portId,
        action: 'webhook_dead_letter',
        entityType: 'webhook_delivery',
        entityId: deliveryId,
        metadata: { webhookId, event, responseStatus, attempt, responseBody },
        source: 'webhook',
        severity: 'error',
      });

      // M22: notify only super-admins who actually operate in the
      // originating port. The webhook's `description` embeds the
      // admin-controlled `webhook.name` and a `/admin/webhooks/{id}` link
      // scoped to THIS tenant; the old query selected every super-admin of
      // every tenant, leaking Port A's webhook name (and a minor injection
      // vector) into unrelated tenants' notification feeds. Requiring a
      // `userPortRoles` row for `portId` keeps the alert inside the tenant.
      // `select` with the inner join can fan out one row per role, so we
      // dedupe userIds before notifying.
      try {
        const superAdminRows = await db
          .selectDistinct({ userId: userProfiles.userId })
          .from(userProfiles)
          .innerJoin(userPortRoles, eq(userPortRoles.userId, userProfiles.userId))
          .where(
            and(
              eq(userProfiles.isSuperAdmin, true),
              eq(userProfiles.isActive, true),
              eq(userPortRoles.portId, portId),
            ),
          );

        for (const admin of superAdminRows) {
          void createNotification({
            portId,
            userId: admin.userId,
            type: 'system_alert',
            title: 'Webhook delivery failed permanently',
            description: `Webhook "${webhook.name}" failed to deliver event "${event}" after ${maxAttempts} attempts.`,
            link: `/admin/webhooks/${webhookId}`,
            entityType: 'webhook',
            entityId: webhookId,
            dedupeKey: `webhook:dead_letter:${deliveryId}`,
            cooldownMs: 0,
          });
        }
      } catch (notifyErr) {
        logger.error({ notifyErr }, 'Failed to send dead_letter notification');
      }

      // Throw to let BullMQ mark job as failed (it won't retry since it's the final attempt)
      throw new Error(
        `Webhook delivery failed after ${attempt} attempts. Status: ${responseStatus ?? 'network error'}`,
      );
    } else {
      // 6c. Non-final failure → update record then throw to trigger retry
      await db
        .update(webhookDeliveries)
        .set({
          status: 'failed',
          responseStatus,
          responseBody,
          attempt,
        })
        .where(eq(webhookDeliveries.id, deliveryId));

      void createAuditLog({
        userId: null,
        portId,
        action: 'webhook_failed',
        entityType: 'webhook_delivery',
        entityId: deliveryId,
        metadata: { webhookId, event, responseStatus, attempt },
        source: 'webhook',
        severity: 'warning',
      });

      throw new Error(
        `Webhook delivery attempt ${attempt} failed. Status: ${responseStatus ?? 'network error'}. Retrying...`,
      );
    }
  },
  {
    connection: { url: env.REDIS_URL } as ConnectionOptions,
    concurrency: QUEUE_CONFIGS.webhooks.concurrency,
  },
);

webhooksWorker.on('failed', (job, err) => {
  logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Webhooks job failed');
});

attachWorkerAudit(webhooksWorker, 'webhooks');
