import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto';
import { match } from 'ts-pattern';

import { db } from '@/lib/db';
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
import { extractSigningToken } from '@/lib/services/documenso-signers';
import {
  handleRecipientSigned,
  handleDocumentCompleted,
  handleDocumentExpired,
  handleDocumentOpened,
  handleDocumentRejected,
  handleDocumentCancelled,
} from '@/lib/services/documents.service';
import { logger } from '@/lib/logger';
import { createAuditLog } from '@/lib/audit';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { captureErrorEvent } from '@/lib/services/error-events.service';
import { withPublicContext } from '@/lib/api/helpers';

// BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice)

// Documenso emits Prisma enum names on the wire (e.g. "DOCUMENT_SIGNED").
// The UI displays them as lowercase-dotted ("document.signed") but the JSON
// body uses the enum value as-is. Normalize both forms in case 2.x ever flips.
function canonicalizeEvent(event: string): string {
  return event.toUpperCase().replace(/\./g, '_');
}

// Discriminated union of every Documenso event we know how to react to.
// Adding a new event type forces a compile error in the `match(...)`
// below via `.exhaustive()` - so we can't ship a Documenso 2.x bump
// without consciously deciding how to handle each new event. Anything
// not in this list falls through to the structured-log catch-all below.
type KnownDocumensoEvent =
  | 'DOCUMENT_SIGNED'
  | 'DOCUMENT_RECIPIENT_COMPLETED'
  | 'RECIPIENT_SIGNED'
  | 'DOCUMENT_OPENED'
  | 'RECIPIENT_VIEWED'
  | 'DOCUMENT_COMPLETED'
  | 'DOCUMENT_REJECTED'
  | 'DOCUMENT_DECLINED'
  | 'DOCUMENT_CANCELLED'
  | 'DOCUMENT_EXPIRED'
  | 'DOCUMENT_REMINDER_SENT'
  | 'DOCUMENT_CREATED'
  | 'DOCUMENT_SENT';

const KNOWN_DOCUMENSO_EVENTS: ReadonlySet<KnownDocumensoEvent> = new Set<KnownDocumensoEvent>([
  'DOCUMENT_SIGNED',
  'DOCUMENT_RECIPIENT_COMPLETED',
  'RECIPIENT_SIGNED',
  'DOCUMENT_OPENED',
  'RECIPIENT_VIEWED',
  'DOCUMENT_COMPLETED',
  'DOCUMENT_REJECTED',
  'DOCUMENT_DECLINED',
  'DOCUMENT_CANCELLED',
  'DOCUMENT_EXPIRED',
  'DOCUMENT_REMINDER_SENT',
  'DOCUMENT_CREATED',
  'DOCUMENT_SENT',
]);

function isKnownEvent(event: string): event is KnownDocumensoEvent {
  return KNOWN_DOCUMENSO_EVENTS.has(event as KnownDocumensoEvent);
}

/**
 * Pull the recipient's signing token out of a Documenso webhook
 * payload. v1.13 emits `recipients[].token`; some 2.x payloads use
 * `signingToken`; both versions always carry a `signingUrl` whose tail
 * IS the token. Prefer the explicit fields, fall back to URL extraction
 * so the cascade still works when Documenso reshapes its payload.
 */
function resolveRecipientToken(r: DocumensoRecipient): string | null {
  if (r.token) return r.token;
  if (r.signingToken) return r.signingToken;
  if (r.signingUrl) return extractSigningToken(r.signingUrl);
  return null;
}

type DocumensoRecipient = {
  email: string;
  signingStatus?: string;
  readStatus?: string;
  signedAt?: string | null;
  /** Per-recipient signing token Documenso uses as the URL tail.
   *  Present on both v1.13 and v2 payloads under varied field names -
   *  we coalesce them below. Phase 2: passed through to the handlers
   *  so they can match against `document_signers.signing_token`
   *  instead of email. */
  token?: string | null;
  signingToken?: string | null;
  signingUrl?: string | null;
  /** Free-text reason the recipient typed into Documenso's reject dialog.
   *  v2 payloads carry `rejectionReason`; some 1.x payloads use the
   *  legacy `declineReason` field name. Either way we surface the
   *  cleartext to the rep so they don't have to log into Documenso to
   *  see why a deal stalled. */
  rejectionReason?: string | null;
  declineReason?: string | null;
};

type DocumensoWebhookBody = {
  event: string;
  payload: {
    id: number | string;
    recipients?: DocumensoRecipient[];
  };
};

async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
  let rawBody: string;

  try {
    rawBody = await req.text();
  } catch {
    return NextResponse.json({ ok: false }, { status: 200 });
  }

  // Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
  // Resolve the matching port by trying each configured per-port secret
  // (plus the global env fallback) with timing-safe comparison. The
  // resolved portId, when non-null, is threaded into handleDocumentExpired
  // so two ports sharing a documensoId can't cross-mutate (auditor-D §22).
  const providedSecret = req.headers.get('x-documenso-secret') ?? '';
  const secrets = await listDocumensoWebhookSecrets();
  let matchedPortId: string | null = null;
  let matched = false;
  for (const entry of secrets) {
    if (verifyDocumensoSecret(providedSecret, entry.secret)) {
      matched = true;
      matchedPortId = entry.portId;
      break;
    }
  }
  if (!matched) {
    const callerIp =
      req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
      req.headers.get('x-real-ip') ??
      'unknown';
    // Rate-limit per IP. Real Documenso traffic won't fail the secret
    // check, so any traffic here is enumeration / brute-force; we cap
    // it sharply to keep audit-log volume bounded too.
    const rl = await checkRateLimit(callerIp, rateLimiters.webhookBadSecret);
    logger.warn(
      { providedLen: providedSecret.length, ip: callerIp, allowed: rl.allowed },
      'Invalid Documenso webhook secret',
    );
    if (rl.allowed) {
      void createAuditLog({
        userId: null,
        portId: null,
        action: 'webhook_failed',
        entityType: 'webhook_inbound',
        entityId: 'documenso',
        metadata: {
          reason: 'invalid_secret',
          providedLen: providedSecret.length,
        },
        ipAddress: callerIp,
        userAgent: req.headers.get('user-agent') ?? '',
        severity: 'warning',
        source: 'webhook',
      });
    }
    // Always return 200 (webhook best-practice - don't leak signal). Body
    // is intentionally empty/uniform - error-ux-auditor H5 noted the
    // literal "Invalid secret" string confirms the endpoint expects a
    // secret, which is a free reconnaissance hint for enumeration.
    return NextResponse.json({ ok: false }, { status: 200 });
  }

  // Compute deduplication hash
  const signatureHash = createHash('sha256').update(rawBody).digest('hex');

  let parsed: DocumensoWebhookBody;

  try {
    parsed = JSON.parse(rawBody) as DocumensoWebhookBody;
  } catch {
    logger.warn('Failed to parse Documenso webhook payload');
    return NextResponse.json({ ok: false }, { status: 200 });
  }

  // Replay guard: if any event with this hash already exists, skip.
  try {
    const existing = await db.query.documentEvents.findFirst({
      where: (de, { eq }) => eq(de.signatureHash, signatureHash),
    });

    if (existing) {
      logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
      return NextResponse.json({ ok: true }, { status: 200 });
    }
  } catch (err) {
    logger.error({ err }, 'Failed to check duplicate webhook');
  }

  const event = canonicalizeEvent(parsed.event);
  const documensoId = String(parsed.payload?.id ?? '');
  const recipients = parsed.payload?.recipients ?? [];

  if (!documensoId) {
    logger.warn({ event }, 'Documenso webhook missing payload.id');
    return NextResponse.json({ ok: true }, { status: 200 });
  }

  // Every handler accepts an optional `portId` and refuses to mutate when
  // the lookup is ambiguous across multiple ports without one. Forward
  // the secret-resolved portId everywhere - not just the expired path -
  // so signed/completed/opened/rejected/cancelled events can't flip a
  // foreign-tenant document via documensoId reuse.
  const portScope = matchedPortId ? { portId: matchedPortId } : {};

  try {
    if (!isKnownEvent(event)) {
      // New / unknown Documenso event - structured log catches the
      // shape so we can add a handler before the next webhook lands.
      logger.info({ event }, 'Unhandled Documenso webhook event type');
    } else {
      await match(event)
        .with('DOCUMENT_SIGNED', 'DOCUMENT_RECIPIENT_COMPLETED', 'RECIPIENT_SIGNED', async (e) => {
          // v1.13 fires DOCUMENT_SIGNED per recipient sign;
          // 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics.
          // Some 2.x deployments emit RECIPIENT_SIGNED as a v2-flavoured alias
          // - log when we see it (telemetry) and route to the same handler so
          // v2 deployments don't silently drop per-recipient signs.
          if (e === 'RECIPIENT_SIGNED') {
            logger.info(
              { event: e, documensoId },
              'Documenso v2 RECIPIENT_SIGNED received - routing to recipient-signed handler',
            );
          }
          const signedRecipients = recipients.filter(
            (r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt),
          );
          for (const r of signedRecipients) {
            await handleRecipientSigned({
              documentId: documensoId,
              recipientEmail: r.email,
              recipientToken: resolveRecipientToken(r),
              signatureHash: `${signatureHash}:signed:${r.email}`,
              ...portScope,
            });
          }
        })
        .with('DOCUMENT_OPENED', 'RECIPIENT_VIEWED', async (e) => {
          // Documenso v1 sends `readStatus: 'OPENED'`; v2 has used both
          // upper and lower case across releases and may omit the field
          // entirely (the event itself signals the open). Treat the event
          // as the signal: dispatch a per-recipient open for every
          // recipient on the document so v2 deployments stop silently
          // dropping opens.
          if (e === 'RECIPIENT_VIEWED') {
            logger.info(
              { event: e, documensoId },
              'Documenso v2 RECIPIENT_VIEWED received - routing to document-opened handler',
            );
          }
          const openedRecipients = recipients.filter(
            (r) => !r.readStatus || String(r.readStatus).toUpperCase() === 'OPENED',
          );
          for (const r of openedRecipients) {
            await handleDocumentOpened({
              documentId: documensoId,
              recipientEmail: r.email,
              recipientToken: resolveRecipientToken(r),
              signatureHash: `${signatureHash}:opened:${r.email}`,
              ...portScope,
            });
          }
        })
        .with('DOCUMENT_COMPLETED', async () => {
          await handleDocumentCompleted({ documentId: documensoId, ...portScope });
        })
        .with('DOCUMENT_REJECTED', 'DOCUMENT_DECLINED', async () => {
          // v2 distinguishes Decline (recipient refuses to sign) from
          // Reject (admin cancels). Both currently map to the same
          // "rejected" terminal state in our domain.
          const rejecting = recipients.find(
            (r) => r.signingStatus === 'REJECTED' || r.signingStatus === 'DECLINED',
          );
          // Documenso uses two field names across versions: v2
          // `rejectionReason`, some 1.x payloads `declineReason`. Coalesce
          // so handlers downstream see one stable field. Empty string
          // (vs null) normalised to null so the UI's "no reason given"
          // copy fires consistently.
          const rawReason = rejecting?.rejectionReason ?? rejecting?.declineReason ?? null;
          const rejectionReason =
            rawReason && rawReason.trim().length > 0 ? rawReason.trim() : null;
          await handleDocumentRejected({
            documentId: documensoId,
            recipientEmail: rejecting?.email,
            rejectionReason,
            signatureHash,
            ...portScope,
          });
        })
        .with('DOCUMENT_CANCELLED', async () => {
          await handleDocumentCancelled({ documentId: documensoId, signatureHash, ...portScope });
        })
        .with('DOCUMENT_EXPIRED', async () => {
          await handleDocumentExpired({ documentId: documensoId, ...portScope });
        })
        .with('DOCUMENT_REMINDER_SENT', async () => {
          // Auto-reminder - informational only, no state change.
          logger.info(
            {
              documensoId,
              recipients: recipients.map((r) => r.email),
              ...portScope,
            },
            'Documenso auto-reminder sent',
          );
        })
        .with('DOCUMENT_CREATED', 'DOCUMENT_SENT', async (e) => {
          // We initiated these from our side; log for forward-compat /
          // out-of-band-creation telemetry.
          logger.info({ event: e, documensoId, ...portScope }, 'Documenso lifecycle event');
        })
        .exhaustive();
    }
  } catch (err) {
    logger.error({ err, event }, 'Error processing Documenso webhook');
    // The audit caught that webhook handlers were the only API surface
    // bypassing the platform-error pipeline - admin/errors was silent on
    // Documenso webhook crashes. Pipe them in so they surface alongside
    // every other 5xx.
    void captureErrorEvent({
      statusCode: 500,
      error: err,
      metadata: { source: 'webhook', provider: 'documenso', event },
    });
  }

  return NextResponse.json({ ok: true }, { status: 200 });
}

// Wrap with withPublicContext so the handler runs inside a
// runWithRequestContext ALS frame - without it the inline
// `captureErrorEvent` call in the catch block silently no-ops because
// getRequestContext() returns null for unauthenticated routes.
export const POST = withPublicContext(handleDocumensoWebhook);
