import type {
  ComposeErrorKind,
  FailedPreparedOp,
  GenericComposeErrorKind,
  SimulationRevert,
} from '@lifi/compose-spec';

import {
  parseServerErrorBody,
  type ServerErrorBody,
} from './responseSchemas.js';

/**
 * Machine-readable error codes returned by the SDK.
 *
 * - `NETWORK_ERROR` — The HTTP request failed (DNS, timeout, connection refused).
 * - `VALIDATION_ERROR` — The server rejected the request (HTTP 400/422).
 * - `UNAUTHENTICATED` — The request lacks valid authentication credentials (HTTP 401).
 * - `FORBIDDEN` — The server understood the request but refuses to authorise it (HTTP 403).
 * - `SERVER_ERROR` — The server returned a 5xx status.
 * - `RATE_LIMITED` — The server returned HTTP 429.
 * - `NOT_FOUND` — The requested resource does not exist (HTTP 404).
 * - `UNKNOWN_ERROR` — An unexpected error that doesn't fit other categories.
 */
export type ComposeErrorCode =
  | 'NETWORK_ERROR'
  | 'VALIDATION_ERROR'
  | 'UNAUTHENTICATED'
  | 'FORBIDDEN'
  | 'SERVER_ERROR'
  | 'RATE_LIMITED'
  | 'NOT_FOUND'
  | 'UNKNOWN_ERROR';

/**
 * Error class for all failures originating from the Compose SDK or API.
 *
 * Includes structured metadata beyond the error message to support
 * programmatic error handling.
 *
 * @example
 * ```ts
 * try {
 *   await builder.compile(run);
 * } catch (e) {
 *   if (isComposeError(e) && e.code === 'VALIDATION_ERROR') {
 *     console.error('Invalid request:', e.message, e.path);
 *   }
 * }
 * ```
 */
export class ComposeError extends Error {
  override readonly name = 'ComposeError';
  /** Machine-readable error category. */
  readonly code: ComposeErrorCode;
  /** HTTP status code, when the error originated from an HTTP response. */
  readonly status?: number;
  /** The request URL that produced the error. */
  readonly url?: string;
  /** Server-provided error kind for finer-grained classification. */
  readonly kind?: ComposeErrorKind;
  /** JSON-pointer path to the field that caused a validation error. */
  readonly path?: string;
  /**
   * Simulation revert diagnostics attached to `simulation_revert` errors.
   * Contains the raw error bytes and decoded error candidates when the
   * backend can parse the revert reason.
   */
  readonly details?: SimulationRevert;
  /**
   * The prepared ops that failed, attached to `preparation_error` errors.
   * Each entry carries the `callId` of the failing node so callers can drop
   * the unroutable legs and resubmit a smaller flow.
   */
  readonly failedOps?: readonly FailedPreparedOp[];
  /**
   * The `callId`s of the prepared ops that succeeded, attached to
   * `preparation_error` errors alongside {@link ComposeError.failedOps}.
   */
  readonly succeededOps?: readonly string[];

  constructor(
    code: ComposeErrorCode,
    message: string,
    options?: {
      status?: number;
      url?: string;
      cause?: unknown;
      kind?: ComposeErrorKind;
      path?: string;
      details?: SimulationRevert;
      failedOps?: readonly FailedPreparedOp[];
      succeededOps?: readonly string[];
    },
  ) {
    super(message, { cause: options?.cause });
    this.code = code;
    this.status = options?.status;
    this.url = options?.url;
    this.kind = options?.kind;
    this.path = options?.path;
    this.details = options?.details;
    this.failedOps = options?.failedOps;
    this.succeededOps = options?.succeededOps;
  }
}

/**
 * The per-op preparation diagnostics carried by `preparation_error` responses,
 * with both arrays known to be present. Produced by narrowing with
 * {@link isComposePreparationError}.
 */
export interface ComposePreparationOps {
  readonly failedOps: readonly FailedPreparedOp[];
  readonly succeededOps: readonly string[];
}

/**
 * Type guard that narrows an unknown error to {@link ComposeError}.
 * @param e - The value to check.
 * @returns `true` if `e` is an instance of `ComposeError`.
 */
export const isComposeError = (e: unknown): e is ComposeError =>
  e instanceof ComposeError ||
  (e instanceof Error && e.name === 'ComposeError' && 'code' in e);

/**
 * Type guard for `preparation_error` failures (HTTP 422): a mixed basket where
 * some prepared ops failed and others succeeded. Narrows both
 * {@link ComposeError.failedOps} and {@link ComposeError.succeededOps} to
 * present, so callers can read them without a cast or a truthiness check.
 *
 * @param e - The value to check.
 * @returns `true` if `e` is a `ComposeError` carrying per-op preparation diagnostics.
 *
 * @example
 * ```ts
 * try {
 *   await builder.compile(run);
 * } catch (e) {
 *   if (isComposePreparationError(e)) {
 *     console.error('Failed ops:', e.failedOps);
 *     console.error('Succeeded ops:', e.succeededOps);
 *   }
 * }
 * ```
 */
export const isComposePreparationError = (
  e: unknown,
): e is ComposeError & ComposePreparationOps =>
  isComposeError(e) &&
  e.kind === 'preparation_error' &&
  Array.isArray(e.failedOps) &&
  Array.isArray(e.succeededOps);

const STATUS_TO_CODE: ReadonlyMap<number, ComposeErrorCode> = new Map<
  number,
  ComposeErrorCode
>([
  [400, 'VALIDATION_ERROR'],
  [401, 'UNAUTHENTICATED'],
  [403, 'FORBIDDEN'],
  [404, 'NOT_FOUND'],
  [422, 'VALIDATION_ERROR'],
  [429, 'RATE_LIMITED'],
]);

const tryParseErrorBody = (body: string): ServerErrorBody | null => {
  let json: unknown;
  try {
    json = JSON.parse(body);
  } catch {
    return null;
  }
  return parseServerErrorBody(json);
};

/**
 * Constructs a {@link ComposeError} from an HTTP error response, extracting
 * structured error details from the response body when available.
 *
 * @param status - The HTTP status code.
 * @param body - The raw response body text.
 * @param url - The request URL that produced the error.
 * @returns A `ComposeError` with the appropriate error code and metadata.
 */
export const errorFromHttpResponse = (
  status: number,
  body: string,
  url: string,
): ComposeError => {
  const parsed = tryParseErrorBody(body);
  const serverError = parsed?.error;

  return new ComposeError(
    STATUS_TO_CODE.get(status) ??
      (status >= 500 ? 'SERVER_ERROR' : 'UNKNOWN_ERROR'),
    (serverError?.message ?? body) || `HTTP ${status}`,
    {
      status,
      url,
      kind: serverError?.kind as ComposeErrorKind | undefined,
      path: serverError?.path,
      details: serverError?.details,
      // The wire schema types `kind` as a bare string; the server only ever
      // reports generic kinds for individual ops.
      failedOps: serverError?.failedOps?.map((op) => ({
        ...op,
        kind: op.kind as GenericComposeErrorKind,
      })),
      succeededOps: serverError?.succeededOps,
    },
  );
};
