import { and, eq, sql } from 'drizzle-orm';

import { db } from '@/lib/db';
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
import { emitToRoom } from '@/lib/socket/server';
import { sendEmail } from '@/lib/email';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';

export async function listUsers(portId: string) {
  // Two passes:
  //   1. Users with an explicit user_port_roles row for this port
  //   2. All super-admins (they have global access via the
  //      userProfiles.isSuperAdmin flag, no per-port row required -
  //      previous query missed them and the admin list looked empty
  //      to the only super-admin viewing it)
  const portRoleRows = await db
    .select({
      userId: userPortRoles.userId,
      displayName: userProfiles.displayName,
      firstName: userProfiles.firstName,
      lastName: userProfiles.lastName,
      fullName: user.name,
      email: user.email,
      signingEmail: userProfiles.signingEmail,
      phone: userProfiles.phone,
      isActive: userProfiles.isActive,
      isSuperAdmin: userProfiles.isSuperAdmin,
      lastLoginAt: userProfiles.lastLoginAt,
      roleId: roles.id,
      roleName: roles.name,
      assignedAt: userPortRoles.createdAt,
    })
    .from(userPortRoles)
    .innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
    .innerJoin(user, eq(userPortRoles.userId, user.id))
    .innerJoin(roles, eq(userPortRoles.roleId, roles.id))
    .where(eq(userPortRoles.portId, portId));

  const superAdminRows = await db
    .select({
      userId: userProfiles.userId,
      displayName: userProfiles.displayName,
      firstName: userProfiles.firstName,
      lastName: userProfiles.lastName,
      fullName: user.name,
      email: user.email,
      signingEmail: userProfiles.signingEmail,
      phone: userProfiles.phone,
      isActive: userProfiles.isActive,
      isSuperAdmin: userProfiles.isSuperAdmin,
      lastLoginAt: userProfiles.lastLoginAt,
      assignedAt: userProfiles.createdAt,
    })
    .from(userProfiles)
    .innerJoin(user, eq(userProfiles.userId, user.id))
    .where(eq(userProfiles.isSuperAdmin, true));

  // Dedup: a super-admin who ALSO has an explicit per-port role
  // appears once with their port-role displayed (more specific).
  const seen = new Set(portRoleRows.map((r) => r.userId));
  const merged = [
    ...portRoleRows.map((row) => ({
      userId: row.userId,
      displayName: row.displayName,
      firstName: row.firstName,
      lastName: row.lastName,
      fullName: row.fullName,
      email: row.email,
      signingEmail: row.signingEmail,
      phone: row.phone,
      isActive: row.isActive,
      isSuperAdmin: row.isSuperAdmin,
      lastLoginAt: row.lastLoginAt,
      role: { id: row.roleId, name: row.roleName },
      assignedAt: row.assignedAt,
    })),
    ...superAdminRows
      .filter((row) => !seen.has(row.userId))
      .map((row) => ({
        userId: row.userId,
        displayName: row.displayName,
        firstName: row.firstName,
        lastName: row.lastName,
        fullName: row.fullName,
        email: row.email,
        phone: row.phone,
        isActive: row.isActive,
        isSuperAdmin: row.isSuperAdmin,
        lastLoginAt: row.lastLoginAt,
        // Synthetic role label - super admins don't have a per-port
        // role row, but the UI expects a `role` object. The list
        // already shows the "Super Admin" badge separately.
        role: { id: 'super_admin', name: 'super_admin' },
        assignedAt: row.assignedAt,
      })),
  ];

  merged.sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? ''));
  return merged;
}

export async function getUser(userId: string, portId: string) {
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, userId),
  });
  if (!profile) throw new NotFoundError('User');

  const authUser = await db.query.user.findFirst({
    where: eq(user.id, userId),
  });

  const portRole = await db.query.userPortRoles.findFirst({
    where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
    with: { role: true },
  });
  if (!portRole) throw new NotFoundError('User not assigned to this port');

  return {
    userId: profile.userId,
    displayName: profile.displayName,
    firstName: profile.firstName,
    lastName: profile.lastName,
    fullName: authUser?.name ?? null,
    email: authUser?.email ?? '',
    signingEmail: profile.signingEmail,
    phone: profile.phone,
    isActive: profile.isActive,
    isSuperAdmin: profile.isSuperAdmin,
    lastLoginAt: profile.lastLoginAt,
    avatarUrl: profile.avatarUrl,
    preferences: profile.preferences,
    role: { id: portRole.role.id, name: portRole.role.name },
    residentialAccess: portRole.residentialAccess,
    createdAt: profile.createdAt,
  };
}

export async function createUser(portId: string, data: CreateUserInput, meta: AuditMeta) {
  // Check email uniqueness
  const existingUser = await db.query.user.findFirst({
    where: eq(user.email, data.email.toLowerCase()),
  });
  if (existingUser) {
    throw new ConflictError('A user with this email already exists');
  }

  // Validate role exists
  const role = await db.query.roles.findFirst({
    where: eq(roles.id, data.roleId),
  });
  if (!role) throw new ValidationError('Invalid role ID');

  // Optional sign-in username. Validated up front (before the auth user is
  // minted) so an invalid/taken username can't leave an orphaned account.
  // Mirrors the self-service /api/v1/me checks: shape + reserved + unique.
  let username: string | null = null;
  if (data.username && data.username.trim()) {
    const candidate = data.username.trim().toLowerCase();
    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) {
      throw new ConflictError('That username is already taken.');
    }
    username = candidate;
  }

  // Two onboarding modes:
  //   - setup-email (default when no password is supplied): provision the
  //     account with a throwaway random password the admin never sees, then
  //     email the user a link to set their own. The /set-password page
  //     consumes the better-auth reset token.
  //   - manual: the admin typed a password inline; use it verbatim.
  const useSetupEmail = data.sendSetupEmail ?? !data.password;
  const initialPassword = useSetupEmail
    ? `${crypto.randomUUID()}${crypto.randomUUID()}`
    : data.password!;

  // Create Better Auth user
  const authResult = await auth.api.signUpEmail({
    body: {
      email: data.email.toLowerCase(),
      password: initialPassword,
      name: data.name,
    },
  });

  const newUserId = authResult.user.id;

  // Create CRM profile
  await db.insert(userProfiles).values({
    userId: newUserId,
    displayName: data.displayName,
    username,
    firstName: data.firstName ?? null,
    lastName: data.lastName ?? null,
    phone: data.phone ?? null,
  });

  // Assign to port with role
  await db.insert(userPortRoles).values({
    userId: newUserId,
    portId,
    roleId: data.roleId,
    residentialAccess: data.residentialAccess ?? false,
    assignedBy: meta.userId,
  });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'user',
    entityId: newUserId,
    newValue: { email: data.email, displayName: data.displayName, role: role.name },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'user:created',
    message: `User "${data.displayName}" added`,
    severity: 'info',
  });

  // Setup-email mode: dispatch a "set your password" link. Reuses better-auth's
  // password-reset token, which the existing /set-password page consumes. Done
  // after the role assignment so the user is fully provisioned the moment they
  // set their password. A send failure must not roll back the created account —
  // the admin can resend, or fall back to setting a password manually.
  if (useSetupEmail) {
    // Flag this recipient so the shared sendResetPassword callback renders the
    // welcome email rather than the "you requested a reset" copy.
    const { markPendingWelcome, consumePendingWelcome } =
      await import('@/lib/auth/pending-welcome');
    markPendingWelcome(data.email);
    try {
      await auth.api.requestPasswordReset({
        body: { email: data.email.toLowerCase(), redirectTo: '/set-password' },
      });
    } catch (err) {
      // Clear the flag if the reset never dispatched, so a later self-service
      // reset for this address isn't mistaken for a welcome.
      consumePendingWelcome(data.email);
      logger.error(
        { err, userId: newUserId },
        'createUser: failed to send welcome / set-password email (account was still created)',
      );
    }
  }

  return getUser(newUserId, portId);
}

export async function updateUser(
  userId: string,
  portId: string,
  data: UpdateUserInput,
  meta: AuditMeta,
  /**
   * Caller's effective permission map at call time. Used to enforce the
   * caller-superset rule on role reassignment so a port admin holding
   * only `admin.manage_users` can't promote a peer to a role that grants
   * permissions the caller doesn't themselves hold (authz-auditor C-1
   * parallel path). Super admins bypass via `callerIsSuperAdmin`.
   */
  callerPermissions?: Record<string, Record<string, boolean>> | null,
  callerIsSuperAdmin?: boolean,
) {
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, userId),
  });
  if (!profile) throw new NotFoundError('User');

  const portRole = await db.query.userPortRoles.findFirst({
    where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
  });
  if (!portRole) throw new NotFoundError('User not assigned to this port');

  // Self-target guard (audit M12): an admin must not change their OWN
  // privileged fields here — self-deactivation (lockout) or self-escalation
  // of role / residentialAccess. Mirrors the permission-override route's
  // self-target block. Non-privileged profile edits (name/phone) stay allowed.
  if (userId === meta.userId) {
    if (data.isActive !== undefined && data.isActive !== profile.isActive) {
      throw new ForbiddenError('You cannot change your own active status.');
    }
    if (data.roleId !== undefined && data.roleId !== portRole.roleId) {
      throw new ForbiddenError('You cannot change your own role.');
    }
    if (
      data.residentialAccess !== undefined &&
      data.residentialAccess !== portRole.residentialAccess
    ) {
      throw new ForbiddenError('You cannot change your own residential access.');
    }
  }

  // Update profile fields
  const profileUpdates: Record<string, unknown> = { updatedAt: new Date() };
  if (data.displayName !== undefined) profileUpdates.displayName = data.displayName;
  if (data.firstName !== undefined) profileUpdates.firstName = data.firstName;
  if (data.lastName !== undefined) profileUpdates.lastName = data.lastName;
  if (data.phone !== undefined) profileUpdates.phone = data.phone;
  if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
  // Signing-identity override. Empty string is the "clear it" sentinel; any
  // non-empty value is lowercased (the validator already guaranteed email
  // shape). NEVER touches auth identity — this is signing-only.
  if (data.signingEmail !== undefined) {
    profileUpdates.signingEmail = data.signingEmail === '' ? null : data.signingEmail.toLowerCase();
  }

  if (Object.keys(profileUpdates).length > 1) {
    await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
  }

  // Auth-table updates: full name + email. Both go through Better Auth's
  // `user` table; the email change is admin-initiated and forces the
  // user to sign in with the new address (we notify the prior one).
  const authUserRow = await db.query.user.findFirst({ where: eq(user.id, userId) });
  const previousEmail = authUserRow?.email ?? null;
  const wantsEmailChange =
    typeof data.email === 'string' &&
    previousEmail !== null &&
    data.email.toLowerCase() !== previousEmail.toLowerCase();

  if (data.fullName !== undefined || wantsEmailChange) {
    const userUpdates: Record<string, unknown> = { updatedAt: new Date() };
    if (data.fullName !== undefined) userUpdates.name = data.fullName;
    if (wantsEmailChange) userUpdates.email = data.email!.toLowerCase();
    await db.update(user).set(userUpdates).where(eq(user.id, userId));
  }

  if (wantsEmailChange) {
    const newEmailLower = data.email!.toLowerCase();
    // Better Auth's credential provider authenticates by
    // `account.accountId` (the email captured at sign-up), NOT by
    // `user.email`. Without this update the user can't sign in with
    // either address - old fails because user.email no longer matches,
    // new fails because there's no account.accountId row for it.
    await db
      .update(account)
      .set({ accountId: newEmailLower, updatedAt: new Date() })
      .where(and(eq(account.userId, userId), eq(account.providerId, 'credential')));

    // Revoke every active session - the admin just changed the identity
    // the user authenticates with, so existing sessions are effectively
    // orphaned and a security risk if the account is being rotated due
    // to compromise. The user re-authenticates with the new address.
    await db.delete(session).where(eq(session.userId, userId));
  }

  if (wantsEmailChange && previousEmail) {
    // Best-effort notification - failure to send doesn't roll back the
    // change because Better Auth's primary identity has already moved.
    // The user still gets in with the new address; this is just an
    // outbound courtesy.
    void notifyAdminEmailChange({
      previousEmail,
      newEmail: data.email!.toLowerCase(),
      displayName: data.displayName ?? profile.displayName,
      changedByUserId: meta.userId,
      portId,
    });
  }

  // Update role assignment + per-user toggles
  const portRoleUpdates: Record<string, unknown> = {};
  if (data.roleId && data.roleId !== portRole.roleId) {
    const newRole = await db.query.roles.findFirst({
      where: eq(roles.id, data.roleId),
    });
    if (!newRole) throw new ValidationError('Invalid role ID');

    // Caller-superset check (authz-auditor C-1 parallel path): refuse to
    // assign a role whose effective permission set contains any leaf
    // the caller doesn't hold. Super admins bypass. When
    // `callerPermissions` isn't passed (legacy callers / system jobs)
    // we skip the check - but every interactive API route should pass
    // ctx.permissions + ctx.isSuperAdmin through.
    if (callerPermissions && !callerIsSuperAdmin) {
      const newRolePerms = newRole.permissions as Record<string, Record<string, boolean>>;
      for (const [resource, actions] of Object.entries(newRolePerms ?? {})) {
        for (const [action, value] of Object.entries(actions)) {
          if (value !== true) continue;
          if (callerPermissions[resource]?.[action] !== true) {
            throw new ForbiddenError(
              `You don't hold ${resource}.${action} yourself, so you can't assign a role that grants it.`,
            );
          }
        }
      }
    }

    portRoleUpdates.roleId = data.roleId;
    portRoleUpdates.assignedBy = meta.userId;
  }
  if (
    data.residentialAccess !== undefined &&
    data.residentialAccess !== portRole.residentialAccess
  ) {
    // Caller-superset (audit H8): enabling residentialAccess grants the full
    // residential CRUD set (see the resolver in helpers.ts). A caller who
    // doesn't hold those leaves themselves must not be able to grant them via
    // this flag — the role path is already superset-checked above, and the
    // flag must not be an escalation back door. Super admins bypass.
    if (data.residentialAccess === true && callerPermissions && !callerIsSuperAdmin) {
      const grantedByFlag: Record<string, string[]> = {
        residential_clients: ['view', 'create', 'edit', 'delete'],
        residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
      };
      for (const [resource, actions] of Object.entries(grantedByFlag)) {
        for (const action of actions) {
          if (callerPermissions[resource]?.[action] !== true) {
            throw new ForbiddenError(
              `You don't hold ${resource}.${action} yourself, so you can't grant residential access.`,
            );
          }
        }
      }
    }
    portRoleUpdates.residentialAccess = data.residentialAccess;
  }
  if (Object.keys(portRoleUpdates).length > 0) {
    await db
      .update(userPortRoles)
      .set(portRoleUpdates)
      .where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
  }

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'user',
    entityId: userId,
    oldValue: {
      displayName: profile.displayName,
      isActive: profile.isActive,
      roleId: portRole.roleId,
    },
    newValue: data,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'user:updated',
    message: `User "${data.displayName ?? profile.displayName}" updated`,
    severity: 'info',
  });

  return getUser(userId, portId);
}

export async function removeUserFromPort(userId: string, portId: string, meta: AuditMeta) {
  const portRole = await db.query.userPortRoles.findFirst({
    where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
  });
  if (!portRole) throw new NotFoundError('User not assigned to this port');

  // Prevent removing yourself
  if (userId === meta.userId) {
    throw new ValidationError('Cannot remove yourself from the port');
  }

  await db
    .delete(userPortRoles)
    .where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));

  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, userId),
  });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'delete',
    entityType: 'user',
    entityId: userId,
    oldValue: { displayName: profile?.displayName, roleId: portRole.roleId },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'user:removed',
    message: `User "${profile?.displayName}" removed from port`,
    severity: 'info',
  });
}

/**
 * Sends the "your admin changed your sign-in email" courtesy notice to
 * the prior address. Best-effort - failures are logged but don't roll
 * back the change; Better Auth has already pointed the account at the
 * new address by the time this fires.
 */
async function notifyAdminEmailChange(args: {
  previousEmail: string;
  newEmail: string;
  displayName: string;
  changedByUserId: string;
  portId: string;
}): Promise<void> {
  try {
    const [admin, port, branding] = await Promise.all([
      db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, args.changedByUserId) }),
      db.query.ports.findFirst({ where: eq(ports.id, args.portId) }),
      getBrandingShell(args.portId).catch(() => null),
    ]);

    const { subject, html, text } = await adminEmailChangeEmail(
      {
        recipientName: args.displayName,
        newEmail: args.newEmail,
        changedByDisplayName: admin?.displayName,
        portName: port?.name,
        loginUrl: env.APP_URL ? `${env.APP_URL}/login` : undefined,
      },
      { branding },
    );

    await sendEmail(args.previousEmail, subject, html, undefined, text, args.portId);
  } catch (err) {
    logger.warn(
      { err, previousEmail: args.previousEmail, newEmail: args.newEmail },
      'admin email-change notification failed (non-fatal)',
    );
  }
}
