import { idempotencyKey, WebhookVerificationError, type ProviderEvent } from '../index.js'
import { PaypalClient } from './client.js'
import {
  normalizeMinorUnits,
  normalizePeriod,
  parsePositivePaypalMoney,
  PaypalPayloadError,
  requireString,
  resolveChargeKey,
  type PaypalCreds,
  type PaypalWebhookEvent,
  type PaypalWebhookHeaders,
} from './types.js'

const TEXT_ENCODER = new TextEncoder()

export async function parsePaypalWebhook(
  creds: PaypalCreds,
  rawBody: string,
  headers: Headers,
): Promise<ProviderEvent> {
  const client = new PaypalClient(creds)
  const signatureHeaders = readWebhookHeaders(headers)
  await verifyWebhookSignature(client, creds.webhookId, signatureHeaders, rawBody)

  let payload: PaypalWebhookEvent
  try {
    payload = JSON.parse(rawBody) as PaypalWebhookEvent
  } catch {
    throw new PaypalPayloadError('PayPal webhook payload is not valid JSON')
  }

  const eventId = requireString(payload.id, 'webhook.id')
  const eventType = requireString(payload.event_type, 'webhook.event_type')
  const resource = asRecord(payload.resource, 'webhook.resource')

  if (eventType === 'PAYMENT.SALE.COMPLETED') {
    const providerRef = requireString(resource.id, 'resource.id')
    const subscriptionId = readFirstString(
      resource,
      'billing_agreement_id',
      'subscription_id',
      'agreement_id',
    )
    const period = resolveWebhookPeriod(resource)
    const chargeKey = resolveChargeKey(subscriptionId, period, providerRef)
    const { amountMinor, currency } = parsePositivePaypalMoney(readMoney(resource))

    return {
      eventId,
      kind: 'settlement',
      chargeKey,
      providerRef,
      amount: normalizeMinorUnits(amountMinor),
      currency,
      subscriptionId: subscriptionId ?? undefined,
      period: period ?? undefined,
      amountMinor,
    }
  }

  if (eventType === 'PAYMENT.SALE.REFUNDED') {
    const refundId = requireString(resource.id, 'resource.id')
    const subscriptionId = readFirstString(
      resource,
      'billing_agreement_id',
      'subscription_id',
      'agreement_id',
    )
    const period = resolveRefundChargePeriod(resource)
    const fallbackChargeKey =
      readFirstString(resource, 'sale_id', 'parent_payment') ?? refundId
    const providerChargeId = requireString(
      readFirstString(resource, 'sale_id', 'parent_payment'),
      'resource.sale_id',
    )
    const chargeKey = resolveChargeKey(subscriptionId, period, fallbackChargeKey)
    const { amountMinor, currency } = parsePositivePaypalMoney(readMoney(resource))

    return {
      eventId,
      kind: 'refund',
      refundKey: idempotencyKey(['refund', chargeKey, refundId]),
      chargeKey,
      providerChargeId,
      providerRef: refundId,
      amount: normalizeMinorUnits(amountMinor),
      currency,
    }
  }

  return {
    eventId,
    kind: 'other',
    raw: payload,
  }
}

function readWebhookHeaders(headers: Headers): PaypalWebhookHeaders {
  return {
    authAlgo: requireHeader(
      headers,
      'paypal-auth-algo',
      'paypal-auth-algo',
      'paypal-auth_algo',
    ),
    certUrl: requireHeader(headers, 'paypal-cert-url', 'paypal-cert_url'),
    signature: requireHeader(
      headers,
      'paypal-transmission-sig',
      'paypal-transmission_sig',
    ),
    transmissionId: requireHeader(
      headers,
      'paypal-transmission-id',
      'paypal-transmission_id',
    ),
    transmissionTime: requireHeader(
      headers,
      'paypal-transmission-time',
      'paypal-transmission_time',
    ),
  }
}

async function verifyWebhookSignature(
  client: PaypalClient,
  webhookId: string,
  headers: PaypalWebhookHeaders,
  rawBody: string,
): Promise<void> {
  assertTrustedCertUrl(headers.certUrl)

  const pem = await client.fetchCertificate(headers.certUrl)
  const spki = pemToSpki(pem)
  const algorithm = resolveVerifyAlgorithm(headers.authAlgo)
  const key = await crypto.subtle.importKey('spki', spki, algorithm, false, ['verify'])

  const signedMessage = `${headers.transmissionId}|${headers.transmissionTime}|${webhookId}|${crc32(
    rawBody,
  )}`
  const signature = decodeBase64(headers.signature)
  const isValid = await crypto.subtle.verify(
    algorithm,
    key,
    copyBytes(signature),
    copyBytes(TEXT_ENCODER.encode(signedMessage)),
  )

  if (!isValid) {
    throw new WebhookVerificationError('PayPal webhook signature verification failed')
  }
}

function requireHeader(headers: Headers, ...names: string[]): string {
  for (const name of names) {
    const value = headers.get(name)
    if (typeof value === 'string' && value.trim() !== '') {
      return value
    }
  }
  throw new WebhookVerificationError(`missing PayPal webhook header: ${names[0]}`)
}

function resolveVerifyAlgorithm(name: string): RsaHashedImportParams {
  if (name === 'SHA256withRSA' || name === 'RSASSA-PKCS1-v1_5') {
    return {
      name: 'RSASSA-PKCS1-v1_5',
      hash: 'SHA-256',
    }
  }
  throw new WebhookVerificationError(`unsupported PayPal webhook algorithm: ${name}`)
}

function assertTrustedCertUrl(value: string): void {
  let url: URL
  try {
    url = new URL(value)
  } catch {
    throw new WebhookVerificationError(`invalid PayPal cert url: ${value}`)
  }

  const hostname = url.hostname.toLowerCase()
  if (
    url.protocol !== 'https:' ||
    (hostname !== 'paypal.com' && hostname !== 'api-m.paypal.com' && !hostname.endsWith('.paypal.com'))
  ) {
    throw new WebhookVerificationError(`untrusted PayPal cert url: ${value}`)
  }
}

function pemToSpki(pem: string): ArrayBuffer {
  const trimmed = pem.trim()
  if (trimmed.includes('BEGIN PUBLIC KEY')) {
    return copyArrayBuffer(decodePemBlock(trimmed, 'PUBLIC KEY'))
  }
  if (trimmed.includes('BEGIN CERTIFICATE')) {
    return copyArrayBuffer(extractSpkiFromCertificate(decodePemBlock(trimmed, 'CERTIFICATE')))
  }
  throw new WebhookVerificationError('unsupported PayPal certificate PEM block')
}

function decodePemBlock(pem: string, label: string): Uint8Array {
  const begin = `-----BEGIN ${label}-----`
  const end = `-----END ${label}-----`
  const withoutMarkers = pem.replace(begin, '').replace(end, '').replace(/\s+/g, '')
  return decodeBase64(withoutMarkers)
}

function decodeBase64(value: string): Uint8Array {
  const decoded = atob(value)
  const bytes = new Uint8Array(decoded.length)
  for (let index = 0; index < decoded.length; index += 1) {
    bytes[index] = decoded.charCodeAt(index)
  }
  return bytes
}

function copyBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
  const copy = new Uint8Array(bytes.byteLength) as Uint8Array<ArrayBuffer>
  copy.set(bytes)
  return copy
}

function copyArrayBuffer(bytes: Uint8Array): ArrayBuffer {
  return copyBytes(bytes).buffer as ArrayBuffer
}

function extractSpkiFromCertificate(certificateDer: Uint8Array): Uint8Array {
  const certificate = readDerElement(certificateDer, 0)
  const certificateBody = certificateDer.subarray(certificate.valueOffset, certificate.endOffset)
  let cursor = 0
  const tbsCertificate = readDerElement(certificateBody, cursor)
  const tbsValue = certificateBody.subarray(tbsCertificate.valueOffset, tbsCertificate.endOffset)
  cursor = 0

  let element = readDerElement(tbsValue, cursor)
  if (element.tag === 0xa0) {
    cursor = element.nextOffset
    element = readDerElement(tbsValue, cursor)
  }

  cursor = element.nextOffset
  element = readDerElement(tbsValue, cursor)
  cursor = element.nextOffset
  element = readDerElement(tbsValue, cursor)
  cursor = element.nextOffset
  element = readDerElement(tbsValue, cursor)
  cursor = element.nextOffset
  element = readDerElement(tbsValue, cursor)
  cursor = element.nextOffset
  element = readDerElement(tbsValue, cursor)

  return tbsValue.subarray(element.startOffset, element.nextOffset)
}

type DerElement = {
  tag: number
  startOffset: number
  valueOffset: number
  endOffset: number
  nextOffset: number
}

function readDerElement(bytes: Uint8Array, offset: number): DerElement {
  const tag = bytes[offset]
  if (tag === undefined) {
    throw new WebhookVerificationError('unexpected end of DER payload')
  }

  const lengthByte = bytes[offset + 1]
  if (lengthByte === undefined) {
    throw new WebhookVerificationError('missing DER length')
  }

  let length = 0
  let valueOffset = offset + 2
  if ((lengthByte & 0x80) === 0) {
    length = lengthByte
  } else {
    const lengthBytes = lengthByte & 0x7f
    if (lengthBytes === 0 || lengthBytes > 4) {
      throw new WebhookVerificationError('unsupported DER length encoding')
    }
    valueOffset = offset + 2 + lengthBytes
    for (let index = 0; index < lengthBytes; index += 1) {
      const value = bytes[offset + 2 + index]
      if (value === undefined) {
        throw new WebhookVerificationError('truncated DER length')
      }
      length = (length << 8) | value
    }
  }

  const endOffset = valueOffset + length
  if (endOffset > bytes.length) {
    throw new WebhookVerificationError('truncated DER element')
  }

  return {
    tag,
    startOffset: offset,
    valueOffset,
    endOffset,
    nextOffset: endOffset,
  }
}

function crc32(value: string): number {
  let crc = 0xffffffff
  const bytes = TEXT_ENCODER.encode(value)
  for (const byte of bytes) {
    crc ^= byte
    for (let bit = 0; bit < 8; bit += 1) {
      const mask = -(crc & 1)
      crc = (crc >>> 1) ^ (0xedb88320 & mask)
    }
  }
  return (crc ^ 0xffffffff) >>> 0
}

function asRecord(value: unknown, field: string): Record<string, unknown> {
  if (!value || typeof value !== 'object') {
    throw new PaypalPayloadError(`missing PayPal object "${field}"`)
  }
  return value as Record<string, unknown>
}

function readFirstString(record: Record<string, unknown>, ...keys: string[]): string | null {
  for (const key of keys) {
    const value = record[key]
    if (typeof value === 'string' && value.trim() !== '') {
      return value
    }
  }
  return null
}

function readMoney(resource: Record<string, unknown>) {
  const amount = resource.amount
  if (!amount || typeof amount !== 'object') {
    throw new PaypalPayloadError('missing PayPal resource amount')
  }
  return amount as {
    currency?: string
    currency_code?: string
    total?: string
    value?: string
  }
}

function resolveWebhookPeriod(resource: Record<string, unknown>): string | null {
  const billingPeriod = resource.billing_period
  if (billingPeriod && typeof billingPeriod === 'object') {
    const record = billingPeriod as Record<string, unknown>
    const normalized =
      normalizePeriod(record.start_date) ??
      normalizePeriod(record.period_start) ??
      normalizePeriod(record.end_date) ??
      normalizePeriod(record.period_end)
    if (normalized) return normalized
  }

  return (
    normalizePeriod(resource.create_time) ??
    normalizePeriod(resource.update_time) ??
    normalizePeriod(resource.time) ??
    normalizePeriod(resource.sale_time)
  )
}

function resolveRefundChargePeriod(resource: Record<string, unknown>): string | null {
  for (const key of ['sale', 'capture', 'parent_sale', 'parent_capture']) {
    const nested = resource[key]
    if (nested && typeof nested === 'object') {
      const period = resolveWebhookPeriod(nested as Record<string, unknown>)
      if (period) return period
    }
  }

  const billingPeriod = resource.billing_period
  if (billingPeriod && typeof billingPeriod === 'object') {
    const period = resolveWebhookPeriod({ billing_period: billingPeriod })
    if (period) return period
  }

  const subscriptionId = readFirstString(
    resource,
    'billing_agreement_id',
    'subscription_id',
    'agreement_id',
  )
  if (subscriptionId) {
    throw new PaypalPayloadError('missing PayPal original charge period for subscription refund')
  }

  return null
}
