import type { ServiceResult } from "./types.ts";

export const RPC_ERROR_CODES = {
  /** The request never reached a handler: connection, timeout, 5xx. */
  transport: "RPC_TRANSPORT",
  /** Input failed the contract's schema. */
  invalid: "RPC_INVALID",
  /** The callee's permission check refused. */
  denied: "RPC_DENIED",
  /** No such service or procedure on the callee. */
  unknown: "RPC_UNKNOWN",
  /** The handler threw or returned a failure. */
  handler: "RPC_HANDLER",
  /** Identity token missing, malformed, expired, or for another audience. */
  identity: "RPC_IDENTITY",
  /**
   * The callee answered, but not with a ServiceResult — a proxy's HTML error
   * page, a truncated body, an unexpected shape. Distinct from `transport`:
   * something DID respond, so retrying returns the same thing.
   */
  malformed: "RPC_MALFORMED",
} as const;

export type RpcErrorCode = (typeof RPC_ERROR_CODES)[keyof typeof RPC_ERROR_CODES];

/** Only a transport failure is worth retrying; everything else is final. */
function retryableFor(code: string): boolean {
  return code === RPC_ERROR_CODES.transport;
}

/**
 * 5xx, 429 and 408 mean "the callee could not answer, try later". Any other
 * 4xx is the callee saying no — retrying just repeats the same rejection.
 *
 * The range is bounded on BOTH sides deliberately: an unbounded `>= 500`
 * puts a garbage status like 1000 in the retryable bucket, and this function
 * is the sole gate the client and HTTP transport trust for retry safety.
 * An out-of-range value must fail closed, i.e. not retryable.
 */
export function isRetryableStatus(status: number): boolean {
  if (status === 408 || status === 429) return true;
  return status >= 500 && status <= 599;
}

export function success<T>(value: T): ServiceResult<T> {
  return { ok: true, value };
}

export function failure(code: string, message: string): ServiceResult<never> {
  return { ok: false, code, message, retryable: retryableFor(code) };
}

export interface ToResultOptions {
  /** Include the original message. Off by default: it may name internals. */
  exposeMessage?: boolean;
}

export class ServiceError extends Error {
  readonly code: string;
  readonly retryable: boolean;

  /**
   * `retryable` defaults to the code-derived value for callers that
   * construct a `ServiceError` directly. Pass it explicitly when relaying a
   * wire result: the transport already computed the authoritative value
   * (e.g. a bounded HTTP-status check), and recomputing it here from the
   * code alone would silently flip it — `RPC_TRANSPORT` derives to `true`,
   * even for a non-retryable 403.
   */
  constructor(code: string, message: string, retryable?: boolean) {
    super(message);
    this.name = "ServiceError";
    this.code = code;
    this.retryable = retryable ?? retryableFor(code);
  }

  /**
   * Convert to a wire result. The message is replaced unless explicitly
   * exposed: a handler's error text routinely names tables, hosts, or
   * credentials, and this value crosses an app boundary.
   */
  toResult(options: ToResultOptions = {}): ServiceResult<never> {
    return {
      ok: false,
      code: this.code,
      message: options.exposeMessage ? this.message : "Internal error",
      retryable: this.retryable,
    };
  }
}
