import {
  type ArgumentsHost,
  Catch,
  type ExceptionFilter,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { trace } from '@opentelemetry/api';
import type { Response } from 'express';
import { Logger } from 'nestjs-pino';

/**
 * Shape of every error response coming out of the BFF. The
 * generic Nest default leaks `message` + `statusCode` at the top
 * level, and on 500 it leaks the full exception class name. This
 * filter normalises everything to the same envelope so the SPA
 * (and any future API consumer) gets a single contract to parse:
 *
 *   {
 *     "error": {
 *       "code": "csrf",                             // stable token
 *       "message": "CSRF token missing or invalid", // safe, human-readable
 *       "traceId": "abc123def…"                     // OTel trace id for correlation
 *     }
 *   }
 *
 * Server-internal details (stack traces, full Prisma errors, etc.)
 * are written to the Pino log line at `error` level — never to the
 * response body, even on 5xx.
 */
export interface ErrorResponseBody {
  error: {
    code: string;
    message: string;
    traceId: string | null;
  };
}

/**
 * Codes returned in `error.code`. Limited and stable so the SPA
 * can switch on them. Adding a new code is a deliberate API
 * change; the consumer's TypeScript union picks it up.
 *
 * Current entries are derived from existing code paths that throw
 * `HttpException`. The list grows organically as new typed errors
 * land.
 */
const STATUS_CODE_MAP: Record<number, string> = {
  [HttpStatus.BAD_REQUEST]: 'bad_request',
  [HttpStatus.UNAUTHORIZED]: 'unauthenticated',
  [HttpStatus.FORBIDDEN]: 'forbidden',
  [HttpStatus.NOT_FOUND]: 'not_found',
  [HttpStatus.CONFLICT]: 'conflict',
  [HttpStatus.UNPROCESSABLE_ENTITY]: 'unprocessable',
  [HttpStatus.TOO_MANY_REQUESTS]: 'rate_limited',
  [HttpStatus.SERVICE_UNAVAILABLE]: 'service_unavailable',
};

/**
 * Global exception filter. Registered in `main.ts` via
 * `app.useGlobalFilters(...)`. Catches everything (`@Catch()` no
 * args), classifies the status, picks a safe error code + message,
 * logs the full exception via Pino, and writes the structured
 * envelope to the response.
 *
 * Specific code overrides — e.g. CSRF middleware that already
 * formats a response — write directly to the response and bypass
 * the Nest filter chain. Those call sites set the same envelope
 * shape manually via the `errorResponse(...)` helper exported
 * below, so the contract holds whether the error came through
 * Nest's filter or through a raw middleware short-circuit.
 */
@Catch()
export class StructuredErrorFilter implements ExceptionFilter {
  constructor(private readonly logger: Logger) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();

    const { status, code, message, internalReason } = this.classify(exception);

    // Always log the full exception with stack; the response body
    // never carries it. Pino's stack-serialiser pulls the class
    // name + message + stack out of an Error instance.
    if (status >= 500) {
      this.logger.error(
        {
          event: 'http.error',
          status,
          code,
          err: exception,
        },
        'StructuredError',
      );
    } else {
      this.logger.warn(
        {
          event: 'http.client_error',
          status,
          code,
          reason: internalReason,
        },
        'StructuredError',
      );
    }

    res.status(status).json(errorResponse(code, message));
  }

  private classify(exception: unknown): {
    status: number;
    code: string;
    message: string;
    internalReason: string;
  } {
    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const response = exception.getResponse();
      // Nest builtin HttpException response is either a string
      // ("Forbidden", etc.) or an object like
      // `{ statusCode, message, error }`. Pick a safe code +
      // message from whatever shape we get.
      const { code, message } = unwrapHttpException(status, response);
      return { status, code, message, internalReason: message };
    }
    return {
      status: HttpStatus.INTERNAL_SERVER_ERROR,
      code: 'internal',
      message: 'Internal server error',
      internalReason: exception instanceof Error ? exception.message : String(exception),
    };
  }
}

function unwrapHttpException(
  status: number,
  response: string | object,
): { code: string; message: string } {
  const fallbackCode = STATUS_CODE_MAP[status] ?? 'http_error';

  if (typeof response === 'string') {
    return { code: fallbackCode, message: response };
  }

  // Nest's default object has `message: string | string[]` and
  // sometimes a `code` we control via custom HttpException
  // subclasses. Prefer caller-supplied code/message; fall back to
  // the status-derived defaults.
  const obj = response as { code?: unknown; message?: unknown };
  const code = typeof obj.code === 'string' ? obj.code : fallbackCode;
  const message = normaliseMessage(obj.message) ?? defaultMessageFor(status);
  return { code, message };
}

function normaliseMessage(message: unknown): string | null {
  if (typeof message === 'string') return message;
  if (Array.isArray(message) && message.every((m) => typeof m === 'string')) {
    return (message as string[]).join('; ');
  }
  return null;
}

function defaultMessageFor(status: number): string {
  switch (status) {
    case HttpStatus.UNAUTHORIZED:
      return 'Unauthenticated';
    case HttpStatus.FORBIDDEN:
      return 'Forbidden';
    case HttpStatus.NOT_FOUND:
      return 'Not found';
    case HttpStatus.TOO_MANY_REQUESTS:
      return 'Too many requests';
    case HttpStatus.SERVICE_UNAVAILABLE:
      return 'Service unavailable';
    default:
      return status >= 500 ? 'Internal server error' : 'Request failed';
  }
}

/**
 * Helper for code paths that write the response directly (raw
 * Express middlewares like CSRF) — keep the same envelope as the
 * filter. Picks up the current OTel trace id automatically.
 */
export function errorResponse(code: string, message: string): ErrorResponseBody {
  return {
    error: {
      code,
      message,
      traceId: trace.getActiveSpan()?.spanContext().traceId ?? null,
    },
  };
}
