/**
 * WebhookDeliveryWorker — Bull queue processor for outbound webhook HTTP delivery.
 *
 * Each job carries a delivery ID, subscription ID, organization ID, and the full
 * IWebhookPayload to POST. The worker:
 *   1. Fetches the subscription record from PostgreSQL.
 *   2. Resolves the target hostname and blocks SSRF attempts (RFC 1918, loopback, link-local).
 *   3. Computes HMAC-SHA256 signature using the secret from Vault (or logs a warning and sends
 *      unsigned when in bcrypt-only mode where the raw secret is unavailable).
 *   4. POSTs the payload with SentryAgent signature headers.
 *   5. Updates the delivery record status, retry schedule, and subscription failure count.
 *
 * Retry strategy: up to 5 attempts with exponential backoff (5 s × 2^attempt).
 * On exhausting all retries the delivery is marked dead_letter and the
 * `agentidp_webhook_dead_letters_total` Prometheus counter is incremented.
 */

import Bull from 'bull';
import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import crypto from 'crypto';
import { promises as dns } from 'dns';
import { VaultClient } from '../vault/VaultClient.js';
import { IWebhookPayload } from '../types/webhook.js';
import { webhookDeadLettersTotal } from '../metrics/registry.js';

// ============================================================================
// Job data interface
// ============================================================================

/** Data carried by every webhook delivery Bull job. */
export interface WebhookDeliveryJob {
  deliveryId: string;
  subscriptionId: string;
  organizationId: string;
  payload: IWebhookPayload;
}

// ============================================================================
// Internal DB row shapes
// ============================================================================

/** Minimal subscription fields needed for delivery. */
interface SubscriptionDeliveryRow {
  id: string;
  organization_id: string;
  url: string;
  active: boolean;
  vault_secret_path: string;
  secret_hash: string;
  failure_count: number;
}

// ============================================================================
// SSRF protection helpers
// ============================================================================

/** Regular expressions covering RFC 1918, loopback, and link-local address ranges. */
const BLOCKED_IPV4_PREFIXES: RegExp[] = [
  /^10\./,                          // 10.0.0.0/8
  /^172\.(1[6-9]|2[0-9]|3[01])\./,  // 172.16.0.0/12
  /^192\.168\./,                    // 192.168.0.0/16
  /^127\./,                         // 127.0.0.0/8 (loopback)
  /^169\.254\./,                    // 169.254.0.0/16 (link-local)
];

const BLOCKED_IPV6: RegExp[] = [
  /^::1$/,                          // IPv6 loopback
  /^fe80:/i,                        // IPv6 link-local
];

/**
 * Returns true if the given IP address is in a blocked range.
 *
 * @param address - IPv4 or IPv6 address string.
 * @returns Whether the address is blocked (SSRF risk).
 */
function isBlockedAddress(address: string): boolean {
  for (const pattern of BLOCKED_IPV4_PREFIXES) {
    if (pattern.test(address)) return true;
  }
  for (const pattern of BLOCKED_IPV6) {
    if (pattern.test(address)) return true;
  }
  return false;
}

/**
 * Resolves the hostname of a URL and throws if it resolves to a blocked address.
 *
 * @param targetUrl - The fully-qualified HTTPS URL to check.
 * @throws Error if the hostname resolves to a blocked (SSRF-risk) IP address.
 */
async function guardSsrf(targetUrl: string): Promise<void> {
  const { hostname } = new URL(targetUrl);
  const { address } = await dns.lookup(hostname);
  if (isBlockedAddress(address)) {
    throw new Error(
      `SSRF protection: hostname '${hostname}' resolves to blocked address '${address}'.`,
    );
  }
}

// ============================================================================
// Worker class
// ============================================================================

/**
 * Bull queue worker that processes outbound webhook delivery jobs.
 * Each instance manages a single Bull queue named 'webhook-delivery'.
 */
export class WebhookDeliveryWorker {
  private readonly queue: Bull.Queue<WebhookDeliveryJob>;

  /**
   * @param pool - PostgreSQL connection pool for delivery record updates.
   * @param vaultClient - Optional VaultClient for fetching HMAC signing secrets.
   * @param redis - Redis client (unused directly; Bull uses redisUrl).
   * @param redisUrl - Redis connection URL passed to Bull.
   */
  constructor(
    private readonly pool: Pool,
    private readonly vaultClient: VaultClient | null,
    _redis: RedisClientType, // reserved for future use; Bull uses redisUrl directly
    redisUrl: string,
  ) {
    this.queue = new Bull<WebhookDeliveryJob>('webhook-delivery', redisUrl);
  }

  /**
   * Registers the job processor with the Bull queue.
   * Must be called once after construction.
   */
  start(): void {
    this.queue.process(this.processJob.bind(this));
  }

  /**
   * Adds a webhook delivery job to the Bull queue.
   * The job will be retried up to 5 times with exponential backoff starting at 5 seconds.
   *
   * @param job - The delivery job data.
   */
  async enqueue(job: WebhookDeliveryJob): Promise<void> {
    await this.queue.add(job, {
      attempts: 5,
      backoff: { type: 'exponential', delay: 5000 },
    });
  }

  // ──────────────────────────────────────────────────────────────────────────
  // Private processor
  // ──────────────────────────────────────────────────────────────────────────

  /**
   * Processes a single webhook delivery job.
   *
   * Steps:
   *   1. Load subscription from DB.
   *   2. Mark dead_letter if subscription is missing or inactive.
   *   3. SSRF check — resolve hostname and block private/loopback/link-local addresses.
   *   4. Fetch HMAC secret and compute HMAC-SHA256 signature.
   *   5. POST payload with SentryAgent signature headers.
   *   6. Update delivery record and subscription failure_count.
   *
   * @param job - Bull job carrying WebhookDeliveryJob data.
   */
  private async processJob(job: Bull.Job<WebhookDeliveryJob>): Promise<void> {
    const { deliveryId, subscriptionId, organizationId, payload } = job.data;

    // 1. Load subscription
    const subResult = await this.pool.query<SubscriptionDeliveryRow>(
      `SELECT id, organization_id, url, active, vault_secret_path, secret_hash, failure_count
       FROM webhook_subscriptions
       WHERE id = $1`,
      [subscriptionId],
    );

    if (subResult.rowCount === 0 || !subResult.rows[0].active) {
      // Subscription gone or disabled — no point retrying
      await this.markDeadLetter(deliveryId, null, organizationId);
      return;
    }

    const subscription = subResult.rows[0];

    // 2. SSRF check
    try {
      await guardSsrf(subscription.url);
    } catch (ssrfErr) {
      console.error(
        `[WebhookDeliveryWorker] SSRF blocked delivery ${deliveryId}: ${(ssrfErr as Error).message}`,
      );
      await this.markDeadLetter(deliveryId, null, organizationId);
      return;
    }

    // 3. Fetch signing secret
    let secret: string | null = null;
    if (subscription.vault_secret_path !== 'local' && this.vaultClient !== null) {
      try {
        secret = await this.fetchSecretFromVault(subscription.vault_secret_path);
      } catch (vaultErr) {
        console.error(
          `[WebhookDeliveryWorker] Failed to retrieve secret from Vault for delivery ${deliveryId}: ${(vaultErr as Error).message}`,
        );
      }
    } else {
      // Local (bcrypt) mode — raw secret is unrecoverable; send without signature
      console.warn(
        `[WebhookDeliveryWorker] Delivery ${deliveryId}: subscription in local mode — sending without HMAC signature.`,
      );
    }

    // 4. Compute HMAC-SHA256 signature if secret is available
    const payloadJson = JSON.stringify(payload);
    let signatureHeader = '';
    if (secret !== null) {
      const hex = crypto.createHmac('sha256', secret).update(payloadJson).digest('hex');
      signatureHeader = `sha256=${hex}`;
    }

    // 5. POST to subscription URL
    const attemptCount = (job.attemptsMade ?? 0) + 1;
    let httpStatus: number | null = null;
    let deliverySucceeded = false;

    try {
      const headers: Record<string, string> = {
        'Content-Type': 'application/json',
        'X-SentryAgent-Event': payload.event,
        'X-SentryAgent-Delivery': deliveryId,
      };
      if (signatureHeader) {
        headers['X-SentryAgent-Signature'] = signatureHeader;
      }

      const response = await fetch(subscription.url, {
        method: 'POST',
        headers,
        body: payloadJson,
      });

      httpStatus = response.status;
      deliverySucceeded = response.ok; // 2xx
    } catch (networkErr) {
      // Network error — treat as failure
      console.error(
        `[WebhookDeliveryWorker] Network error for delivery ${deliveryId}: ${(networkErr as Error).message}`,
      );
    }

    // 6. Update delivery record
    if (deliverySucceeded) {
      await this.markDelivered(deliveryId, httpStatus as number, subscriptionId);
    } else {
      const isLastAttempt = attemptCount >= 5;
      if (isLastAttempt) {
        await this.markDeadLetter(deliveryId, httpStatus, organizationId);
        await this.incrementFailureCount(subscriptionId);
      } else {
        // Exponential backoff: delay = 5000 * 2^(attempt-1) ms
        const delayMs = 5000 * Math.pow(2, attemptCount - 1);
        const nextRetryAt = new Date(Date.now() + delayMs);
        await this.markFailed(deliveryId, httpStatus, attemptCount, nextRetryAt);
        await this.incrementFailureCount(subscriptionId);
        // Re-throw so Bull retries the job
        throw new Error(
          `Webhook delivery ${deliveryId} failed (HTTP ${httpStatus ?? 'network error'}), attempt ${attemptCount}/5.`,
        );
      }
    }
  }

  // ──────────────────────────────────────────────────────────────────────────
  // DB update helpers
  // ──────────────────────────────────────────────────────────────────────────

  /** Marks a delivery as successfully delivered. */
  private async markDelivered(
    deliveryId: string,
    httpStatus: number,
    subscriptionId: string,
  ): Promise<void> {
    await this.pool.query(
      `UPDATE webhook_deliveries
       SET status = 'delivered', http_status_code = $2, delivered_at = NOW(), updated_at = NOW()
       WHERE id = $1`,
      [deliveryId, httpStatus],
    );
    // Reset failure count on the subscription
    await this.pool.query(
      `UPDATE webhook_subscriptions SET failure_count = 0, updated_at = NOW() WHERE id = $1`,
      [subscriptionId],
    );
  }

  /** Marks a delivery as failed and schedules the next retry. */
  private async markFailed(
    deliveryId: string,
    httpStatus: number | null,
    attemptCount: number,
    nextRetryAt: Date,
  ): Promise<void> {
    await this.pool.query(
      `UPDATE webhook_deliveries
       SET status = 'failed',
           http_status_code = $2,
           attempt_count = $3,
           next_retry_at = $4,
           updated_at = NOW()
       WHERE id = $1`,
      [deliveryId, httpStatus, attemptCount, nextRetryAt],
    );
  }

  /**
   * Marks a delivery as dead_letter, fires the Prometheus counter,
   * and resets next_retry_at to NULL.
   */
  private async markDeadLetter(
    deliveryId: string,
    httpStatus: number | null,
    organizationId: string,
  ): Promise<void> {
    await this.pool.query(
      `UPDATE webhook_deliveries
       SET status = 'dead_letter',
           http_status_code = $2,
           next_retry_at = NULL,
           updated_at = NOW()
       WHERE id = $1`,
      [deliveryId, httpStatus],
    );
    webhookDeadLettersTotal.inc({ organization_id: organizationId });
  }

  /** Increments the failure_count on the subscription. */
  private async incrementFailureCount(subscriptionId: string): Promise<void> {
    await this.pool.query(
      `UPDATE webhook_subscriptions
       SET failure_count = failure_count + 1, updated_at = NOW()
       WHERE id = $1`,
      [subscriptionId],
    );
  }

  // ──────────────────────────────────────────────────────────────────────────
  // Vault helper
  // ──────────────────────────────────────────────────────────────────────────

  /**
   * Fetches a webhook HMAC secret from Vault at the given KV v2 data path.
   *
   * @param vaultPath - Full KV v2 data path.
   * @returns The raw HMAC secret string.
   * @throws Error if the secret is missing or empty.
   */
  private async fetchSecretFromVault(vaultPath: string): Promise<string> {
    const data = await this.vaultClient!.readArbitrarySecret(vaultPath);
    const secret = data['webhookSecret'];
    if (typeof secret !== 'string' || secret.length === 0) {
      throw new Error(`Empty or missing webhook secret at Vault path: ${vaultPath}`);
    }
    return secret;
  }
}
