/**
 * define-api.ts — route adapter that collapses lifecycle scaffolding into
 * a single typed wrapper.
 *
 * Spec 01 — defineApi route adapter.
 *
 * Adapter pipeline (per request):
 *   1. DB env guard  — 503 CONFIG_ERROR when DATABASE_URL missing (opt-out via db:'none').
 *  1.5. Preflight    — optional fail-closed route guard before body consumption.
 *   2. Rate limit    — 429 RATE_LIMITED via applyRateLimitFor (pass-through verbatim).
 *   3. Auth check    — 401 AUTH_REQUIRED when auth:'required' and no session.
 *   4. Body limit    — 413 BODY_TOO_LARGE when streamed bytes exceed maxBodyBytes.
 *   5. Body parse    — 400 INVALID_BODY on JSON parse failure.
 *   6. Body validate — 400/422 VALIDATION_ERROR on Zod failure.
 *  6.5. CSRF check   — 403 CSRF_INVALID (when spec.csrf:'after-body'; runs after body validation
 *                      so malformed-body requests get 400 before CSRF is evaluated).
 *   7. Handler call  — business logic; adapter translates ApiResult → Response.
 *   8. Error catch   — unhandled throws → 500 INTERNAL_ERROR via Sentry.
 *
 * CSRF: by default enforced globally by csrfMiddleware (middleware.ts).
 *       When spec.csrf is 'after-body', the route's prefix must be added to
 *       CSRF_OPT_OUT_PREFIXES in middleware/csrf.ts so the global check is
 *       skipped, and CSRF is enforced here after body validation instead.
 * Rate-limit response: passed through verbatim (preserves rate-limit headers).
 */

import type { APIRoute, APIContext } from 'astro';
import { env } from '@/server/env';
import { applyRateLimitFor } from '@/server/security/rate-limit.js';
import { captureCaught } from '@/server/observability/capture.server';
import { verifyCsrf } from '@/server/auth/csrf.js';
import { respondError, respondOk, statusFromCode } from './error-envelope.js';
import type { ApiSpec, ApiHandler, ApiHandlerCtx, ApiResult, ApiQuery } from './types.js';
import type { ServiceKey, Services } from '@/server/services/types.js';
import { firstZodIssue } from '@/lib/validation/zod.js';
import { respondDomainError, toDomainError } from './domain-error.js';
import { BodyTooLargeError, readBodyWithinLimit } from '@/server/security/read-limited-body.js';

/** Default maximum request body size: 1 MB. */
const DEFAULT_MAX_BODY_BYTES = 1_048_576;

function resolveSchemas<
  TBody,
  TData,
  TErrCode extends string,
  TServices extends readonly ServiceKey[],
  TParams,
  TQuery,
>(spec: ApiSpec<TBody, TData, TErrCode, TServices, TParams, TQuery>) {
  type Schemas = NonNullable<typeof spec.schemas>;
  const legacy = spec as unknown as {
    body?: Schemas['body'];
    params?: Schemas['params'];
    query?: Schemas['query'];
  };
  return {
    body: spec.schemas?.body ?? legacy.body,
    params: spec.schemas?.params ?? legacy.params,
    query: spec.schemas?.query ?? legacy.query,
  };
}

/**
 * Resolve the auth mode and optional message from the flexible `auth` field.
 */
function resolveAuth(auth: ApiSpec<unknown, unknown, string>['auth']): {
  mode: 'required' | 'optional' | 'public';
  message: string;
} {
  if (typeof auth === 'string') {
    return { mode: auth, message: 'Authentication required' };
  }
  return {
    mode: auth.mode,
    message: auth.mode === 'required' && auth.message ? auth.message : 'Authentication required',
  };
}

/**
 * defineApi — wraps a declarative spec + handler into an Astro APIRoute.
 *
 * Type parameters:
 *   TBody     — validated body type (inferred from spec.body schema).
 *   TData     — success data payload type.
 *   TErrCode  — handler-specific error code union.
 *   TServices — readonly tuple of ServiceKey literals; drives ctx.services projection.
 */
export function defineApi<
  TBody = unknown,
  TData = unknown,
  TErrCode extends string = never,
  TServices extends readonly ServiceKey[] = readonly ServiceKey[],
  TParams = Record<string, string | undefined>,
  TQuery = ApiQuery,
>(
  spec: ApiSpec<TBody, TData, TErrCode, TServices, TParams, TQuery>,
  handler: ApiHandler<TBody, TData, TErrCode, TServices, TParams, TQuery>,
): APIRoute {
  const scope = spec.scope ?? 'pages.api.unknown';
  const maxBodyBytes = spec.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
  const { mode: authMode, message: authMessage } = resolveAuth(spec.auth);
  const dbRequired = spec.db !== 'none';
  // Default is 422, NOT 400 — dozens of routes (contact.ts et al.) omit this
  // field and depend on the 422 default to match their pre-migration behavior.
  // Routes that want 400 set `validationErrorStatus: 400` explicitly; do not
  // flip this default, it will silently break every route that omits it.
  const validationErrorStatus = spec.validationErrorStatus ?? 422;
  const adminScope = scope.startsWith('pages.api.admin.');
  const schemas = resolveSchemas(spec);

  return async function apiRoute(context: APIContext): Promise<Response> {
    const { request, locals, params } = context;
    const url = new URL(request.url);

    // ── 1. DB env guard ────────────────────────────────────────────────────
    if (dbRequired && !env.DATABASE_URL) {
      return respondError('CONFIG_ERROR', 'Service unavailable');
    }

    if (spec.preflight) {
      const rejected = await spec.preflight(request);
      if (rejected) return rejected;
    }

    // ── 2. Rate limit ──────────────────────────────────────────────────────
    if (spec.rateLimit) {
      const limited = await applyRateLimitFor(request, spec.rateLimit.scope);
      if (limited) return limited; // pass-through verbatim (preserves headers)
    }

    // ── 3. Auth check ──────────────────────────────────────────────────────
    if (authMode === 'required' && !locals.user) {
      return respondError('AUTH_REQUIRED', authMessage, 401);
    }

    if (adminScope && authMode !== 'public' && !locals.user?.isAdmin) {
      return respondError('FORBIDDEN', 'Forbidden', 403);
    }

    let parsedParams = params as TParams;
    if (schemas.params) {
      const parsed = schemas.params.safeParse(params);
      if (!parsed.success) {
        return respondError('VALIDATION_ERROR', firstZodIssue(parsed.error), validationErrorStatus);
      }
      parsedParams = parsed.data;
    }

    let parsedQuery = normalizeQueryParams(url.searchParams) as TQuery;
    if (schemas.query) {
      const parsed = schemas.query.safeParse(parsedQuery);
      if (!parsed.success) {
        return respondError('VALIDATION_ERROR', firstZodIssue(parsed.error), validationErrorStatus);
      }
      parsedQuery = parsed.data;
    }

    // ── 4 + 5. Body size limit + JSON parse ────────────────────────────────
    let body: TBody = undefined as unknown as TBody;

    if (schemas.body && request.method !== 'GET') {
      // Content-Length check (advisory — not always present, checked when available)
      const contentLength = Number(request.headers.get('content-length') ?? '0');
      if (contentLength > maxBodyBytes) {
        return respondError('BODY_TOO_LARGE', 'Request body too large', 413);
      }

      let rawBody: unknown;
      try {
        const rawText = await readBodyWithinLimit(request, maxBodyBytes);
        rawBody = rawText ? JSON.parse(rawText) : undefined;
      } catch (err) {
        if (err instanceof BodyTooLargeError) {
          return respondError('BODY_TOO_LARGE', 'Request body too large', 413);
        }
        captureCaught(err, { scope, severity: 'warning' });
        return respondError('INVALID_BODY', 'Invalid JSON', 400);
      }

      // ── 6. Body validation ──────────────────────────────────────────────
      const parsed = schemas.body.safeParse(rawBody);
      if (!parsed.success) {
        const message = firstZodIssue(parsed.error);
        return respondError('VALIDATION_ERROR', message, validationErrorStatus);
      }
      body = parsed.data;
    }

    // ── 6.5. In-handler CSRF check (when spec.csrf === 'after-body') ───────
    // Runs after body validation so malformed-body requests surface 400 first.
    // The route's prefix must be in CSRF_OPT_OUT_PREFIXES so the global
    // csrfMiddleware does not duplicate this check.
    if (spec.csrf === 'after-body' && request.method !== 'GET') {
      const sessionCsrfToken = locals.session?.csrfToken;
      if (!sessionCsrfToken) {
        return respondError('CSRF_INVALID', 'CSRF validation failed', 403);
      }
      const headerToken = request.headers.get('x-csrf-token');
      const valid = await verifyCsrf({ sessionCsrfToken, headerToken });
      if (!valid) {
        return respondError('CSRF_INVALID', 'CSRF validation failed', 403);
      }
    }

    // ── 7. Handler invocation ──────────────────────────────────────────────
    const serviceProjection = pickServices(locals.services, spec.services);

    const ctx: ApiHandlerCtx<TBody, TServices, TParams, TQuery> = {
      user: locals.user ?? null,
      body,
      request,
      locals,
      params: parsedParams,
      query: parsedQuery,
      services: serviceProjection,
    };

    let result: ApiResult<TData, TErrCode>;
    try {
      result = await handler(ctx);
    } catch (err) {
      const mapped = spec.errorMap?.(err);
      if (mapped != null) {
        result = mapped;
      } else {
        const domainError = toDomainError(err);
        if (domainError) {
          return respondDomainError(domainError);
        }

        // ── 8. Unhandled error catch ───────────────────────────────────────
        try {
          captureCaught(err, { scope, severity: 'error' });
        } catch (sentryErr) {
          console.warn(
            JSON.stringify({
              level: 'warn',
              msg: 'define_api_capture_failed',
              scope,
              err: String(sentryErr),
            }),
          );
        }
        return respondError('INTERNAL_ERROR', 'Internal error', 500);
      }
    }

    // ── Translate ApiResult → Response ─────────────────────────────────────
    if ('raw' in result) {
      return result.raw;
    }

    if (result.ok) {
      return respondOk(result.data, result.status, result.headers);
    }

    const errStatus = result.status ?? statusFromCode(result.code);
    return respondError(result.code, result.error, errStatus);
  };
}

function pickServices<TServices extends readonly ServiceKey[]>(
  services: Services,
  keys: TServices | undefined,
): Pick<Services, TServices[number]> {
  if (!keys) return services as Pick<Services, TServices[number]>;

  return Object.fromEntries(keys.map((key) => [key, services[key]])) as Pick<
    Services,
    TServices[number]
  >;
}

function normalizeQueryParams(searchParams: URLSearchParams): ApiQuery {
  const query: ApiQuery = {};

  for (const [key, value] of searchParams.entries()) {
    const current = query[key];
    if (current === undefined) {
      query[key] = value;
      continue;
    }
    query[key] = Array.isArray(current) ? [...current, value] : [current, value];
  }

  return query;
}
