import { randomUUID } from 'node:crypto';

import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import {
  portRoleOverrides,
  ports,
  userPermissionOverrides,
  userPortRoles,
  userProfiles,
} from '@/lib/db/schema';
import { type RolePermissions } from '@/lib/db/schema/users';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { runWithRequestContext, getRequestContext } from '@/lib/request-context';
import {
  checkRateLimit,
  rateLimiters,
  rateLimitHeaders,
  type RateLimiterName,
} from '@/lib/rate-limit';

// ─── Types ────────────────────────────────────────────────────────────────────

/**
 * Authenticated request context resolved by `withAuth`.
 * Passed as the second argument to every wrapped route handler.
 */
export interface AuthContext {
  userId: string;
  portId: string;
  portSlug: string;
  /** true for super_admin users - bypasses all permission checks. */
  isSuperAdmin: boolean;
  /**
   * Effective permissions after role + port override deep-merge.
   * null when isSuperAdmin is true (super admins bypass permission checks).
   */
  permissions: RolePermissions | null;
  user: {
    email: string;
    name: string;
    /** Optional signing-identity override (user_profiles.signing_email). The
     *  address representing this user in signing contexts; null when they sign
     *  as their login email. Never a login credential. */
    signingEmail: string | null;
  };
  /** Client IP extracted from X-Forwarded-For header. */
  ipAddress: string;
  userAgent: string;
}

/**
 * Route params type. Defaults to `Record<string, string>` for the common
 * `[id]`-style routes. Catch-all routes (`[...slug]`) need to override
 * `TParams` so Next.js 15.5+'s stricter route-type checking accepts the
 * exported handler against the inferred `{ slug: string[] }` shape.
 */
export type RouteParams = Record<string, string | string[]>;

export type RouteHandler<TParams extends RouteParams = Record<string, string>, T = unknown> = (
  req: NextRequest,
  ctx: AuthContext,
  params: TParams,
) => Promise<NextResponse<T>>;

// ─── deepMerge ───────────────────────────────────────────────────────────────

/**
 * Recursively merges `source` into `target`.
 * Used to apply port-level role permission overrides on top of the base role.
 */
export function deepMerge(
  target: Record<string, unknown>,
  source: Record<string, unknown>,
): Record<string, unknown> {
  const result = { ...target };
  for (const key of Object.keys(source)) {
    const sourceVal = source[key];
    const targetVal = result[key];
    if (
      typeof sourceVal === 'object' &&
      sourceVal !== null &&
      !Array.isArray(sourceVal) &&
      typeof targetVal === 'object' &&
      targetVal !== null &&
      !Array.isArray(targetVal)
    ) {
      result[key] = deepMerge(
        targetVal as Record<string, unknown>,
        sourceVal as Record<string, unknown>,
      );
    } else {
      result[key] = sourceVal;
    }
  }
  return result;
}

// ─── withAuth ────────────────────────────────────────────────────────────────

/**
 * Validates the session, loads the user profile, resolves port context and
 * applies port-level role overrides before calling the inner handler.
 *
 * Usage:
 * ```ts
 * export const GET = withAuth(handler);
 * export const POST = withAuth(withPermission('clients', 'create', handler));
 * ```
 */
export function withAuth<TParams extends RouteParams = Record<string, string>>(
  handler: RouteHandler<TParams>,
): (req: NextRequest, routeContext: { params: Promise<TParams> }) => Promise<NextResponse> {
  // M14: apply the broad per-user `api` limiter (120/min) as a default
  // backstop for EVERY authenticated v1 request. Tighter named limiters
  // (`ai`, `bulk`, `ocr`, …) still compose ON TOP via `withRateLimit`
  // inside the handler chain - they use distinct Redis key prefixes, so
  // a request that trips a named limiter is counted in its own bucket
  // AND this `api` bucket independently (no double-counting within a
  // single bucket). `checkRateLimit` fails OPEN on a Redis outage
  // (see rate-limit.ts), so this can never lock the API out.
  const rateLimited = withRateLimit('api', handler as RouteHandler) as RouteHandler<TParams>;
  return async (req, routeContext) => {
    // Mint or accept a request id BEFORE entering the ALS frame so every
    // log line + the response header reference the same value. Clients
    // (or upstream proxies) may pre-supply via X-Request-Id; otherwise
    // generate a fresh UUID. Pattern-validated so a crafted header can't
    // smuggle log-injection chars.
    const incomingId = req.headers.get('x-request-id');
    const requestId =
      incomingId && /^[A-Za-z0-9-]{8,64}$/.test(incomingId) ? incomingId : randomUUID();

    /** Stamp `X-Request-Id` onto every response leaving the wrapper. */
    const tag = (res: NextResponse): NextResponse => {
      res.headers.set('X-Request-Id', requestId);
      return res;
    };

    return runWithRequestContext(
      {
        requestId,
        portId: '',
        userId: '',
        method: req.method,
        path: new URL(req.url).pathname,
        startedAt: Date.now(),
      },
      async () => {
        try {
          // 1. Validate session via Better Auth.
          const session = await auth.api.getSession({ headers: req.headers });
          if (!session?.user) {
            return tag(NextResponse.json({ error: 'Authentication required' }, { status: 401 }));
          }

          // 2. Load the CRM user profile (keyed on Better Auth user ID).
          const profile = await db.query.userProfiles.findFirst({
            where: eq(userProfiles.userId, session.user.id),
          });
          if (!profile || !profile.isActive) {
            return tag(NextResponse.json({ error: 'Account disabled' }, { status: 403 }));
          }

          // 3. Resolve port context. Port id comes from the X-Port-Id
          //    header (set by the client after port selection), falling
          //    back to the user's default port preference. NEVER from the
          //    request body - SECURITY-GUIDELINES.md §2.1.
          const portIdFromHeader = req.headers.get('X-Port-Id');
          const portId =
            portIdFromHeader ??
            (profile.preferences as { defaultPortId?: string } | null)?.defaultPortId ??
            null;

          if (!portId && !profile.isSuperAdmin) {
            return tag(NextResponse.json({ error: 'Port context required' }, { status: 400 }));
          }

          // 4. Resolve effective permissions.
          let permissions: RolePermissions | null = null;
          let portSlug = '';

          if (!profile.isSuperAdmin && portId) {
            const portRole = await db.query.userPortRoles.findFirst({
              where: and(
                eq(userPortRoles.userId, profile.userId),
                eq(userPortRoles.portId, portId),
              ),
              with: {
                role: true,
                port: true,
              },
            });

            if (!portRole) {
              return tag(NextResponse.json({ error: 'No access to this port' }, { status: 403 }));
            }

            permissions = { ...(portRole.role.permissions as RolePermissions) };
            portSlug = (portRole.port as { slug: string } | null)?.slug ?? '';

            // Apply port-specific role overrides (deep-merge on top of base role).
            const override = await db.query.portRoleOverrides.findFirst({
              where: and(
                eq(portRoleOverrides.portId, portId),
                eq(portRoleOverrides.roleId, portRole.roleId),
              ),
            });

            if (override?.permissionOverrides) {
              permissions = deepMerge(
                permissions as unknown as Record<string, unknown>,
                override.permissionOverrides as Record<string, unknown>,
              ) as RolePermissions;
            }

            // Per-user residential toggle.
            if (portRole.residentialAccess && permissions) {
              permissions = {
                ...permissions,
                residential_clients: { view: true, create: true, edit: true, delete: true },
                residential_interests: {
                  view: true,
                  create: true,
                  edit: true,
                  delete: true,
                  change_stage: true,
                },
              };
            }

            // Per-user permission overrides. Final layer in the chain so
            // they win over role + port-role-override. Most users will
            // never have a row here; admins flip individual leaves from
            // the user-edit drawer's Permissions tab.
            const userOverride = await db.query.userPermissionOverrides.findFirst({
              where: and(
                eq(userPermissionOverrides.userId, profile.userId),
                eq(userPermissionOverrides.portId, portId),
              ),
            });
            if (userOverride?.permissionOverrides && permissions) {
              permissions = deepMerge(
                permissions as unknown as Record<string, unknown>,
                userOverride.permissionOverrides as Record<string, unknown>,
              ) as RolePermissions;
            }
          } else if (profile.isSuperAdmin && portId) {
            const port = await db.query.ports.findFirst({
              where: eq(ports.id, portId),
            });
            if (!port) {
              return tag(NextResponse.json({ error: 'Port not found' }, { status: 404 }));
            }
            portSlug = port.slug;
          }

          // Now that the user + port are resolved, enrich the ALS frame
          // so log lines + error_events rows pick up the identifiers.
          const frame = getRequestContext();
          if (frame) {
            frame.userId = profile.userId;
            frame.portId = portId ?? '';
          }

          const ctx: AuthContext = {
            userId: profile.userId,
            portId: portId ?? '',
            portSlug,
            isSuperAdmin: profile.isSuperAdmin,
            permissions,
            user: {
              email: session.user.email,
              name: session.user.name,
              signingEmail: profile.signingEmail ?? null,
            },
            ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
            userAgent: req.headers.get('user-agent') ?? 'unknown',
          };

          const params = await routeContext.params;
          // Call through the `api`-limited wrapper (M14). On a 429 it
          // short-circuits before the inner handler; otherwise it
          // delegates straight to the original handler.
          return tag(await rateLimited(req, ctx, params));
        } catch (error) {
          return tag(errorResponse(error));
        }
      },
    );
  };
}

// ─── requireSuperAdmin ───────────────────────────────────────────────────────

/**
 * Throws ForbiddenError when the caller is not a super-admin. Use inside
 * route handlers (after withAuth) for endpoints that mutate global, cross-
 * tenant state - global roles, cross-port migrations, system jobs.
 *
 * Logs the denied attempt to the audit trail (mirrors withPermission).
 */
export function requireSuperAdmin(ctx: AuthContext, attemptedAction = 'super_admin_only'): void {
  if (ctx.isSuperAdmin) return;
  logger.warn({ userId: ctx.userId, attemptedAction }, 'Super-admin gate denied');
  void createAuditLog({
    userId: ctx.userId,
    portId: ctx.portId,
    action: 'permission_denied',
    entityType: 'super_admin',
    entityId: '',
    metadata: { attemptedAction },
    ipAddress: ctx.ipAddress,
    userAgent: ctx.userAgent,
  });
  throw new ForbiddenError('Super admin access required');
}

// ─── withPermission ──────────────────────────────────────────────────────────

/**
 * Wraps a route handler with a permission gate.
 * Denied attempts are logged to the audit trail.
 *
 * Compose inside withAuth:
 * ```ts
 * export const DELETE = withAuth(withPermission('clients', 'delete', handler));
 * ```
 */
export function withPermission<TParams extends RouteParams = Record<string, string>>(
  resource: keyof RolePermissions,
  action: string,
  handler: RouteHandler<TParams>,
): RouteHandler<TParams> {
  return async (req, ctx, params) => {
    if (!ctx.isSuperAdmin) {
      const resourcePerms = ctx.permissions?.[resource] as Record<string, boolean> | undefined;

      if (!resourcePerms || !resourcePerms[action]) {
        logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied');

        // Log the denied attempt - fire-and-forget; audit must never block response.
        void createAuditLog({
          userId: ctx.userId,
          portId: ctx.portId,
          action: 'permission_denied',
          entityType: resource,
          entityId: '',
          metadata: { attemptedAction: action },
          ipAddress: ctx.ipAddress,
          userAgent: ctx.userAgent,
        });

        return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
      }
    }

    return handler(req, ctx, params);
  };
}

// ─── withRateLimit ───────────────────────────────────────────────────────────

/**
 * Wraps a route handler with a per-user rate-limit gate. Compose inside
 * withAuth so the userId is available - falls back to IP for anonymous
 * routes (we don't currently expose any).
 *
 * 429 responses include `X-RateLimit-Limit` / `Remaining` / `Reset` headers
 * and a `Retry-After` hint.
 *
 * ```ts
 * export const POST = withAuth(
 *   withPermission('expenses', 'create',
 *     withRateLimit('ocr', handler)
 *   )
 * );
 * ```
 */
export function withRateLimit(name: RateLimiterName, handler: RouteHandler): RouteHandler {
  const config = rateLimiters[name];
  return async (req, ctx, params) => {
    const identifier = `${ctx.userId}`;
    const result = await checkRateLimit(identifier, config);
    if (!result.allowed) {
      const retryAfterSec = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
      logger.warn(
        { userId: ctx.userId, limiter: name, limit: result.limit },
        'Rate limit exceeded',
      );
      return NextResponse.json(
        { error: 'Rate limit exceeded', retryAfter: retryAfterSec },
        {
          status: 429,
          headers: {
            ...rateLimitHeaders(result),
            'Retry-After': String(retryAfterSec),
          },
        },
      );
    }
    return handler(req, ctx, params);
  };
}

// ─── withPublicContext ───────────────────────────────────────────────────────

/**
 * Wraps a public (unauthenticated) route - webhooks, health checks,
 * public APIs - so it runs inside the same `runWithRequestContext` ALS
 * frame that `withAuth` installs for authenticated routes. Without this
 * frame, `captureErrorEvent`, `getRequestId`, and the logger's request-id
 * mixin silently no-op for these endpoints, leaving webhook failures
 * invisible in the platform-errors dashboard.
 *
 * Top-level errors thrown by the handler are forwarded to
 * `captureErrorEvent` (so they surface in admin/errors) and re-raised
 * so Next's runtime can return a 500. Webhook handlers that prefer to
 * always-return-200 can catch internally - this wrapper only catches the
 * uncaught path.
 */
export function withPublicContext(
  handler: (req: NextRequest) => Promise<NextResponse>,
): (req: NextRequest) => Promise<NextResponse> {
  return async (req) => {
    const incomingId = req.headers.get('x-request-id');
    const requestId =
      incomingId && /^[A-Za-z0-9-]{8,64}$/.test(incomingId) ? incomingId : randomUUID();
    const tag = (res: NextResponse): NextResponse => {
      res.headers.set('X-Request-Id', requestId);
      return res;
    };

    return runWithRequestContext(
      {
        requestId,
        portId: '',
        userId: '',
        method: req.method,
        path: new URL(req.url).pathname,
        startedAt: Date.now(),
      },
      async () => {
        try {
          return tag(await handler(req));
        } catch (error) {
          const { captureErrorEvent } = await import('@/lib/services/error-events.service');
          void captureErrorEvent({ statusCode: 500, error });
          logger.error({ err: error }, 'Public route handler threw');
          return tag(errorResponse(error));
        }
      },
    );
  };
}
