/**
 * Typed domain errors.
 *
 * The domain layer never constructs HTTP responses; it throws these. The
 * transport layer maps `code` onto a status via {@link httpStatusForErrorCode}
 * and serializes `toPublicJSON()`, which is guaranteed to contain nothing
 * sensitive.
 */

export type ErrorCode =
  | 'VALIDATION_FAILED'
  | 'UNAUTHENTICATED'
  | 'FORBIDDEN'
  | 'NOT_FOUND'
  | 'CONFLICT'
  | 'PRECONDITION_FAILED'
  | 'RATE_LIMITED'
  | 'NOT_CONFIGURED'
  | 'INTERNAL';

const HTTP_STATUS: Record<ErrorCode, number> = {
  VALIDATION_FAILED: 422,
  UNAUTHENTICATED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  CONFLICT: 409,
  PRECONDITION_FAILED: 412,
  RATE_LIMITED: 429,
  NOT_CONFIGURED: 501,
  INTERNAL: 500,
};

export function httpStatusForErrorCode(code: ErrorCode): number {
  return HTTP_STATUS[code];
}

/** A single field-level problem, safe to show to the caller. */
export interface FieldIssue {
  path: string;
  message: string;
}

export interface DomainErrorOptions {
  /** Field-level problems (validation, unmet preconditions). */
  details?: FieldIssue[];
  /**
   * Non-sensitive context for the caller, e.g. `{ requiredPermission }`.
   * Never put PII, secrets or document contents here — it is serialized.
   */
  meta?: Record<string, unknown>;
  cause?: unknown;
}

export class DomainError extends Error {
  readonly code: ErrorCode;
  readonly details: FieldIssue[];
  readonly meta: Record<string, unknown>;

  constructor(code: ErrorCode, message: string, options: DomainErrorOptions = {}) {
    super(message, options.cause === undefined ? undefined : { cause: options.cause });
    this.name = new.target.name;
    this.code = code;
    this.details = options.details ?? [];
    this.meta = options.meta ?? {};
  }

  get httpStatus(): number {
    return httpStatusForErrorCode(this.code);
  }

  /** The exact shape placed inside the API error envelope. */
  toPublicJSON(): {
    code: ErrorCode;
    message: string;
    details?: FieldIssue[];
    meta?: Record<string, unknown>;
  } {
    return {
      code: this.code,
      message: this.message,
      ...(this.details.length > 0 ? { details: this.details } : {}),
      ...(Object.keys(this.meta).length > 0 ? { meta: this.meta } : {}),
    };
  }
}

export class ValidationError extends DomainError {
  constructor(message = 'The submitted data is invalid.', options: DomainErrorOptions = {}) {
    super('VALIDATION_FAILED', message, options);
  }
}

export class UnauthenticatedError extends DomainError {
  constructor(message = 'Authentication is required.', options: DomainErrorOptions = {}) {
    super('UNAUTHENTICATED', message, options);
  }
}

export class ForbiddenError extends DomainError {
  constructor(message = 'You do not have permission to perform this action.', options: DomainErrorOptions = {}) {
    super('FORBIDDEN', message, options);
  }
}

/**
 * Also used for resources that exist but lie outside the caller's scope.
 * Returning 404 rather than 403 prevents probing for the existence of records
 * the caller may not know about (SPEC.md §25).
 */
export class NotFoundError extends DomainError {
  constructor(message = 'The requested resource was not found.', options: DomainErrorOptions = {}) {
    super('NOT_FOUND', message, options);
  }
}

export class ConflictError extends DomainError {
  constructor(message = 'The request conflicts with the current state.', options: DomainErrorOptions = {}) {
    super('CONFLICT', message, options);
  }
}

/** A rule that must hold before the action is allowed does not currently hold. */
export class PreconditionFailedError extends DomainError {
  constructor(message = 'A required precondition was not met.', options: DomainErrorOptions = {}) {
    super('PRECONDITION_FAILED', message, options);
  }
}

export class RateLimitedError extends DomainError {
  constructor(
    message = 'Too many requests. Please try again shortly.',
    readonly retryAfterSeconds = 60,
    options: DomainErrorOptions = {},
  ) {
    super('RATE_LIMITED', message, options);
  }
}

/**
 * An optional integration was invoked without being configured. Thrown by the
 * null adapters (payment gateway, external exam provider) so that a missing
 * credential surfaces as a clear, actionable error rather than a crash.
 */
export class NotConfiguredError extends DomainError {
  constructor(integration: string, options: DomainErrorOptions = {}) {
    super('NOT_CONFIGURED', `The "${integration}" integration is not configured in this environment.`, {
      ...options,
      meta: { integration, ...options.meta },
    });
  }
}

export function isDomainError(error: unknown): error is DomainError {
  return error instanceof DomainError;
}

/**
 * Wraps an unknown thrown value as a DomainError. Unknown errors become a
 * generic INTERNAL error whose message deliberately reveals nothing about the
 * failure; the original is preserved as `cause` for server-side logging.
 */
export function toDomainError(error: unknown): DomainError {
  if (isDomainError(error)) return error;
  return new DomainError('INTERNAL', 'An unexpected error occurred.', { cause: error });
}
