/**
 * Error catalog and AppError definitions for Beignet
 */

import type { StandardSchemaV1 } from "@standard-schema/spec";

type ErrorDetailsSchema = StandardSchemaV1<unknown, unknown>;
const APP_ERROR_BRAND = Symbol.for("beignet.AppError");

/**
 * Definition for one application error catalog entry.
 */
export interface ErrorDef<
  TDetails extends ErrorDetailsSchema | undefined =
    | ErrorDetailsSchema
    | undefined,
> {
  /** Unique, stable code used across layers, such as `"POST_NOT_FOUND"`. */
  code: string;
  /** HTTP status code returned when this error crosses the HTTP boundary. */
  status: number;
  /** Default human-readable message. */
  message: string;
  /**
   * Optional schema for the structured details value.
   *
   * Beignet uses this for TypeScript inference. Details are not runtime
   * validated by `AppError`; validate before constructing errors when needed.
   * Details become public response data when the error crosses the HTTP
   * boundary, so use app-owned safe fields instead of raw diagnostics.
   */
  details?: TDetails;
}

/**
 * App error catalog keyed by developer-friendly names.
 *
 * Keys are local identifiers used by `createAppError(...)`; the public stable
 * error code lives on each `ErrorDef.code`.
 */
export type ErrorCatalog = Record<string, ErrorDef>;

/**
 * Infer the details input type for an error definition.
 */
export type InferErrorDetails<TDef extends ErrorDef> = TDef extends {
  details: ErrorDetailsSchema;
}
  ? StandardSchemaV1.InferInput<TDef["details"]>
  : unknown;

/**
 * Define an application error catalog without losing literal key and code types.
 */
export function defineErrors<const T extends ErrorCatalog>(defs: T): T {
  return defs;
}

/**
 * Application error thrown from use cases, policies, and route handlers.
 *
 * The server maps `AppError` instances to Beignet's standard error envelope and
 * marks them as route-owned errors when the contract declares the catalog entry.
 */
export class AppError<TDef extends ErrorDef = ErrorDef> extends Error {
  readonly [APP_ERROR_BRAND] = true;
  /** Error definition from the catalog. */
  readonly def: TDef;
  /**
   * Optional structured details for clients or UI.
   *
   * Beignet does not automatically redact route-owned error details. Keep
   * provider errors, stack traces, secrets, and private content in `cause`,
   * logs, or error reporting instead.
   */
  readonly details?: InferErrorDetails<TDef>;
  /**
   * Optional HTTP response headers set when this error crosses the HTTP
   * boundary, such as `Retry-After` on a 429. Headers are public response
   * data; only include values that are safe to expose.
   */
  readonly headers?: Record<string, string>;

  constructor(
    def: TDef,
    details?: InferErrorDetails<TDef>,
    overrideMessage?: string,
    options?: { cause?: unknown; headers?: Record<string, string> },
  ) {
    super(overrideMessage ?? def.message, { cause: options?.cause });
    this.name = "AppError";
    this.def = def;
    this.details = details;
    this.headers = options?.headers;
  }

  /**
   * Stable public error code.
   */
  get code(): string {
    return this.def.code;
  }

  /**
   * HTTP status code associated with this error.
   */
  get status(): number {
    return this.def.status;
  }
}

/**
 * Callable helper for creating `AppError` instances from a catalog.
 */
export type AppErrorCreator<TCatalog extends ErrorCatalog> = {
  /**
   * Original catalog bound to this creator.
   */
  catalog: TCatalog;
  /**
   * Create an `AppError` from a catalog key.
   */
  <Key extends keyof TCatalog>(
    key: Key,
    options?: {
      cause?: unknown;
      details?: InferErrorDetails<TCatalog[Key]>;
      headers?: Record<string, string>;
      message?: string;
    },
  ): AppError<TCatalog[Key]>;
};

/**
 * Create a callable `AppError` helper bound to a specific catalog.
 *
 * The returned function validates the catalog key and preserves each entry's
 * details type for `options.details`.
 */
export function createAppError<const T extends ErrorCatalog>(
  catalog: T,
): AppErrorCreator<T> {
  const appError = (<Key extends keyof T>(
    key: Key,
    options?: {
      cause?: unknown;
      details?: InferErrorDetails<T[Key]>;
      headers?: Record<string, string>;
      message?: string;
    },
  ): AppError<T[Key]> => {
    const def = catalog[key as keyof T] as T[typeof key] | undefined;
    if (!def) {
      throw new Error(`Unknown error catalog key: ${String(key)}`);
    }
    return new AppError<T[typeof key]>(
      def,
      options?.details,
      options?.message,
      {
        cause: options?.cause,
        headers: options?.headers,
      },
    );
  }) as AppErrorCreator<T>;

  appError.catalog = catalog;
  return appError;
}

/**
 * Check whether an unknown value is a Beignet `AppError`.
 */
export function isAppError(err: unknown): err is AppError<ErrorDef> {
  if (err instanceof AppError) return true;
  if (typeof err !== "object" || err === null) return false;

  const value = err as Record<PropertyKey, unknown>;
  if (value[APP_ERROR_BRAND] === true) return true;
  if (value.name !== "AppError") return false;

  const def = value.def;
  if (typeof def !== "object" || def === null) return false;

  const errorDef = def as Record<string, unknown>;
  return (
    typeof errorDef.code === "string" &&
    typeof errorDef.status === "number" &&
    typeof errorDef.message === "string"
  );
}
