import type { ChargeRequest, RefundRequest } from '../index.js'
import type { CreateSubscriptionInput } from '../subscriptions/index.js'
import {
  buildOrderCreateBody,
  buildSubscriptionCreateBody,
  assertPositiveMinorUnits,
  extractCaptureId,
  getPaypalApiBaseUrl,
  getPaypalFetch,
  mapSubscriptionRecord,
  type PaypalCapture,
  type PaypalCreds,
  PaypalApiError,
  type PaypalOrder,
  type PaypalRefund,
  type PaypalSubscriptionRecord,
} from './types.js'

function encodeBasicAuth(value: string): string {
  return btoa(value)
}

type AccessTokenResponse = {
  access_token?: string
  expires_in?: number
}

export class PaypalClient {
  private readonly fetchImpl: typeof fetch
  private readonly apiBaseUrl: string
  private accessToken: string | null = null
  private accessTokenExpiresAt = 0

  constructor(private readonly creds: PaypalCreds) {
    this.fetchImpl = getPaypalFetch(creds)
    this.apiBaseUrl = getPaypalApiBaseUrl(creds)
  }

  async createOrder(req: ChargeRequest): Promise<PaypalOrder> {
    return this.requestJson<PaypalOrder>('/v2/checkout/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'PayPal-Request-Id': req.chargeKey,
      },
      body: JSON.stringify(buildOrderCreateBody(req)),
    }, [200, 201])
  }

  async getCapture(id: string): Promise<PaypalCapture> {
    return this.requestJson<PaypalCapture>(`/v2/payments/captures/${id}`, {
      method: 'GET',
    })
  }

  async refundCapture(req: RefundRequest): Promise<PaypalRefund> {
    assertPositiveMinorUnits(req.amount, 'refund amount')
    const captureId = extractCaptureId(req)
    const capture = await this.getCapture(captureId)
    const currency =
      capture.amount?.currency_code ?? capture.amount?.currency ?? undefined

    return this.requestJson<PaypalRefund>(
      `/v2/payments/captures/${captureId}/refund`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'PayPal-Request-Id': req.refundKey,
        },
        body: JSON.stringify(
          currency
            ? {
                amount: {
                  currency_code: currency,
                  value: (req.amount / 100).toFixed(2),
                },
              }
            : {},
        ),
      },
      [200, 201, 202],
    )
  }

  async createSubscription(input: CreateSubscriptionInput) {
    const record = await this.requestJson<PaypalSubscriptionRecord>(
      '/v1/billing/subscriptions',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'PayPal-Request-Id': input.idempotencyKey,
        },
        body: JSON.stringify(buildSubscriptionCreateBody(input)),
      },
      [200, 201],
    )

    return mapSubscriptionRecord(record)
  }

  async cancelSubscription(id: string): Promise<void> {
    await this.requestJson<undefined>(
      `/v1/billing/subscriptions/${id}/cancel`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ reason: 'Canceled by merchant' }),
      },
      [200, 202, 204],
    )
  }

  async getSubscription(id: string) {
    const record = await this.requestJson<PaypalSubscriptionRecord>(
      `/v1/billing/subscriptions/${id}`,
      {
        method: 'GET',
      },
    )

    return mapSubscriptionRecord(record)
  }

  async fetchCertificate(url: string): Promise<string> {
    const response = await this.fetchImpl(url, { method: 'GET' })
    if (!response.ok) {
      throw new PaypalApiError(url, response.status, await safeParseBody(response))
    }
    return response.text()
  }

  private async requestJson<T>(
    path: string,
    init: RequestInit,
    okStatuses: readonly number[] = [200],
  ): Promise<T> {
    const token = await this.getAccessToken()
    const response = await this.fetchImpl(`${this.apiBaseUrl}${path}`, {
      ...init,
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${token}`,
        ...(init.headers ?? {}),
      },
    })

    if (!okStatuses.includes(response.status)) {
      throw new PaypalApiError(path, response.status, await safeParseBody(response))
    }

    if (response.status === 204) {
      return undefined as T
    }

    return (await response.json()) as T
  }

  private async getAccessToken(): Promise<string> {
    const now = Date.now()
    if (this.accessToken && now < this.accessTokenExpiresAt) {
      return this.accessToken
    }

    const response = await this.fetchImpl(`${this.apiBaseUrl}/v1/oauth2/token`, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        Authorization: `Basic ${encodeBasicAuth(`${this.creds.clientId}:${this.creds.clientSecret}`)}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(),
    })

    const body = (await safeParseBody(response)) as AccessTokenResponse | string
    if (!response.ok) {
      throw new PaypalApiError('/v1/oauth2/token', response.status, body)
    }

    const accessToken =
      typeof body === 'object' && body !== null ? body.access_token : undefined
    if (typeof accessToken !== 'string' || accessToken.trim() === '') {
      throw new PaypalApiError('/v1/oauth2/token', response.status, body)
    }

    const expiresIn =
      typeof body === 'object' && body !== null && typeof body.expires_in === 'number'
        ? body.expires_in
        : 300
    this.accessToken = accessToken
    this.accessTokenExpiresAt = now + Math.max(1, expiresIn - 30) * 1000
    return accessToken
  }
}

async function safeParseBody(response: Response): Promise<unknown> {
  const contentType = response.headers.get('content-type') ?? ''
  if (contentType.includes('application/json')) {
    try {
      return await response.json()
    } catch {
      return null
    }
  }

  try {
    return await response.text()
  } catch {
    return null
  }
}
