/**
 * Route handler wrapper.
 *
 * Every API route goes through here, which is what makes the security guarantees
 * uniform rather than per-file discipline: authentication, permission checks, rate
 * limiting, input validation, audit logging and safe error shaping all happen in one
 * place. A route that forgets to declare a permission gets the default (authenticated),
 * never "open".
 */

import 'server-only'

import { NextResponse, type NextRequest } from 'next/server'
import { ZodError, type ZodType } from 'zod'

import { env } from '../env'
import { writeAudit, clientIp } from '../audit'
import { getCurrentUser, type CurrentUser } from '../auth/current-user'
import { ApiError, ERROR_CODES, ERROR_MESSAGES, type ErrorCode } from './errors'
import { rateLimit, rateLimitHeaders } from './rate-limit'

export interface ApiContext<TParams = Record<string, string>, TBody = unknown> {
  request: NextRequest
  user: CurrentUser
  params: TParams
  searchParams: URLSearchParams
  body: TBody
}

export interface RouteOptions<TBody> {
  /** Permission key(s) required. Any one of them suffices. Omit for authenticated-only. */
  permission?: string | string[]
  /** Allow unauthenticated access (login, health). */
  public?: boolean
  /** Zod schema for the JSON request body. */
  bodySchema?: ZodType<TBody>
  /** Requests per window for this route. Defaults to the global limit. */
  limit?: number
  /** Audit entry written on success. */
  audit?: {
    action: string
    entity: string
    entityId?: (ctx: { params: Record<string, string>; body: unknown; result: unknown }) => string | null
    detail?: string
  }
}

function errorResponse(code: ErrorCode, status: number, details?: unknown, headers?: HeadersInit) {
  return NextResponse.json(
    {
      ok: false,
      error: { code, message: ERROR_MESSAGES[code].en, messageAr: ERROR_MESSAGES[code].ar, details },
    },
    { status, headers },
  )
}

/**
 * Turns a zod issue list into `{ field: message }`, which is what the forms consume.
 */
/**
 * One stable code per offending field.
 *
 * Zod's own message is English prose written for a developer reading a stack trace —
 * "Too small: expected string to have >=1 characters". Putting that on an Arabic sign-in
 * form is a bug twice over: it is untranslated, and it describes the schema rather than
 * telling the person what to do. The wire carries a code; the screen carries the sentence.
 *
 * The mapping is deliberately coarse. A form needs to say *what is wrong with this field*,
 * and six answers cover every schema in this application.
 */
function fieldErrorCode(issue: ZodError['issues'][number]): string {
  switch (issue.code) {
    case 'too_small':
      // An empty string against `min(1)` is a missing value, not a short one, and the two
      // deserve different sentences.
      return issue.origin === 'string' && Number(issue.minimum) <= 1 ? 'required' : 'tooSmall'
    case 'too_big':
      return 'tooLarge'
    case 'invalid_type':
      return issue.input === undefined || issue.input === null ? 'required' : 'invalidType'
    case 'invalid_format':
      return 'invalidFormat'
    case 'invalid_value':
    case 'invalid_union':
      return 'invalidValue'
    default:
      return 'invalidValue'
  }
}

function fieldErrors(error: ZodError): Record<string, string> {
  const out: Record<string, string> = {}
  for (const issue of error.issues) {
    const path = issue.path.join('.') || '_'
    if (!out[path]) out[path] = fieldErrorCode(issue)
  }
  return out
}

export function apiRoute<TParams extends Record<string, string> = Record<string, string>, TBody = unknown>(
  options: RouteOptions<TBody>,
  handler: (ctx: ApiContext<TParams, TBody>) => Promise<unknown>,
) {
  // The context parameter is declared as required because Next's generated route types
  // expect it; it is still read defensively since static routes carry no params.
  return async (request: NextRequest, segment: { params: Promise<TParams> }) => {
    const url = new URL(request.url)

    // ── Rate limiting ────────────────────────────────────────────────────────
    const limit = options.limit ?? env.rateLimitMaxRequests
    const bucketKey = `${clientIp(request) ?? 'local'}:${url.pathname}`
    const limitResult = await rateLimit(bucketKey, limit, env.rateLimitWindowMs)
    const limitHeaders = rateLimitHeaders(limitResult)
    if (!limitResult.allowed) {
      return errorResponse(ERROR_CODES.rateLimited, 429, undefined, {
        ...limitHeaders,
        'Retry-After': String(Math.max(1, Math.ceil((limitResult.resetAt - Date.now()) / 1000))),
      })
    }

    try {
      // ── Authentication ─────────────────────────────────────────────────────
      const user = await getCurrentUser()
      if (!options.public && !user) {
        return errorResponse(ERROR_CODES.unauthenticated, 401, undefined, limitHeaders)
      }

      // ── Authorisation ──────────────────────────────────────────────────────
      // Enforced on the server for every request. Hiding a control in the UI is a
      // usability affordance, never the access control itself (§34).
      if (options.permission && user) {
        const required = Array.isArray(options.permission) ? options.permission : [options.permission]
        if (!required.some((key) => user.permissions.has(key))) {
          await writeAudit({
            action: 'authz.denied',
            entity: url.pathname,
            user,
            status: 'denied',
            detail: `Required one of: ${required.join(', ')}`,
            request,
          })
          return errorResponse(ERROR_CODES.permissionDenied, 403, undefined, limitHeaders)
        }
      }

      // ── Input validation ───────────────────────────────────────────────────
      let body = undefined as TBody
      if (options.bodySchema) {
        let raw: unknown
        try {
          raw = await request.json()
        } catch {
          throw new ApiError(ERROR_CODES.validationFailed, {
            details: { _: 'Request body must be valid JSON.' },
          })
        }
        const parsed = options.bodySchema.safeParse(raw)
        if (!parsed.success) {
          throw new ApiError(ERROR_CODES.validationFailed, { details: fieldErrors(parsed.error) })
        }
        body = parsed.data
      }

      const params = ((await segment?.params) ?? {}) as TParams

      const result = await handler({
        request,
        user: user as CurrentUser,
        params,
        searchParams: url.searchParams,
        body,
      })

      if (result instanceof Response) return result

      if (options.audit && user) {
        await writeAudit({
          action: options.audit.action,
          entity: options.audit.entity,
          entityId: options.audit.entityId?.({ params, body, result }) ?? null,
          user,
          detail: options.audit.detail,
          request,
        })
      }

      return NextResponse.json({ ok: true, data: result }, { headers: limitHeaders })
    } catch (error) {
      if (error instanceof ApiError) {
        return errorResponse(error.code, error.status, error.details, limitHeaders)
      }
      if (error instanceof ZodError) {
        return errorResponse(ERROR_CODES.validationFailed, 422, fieldErrors(error), limitHeaders)
      }
      // Anything unexpected is logged server-side and reported generically. The client
      // never sees a stack trace, a query, or a database message.
      console.error(`[api] ${request.method} ${url.pathname} failed:`, error)
      return errorResponse(ERROR_CODES.serverError, 500, undefined, limitHeaders)
    }
  }
}

/** Parses `?page=&pageSize=` with sane bounds (§50). */
export function pagination(searchParams: URLSearchParams, defaultSize = 25) {
  const page = Math.max(1, Number(searchParams.get('page') ?? 1) || 1)
  const pageSize = Math.min(200, Math.max(1, Number(searchParams.get('pageSize') ?? defaultSize) || defaultSize))
  return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize }
}

export function paginated<T>(items: T[], total: number, page: number, pageSize: number) {
  return {
    items,
    page,
    pageSize,
    total,
    pageCount: Math.max(1, Math.ceil(total / pageSize)),
    hasMore: page * pageSize < total,
  }
}
