import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { randomBytes, randomInt } from 'node:crypto';
import { DeviceType, Prisma, SessionStatus, User, UserStatus } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { RedisService } from '../infra/redis.service';
import { LdapClientService, LdapConnectionConfig } from '../infra/ldap-client.service';
import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, validateLdapConfig } from '../infra/ldap-config.util';
import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
import { AccessService } from './access.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
import { NotificationsService } from './notifications.service';
import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
import { TotpService } from './totp.service';
import { RbacService } from './rbac.service';
import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants';
import {
  assertHumanAccount,
  assertNotReservedUsername,
  HUMAN_USER_WHERE
} from './system-account.util';

const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;

type DeviceCommand = Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>;

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly redis: RedisService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
    private readonly access: AccessService,
    private readonly settings: SettingsService,
    private readonly ldapClient: LdapClientService,
    private readonly messaging: MessagingService,
    private readonly notifications: NotificationsService,
    private readonly totp: TotpService,
    private readonly rbac: RbacService
  ) {}

  async register(command: RegisterCommand): Promise<PublicUser> {
    if (!command.email && !command.phone) {
      throw new BadRequestException('Укажите почту или телефон');
    }

    assertNotReservedUsername(command.username);

    const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null;
    const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);

    if (!lockAcquired) {
      throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
    }

    try {
      const user = await this.prisma.$transaction(
        async (tx) => {
          const usersCount = await tx.user.count({ where: { deletedAt: null } });
          return tx.user.create({
            data: {
              email: command.email,
              phone: command.phone,
              passwordHash,
              displayName: command.displayName,
              username: command.username,
              isSuperAdmin: usersCount === 0
            }
          });
        },
        { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
      );

      await this.rbac.ensureDefaultUserRole(user.id);
      return await this.toPublicUser(user);
    } catch (error) {
      if (this.isUniqueError(error)) {
        throw new BadRequestException('Пользователь с такими данными уже существует');
      }
      throw error;
    } finally {
      await this.redis.releaseLock(FIRST_ADMIN_LOCK);
    }
  }

  async login(command: LoginCommand): Promise<AuthTokens> {
    const user = await this.prisma.user.findFirst({
      where: {
        OR: [{ email: command.login }, { phone: command.login }, { username: command.login }],
        deletedAt: null,
        status: UserStatus.ACTIVE,
        ...HUMAN_USER_WHERE
      },
      include: { pinCode: true }
    });

    if (!user || user.status !== UserStatus.ACTIVE) {
      throw new UnauthorizedException('Неверный логин или пароль');
    }

    assertHumanAccount(user, { asAuth: true });

    if (!user.passwordHash) {
      throw new UnauthorizedException('Для этого аккаунта используйте вход по коду');
    }

    const passwordMatches = await bcrypt.compare(command.password, user.passwordHash);
    if (!passwordMatches) {
      await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
      throw new UnauthorizedException('Неверный логин или пароль');
    }

    const device = await this.upsertDevice(user.id, command);

    return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true });
  }

  async beginTotpLogin(command: DeviceCommand & { recipient: string }) {
    const user = await this.findUserByRecipient(command.recipient);
    if (!user || user.status !== UserStatus.ACTIVE) {
      throw new UnauthorizedException('Пользователь не найден');
    }
    assertHumanAccount(user, { asAuth: true });
    if (!(await this.totp.isEnabledForUser(user.id))) {
      throw new BadRequestException('Для этого аккаунта не настроен аутентификатор');
    }

    const device = await this.upsertDevice(user.id, command);
    const challenge = await this.issueTotpChallenge(
      user,
      command,
      { ...command, login: command.recipient, password: '' },
      device.id
    );
    return { totpChallengeToken: challenge.totpChallengeToken };
  }

  async verifyTotpLogin(totpChallengeToken: string, code: string) {
    let payload: { sub: string; typ: string; jti?: string };
    try {
      payload = await this.jwt.verifyAsync(totpChallengeToken, {
        secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
        issuer: 'id.lendry.ru'
      });
    } catch {
      throw new UnauthorizedException('Сессия входа истекла, повторите попытку');
    }

    if (payload.typ !== 'totp_login_challenge' || !payload.jti) {
      throw new UnauthorizedException('Некорректный токен подтверждения');
    }

    const challengeKey = this.totpLoginChallengeKey(payload.jti);
    const raw = await this.redis.client.get(challengeKey);
    if (!raw) {
      throw new UnauthorizedException('Сессия входа истекла, повторите попытку');
    }

    const challenge = JSON.parse(raw) as DeviceCommand & { userId: string; deviceId: string; signIn: LoginCommand };
    if (challenge.userId !== payload.sub) {
      throw new UnauthorizedException('Некорректный токен подтверждения');
    }

    const attemptKey = this.totpLoginAttemptKey(payload.jti);
    const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5);
    const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0);
    if (attempts >= maxAttempts) {
      throw new UnauthorizedException('Превышено число попыток ввода кода аутентификатора');
    }

    const isValid = await this.totp.verifyEnabledCode(payload.sub, code);
    if (!isValid) {
      await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', TOTP_LOGIN_CHALLENGE_TTL_SECONDS);
      throw new UnauthorizedException('Неверный код аутентификатора');
    }

    await this.redis.client.del(challengeKey);
    await this.redis.client.del(attemptKey);

    const user = await this.prisma.user.findFirst({
      where: { id: payload.sub, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
      include: { pinCode: true }
    });
    if (!user) {
      throw new UnauthorizedException('Пользователь не найден или заблокирован');
    }

    assertHumanAccount(user, { asAuth: true });

    const auth = await this.createAuthTokens(user, challenge.deviceId, challenge);
    await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId);
    return auth;
  }

  async identify(login: string) {
    const user = await this.findUserByRecipient(login);
    const methods: Array<{ kind: string; channel: string; masked: string }> = [];
    const otpChannels = user ? this.buildOtpChannels(user) : [];

    if (user?.passwordHash) {
      methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
    }

    if (user) {
      const backupChannels: Array<{ channel: string; value: string | null }> = [
        { channel: 'backupEmail', value: user.backupEmail },
        { channel: 'backupPhone', value: user.backupPhone }
      ];
      for (const item of backupChannels) {
        if (item.value) {
          methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
        }
      }
    }

    return {
      exists: Boolean(user),
      hasPassword: Boolean(user?.passwordHash),
      isPinEnabled: Boolean(user?.pinCode?.isEnabled),
      isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
      otpChannels,
      methods
    };
  }

  async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
    const existingUser = await this.findUserByRecipient(recipient);
    const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser));
    const cooldownSeconds = await this.settings.getNumber('OTP_RESEND_COOLDOWN_SECONDS', 60);
    const resendKey = this.otpResendKey(target);

    const ttl = await this.redis.client.ttl(resendKey);
    if (ttl > 0) {
      throw new HttpException(
        {
          message: `Запросить новый код можно через ${this.formatOtpCooldown(ttl)}`,
          code: 'OTP_RESEND_COOLDOWN',
          resendAvailableAt: new Date(Date.now() + ttl * 1000).toISOString()
        },
        HttpStatus.TOO_MANY_REQUESTS
      );
    }

    await this.prisma.authCode.updateMany({
      where: {
        target,
        purpose: 'passwordless-login',
        usedAt: null
      },
      data: { usedAt: new Date() }
    });

    const deliveryChannel = target.includes('@') ? 'email' : 'sms';
    const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
    const code = String(randomInt(100000, 999999));
    const expiresAt = new Date(Date.now() + expiryMinutes * 60_000);
    await this.prisma.authCode.create({
      data: {
        userId: existingUser?.id,
        target,
        channel: deliveryChannel,
        purpose: 'passwordless-login',
        codeHash: await bcrypt.hash(code, 10),
        expiresAt
      }
    });
    await this.messaging.sendVerificationCode({
      channel: deliveryChannel as 'email' | 'sms',
      target,
      code,
      expiryMinutes
    });
    if (existingUser) {
      await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
    }
    await this.redis.client.del(this.otpAttemptKey(target));
    await this.redis.client.set(resendKey, '1', 'EX', cooldownSeconds);
    const resendAvailableAt = new Date(Date.now() + cooldownSeconds * 1000).toISOString();
    return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target), resendAvailableAt };
  }

  private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) {
    const ipLabel = ipAddress?.trim() || 'неизвестного IP';
    await this.notifications.create(
      userId,
      'otp_code',
      action,
      `Попытка входа с ${ipLabel} — код для верификации: ${code}`,
      { code, ipAddress: ipAddress ?? null, action },
      { skipPublish: false }
    );
  }

  async verifyOtp(command: LoginCommand & { recipient: string; code: string }) {
    const existingUser = await this.findUserByRecipient(command.recipient);
    const targetVariants = this.recipientLookupTargets(command.recipient);
    const authCode = await this.prisma.authCode.findFirst({
      where: {
        purpose: 'passwordless-login',
        usedAt: null,
        expiresAt: { gt: new Date() },
        OR: [
          { target: { in: targetVariants } },
          ...(existingUser ? [{ userId: existingUser.id }] : [])
        ]
      },
      orderBy: { createdAt: 'desc' }
    });

    if (!authCode) throw new UnauthorizedException('Код входа не найден или истек');

    const attemptKey = this.otpAttemptKey(authCode.target);
    const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5);
    const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0);
    if (attempts >= maxAttempts) {
      throw new UnauthorizedException('Превышено число попыток ввода кода');
    }

    const normalizedCode = command.code.replace(/\s/g, '');
    const matches = await bcrypt.compare(normalizedCode, authCode.codeHash);
    if (!matches) {
      await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60);
      throw new UnauthorizedException('Неверный код входа');
    }
    await this.redis.client.del(attemptKey);
    await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });

    const user = await this.findOrCreatePasswordlessUser(command.recipient);

    const device = await this.upsertDevice(user.id, command);
    const auth = await this.finalizeAuthentication(
      user,
      command,
      { ...command, login: command.recipient, password: '' },
      device.id,
      { skipTotp: true }
    );
    return {
      requiresPassword: false,
      auth
    };
  }

  private buildOtpChannels(user: { email: string | null; phone: string | null }) {
    const channels: Array<{ channel: string; masked: string }> = [];
    if (user.email) {
      channels.push({ channel: 'email', masked: this.maskTarget(user.email) });
    }
    if (user.phone) {
      channels.push({ channel: 'phone', masked: this.maskTarget(user.phone) });
    }
    return channels;
  }

  private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
    if (!channel || channel === 'primary' || !user) return recipient;
    const map: Record<string, string | null> = {
      email: user.email,
      phone: user.phone,
      backupEmail: user.backupEmail,
      backupPhone: user.backupPhone
    };
    return map[channel] ?? recipient;
  }

  private otpAttemptKey(target: string) {
    return `otp:attempts:passwordless-login:${target.toLowerCase()}`;
  }

  private otpResendKey(target: string) {
    return `otp:resend:passwordless-login:${target.toLowerCase()}`;
  }

  private formatOtpCooldown(seconds: number) {
    const minutes = Math.floor(seconds / 60);
    const rest = seconds % 60;
    if (minutes > 0 && rest > 0) {
      return `${minutes}:${String(rest).padStart(2, '0')}`;
    }
    if (minutes > 0) {
      return `${minutes} мин`;
    }
    return `${rest} сек`;
  }

  private maskTarget(value: string) {
    if (value.includes('@')) {
      const [name, domain] = value.split('@');
      const visible = name.slice(0, 1);
      return `${visible}${'*'.repeat(Math.max(name.length - 1, 1))}@${domain}`;
    }
    const tail = value.slice(-2);
    const head = value.slice(0, value.startsWith('+') ? 2 : 1);
    return `${head}${'*'.repeat(Math.max(value.length - head.length - 2, 2))}${tail}`;
  }

  async loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) {
    const user = command.tempAuthToken
      ? await this.findUserByTempPasswordToken(command.tempAuthToken)
      : await this.findUserByRecipient(command.login);
    if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован');
    assertHumanAccount(user, { asAuth: true });
    const matches = await bcrypt.compare(command.password, user.passwordHash);
    if (!matches) {
      await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
      throw new UnauthorizedException('Неверный пароль');
    }
    const device = await this.upsertDevice(user.id, command);
    return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true });
  }

  async createQrApprovedLogin(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
    const user = await this.prisma.user.findFirst({
      where: { id: userId, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
      include: { pinCode: true }
    });
    if (!user) {
      throw new UnauthorizedException('Пользователь не найден или заблокирован');
    }
    assertHumanAccount(user, { asAuth: true });
    const device = await this.upsertDevice(user.id, {
      fingerprint: command.fingerprint,
      deviceName: command.deviceName,
      deviceType: command.deviceType,
      ipAddress: command.ipAddress
    });
    return this.finalizeAuthentication(
      user,
      command,
      {
        fingerprint: command.fingerprint,
        deviceName: command.deviceName,
        deviceType: command.deviceType,
        ipAddress: command.ipAddress,
        userAgent: command.userAgent,
        login: 'qr-login',
        password: ''
      },
      device.id,
      { skipTotp: true }
    );
  }

  async loginWithLdap(command: {
    username: string;
    password: string;
    fingerprint: string;
    deviceName?: string;
    deviceType?: string;
    ipAddress?: string;
    userAgent?: string;
  }): Promise<AuthTokens> {
    const enabled = await this.settings.getBoolean('LDAP_ENABLED', false);
    if (!enabled) {
      throw new UnauthorizedException('LDAP-аутентификация отключена');
    }

    const ldapConfig = await this.getLdapConfig();
    validateLdapConfig(ldapConfig);
    let ldapResult;
    try {
      ldapResult = await this.ldapClient.authenticate(ldapConfig, command.username, command.password);
    } catch (error) {
      throw new UnauthorizedException(error instanceof Error ? error.message : 'Неверный логин или пароль LDAP');
    }

    const user = await this.findOrCreateLdapUser(ldapResult);
    const device = await this.upsertDevice(user.id, command);
    return this.finalizeAuthentication(
      user,
      command,
      {
        login: command.username,
        password: '',
        fingerprint: command.fingerprint,
        deviceName: command.deviceName,
        deviceType: command.deviceType,
        ipAddress: command.ipAddress,
        userAgent: command.userAgent
      },
      device.id,
      { skipTotp: true }
    );
  }

  private async getLdapConfig(): Promise<LdapConnectionConfig> {
    const useLdaps = await this.settings.getBoolean('LDAP_USE_LDAPS', false);
    const rawPort = await this.settings.getNumber('LDAP_PORT', useLdaps ? 636 : 389);
    const domain = await this.settings.getValue('LDAP_DOMAIN', '');
    const bindUsername = await this.settings.getValue('LDAP_BIND_USERNAME', '');
    const bindDnRaw = await this.settings.getValue('LDAP_BIND_DN', '');
    const bindDn = resolveLdapBindIdentity(bindUsername, bindDnRaw, domain);
    return {
      host: await this.settings.getValue('LDAP_HOST', ''),
      port: resolveLdapPort(rawPort, useLdaps),
      useLdaps,
      skipTlsVerify: await this.settings.getBoolean('LDAP_SKIP_TLS_VERIFY', false),
      domain,
      baseDn: await this.settings.getValue('LDAP_BASE_DN', ''),
      bindDn,
      bindPassword: await this.settings.getValue('LDAP_BIND_PASSWORD', ''),
      userFilter: normalizeLdapUserFilter(
        await this.settings.getValue(
          'LDAP_USER_FILTER',
          '(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))'
        )
      ),
      usernameAttr: await this.settings.getValue('LDAP_USERNAME_ATTR', 'sAMAccountName'),
      emailAttr: await this.settings.getValue('LDAP_EMAIL_ATTR', 'mail'),
      displayNameAttr: await this.settings.getValue('LDAP_DISPLAY_NAME_ATTR', 'displayName')
    };
  }

  private async findOrCreateLdapUser(ldapResult: { dn: string; username: string; email?: string; displayName: string }) {
    const linked = await this.prisma.linkedAccount.findUnique({
      where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } },
      include: { user: { include: { pinCode: true } } }
    });
    if (linked?.user && !linked.user.deletedAt && linked.user.status === UserStatus.ACTIVE) {
      return linked.user;
    }

    if (ldapResult.email) {
      const byEmail = await this.prisma.user.findFirst({
        where: { email: ldapResult.email, deletedAt: null, status: UserStatus.ACTIVE },
        include: { pinCode: true }
      });
      if (byEmail) {
        await this.prisma.linkedAccount.upsert({
          where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } },
          create: {
            userId: byEmail.id,
            providerName: 'ldap',
            providerId: ldapResult.dn,
            email: ldapResult.email,
            displayName: ldapResult.displayName
          },
          update: { email: ldapResult.email, displayName: ldapResult.displayName }
        });
        return byEmail;
      }
    }

    const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
    if (!lockAcquired) {
      throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
    }

    try {
      const user = await this.prisma.$transaction(
        async (tx) => {
          const usersCount = await tx.user.count({ where: { deletedAt: null } });
          return tx.user.create({
            data: {
              email: ldapResult.email ?? undefined,
              username: ldapResult.username,
              displayName: ldapResult.displayName,
              passwordHash: null,
              isSuperAdmin: usersCount === 0,
              linkedAccounts: {
                create: {
                  providerName: 'ldap',
                  providerId: ldapResult.dn,
                  email: ldapResult.email,
                  displayName: ldapResult.displayName
                }
              }
            },
            include: { pinCode: true }
          });
        },
        { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
      );
      await this.rbac.ensureDefaultUserRole(user.id);
      return user;
    } finally {
      await this.redis.releaseLock(FIRST_ADMIN_LOCK);
    }
  }

  private async finalizeAuthentication(
    user: User & { pinCode?: { isEnabled: boolean } | null },
    deviceCommand: DeviceCommand,
    signInCommand: LoginCommand,
    deviceId: string,
    options?: { skipTotp?: boolean }
  ): Promise<AuthTokens> {
    assertHumanAccount(user, { asAuth: true });

    if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) {
      return this.issueTotpChallenge(user, deviceCommand, signInCommand, deviceId);
    }

    const auth = await this.createAuthTokens(user, deviceId, deviceCommand);
    await this.writeSignInEvent(user.id, true, signInCommand, deviceId);
    return auth;
  }

  private async issueTotpChallenge(
    user: User & { pinCode?: { isEnabled: boolean } | null },
    deviceCommand: DeviceCommand,
    signInCommand: LoginCommand,
    deviceId: string
  ): Promise<AuthTokens> {
    const challengeId = randomBytes(16).toString('base64url');
    const totpChallengeToken = await this.jwt.signAsync(
      { sub: user.id, typ: 'totp_login_challenge', jti: challengeId },
      { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' }
    );

    await this.redis.client.set(
      this.totpLoginChallengeKey(challengeId),
      JSON.stringify({
        userId: user.id,
        deviceId,
        signIn: signInCommand,
        ...deviceCommand
      }),
      'EX',
      TOTP_LOGIN_CHALLENGE_TTL_SECONDS
    );

    return {
      requiresTotp: true,
      totpChallengeToken,
      accessToken: '',
      refreshToken: '',
      expiresAt: new Date().toISOString(),
      pinVerified: false,
      sessionId: '',
      user: await this.toPublicUser(user)
    };
  }

  private totpLoginChallengeKey(challengeId: string) {
    return `totp:login:${challengeId}`;
  }

  private totpLoginAttemptKey(challengeId: string) {
    return `totp:login:attempts:${challengeId}`;
  }

  private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) {
    return this.prisma.device.upsert({
      where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
      create: {
        userId,
        fingerprint: command.fingerprint,
        name: command.deviceName ?? 'Новое устройство',
        type: this.toDeviceType(command.deviceType),
        lastIp: command.ipAddress
      },
      update: {
        lastSeenAt: new Date(),
        lastIp: command.ipAddress,
        name: command.deviceName ?? undefined,
        type: this.toDeviceType(command.deviceType)
      }
    });
  }

  private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
    assertHumanAccount(user, { asAuth: true });

    const pinVerified = !user.pinCode?.isEnabled;
    const refreshToken = randomBytes(48).toString('base64url');
    const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
    const session = await this.prisma.session.create({
      data: {
        userId: user.id,
        deviceId,
        refreshHash: await bcrypt.hash(refreshToken, 12),
        pinVerified,
        status: pinVerified ? SessionStatus.ACTIVE : SessionStatus.LOCKED,
        ipAddress: command.ipAddress,
        userAgent: command.userAgent,
        expiresAt
      }
    });

    await this.notifications.dismissOtpCodeNotifications(user.id);

    return {
      accessToken: await this.issueAccessToken(user, session.id, pinVerified),
      refreshToken,
      expiresAt: expiresAt.toISOString(),
      pinVerified,
      user: await this.toPublicUser(user),
      sessionId: session.id
    };
  }

  private async issueTempPasswordToken(userId: string) {
    return this.jwt.signAsync(
      { sub: userId, typ: 'password_challenge' },
      { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' }
    );
  }

  private normalizeOtpTarget(target: string) {
    if (target.includes('@')) {
      return target.trim().toLowerCase();
    }
    return canonicalPhoneE164(target);
  }

  private recipientLookupTargets(recipient: string) {
    if (recipient.includes('@')) {
      return [recipient.trim().toLowerCase()];
    }
    return phoneLookupVariants(recipient);
  }

  private async findUserByRecipient(recipient: string) {
    if (recipient.includes('@')) {
      return this.prisma.user.findFirst({
        where: { email: recipient.trim().toLowerCase(), deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
        include: { pinCode: true }
      });
    }

    const variants = phoneLookupVariants(recipient);
    return this.prisma.user.findFirst({
      where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
      include: { pinCode: true }
    });
  }

  private async findUserByTempPasswordToken(tempAuthToken: string) {
    const payload = await this.jwt.verifyAsync<{ sub: string; typ: string }>(tempAuthToken, {
      secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
      issuer: 'id.lendry.ru'
    });
    if (payload.typ !== 'password_challenge') throw new UnauthorizedException('Временный токен недействителен');
    const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, include: { pinCode: true } });
    if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
      throw new UnauthorizedException('Пользователь не найден или заблокирован');
    }
    assertHumanAccount(user, { asAuth: true });
    return user;
  }

  private async findOrCreatePasswordlessUser(recipient: string) {
    const existingUser = await this.findUserByRecipient(recipient);
    if (existingUser) return existingUser;

    const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
    if (!lockAcquired) throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
    try {
      const user = await this.prisma.$transaction(
        async (tx) => {
          const usersCount = await tx.user.count({ where: { deletedAt: null } });
          return tx.user.create({
            data: {
              email: recipient.includes('@') ? recipient : undefined,
              phone: recipient.includes('@') ? undefined : recipient,
              passwordHash: null,
              displayName: recipient,
              isSuperAdmin: usersCount === 0
            }
          });
        },
        { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
      );
      await this.rbac.ensureDefaultUserRole(user.id);
      return user;
    } finally {
      await this.redis.releaseLock(FIRST_ADMIN_LOCK);
    }
  }

  async getMe(userId: string): Promise<PublicUser> {
    const user = await this.prisma.user.findUnique({
      where: { id: userId }
    });

    if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
      throw new UnauthorizedException('Пользователь не найден или заблокирован');
    }

    assertHumanAccount(user, { asAuth: true });

    return this.toPublicUser(user);
  }

  async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'isVerified' | 'verificationIcon' | 'status' | 'passwordHash' | 'isSystemAccount' | 'linkedBotId'>): Promise<PublicUser> {
    const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
    return {
      id: user.id,
      email: user.email,
      phone: user.phone,
      backupEmail: user.backupEmail,
      backupPhone: user.backupPhone,
      displayName: user.displayName,
      username: user.username,
      avatarUrl: user.avatarUrl,
      hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl),
      isSuperAdmin: user.isSuperAdmin,
      isVerified: user.isVerified,
      verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined,
      status: user.status,
      hasPassword: Boolean(user.passwordHash),
      roles: access.roles,
      permissions: access.permissions,
      canAccessAdmin: access.canAccessAdmin,
      canManageRoles: access.canManageRoles,
      canViewOAuth: access.canViewOAuth,
      canManageOAuth: access.canManageOAuth,
      canManageAllOAuth: access.canManageAllOAuth,
      canManageUsers: access.canManageUsers,
      canManageAllUsers: access.canManageAllUsers,
      canManageSettings: access.canManageSettings,
      canViewUsers: access.canViewUsers,
      canViewUserDocuments: access.canViewUserDocuments,
      canVerifyUsers: access.canVerifyUsers,
      canManageBots: access.canManageBots
    };
  }

  async issueAccessToken(user: Pick<User, 'id' | 'isSuperAdmin' | 'isSystemAccount' | 'linkedBotId'>, sessionId: string, pinVerified: boolean) {
    assertHumanAccount(user, { asAuth: true });

    return this.jwt.signAsync(
      {
        sub: user.id,
        sessionId,
        pinVerified,
        isSuperAdmin: user.isSuperAdmin
      },
      {
        secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
        expiresIn: '15m',
        issuer: 'id.lendry.ru'
      }
    );
  }

  private async writeSignInEvent(userId: string, success: boolean, command: LoginCommand, deviceId?: string, reason?: string) {
    await this.prisma.signInEvent.create({
      data: {
        userId,
        deviceId,
        success,
        reason,
        ipAddress: command.ipAddress,
        userAgent: command.userAgent
      }
    });
  }

  private toDeviceType(value?: string): DeviceType {
    if (!value) return DeviceType.WEB;
    const normalized = value.toUpperCase();
    return Object.values(DeviceType).includes(normalized as DeviceType) ? (normalized as DeviceType) : DeviceType.OTHER;
  }

  private isUniqueError(error: unknown): boolean {
    return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
  }
}
