import { notifyUnauthorized } from './auth';
import type { components } from './generated/v1';

type ProblemDetails = components['schemas']['ProblemDetails'];
type RequestBody = BodyInit | Record<string, unknown> | null;

const MUTATING_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];

export class ApiError extends Error {
  readonly detail?: string | null;
  readonly problem?: ProblemDetails;
  readonly status: number;

  constructor(status: number, problem?: ProblemDetails) {
    super(problem?.title ?? `Request failed with status ${status}`);
    this.name = 'ApiError';
    this.detail = problem?.detail;
    this.problem = problem;
    this.status = status;
  }
}

export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
  body?: RequestBody;
  /**
   * When set, a 401 on this request does NOT fire the app-wide unauthorized signal (#295). Used by the
   * auth flows (login / change-password) where a 401 is an expected wrong-credentials answer that must
   * be shown inline, not surfaced as the global "your session expired" banner.
   */
  suppressUnauthorizedSignal?: boolean;
}

export interface ResponseWithMeta<T> {
  data: T;
  /** Strong ETag of the resource's version, when the endpoint emits one (issue #253). */
  etag: string | null;
}

/**
 * Like {@link request} but also returns the response's ETag. Used by the optimistic-concurrency
 * editors: read the ETag from the load GET, send it back as `If-Match` on the replace PUT, and
 * replace it from the PUT response's ETag on every successful save (issue #253 / spa-conventions).
 */
export async function requestWithMeta<TResponse = unknown>(
  path: string,
  options: ApiRequestOptions = {}
): Promise<ResponseWithMeta<TResponse>> {
  const { suppressUnauthorizedSignal, ...fetchOptions } = options;
  const method = (options.method ?? 'GET').toUpperCase();
  const headers = normalizeHeaders({
    Accept: 'application/json',
    ...headersToRecord(options.headers)
  });

  const body = serializeBody(options.body, headers);

  // #295: session-authenticated mutations must carry a CSRF header or the server answers 403. Set it
  // post-normalization (as the API key was), so it survives header canonicalization. The server lookup
  // is case-insensitive, so `X-Csrf` matches its `X-CSRF`. Machine-key (`X-Api-Key`) callers are exempt
  // server-side, but sending the header on every mutation is harmless — the SPA is always session-auth.
  if (MUTATING_METHODS.includes(method)) {
    headers['X-Csrf'] ??= '1';
  }

  const response = await fetch(path, {
    ...fetchOptions,
    body,
    headers,
    method
  });

  if (!response.ok) {
    // A session-gated `/api` surface answers a missing/expired session with 401 (#295). Signal the
    // shell so it can prompt re-login, regardless of which screen made the call — unless the caller
    // opted out (an expected wrong-credentials 401 on login/change-password).
    if (response.status === 401 && !suppressUnauthorizedSignal) {
      notifyUnauthorized();
    }

    throw new ApiError(response.status, await readProblemDetails(response));
  }

  const etag = response.headers.get('ETag');

  if (response.status === 204) {
    return { data: undefined as TResponse, etag };
  }

  return { data: (await readJsonResponse(response)) as TResponse, etag };
}

export async function request<TResponse = unknown>(
  path: string,
  options: ApiRequestOptions = {}
): Promise<TResponse> {
  const { data } = await requestWithMeta<TResponse>(path, options);
  return data;
}

function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
  return Object.fromEntries(
    Object.entries(headers).map(([key, value]) => [canonicalHeaderName(key), value])
  );
}

function canonicalHeaderName(header: string): string {
  return header
    .toLowerCase()
    .split('-')
    .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
    .join('-');
}

function headersToRecord(headers?: HeadersInit): Record<string, string> {
  if (!headers) {
    return {};
  }

  if (headers instanceof Headers) {
    return Object.fromEntries(headers.entries());
  }

  if (Array.isArray(headers)) {
    return Object.fromEntries(headers);
  }

  return headers;
}

function serializeBody(body: RequestBody | undefined, headers: Record<string, string>): BodyInit | null | undefined {
  if (body === undefined || body === null) {
    return body;
  }

  if (body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob) {
    return body;
  }

  if (typeof body === 'string') {
    return body;
  }

  headers['Content-Type'] ??= 'application/json';
  return JSON.stringify(body);
}

async function readProblemDetails(response: Response): Promise<ProblemDetails | undefined> {
  const contentType = response.headers.get('Content-Type') ?? '';

  if (!isJsonContentType(contentType)) {
    return undefined;
  }

  return await response.json() as ProblemDetails;
}

async function readJsonResponse(response: Response): Promise<unknown> {
  const contentLength = response.headers.get('Content-Length');
  const contentType = response.headers.get('Content-Type') ?? '';

  if (contentLength === '0' || !isJsonContentType(contentType)) {
    return undefined;
  }

  try {
    return await response.json();
  } catch (error) {
    if (error instanceof SyntaxError) {
      return undefined;
    }

    throw error;
  }
}

function isJsonContentType(contentType: string): boolean {
  return contentType.includes('application/json') || contentType.includes('problem+json');
}
