import { readPublicRuntimeConfig, type PublicRuntimeConfig } from './config';

export type ApiErrorKind =
  | 'authentication'
  | 'authorization'
  | 'conflict'
  | 'invalid_request'
  | 'not_found'
  | 'rate_limited'
  | 'server'
  | 'network'
  | 'invalid_response';

export interface ApiErrorPayload {
  readonly code?: string;
  readonly message?: string;
  readonly requestId?: string;
}

export class ApiError extends Error {
  override readonly name = 'ApiError';

  constructor(
    message: string,
    readonly kind: ApiErrorKind,
    readonly status: number | null,
    readonly code: string | null = null,
    readonly requestId: string | null = null,
    options?: ErrorOptions,
  ) {
    super(message, options);
  }
}

export interface ApiRequestOptions<TBody = never>
  extends Omit<RequestInit, 'body' | 'credentials' | 'headers' | 'method'> {
  readonly method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  readonly body?: TBody;
  readonly headers?: Readonly<Record<string, string>>;
}

export interface ApiClient {
  request<TResponse, TBody = never>(
    path: `/${string}`,
    options?: ApiRequestOptions<TBody>,
  ): Promise<TResponse>;
}

function kindForStatus(status: number): ApiErrorKind {
  if (status === 400 || status === 422) return 'invalid_request';
  if (status === 401) return 'authentication';
  if (status === 403) return 'authorization';
  if (status === 404) return 'not_found';
  if (status === 409) return 'conflict';
  if (status === 429) return 'rate_limited';
  return 'server';
}

const SAFE_ERROR_MESSAGES: Readonly<Record<ApiErrorKind, string>> = {
  authentication: 'Sign in to continue.',
  authorization: 'You do not have permission to complete this request.',
  conflict: 'The request conflicts with the current state.',
  invalid_request: 'Check the request and try again.',
  not_found: 'The requested resource was not found.',
  rate_limited: 'Too many requests. Try again later.',
  server: 'The service could not complete the request.',
  network: 'Unable to reach the service. Try again.',
  invalid_response: 'The service returned an invalid response.',
};

function boundedIdentifier(value: string | undefined, maxLength: number): string | null {
  if (!value || value.length > maxLength || !/^[A-Za-z0-9._:-]+$/.test(value)) return null;
  return value;
}

async function readErrorPayload(response: Response): Promise<ApiErrorPayload> {
  if (!response.headers.get('content-type')?.includes('application/json')) return {};
  try {
    const value: unknown = await response.json();
    if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
    const record = value as Record<string, unknown>;
    return {
      ...(typeof record.code === 'string' ? { code: record.code } : {}),
      ...(typeof record.message === 'string' ? { message: record.message } : {}),
      ...(typeof record.requestId === 'string' ? { requestId: record.requestId } : {}),
    };
  } catch {
    return {};
  }
}

export function createApiClient(
  config: PublicRuntimeConfig = readPublicRuntimeConfig(),
  fetchImpl: typeof fetch = fetch,
): ApiClient {
  return {
    async request<TResponse, TBody = never>(
      path: `/${string}`,
      options: ApiRequestOptions<TBody> = {},
    ): Promise<TResponse> {
      const { body, method = 'GET', headers: headerValues, ...requestInit } = options;
      const headers = new Headers(headerValues);
      headers.set('accept', 'application/json');
      if (body !== undefined) headers.set('content-type', 'application/json');

      let response: Response;
      try {
        response = await fetchImpl(`${config.apiBaseUrl}${path}`, {
          ...requestInit,
          method,
          credentials: 'include',
          headers,
          ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
        });
      } catch (cause) {
        if (cause instanceof ApiError) throw cause;
        throw new ApiError('Unable to reach the service. Try again.', 'network', null, null, null, {
          cause,
        });
      }

      if (!response.ok) {
        const payload = await readErrorPayload(response);
        const kind = kindForStatus(response.status);
        throw new ApiError(
          SAFE_ERROR_MESSAGES[kind],
          kind,
          response.status,
          boundedIdentifier(payload.code, 64),
          boundedIdentifier(payload.requestId ?? response.headers.get('x-request-id') ?? undefined, 128),
        );
      }

      if (response.status === 204) return undefined as TResponse;
      try {
        return (await response.json()) as TResponse;
      } catch (cause) {
        throw new ApiError(
          'The service returned an invalid response.',
          'invalid_response',
          response.status,
          null,
          response.headers.get('x-request-id'),
          { cause },
        );
      }
    },
  };
}
