import { BadRequestException, 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';

const FIRST_ADMIN_LOCK = 'locks:first-super-admin';

@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
  ) {}

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

    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 }
      );

      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
      },
      include: { pinCode: true }
    });

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

    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);

    const auth = await this.createAuthTokens(user, device.id, command);
    await this.writeSignInEvent(user.id, true, command, device.id);
    return auth;
  }

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

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

    if (user) {
      const channels: Array<{ channel: string; value: string | null }> = [
        { channel: 'email', value: user.email },
        { channel: 'phone', value: user.phone },
        { channel: 'backupEmail', value: user.backupEmail },
        { channel: 'backupPhone', value: user.backupPhone }
      ];
      for (const item of channels) {
        if (item.value && item.value !== login) {
          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),
      methods
    };
  }

  async sendOtp(recipient: string, channel?: string) {
    const existingUser = await this.findUserByRecipient(recipient);
    const target = this.resolveOtpTarget(recipient, channel, existingUser);
    const code = String(randomInt(100000, 999999));
    const expiresAt = new Date(Date.now() + 5 * 60_000);
    await this.prisma.authCode.create({
      data: {
        userId: existingUser?.id,
        target,
        channel: target.includes('@') ? 'email' : 'sms',
        purpose: 'passwordless-login',
        codeHash: await bcrypt.hash(code, 10),
        expiresAt
      }
    });
    console.log('[OTP MOCK] Code for', target, 'is:', code);
    return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
  }

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

    if (!authCode) throw new UnauthorizedException('Код входа не найден или истек');
    const matches = await bcrypt.compare(command.code, authCode.codeHash);
    if (!matches) throw new UnauthorizedException('Неверный код входа');
    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.createAuthTokens(user, device.id, command);
    await this.writeSignInEvent(user.id, true, { ...command, login: command.recipient, password: '' }, device.id);
    return { requiresPassword: false, tempAuthToken: undefined, auth };
  }

  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 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('Пользователь не найден или заблокирован');
    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);
    const auth = await this.createAuthTokens(user, device.id, command);
    await this.writeSignInEvent(user.id, true, command, device.id);
    return auth;
  }

  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);
    const auth = await this.createAuthTokens(user, device.id, command);
    await this.writeSignInEvent(
      user.id,
      true,
      { login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent },
      device.id
    );
    return auth;
  }

  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 {
      return 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 }
      );
    } finally {
      await this.redis.releaseLock(FIRST_ADMIN_LOCK);
    }
  }

  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> {
    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
      }
    });

    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 async findUserByRecipient(recipient: string) {
    return this.prisma.user.findFirst({
      where: recipient.includes('@')
        ? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE }
        : { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE },
      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('Пользователь не найден или заблокирован');
    }
    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 {
      return 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 }
      );
    } 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('Пользователь не найден или заблокирован');
    }

    return this.toPublicUser(user);
  }

  async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status'>): 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,
      status: user.status,
      roles: access.roles,
      permissions: access.permissions,
      canAccessAdmin: access.canAccessAdmin,
      canManageRoles: access.canManageRoles,
      canManageOAuth: access.canManageOAuth,
      canManageUsers: access.canManageUsers,
      canManageSettings: access.canManageSettings,
      canViewUsers: access.canViewUsers,
      canViewUserDocuments: access.canViewUserDocuments
    };
  }

  async issueAccessToken(user: Pick<User, 'id' | 'isSuperAdmin'>, sessionId: string, pinVerified: boolean) {
    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';
  }
}
