/**
 * @beignet/core/webhooks
 *
 * Provider-neutral inbound webhook primitives for Beignet applications.
 */

import type { StandardSchemaV1 } from "@standard-schema/spec";

/**
 * Raw webhook payload. Signature verification must use this unparsed body.
 */
export type WebhookRawBody = string | Uint8Array | ArrayBuffer;

/**
 * Request headers normalized to lowercase keys.
 */
export type WebhookHeaders = Record<string, string | undefined>;

/**
 * Input passed to webhook verifiers.
 */
export interface VerifyWebhookInput {
  rawBody: WebhookRawBody;
  headers?: WebhookHeaders;
  signature?: string;
  receivedAt?: Date;
}

/**
 * Provider-neutral inbound webhook event.
 */
export interface WebhookEvent<TPayload = unknown> {
  id: string;
  type: string;
  provider?: string;
  createdAt?: Date;
  payload: TPayload;
  raw?: unknown;
  metadata?: Record<string, unknown>;
}

/**
 * Verifies an inbound webhook and converts it into a provider-neutral event.
 */
export interface WebhookVerifier<TEvent extends WebhookEvent = WebhookEvent> {
  verify(input: VerifyWebhookInput): Promise<TEvent>;
}

/**
 * Standard Schema payload catalog keyed by provider event type.
 */
export type WebhookEventSchemas = Record<string, StandardSchemaV1>;

/**
 * Infer the parsed output type from a Standard Schema.
 */
export type InferSchemaOutput<T extends StandardSchemaV1> =
  StandardSchemaV1.InferOutput<T>;

/**
 * Infer a typed webhook event from an event catalog entry.
 */
export type WebhookEventForSchema<
  Events extends WebhookEventSchemas,
  Type extends keyof Events & string,
> = WebhookEvent<InferSchemaOutput<Events[Type]>> & { type: Type };

/**
 * Infer any typed event from a webhook definition's event catalog.
 */
export type InferWebhookEvent<TWebhook> =
  TWebhook extends WebhookDef<string, infer Events>
    ? {
        [Type in keyof Events & string]: WebhookEventForSchema<Events, Type>;
      }[keyof Events & string]
    : WebhookEvent;

/**
 * Provider-neutral webhook definition.
 */
export interface WebhookDef<
  Name extends string = string,
  Events extends WebhookEventSchemas = WebhookEventSchemas,
> {
  kind: "webhook";
  name: Name;
  provider?: string;
  events: Events;
  verifier?: WebhookVerifier;
  metadata?: Record<string, unknown>;
}

/**
 * Options accepted by `defineWebhook(...)`.
 */
export interface DefineWebhookOptions<
  Events extends WebhookEventSchemas = WebhookEventSchemas,
> {
  provider?: string;
  events?: Events;
  verifier?: WebhookVerifier;
  metadata?: Record<string, unknown>;
}

/**
 * Options accepted by `verifyWebhook(...)`.
 */
export interface VerifyWebhookOptions<
  AllowUnknownEvents extends boolean = boolean,
> {
  verifier?: WebhookVerifier;
  allowUnknownEvents?: AllowUnknownEvents;
}

type InferWebhookVerificationResult<
  TWebhook extends WebhookDef<string, WebhookEventSchemas>,
  AllowUnknownEvents extends boolean,
> = true extends AllowUnknownEvents
  ? InferWebhookEvent<TWebhook> | WebhookEvent
  : InferWebhookEvent<TWebhook>;

/**
 * Timestamp formats accepted by the generic HMAC webhook verifier.
 */
export type HmacWebhookTimestampFormat =
  | "unix-seconds"
  | "unix-milliseconds"
  | "iso8601";

/**
 * Timestamp source and tolerance for generic HMAC replay protection.
 */
export type HmacWebhookTimestampOptions =
  | {
      /**
       * Header that carries the provider event timestamp. Header timestamps
       * are authenticated as `<timestamp>.<rawBody>`.
       */
      header: string;
      payloadPath?: never;
      /**
       * Timestamp format.
       *
       * @default "unix-seconds"
       */
      format?: HmacWebhookTimestampFormat;
      /**
       * Maximum absolute clock skew between the event timestamp and receipt.
       *
       * @default 300
       */
      toleranceSec?: number;
    }
  | {
      header?: never;
      /**
       * Dot path used to read the provider event timestamp from the JSON
       * payload.
       */
      payloadPath: string;
      /**
       * Timestamp format.
       *
       * @default "unix-seconds"
       */
      format?: HmacWebhookTimestampFormat;
      /**
       * Maximum absolute clock skew between the event timestamp and receipt.
       *
       * @default 300
       */
      toleranceSec?: number;
    };

/**
 * Options for the in-memory webhook verifier.
 */
export interface CreateMemoryWebhookVerifierOptions {
  events?: readonly WebhookEvent[];
}

/**
 * In-memory verifier exposed for tests.
 */
export interface MemoryWebhookVerifier extends WebhookVerifier {
  readonly verifiedEvents: readonly WebhookEvent[];
  queue(event: WebhookEvent): void;
  reset(): void;
}

/**
 * Options for the generic HMAC webhook verifier.
 */
export interface CreateHmacWebhookVerifierOptions {
  secret: string;
  /**
   * Header that carries the provider signature.
   *
   * @default "x-webhook-signature"
   */
  signatureHeader?: string;
  /**
   * Web Crypto HMAC hash algorithm.
   *
   * @default "SHA-256"
   */
  algorithm?: "SHA-256" | "SHA-384" | "SHA-512";
  /**
   * Optional signature prefix, such as "sha256=".
   */
  signaturePrefix?: string;
  /**
   * Provider name attached to verified events.
   */
  provider?: string;
  /**
   * Dot path used to read the event ID from a JSON payload.
   *
   * @default "id"
   */
  eventIdPath?: string;
  /**
   * Dot path used to read the event type from a JSON payload.
   *
   * @default "type"
   */
  eventTypePath?: string;
  /**
   * Optional timestamp source used to reject replayed generic HMAC webhooks.
   */
  timestamp?: HmacWebhookTimestampOptions;
}

/**
 * Error thrown for invalid webhook definitions and inputs.
 */
export class WebhookOptionsError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "WebhookOptionsError";
  }
}

/**
 * Error thrown when verification fails.
 */
export class WebhookVerificationError extends Error {
  readonly webhookName?: string;
  readonly provider?: string;
  readonly code: string;
  readonly cause?: unknown;

  constructor(args: {
    message: string;
    webhookName?: string;
    provider?: string;
    code: string;
    cause?: unknown;
  }) {
    super(args.message);
    this.name = "WebhookVerificationError";
    this.webhookName = args.webhookName;
    this.provider = args.provider;
    this.code = args.code;
    this.cause = args.cause;
  }
}

/**
 * Error thrown when a verified event fails payload validation.
 */
export class WebhookValidationError extends Error {
  readonly webhookName: string;
  readonly eventType: string;
  readonly issues: readonly StandardSchemaV1.Issue[];

  constructor(args: {
    webhookName: string;
    eventType: string;
    issues: readonly StandardSchemaV1.Issue[];
  }) {
    super(
      `Webhook "${args.webhookName}" event "${args.eventType}" payload validation failed: ${formatIssues(args.issues)}`,
    );
    this.name = "WebhookValidationError";
    this.webhookName = args.webhookName;
    this.eventType = args.eventType;
    this.issues = args.issues;
  }
}

/**
 * Define a typed inbound webhook surface.
 */
export function defineWebhook<
  Name extends string,
  Events extends WebhookEventSchemas = WebhookEventSchemas,
>(
  name: Name,
  options: DefineWebhookOptions<Events> = {},
): WebhookDef<Name, Events> {
  if (!name) throw new WebhookOptionsError("Webhook name is required.");

  return {
    kind: "webhook",
    name,
    provider: options.provider,
    events: (options.events ?? {}) as Events,
    verifier: options.verifier,
    metadata: options.metadata,
  };
}

/**
 * Verify a raw webhook request and validate the matching event payload schema.
 */
export async function verifyWebhook<
  TWebhook extends WebhookDef<string, WebhookEventSchemas>,
  AllowUnknownEvents extends boolean = false,
>(
  webhook: TWebhook,
  input: VerifyWebhookInput,
  options: VerifyWebhookOptions<AllowUnknownEvents> = {},
): Promise<InferWebhookVerificationResult<TWebhook, AllowUnknownEvents>> {
  validateWebhook(webhook);
  const verifier = options.verifier ?? webhook.verifier;
  if (!verifier) {
    throw new WebhookOptionsError(
      `Webhook "${webhook.name}" does not have a verifier.`,
    );
  }

  let event: WebhookEvent;
  try {
    event = await verifier.verify(input);
  } catch (error) {
    if (error instanceof WebhookVerificationError) throw error;
    throw new WebhookVerificationError({
      message: `Webhook "${webhook.name}" verification failed.`,
      webhookName: webhook.name,
      provider: webhook.provider,
      code: "verification_failed",
      cause: error,
    });
  }

  return parseWebhookEvent(webhook, event, options);
}

/**
 * Validate a verified webhook event against its catalog entry.
 */
export async function parseWebhookEvent<
  TWebhook extends WebhookDef<string, WebhookEventSchemas>,
  AllowUnknownEvents extends boolean = false,
>(
  webhook: TWebhook,
  event: WebhookEvent,
  options: Pick<
    VerifyWebhookOptions<AllowUnknownEvents>,
    "allowUnknownEvents"
  > = {},
): Promise<InferWebhookVerificationResult<TWebhook, AllowUnknownEvents>> {
  validateWebhook(webhook);
  validateEvent(webhook, event);

  const schema = webhook.events[event.type];
  if (!schema) {
    if (options.allowUnknownEvents === true) {
      return event as InferWebhookVerificationResult<
        TWebhook,
        AllowUnknownEvents
      >;
    }
    throw new WebhookVerificationError({
      message: `Webhook "${webhook.name}" received unknown event type "${event.type}".`,
      webhookName: webhook.name,
      provider: webhook.provider,
      code: "unknown_event_type",
    });
  }

  const result = await schema["~standard"].validate(event.payload);
  if (result.issues?.length) {
    throw new WebhookValidationError({
      webhookName: webhook.name,
      eventType: event.type,
      issues: result.issues,
    });
  }

  if (!("value" in result)) {
    throw new Error("Invalid Standard Schema result: missing value");
  }

  return {
    ...event,
    payload: result.value,
  } as InferWebhookVerificationResult<TWebhook, AllowUnknownEvents>;
}

/**
 * Create an in-memory verifier for tests.
 */
export function createMemoryWebhookVerifier(
  options: CreateMemoryWebhookVerifierOptions = {},
): MemoryWebhookVerifier {
  const queuedEvents = [...(options.events ?? [])];
  const verifiedEvents: WebhookEvent[] = [];

  return {
    get verifiedEvents() {
      return verifiedEvents;
    },
    async verify() {
      const event = queuedEvents.shift();
      if (!event) {
        throw new WebhookVerificationError({
          message: "No memory webhook event is queued.",
          code: "missing_memory_event",
        });
      }
      verifiedEvents.push(event);
      return event;
    },
    queue(event) {
      queuedEvents.push(event);
    },
    reset() {
      queuedEvents.length = 0;
      verifiedEvents.length = 0;
    },
  };
}

/**
 * Create a generic JSON + HMAC verifier.
 */
export function createHmacWebhookVerifier(
  options: CreateHmacWebhookVerifierOptions,
): WebhookVerifier {
  if (!options.secret) {
    throw new WebhookOptionsError("Webhook HMAC secret is required.");
  }

  const signatureHeader = normalizeHeaderName(
    options.signatureHeader ?? "x-webhook-signature",
  );
  const algorithm = options.algorithm ?? "SHA-256";
  const eventIdPath = options.eventIdPath ?? "id";
  const eventTypePath = options.eventTypePath ?? "type";
  const timestamp = normalizeHmacTimestampOptions(options.timestamp);

  return {
    async verify(input) {
      const signature = input.signature ?? input.headers?.[signatureHeader];
      if (!signature) {
        throw new WebhookVerificationError({
          message: `Missing ${signatureHeader} header.`,
          provider: options.provider,
          code: "missing_signature",
        });
      }

      const signedBody = hmacSignedBody({
        input,
        provider: options.provider,
        timestamp,
      });
      const expected = await hmacHex(algorithm, options.secret, signedBody);
      const actual = normalizeSignature(
        signature,
        options.signaturePrefix,
      ).toLowerCase();
      if (!(await timingSafeStringEqual(actual, expected))) {
        throw new WebhookVerificationError({
          message: "Webhook signature is invalid.",
          provider: options.provider,
          code: "invalid_signature",
        });
      }

      const payload = parseJsonBody(input.rawBody);
      const createdAt = validateHmacTimestamp({
        input,
        payload,
        provider: options.provider,
        timestamp,
      });
      const id = readStringPath(payload, eventIdPath);
      const type = readStringPath(payload, eventTypePath);

      if (!id) {
        throw new WebhookVerificationError({
          message: `Webhook payload is missing string event ID at "${eventIdPath}".`,
          provider: options.provider,
          code: "missing_event_id",
        });
      }
      if (!type) {
        throw new WebhookVerificationError({
          message: `Webhook payload is missing string event type at "${eventTypePath}".`,
          provider: options.provider,
          code: "missing_event_type",
        });
      }

      return {
        id,
        type,
        provider: options.provider,
        ...(createdAt ? { createdAt } : {}),
        payload,
        raw: payload,
      };
    },
  };
}

function validateWebhook(webhook: WebhookDef) {
  if (!webhook.name) throw new WebhookOptionsError("Webhook name is required.");
}

function validateEvent(webhook: WebhookDef, event: WebhookEvent) {
  if (!event.id) {
    throw new WebhookVerificationError({
      message: `Webhook "${webhook.name}" event is missing an ID.`,
      webhookName: webhook.name,
      provider: webhook.provider,
      code: "missing_event_id",
    });
  }
  if (!event.type) {
    throw new WebhookVerificationError({
      message: `Webhook "${webhook.name}" event is missing a type.`,
      webhookName: webhook.name,
      provider: webhook.provider,
      code: "missing_event_type",
    });
  }
}

function formatPath(path: StandardSchemaV1.Issue["path"]): string {
  if (!path?.length) return "";

  return path
    .map((segment) =>
      typeof segment === "object" && segment !== null && "key" in segment
        ? String(segment.key)
        : String(segment),
    )
    .join(".");
}

function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string {
  return issues
    .map((issue) => {
      const path = formatPath(issue.path);
      return path ? `${path}: ${issue.message}` : issue.message;
    })
    .join("; ");
}

function normalizeHeaderName(name: string): string {
  return name.toLowerCase();
}

function normalizeSignature(signature: string, prefix: string | undefined) {
  const trimmed = signature.trim();
  if (!prefix) return trimmed;
  return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed;
}

function rawBodyBytes(rawBody: WebhookRawBody): Uint8Array {
  if (typeof rawBody === "string") return new TextEncoder().encode(rawBody);
  if (rawBody instanceof Uint8Array) return rawBody;
  return new Uint8Array(rawBody);
}

function rawBodyText(rawBody: WebhookRawBody): string {
  if (typeof rawBody === "string") return rawBody;
  return new TextDecoder().decode(rawBodyBytes(rawBody));
}

function hmacSignedBody(args: {
  input: VerifyWebhookInput;
  provider: string | undefined;
  timestamp: NormalizedHmacTimestampOptions | undefined;
}): WebhookRawBody {
  if (!args.timestamp || args.timestamp.source === "payload") {
    return args.input.rawBody;
  }

  const value = args.input.headers?.[args.timestamp.key];
  if (value === undefined || value === null || value === "") {
    throw new WebhookVerificationError({
      message: `Missing ${args.timestamp.key} header.`,
      provider: args.provider,
      code: "missing_timestamp",
    });
  }

  const prefix = new TextEncoder().encode(`${value}.`);
  const body = rawBodyBytes(args.input.rawBody);
  const signed = new Uint8Array(prefix.byteLength + body.byteLength);
  signed.set(prefix);
  signed.set(body, prefix.byteLength);
  return signed;
}

function parseJsonBody(rawBody: WebhookRawBody): unknown {
  try {
    return JSON.parse(rawBodyText(rawBody));
  } catch (error) {
    throw new WebhookVerificationError({
      message: "Webhook payload must be valid JSON.",
      code: "invalid_json",
      cause: error,
    });
  }
}

function readPath(input: unknown, path: string): unknown {
  let value = input;
  for (const segment of path.split(".")) {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      return undefined;
    }
    value = (value as Record<string, unknown>)[segment];
  }

  return value;
}

function readStringPath(input: unknown, path: string): string | undefined {
  const value = readPath(input, path);
  return typeof value === "string" && value.length > 0 ? value : undefined;
}

type NormalizedHmacTimestampOptions =
  | {
      source: "header";
      key: string;
      format: HmacWebhookTimestampFormat;
      toleranceSec: number;
    }
  | {
      source: "payload";
      path: string;
      format: HmacWebhookTimestampFormat;
      toleranceSec: number;
    };

function normalizeHmacTimestampOptions(
  timestamp: HmacWebhookTimestampOptions | undefined,
): NormalizedHmacTimestampOptions | undefined {
  if (!timestamp) return undefined;

  const format = timestamp.format ?? "unix-seconds";
  if (
    format !== "unix-seconds" &&
    format !== "unix-milliseconds" &&
    format !== "iso8601"
  ) {
    throw new WebhookOptionsError(
      "Webhook HMAC timestamp format must be unix-seconds, unix-milliseconds, or iso8601.",
    );
  }
  const toleranceSec = timestamp.toleranceSec ?? 300;
  if (!Number.isInteger(toleranceSec) || toleranceSec <= 0) {
    throw new WebhookOptionsError(
      "Webhook HMAC timestamp toleranceSec must be a positive integer.",
    );
  }

  if ("header" in timestamp) {
    if (!timestamp.header) {
      throw new WebhookOptionsError(
        "Webhook HMAC timestamp header is required.",
      );
    }

    return {
      source: "header",
      key: normalizeHeaderName(timestamp.header),
      format,
      toleranceSec,
    };
  }

  if (!timestamp.payloadPath) {
    throw new WebhookOptionsError(
      "Webhook HMAC timestamp payloadPath is required.",
    );
  }

  return {
    source: "payload",
    path: timestamp.payloadPath,
    format,
    toleranceSec,
  };
}

function validateHmacTimestamp(args: {
  input: VerifyWebhookInput;
  payload: unknown;
  provider: string | undefined;
  timestamp: NormalizedHmacTimestampOptions | undefined;
}): Date | undefined {
  const { timestamp } = args;
  if (!timestamp) return undefined;

  const value =
    timestamp.source === "header"
      ? args.input.headers?.[timestamp.key]
      : readPath(args.payload, timestamp.path);

  if (value === undefined || value === null || value === "") {
    throw new WebhookVerificationError({
      message:
        timestamp.source === "header"
          ? `Missing ${timestamp.key} header.`
          : `Webhook payload is missing timestamp at "${timestamp.path}".`,
      provider: args.provider,
      code: "missing_timestamp",
    });
  }

  const createdAt = parseHmacTimestampValue(
    value,
    timestamp.format,
    args.provider,
  );
  const receivedAt = args.input.receivedAt ?? new Date();
  const skewMs = Math.abs(receivedAt.getTime() - createdAt.getTime());
  if (skewMs > timestamp.toleranceSec * 1000) {
    throw new WebhookVerificationError({
      message: `Webhook timestamp is outside the ${timestamp.toleranceSec}s tolerance.`,
      provider: args.provider,
      code: "timestamp_outside_tolerance",
    });
  }

  return createdAt;
}

function parseHmacTimestampValue(
  value: unknown,
  format: HmacWebhookTimestampFormat,
  provider: string | undefined,
): Date {
  const fail = () =>
    new WebhookVerificationError({
      message: "Webhook timestamp is invalid.",
      provider,
      code: "invalid_timestamp",
    });

  let millis: number;
  if (format === "iso8601") {
    if (typeof value !== "string") throw fail();
    millis = Date.parse(value);
  } else {
    const numeric =
      typeof value === "number"
        ? value
        : typeof value === "string"
          ? Number(value)
          : Number.NaN;
    if (!Number.isFinite(numeric)) throw fail();
    millis = format === "unix-seconds" ? numeric * 1000 : numeric;
  }

  if (!Number.isFinite(millis)) throw fail();
  const date = new Date(millis);
  if (Number.isNaN(date.getTime())) throw fail();
  return date;
}

async function hmacHex(
  algorithm: CreateHmacWebhookVerifierOptions["algorithm"],
  secret: string,
  rawBody: WebhookRawBody,
): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: algorithm ?? "SHA-256" },
    false,
    ["sign"],
  );
  const bytes = rawBodyBytes(rawBody);
  const data = bytes.buffer.slice(
    bytes.byteOffset,
    bytes.byteOffset + bytes.byteLength,
  ) as ArrayBuffer;
  const signature = await crypto.subtle.sign("HMAC", key, data);
  return bytesToHex(new Uint8Array(signature));
}

function bytesToHex(bytes: Uint8Array): string {
  return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
  const encoder = new TextEncoder();
  const [digestA, digestB] = await Promise.all([
    crypto.subtle.digest("SHA-256", encoder.encode(a)),
    crypto.subtle.digest("SHA-256", encoder.encode(b)),
  ]);
  const bytesA = new Uint8Array(digestA);
  const bytesB = new Uint8Array(digestB);

  let mismatch = 0;
  for (let index = 0; index < bytesA.length; index++) {
    mismatch |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0);
  }

  return mismatch === 0;
}
