// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.

/**
 * Problem Details for HTTP APIs per
 * {@link https://www.rfc-editor.org/rfc/rfc9457.html | RFC 9457}.
 *
 * Provides {@linkcode createProblemDetailsResponse} to build a `Response` with
 * an `application/problem+json` body, {@linkcode parseProblemDetails} to parse
 * a plain object, {@linkcode parseProblemDetailsResponse} to parse a `Response`
 * body, and {@linkcode isProblemDetailsResponse} to detect problem-details
 * responses by content type.
 *
 * @example Basic 404 response
 * ```ts
 * import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
 *
 * const response = createProblemDetailsResponse({
 *   status: 404,
 *   detail: "No user found with ID 42",
 * });
 * ```
 *
 * @example Parse a Response
 * ```ts ignore
 * import {
 *   isProblemDetailsResponse,
 *   parseProblemDetailsResponse,
 * } from "@std/http/unstable-problem-details";
 *
 * const response = await fetch("https://api.example.com/resource");
 * if (isProblemDetailsResponse(response)) {
 *   const problem = await parseProblemDetailsResponse(response);
 *   console.error(problem.detail);
 * }
 * ```
 *
 * Only the JSON serialization (`application/problem+json`) is implemented.
 * The XML serialization defined by the RFC is not supported.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @see {@link https://www.rfc-editor.org/rfc/rfc9457.html | RFC 9457}
 *
 * @module
 */

import { STATUS_TEXT } from "./status.ts";

const PROBLEM_JSON_MEDIA_TYPE = "application/problem+json";

/**
 * Keys of the five standard Problem Details members defined by RFC 9457.
 * Used to prevent extension members from shadowing standard fields.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 */
export type StandardProblemDetailsMember =
  | "type"
  | "status"
  | "title"
  | "detail"
  | "instance";

/**
 * Constraint for Problem Details extension members.
 *
 * The constraint itself is intentionally loose (effectively
 * `Record<string, unknown>`) so that TypeScript can infer `T` from object
 * literals at call sites without standard keys being captured into `T` and
 * then failing a stricter `never` check. Safety is enforced one level up: the
 * intersection in {@linkcode ProblemDetails} explicitly types the five
 * standard members, so if `T` redeclares any of them with an incompatible
 * type the resulting field collapses to `never` and the value becomes
 * unconstructible.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 */
export type ProblemDetailsExtensions = Omit<
  Record<string, unknown>,
  StandardProblemDetailsMember
>;

/**
 * A Problem Details object as defined by RFC 9457.
 *
 * Standard members are explicitly typed. Extension members are represented
 * as a generic parameter intersected with the base type, so they appear as
 * top-level properties in both the TypeScript type and the serialized JSON
 * — matching the wire format exactly.
 *
 * If `T` declares a property whose name matches one of the five standard
 * members with an incompatible type, the intersection collapses that field
 * to `never`.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 */
export type ProblemDetails<
  T extends ProblemDetailsExtensions = Record<string, never>,
> = {
  /**
   * A URI reference identifying the problem type. Defaults to `"about:blank"`
   * when not provided, per RFC 9457 §4.2.1.
   */
  type?: string;
  /** HTTP status code generated by the origin server for this occurrence. */
  status?: number;
  /** Short, human-readable summary of the problem type. */
  title?: string;
  /** Human-readable explanation specific to this occurrence. */
  detail?: string;
  /** URI reference identifying this specific occurrence. */
  instance?: string;
} & T;

/**
 * Options for {@linkcode createProblemDetailsResponse}.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 */
export interface ProblemDetailsResponseOptions {
  /** Additional headers to include in the response. */
  headers?: HeadersInit;
  /**
   * Status text for the response. When omitted, the `Response` constructor's
   * default (the empty string, per the Fetch spec) is used; we deliberately
   * do not synthesize one from the standard HTTP status text — the JSON
   * `title` field already carries that information.
   */
  statusText?: string;
}

/**
 * Creates a `Response` with an `application/problem+json` body from a
 * {@linkcode ProblemDetails} object.
 *
 * - Sets `Content-Type` to `application/problem+json`, overwriting any
 *   `Content-Type` provided in `options.headers`.
 * - Defaults `type` to `"about:blank"` if not provided.
 * - Defaults `title` to the standard HTTP status text (from `@std/http/status`)
 *   when `type` is `"about:blank"` and `title` is not provided.
 * - Defaults `status` to `500` if not provided.
 * - Serializes directly to JSON — the flat intersection type already matches
 *   the RFC wire format, so no flattening step is needed.
 * - Forwards `options.statusText` to the `Response` only when defined; when
 *   omitted, the constructor's empty-string default is used and the standard
 *   HTTP status text is intentionally not synthesized — the JSON `title`
 *   field already carries that information.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @typeParam T The type of extension members on the problem details object.
 *
 * @param problemDetails The problem details to serialize into the response body.
 * @param options Additional response options such as extra headers.
 *
 * @returns A `Response` with status, `application/problem+json` content type,
 * and the serialized problem details as the body.
 *
 * @throws {RangeError} If `problemDetails.status` is not a finite integer, or
 * is any value the `Response` constructor rejects (i.e. outside 200–599).
 *
 * @example Basic 404 response
 * ```ts
 * import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
 * import { assertEquals } from "@std/assert";
 *
 * const response = createProblemDetailsResponse({
 *   status: 404,
 *   detail: "No user found with ID 42",
 * });
 * assertEquals(response.status, 404);
 * assertEquals(
 *   await response.json(),
 *   {
 *     type: "about:blank",
 *     status: 404,
 *     title: "Not Found",
 *     detail: "No user found with ID 42",
 *   },
 * );
 * ```
 *
 * @example Custom problem type with extensions
 * ```ts
 * import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
 * import { assertEquals } from "@std/assert";
 *
 * const response = createProblemDetailsResponse({
 *   type: "https://example.com/problems/insufficient-credit",
 *   status: 403,
 *   title: "Insufficient credit",
 *   detail: "Your account balance is too low",
 *   instance: "/account/12345/transactions/abc",
 *   balance: 30,
 *   accounts: ["/account/12345", "/account/67890"],
 * });
 * assertEquals(response.status, 403);
 * assertEquals(response.headers.get("Content-Type"), "application/problem+json");
 * ```
 */
export function createProblemDetailsResponse<
  T extends ProblemDetailsExtensions,
>(
  problemDetails: ProblemDetails<T>,
  options?: ProblemDetailsResponseOptions,
): Response {
  const pd: Record<string, unknown> = { ...problemDetails };

  if (pd.type === undefined) pd.type = "about:blank";

  if (pd.status === undefined) pd.status = 500;

  if (!Number.isInteger(pd.status)) {
    throw new RangeError(
      `Cannot create Problem Details response: status must be a finite integer, received ${
        typeof pd.status === "string" ? `"${pd.status}"` : String(pd.status)
      }`,
    );
  }

  if (pd.type === "about:blank" && pd.title === undefined) {
    const statusText = STATUS_TEXT[pd.status as keyof typeof STATUS_TEXT];
    if (statusText !== undefined) {
      pd.title = statusText;
    }
  }

  const body = JSON.stringify(pd);
  const status = pd.status as number;
  const statusText = options?.statusText;

  if (options?.headers === undefined) {
    return new Response(body, {
      status,
      ...(statusText !== undefined ? { statusText } : {}),
      headers: { "Content-Type": PROBLEM_JSON_MEDIA_TYPE },
    });
  }
  const headers = new Headers(options.headers);
  headers.set("Content-Type", PROBLEM_JSON_MEDIA_TYPE);
  return new Response(body, {
    status,
    ...(statusText !== undefined ? { statusText } : {}),
    headers,
  });
}

function normalizeParsedProblemDetails(
  raw: unknown,
): Record<string, unknown> {
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
    throw new TypeError(
      `Cannot parse Problem Details: expected a JSON object, but got ${
        raw === null ? "null" : Array.isArray(raw) ? "an array" : typeof raw
      }`,
    );
  }

  const result: Record<string, unknown> = {};
  const source = raw as Record<string, unknown>;

  // RFC 9457 §3.1: ignore standard members whose value type does not match.
  for (const key in source) {
    if (!Object.hasOwn(source, key)) continue;
    const value = source[key];
    switch (key) {
      case "type":
        if (typeof value === "string") result.type = value;
        break;
      case "status":
        if (Number.isInteger(value)) result.status = value;
        break;
      case "title":
        if (typeof value === "string") result.title = value;
        break;
      case "detail":
        if (typeof value === "string") result.detail = value;
        break;
      case "instance":
        if (typeof value === "string") result.instance = value;
        break;
      default:
        result[key] = value;
    }
  }

  return result;
}

/**
 * Parses a plain JSON value into a {@linkcode ProblemDetails}.
 *
 * Returns the standard members plus any extension members as a flat object.
 * Standard members with invalid types are silently dropped per RFC 9457 §3.1.
 * Does not throw on missing fields — the RFC makes all members optional.
 * Extension member types provided via `T` are asserted at the type level only;
 * values are not validated at runtime.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @typeParam T The type of extension members expected in the parsed result.
 *
 * @param input A JSON value to parse as problem details.
 *
 * @returns The parsed problem details.
 *
 * @throws {TypeError} If `input` is not a non-null, non-array object, or if
 * `input` is a `Response` (use {@linkcode parseProblemDetailsResponse}
 * instead).
 *
 * @example Parse from a plain object
 * ```ts
 * import { parseProblemDetails } from "@std/http/unstable-problem-details";
 * import { assertEquals } from "@std/assert";
 *
 * const problem = parseProblemDetails({
 *   type: "about:blank",
 *   status: 400,
 *   title: "Bad Request",
 *   balance: 30,
 * });
 * assertEquals(problem.status, 400);
 * assertEquals(problem.title, "Bad Request");
 * ```
 */
export function parseProblemDetails<
  T extends ProblemDetailsExtensions = Record<string, never>,
>(input: unknown): ProblemDetails<T> {
  // A `Response` is a non-null, non-array object with no enumerable own keys,
  // so it would silently parse to an empty result. Reject it explicitly so
  // callers who forgot to migrate to `parseProblemDetailsResponse` fail loudly.
  if (input instanceof Response) {
    throw new TypeError(
      "Cannot parse Problem Details: input is a Response, use parseProblemDetailsResponse instead",
    );
  }
  return normalizeParsedProblemDetails(input) as ProblemDetails<T>;
}

/**
 * Parses a `Response` body into a {@linkcode ProblemDetails}.
 *
 * Reads the response body as JSON and delegates to
 * {@linkcode parseProblemDetails}. Standard members with invalid types are
 * silently dropped per RFC 9457 §3.1. Does not throw on missing fields — the
 * RFC makes all members optional. Extension member types provided via `T` are
 * asserted at the type level only; values are not validated at runtime.
 *
 * Note: this consumes the response body. The `Response` cannot be re-read
 * after this call.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @typeParam T The type of extension members expected in the parsed result.
 *
 * @param input The `Response` whose JSON body will be parsed.
 *
 * @returns A promise that resolves to the parsed problem details.
 *
 * @throws {TypeError} If the response body parses to a value that is not a
 * non-null, non-array object.
 * @throws {SyntaxError} If the response body is not valid JSON.
 *
 * @example Parse from a Response
 * ```ts
 * import { parseProblemDetailsResponse } from "@std/http/unstable-problem-details";
 * import { assertEquals } from "@std/assert";
 *
 * const response = new Response(
 *   JSON.stringify({ type: "about:blank", status: 404, title: "Not Found" }),
 *   { headers: { "Content-Type": "application/problem+json" } },
 * );
 * const problem = await parseProblemDetailsResponse(response);
 * assertEquals(problem.status, 404);
 * assertEquals(problem.title, "Not Found");
 * ```
 */
export async function parseProblemDetailsResponse<
  T extends ProblemDetailsExtensions = Record<string, never>,
>(input: Response): Promise<ProblemDetails<T>> {
  const raw = await input.json();
  return normalizeParsedProblemDetails(raw) as ProblemDetails<T>;
}

/**
 * Type guard that checks whether a `Response` has an
 * `application/problem+json` content type.
 *
 * The media type is compared without parameters (e.g. `charset=utf-8` is
 * ignored). Only `application/problem+json` is recognized; any other media
 * type returns `false`.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @param response The `Response` to check.
 *
 * @returns `true` if the response has an `application/problem+json` content
 * type, `false` otherwise.
 *
 * @example Usage
 * ```ts ignore
 * import {
 *   isProblemDetailsResponse,
 *   parseProblemDetailsResponse,
 * } from "@std/http/unstable-problem-details";
 *
 * const response = await fetch("https://api.example.com/resource");
 * if (isProblemDetailsResponse(response)) {
 *   const problem = await parseProblemDetailsResponse(response);
 *   console.error(problem.detail);
 * }
 * ```
 */
export function isProblemDetailsResponse(response: Response): boolean {
  const contentType = response.headers.get("Content-Type");
  if (contentType === null) return false;
  const semi = contentType.indexOf(";");
  const base = semi === -1 ? contentType : contentType.substring(0, semi);
  const mediaType = base.toLowerCase().trim();
  return mediaType === PROBLEM_JSON_MEDIA_TYPE;
}
