/**
 * The single HTTP entry point to the marketplace API.
 *
 * Everything the SPA sends goes through here so that four concerns are handled
 * once rather than at each call site:
 *
 *   - Session credentials. Requests are sent with cookies, and Laravel's CSRF
 *     token is read from its cookie and echoed back as a header.
 *   - CSRF lifecycle. A 419 means the token went stale (a long-idle tab, a
 *     restarted dev server); the client refreshes and retries once rather than
 *     surfacing a confusing failure.
 *   - Error shape. Laravel's validation errors, throttle responses and
 *     two-factor challenges all arrive as HTTP failures with different bodies;
 *     they are normalised into one typed error.
 *   - Auth expiry. A 401 is broadcast so the app can clear its session state
 *     from wherever the request happened to be made.
 */

/** Where the API lives. Same-origin in development via the Vite proxy. */
const API_BASE = import.meta.env.VITE_API_BASE ?? ''

const API_PREFIX = '/api/v1'

/** Fired on a 401 so the auth layer can drop a session that is already gone. */
export const UNAUTHENTICATED_EVENT = 'domainsansar:unauthenticated'

export type ValidationErrors = Record<string, string[]>

/**
 * A failed API call.
 *
 * Carries enough structure that a form can bind field errors, and a caller can
 * distinguish "you typed something wrong" from "you are signed out" from "slow
 * down" without parsing strings.
 */
export class ApiError extends Error {
  readonly status: number

  readonly errors: ValidationErrors

  /** Present on 403 responses about account standing, e.g. `suspended`. */
  readonly accountStatus?: string

  /** Seconds to wait, from a 429. */
  readonly retryAfter?: number

  /** True when the API is asking for a second authentication factor. */
  readonly twoFactorRequired: boolean

  constructor(
    status: number,
    message: string,
    options: {
      errors?: ValidationErrors
      accountStatus?: string
      retryAfter?: number
      twoFactorRequired?: boolean
    } = {},
  ) {
    super(message)
    this.name = 'ApiError'
    this.status = status
    this.errors = options.errors ?? {}
    this.twoFactorRequired = options.twoFactorRequired ?? false

    if (options.accountStatus !== undefined) {
      this.accountStatus = options.accountStatus
    }
    if (options.retryAfter !== undefined) {
      this.retryAfter = options.retryAfter
    }
  }

  get isValidation(): boolean {
    return this.status === 422
  }

  get isUnauthenticated(): boolean {
    return this.status === 401
  }

  get isForbidden(): boolean {
    return this.status === 403
  }

  get isThrottled(): boolean {
    return this.status === 429
  }

  /** The first error for a field, for inline form display. */
  errorFor(field: string): string | undefined {
    return this.errors[field]?.[0]
  }
}

interface RequestOptions {
  method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
  body?: unknown
  /** Query parameters. Null and undefined values are dropped. */
  query?: Record<string, string | number | boolean | null | undefined>
  signal?: AbortSignal
  /** Set false to skip the CSRF preflight, e.g. for a fire-and-forget logout. */
  csrf?: boolean
}

function readCookie(name: string): string | null {
  const match = document.cookie.match(new RegExp(`(^|;\\s*)${name}=([^;]*)`))

  return match?.[2] ? decodeURIComponent(match[2]) : null
}

/**
 * Ask Laravel to set the CSRF cookie.
 *
 * Concurrent callers share one in-flight request: on a cold load several
 * queries can fire at once, and without this they would each trigger their own
 * preflight.
 */
let csrfRequest: Promise<void> | null = null

async function ensureCsrfCookie(force = false): Promise<void> {
  if (!force && readCookie('XSRF-TOKEN') !== null) {
    return
  }

  csrfRequest ??= fetch(`${API_BASE}/sanctum/csrf-cookie`, {
    credentials: 'include',
    headers: { Accept: 'application/json' },
  })
    .then(() => undefined)
    .finally(() => {
      csrfRequest = null
    })

  await csrfRequest
}

function buildUrl(path: string, query?: RequestOptions['query']): string {
  const url = `${API_BASE}${API_PREFIX}${path.startsWith('/') ? path : `/${path}`}`

  if (!query) {
    return url
  }

  const params = new URLSearchParams()

  for (const [key, value] of Object.entries(query)) {
    if (value !== null && value !== undefined && value !== '') {
      params.append(key, String(value))
    }
  }

  const queryString = params.toString()

  return queryString ? `${url}?${queryString}` : url
}

async function parseBody(response: Response): Promise<unknown> {
  if (response.status === 204) {
    return null
  }

  const contentType = response.headers.get('Content-Type') ?? ''

  if (!contentType.includes('json')) {
    return await response.text()
  }

  try {
    return await response.json()
  } catch {
    // A truncated or empty body on an error status is not worth throwing over.
    return null
  }
}

function toApiError(status: number, body: unknown): ApiError {
  const payload = (body ?? {}) as Record<string, unknown>

  const message =
    typeof payload.message === 'string' && payload.message !== ''
      ? payload.message
      : defaultMessageFor(status)

  return new ApiError(status, message, {
    errors: (payload.errors as ValidationErrors | undefined) ?? {},
    ...(typeof payload.status === 'string' ? { accountStatus: payload.status } : {}),
    ...(typeof payload.retry_after === 'number' ? { retryAfter: payload.retry_after } : {}),
    twoFactorRequired: payload.two_factor_required === true,
  })
}

function defaultMessageFor(status: number): string {
  switch (status) {
    case 401:
      return 'Your session has ended. Sign in to continue.'
    case 403:
      return 'You do not have permission to do that.'
    case 404:
      return 'That could not be found.'
    case 409:
      return 'That conflicts with the current state.'
    case 422:
      return 'Please check the highlighted fields.'
    case 429:
      return 'Too many requests. Please wait a moment and try again.'
    case 500:
    case 502:
    case 503:
      return 'Something went wrong on our side. Please try again shortly.'
    default:
      return 'The request could not be completed.'
  }
}

async function send<T>(path: string, options: RequestOptions, isRetry = false): Promise<T> {
  const method = options.method ?? 'GET'
  const mutating = method !== 'GET'

  if (mutating && options.csrf !== false) {
    await ensureCsrfCookie()
  }

  const headers: Record<string, string> = {
    Accept: 'application/json',
    // Tells Laravel to answer with JSON rather than a redirect on auth failure.
    'X-Requested-With': 'XMLHttpRequest',
  }

  if (options.body !== undefined) {
    headers['Content-Type'] = 'application/json'
  }

  const token = readCookie('XSRF-TOKEN')
  if (token !== null && mutating) {
    headers['X-XSRF-TOKEN'] = token
  }

  let response: Response

  try {
    response = await fetch(buildUrl(path, options.query), {
      method,
      headers,
      credentials: 'include',
      ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
      ...(options.signal ? { signal: options.signal } : {}),
    })
  } catch (error) {
    // An aborted request is the caller's own doing; pass it through untouched
    // so React Query can treat it as a cancellation rather than a failure.
    if (error instanceof DOMException && error.name === 'AbortError') {
      throw error
    }

    throw new ApiError(0, 'Cannot reach Domainsansar. Check your connection and try again.')
  }

  if (response.ok) {
    return (await parseBody(response)) as T
  }

  /*
   * 419 is Laravel's "CSRF token mismatch". It happens to real users whose tab
   * sat idle past the session lifetime, so the token is refreshed and the call
   * retried once before bothering them with an error.
   */
  if (response.status === 419 && !isRetry) {
    await ensureCsrfCookie(true)

    return await send<T>(path, options, true)
  }

  const body = await parseBody(response)
  const error = toApiError(response.status, body)

  if (error.isUnauthenticated) {
    window.dispatchEvent(new CustomEvent(UNAUTHENTICATED_EVENT))
  }

  throw error
}

export const api = {
  get: <T>(path: string, options: Omit<RequestOptions, 'method' | 'body'> = {}) =>
    send<T>(path, { ...options, method: 'GET' }),

  post: <T>(path: string, body?: unknown, options: Omit<RequestOptions, 'method' | 'body'> = {}) =>
    send<T>(path, { ...options, method: 'POST', body }),

  patch: <T>(path: string, body?: unknown, options: Omit<RequestOptions, 'method' | 'body'> = {}) =>
    send<T>(path, { ...options, method: 'PATCH', body }),

  put: <T>(path: string, body?: unknown, options: Omit<RequestOptions, 'method' | 'body'> = {}) =>
    send<T>(path, { ...options, method: 'PUT', body }),

  delete: <T>(path: string, body?: unknown, options: Omit<RequestOptions, 'method' | 'body'> = {}) =>
    send<T>(path, { ...options, method: 'DELETE', body }),
}
