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

import { withAuth, type AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema';
import { ConflictError, errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { isReservedUsername, USERNAME_REGEX } from '@/lib/validators/username';
import { sql } from 'drizzle-orm';
import { z } from 'zod';

const updateProfileSchema = z.object({
  firstName: z.string().min(1).max(120).nullable().optional(),
  lastName: z.string().min(1).max(120).nullable().optional(),
  displayName: z.string().min(1).max(200).optional(),
  /**
   * Optional sign-in alias. `null` clears the existing value; a string
   * must match the 2–30 lowercase shape pinned by USERNAME_REGEX (also
   * enforced by `chk_user_profiles_username_shape` in migration 0054).
   * Uniqueness is checked below before the UPDATE - collisions surface
   * as a 409 with a friendly message.
   */
  username: z.union([z.string().transform((s) => s.trim().toLowerCase()), z.null()]).optional(),
  phone: z.string().nullable().optional(),
  // Refuse `javascript:` / `data:` schemes - z.string().url() lets them
  // through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
  // vector if any future renderer treated the value as a link.
  avatarUrl: z
    .string()
    .url()
    .refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
    .nullable()
    .optional(),
  // Strict allow-list - no `.passthrough()` here.  The previous schema let
  // arbitrary client-supplied keys survive validation and persist into
  // `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
  // Add new keys here as the UI surfaces them rather than letting the
  // client mint them at will.
  preferences: z
    .object({
      dark_mode: z.boolean().optional(),
      locale: z.string().optional(),
      timezone: z.string().optional(),
      // multi-port-auditor C3: super-admins land on whichever port
      // sorts alphabetically-first when there's no header + no
      // preference. Persisting their last-picked port here makes
      // the dashboard "remember" their context across tabs.
      // `withAuth` (helpers.ts) already reads this as the
      // X-Port-Id fallback; previously the value could only ever
      // be set via hand-rolled SQL because the allow-list at line
      // 154 silently stripped unknown keys.
      defaultPortId: z.string().uuid().optional(),
      // Per-table column visibility. Keyed by entity type - entries
      // with an empty `hiddenColumns` mean "all visible". The validator
      // caps total entries / IDs so a malicious client can't bloat the
      // 8 KB preferences blob; see merge step below for the byte cap.
      tablePreferences: z
        .record(
          z.string().min(1).max(64),
          z
            .object({
              hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(),
            })
            .strict(),
        )
        .optional(),
      // Phase 4 - per-user default reminder firing time-of-day. HH:MM in
      // 24h local clock (validated below). Per-reminder dueAt overrides
      // this; this is only the dialog's default when the rep doesn't
      // pick an explicit time. Server clamps to '00:00'–'23:59'.
      digestTimeOfDay: z
        .string()
        .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'use HH:MM 24-hour format')
        .optional(),
    })
    .strict()
    .optional(),
});

export const GET = withAuth(async (_req, ctx: AuthContext) => {
  // Hydrate preferences from user_profiles so the client can read its
  // saved table-column visibility (and other prefs) without a second
  // round-trip on app boot.
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, ctx.userId),
    columns: {
      preferences: true,
      avatarFileId: true,
      avatarUrl: true,
      firstName: true,
      lastName: true,
      displayName: true,
      username: true,
    },
  });

  return NextResponse.json({
    data: {
      userId: ctx.userId,
      portId: ctx.portId,
      portSlug: ctx.portSlug,
      permissions: ctx.permissions,
      isSuperAdmin: ctx.isSuperAdmin,
      user: ctx.user,
      preferences: profile?.preferences ?? {},
      profile: {
        avatarFileId: profile?.avatarFileId ?? null,
        avatarUrl: profile?.avatarUrl ?? null,
        firstName: profile?.firstName ?? null,
        lastName: profile?.lastName ?? null,
        displayName: profile?.displayName ?? null,
        username: profile?.username ?? null,
      },
    },
  });
});

export const PATCH = withAuth(async (req, ctx: AuthContext) => {
  try {
    const body = await parseBody(req, updateProfileSchema);

    const profile = await db.query.userProfiles.findFirst({
      where: eq(userProfiles.userId, ctx.userId),
    });
    if (!profile) throw new NotFoundError('profile');

    const updates: Record<string, unknown> = { updatedAt: new Date() };
    if (body.firstName !== undefined) updates.firstName = body.firstName;
    if (body.lastName !== undefined) updates.lastName = body.lastName;
    if (body.displayName !== undefined) updates.displayName = body.displayName;
    if (body.phone !== undefined) updates.phone = body.phone;
    if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
    if (body.username !== undefined) {
      // null clears; non-null must match the shape + not be reserved +
      // be unique (case-insensitive) across all users in the install.
      if (body.username === null || body.username === '') {
        updates.username = null;
      } else {
        const candidate = body.username;
        if (!USERNAME_REGEX.test(candidate)) {
          throw new ValidationError(
            'Username must be 2–30 lowercase letters, digits, dot, underscore, or hyphen.',
          );
        }
        if (isReservedUsername(candidate)) {
          throw new ValidationError('That username is reserved. Please pick another.');
        }
        const taken = await db
          .select({ userId: userProfiles.userId })
          .from(userProfiles)
          .where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
          .limit(1);
        if (taken.length > 0 && taken[0]!.userId !== ctx.userId) {
          throw new ConflictError('That username is already taken.');
        }
        updates.username = candidate;
      }
    }
    if (body.preferences !== undefined) {
      // Allow-list - only retain keys defined in the strict schema. Pre-
      // strict rows may carry extra keys from when the schema was
      // .passthrough(); the merge prunes them so legacy bloat doesn't
      // accumulate forever, and a future schema regression that tries
      // to ship arbitrary keys still gets dropped here at write time.
      const ALLOWED_PREF_KEYS = new Set([
        'dark_mode',
        'locale',
        'timezone',
        'tablePreferences',
        'defaultPortId',
        // Phase 4 - reminder default firing time.
        'digestTimeOfDay',
      ]);
      const existing = (profile.preferences as Record<string, unknown>) ?? {};
      const merged = Object.fromEntries(
        Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
          ALLOWED_PREF_KEYS.has(k),
        ),
      );
      // Hard cap on the merged JSONB - defense in depth against any
      // future schema growth that might re-introduce free-form keys.
      const serialized = JSON.stringify(merged);
      if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
        throw new ValidationError('preferences exceeds 8KB');
      }
      updates.preferences = merged;
    }

    // concurrency-auditor M-2: pre-check at line 132-139 is TOCTOU
    // against `idx_user_profiles_username_unique`. Two concurrent claims
    // on the same username will see "available" in their own pre-check
    // and the loser's UPDATE fails with 23505 - surface that as
    // ConflictError rather than letting it bubble as a generic 500.
    let updated;
    try {
      [updated] = await db
        .update(userProfiles)
        .set(updates)
        .where(eq(userProfiles.userId, ctx.userId))
        .returning();
    } catch (err) {
      if (isUsernameUniqueConflict(err)) {
        throw new ConflictError('That username is already taken.');
      }
      throw err;
    }

    return NextResponse.json({
      data: {
        userId: updated!.userId,
        firstName: updated!.firstName,
        lastName: updated!.lastName,
        displayName: updated!.displayName,
        username: updated!.username,
        phone: updated!.phone,
        avatarUrl: updated!.avatarUrl,
        preferences: updated!.preferences,
      },
    });
  } catch (error) {
    return errorResponse(error);
  }
});

function isUsernameUniqueConflict(err: unknown): boolean {
  if (typeof err !== 'object' || err === null) return false;
  const e = err as { code?: string; constraint_name?: string };
  return e.code === '23505' && e.constraint_name === 'idx_user_profiles_username_unique';
}
