import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Inject,
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type { FastifyReply } from 'fastify';
import type Redis from 'ioredis';

import {
  buildOtpAuthUrl,
  generateRecoveryCodes,
  generateSecureToken,
  generateTotpSecret,
  generateUsernameFromEmail,
  hashPassword,
  hashRecoveryCode,
  hashToken,
  validateUsername,
  verifyPassword,
  verifyRecoveryCode,
  verifyTotpCode,
} from '@hexahost/auth';
import type {
  AuthUser,
  ForgotPasswordRequest,
  LoginRequest,
  MessageResponse,
  RegisterRequest,
  ResetPasswordRequest,
  TwoFactorConfirmRequest,
  TwoFactorDisableRequest,
  TwoFactorVerifyRequest,
  VerifyEmailRequest,
} from '@hexahost/contracts';
import type { AppConfig } from '@hexahost/config';

import { APP_CONFIG } from '../config/config.constants';
import { PrismaService } from '../prisma/prisma.service';

import { AuditService } from './audit.service';
import { SessionService } from './session.service';

const LOCKOUT_ATTEMPTS = 5;
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
const LOGIN_CHALLENGE_TTL_SECONDS = 300;
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;

export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
export const NOTIFICATIONS_QUEUE = Symbol('NOTIFICATIONS_QUEUE');

interface LoginChallengePayload {
  userId: string;
  ipAddress?: string;
  userAgent?: string;
}

interface NotificationEmailPayload {
  type: string;
  to: string;
  subject: string;
  template: string;
  data: Record<string, unknown>;
}

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly sessionService: SessionService,
    private readonly auditService: AuditService,
    @Inject(APP_CONFIG) private readonly config: AppConfig,
    @Inject(REDIS_CLIENT) private readonly redis: Redis,
    @Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
  ) {}

  async register(
    input: RegisterRequest,
    ipAddress?: string,
  ): Promise<MessageResponse> {
    const email = input.email.trim().toLowerCase();
    const existing = await this.prisma.user.findUnique({ where: { email } });

    if (existing) {
      throw new ConflictException('Email is already registered');
    }

    let username = input.username?.trim().toLowerCase();

    if (username) {
      const validation = validateUsername(username);
      if (!validation.valid) {
        throw new BadRequestException(validation.error);
      }
    } else {
      username = generateUsernameFromEmail(email);
    }

    username = await this.ensureUniqueUsername(username);
    const passwordHash = await hashPassword(input.password);

    const user = await this.prisma.$transaction(async (tx) => {
      const created = await tx.user.create({
        data: {
          email,
          username,
          displayName: input.displayName?.trim() ?? null,
        },
      });

      await tx.userCredential.create({
        data: {
          userId: created.id,
          passwordHash,
        },
      });

      return created;
    });

    await this.createEmailVerificationToken(user.id, user.email);

    await this.auditService.record({
      action: 'auth.register',
      userId: user.id,
      entityType: 'user',
      entityId: user.id,
      ipAddress,
    });

    return { message: 'Registration successful. Please verify your email.' };
  }

  async login(
    input: LoginRequest,
    reply: FastifyReply,
    ipAddress?: string,
    userAgent?: string,
  ) {
    const email = input.email.trim().toLowerCase();
    const user = await this.prisma.user.findUnique({
      where: { email },
      include: {
        credentials: { take: 1, orderBy: { createdAt: 'desc' } },
        totpCredential: true,
      },
    });

    if (!user || user.credentials.length === 0) {
      await this.auditService.record({
        action: 'auth.login_failed',
        metadata: { email, reason: 'unknown_user' },
        ipAddress,
      });
      throw new UnauthorizedException('Invalid email or password');
    }

    if (user.status !== 'ACTIVE') {
      throw new ForbiddenException('Account is not active');
    }

    if (user.lockedUntil && user.lockedUntil > new Date()) {
      throw new ForbiddenException('Account is temporarily locked');
    }

    const credential = user.credentials[0]!;
    const passwordValid = await verifyPassword(input.password, credential.passwordHash);

    if (!passwordValid) {
      const failedAttempts = user.failedLoginAttempts + 1;
      const lockedUntil =
        failedAttempts >= LOCKOUT_ATTEMPTS
          ? new Date(Date.now() + LOCKOUT_DURATION_MS)
          : null;

      await this.prisma.user.update({
        where: { id: user.id },
        data: {
          failedLoginAttempts: failedAttempts,
          lockedUntil,
        },
      });

      await this.auditService.record({
        action: 'auth.login_failed',
        userId: user.id,
        metadata: {
          reason: 'invalid_password',
          failedAttempts,
          locked: lockedUntil !== null,
        },
        ipAddress,
      });

      if (lockedUntil) {
        throw new ForbiddenException(
          'Account locked due to too many failed login attempts',
        );
      }

      throw new UnauthorizedException('Invalid email or password');
    }

    await this.prisma.user.update({
      where: { id: user.id },
      data: {
        failedLoginAttempts: 0,
        lockedUntil: null,
      },
    });

    if (user.totpCredential?.enabledAt) {
      const challengeId = this.sessionService.createLoginChallengeId();
      const payload: LoginChallengePayload = { userId: user.id, ipAddress, userAgent };

      await this.redis.set(
        this.challengeKey(challengeId),
        JSON.stringify(payload),
        'EX',
        LOGIN_CHALLENGE_TTL_SECONDS,
      );

      return {
        twoFactorRequired: true as const,
        challengeId,
      };
    }

    return this.completeLogin(user.id, reply, ipAddress, userAgent);
  }

  async verifyTwoFactorLogin(
    input: TwoFactorVerifyRequest,
    reply: FastifyReply,
    ipAddress?: string,
    userAgent?: string,
  ) {
    const raw = await this.redis.get(this.challengeKey(input.challengeId));

    if (!raw) {
      throw new UnauthorizedException('Invalid or expired login challenge');
    }

    const challenge = JSON.parse(raw) as LoginChallengePayload;
    const user = await this.prisma.user.findUnique({
      where: { id: challenge.userId },
      include: { totpCredential: { include: { recoveryCodes: true } } },
    });

    if (!user?.totpCredential?.enabledAt) {
      throw new UnauthorizedException('Two-factor authentication is not enabled');
    }

    const code = input.code.replace(/\s+/gu, '');
    let verified = verifyTotpCode(user.totpCredential.secret, code);

    if (!verified) {
      verified = await this.tryRecoveryCode(user.totpCredential.id, code);
    }

    if (!verified) {
      throw new UnauthorizedException('Invalid two-factor code');
    }

    await this.redis.del(this.challengeKey(input.challengeId));

    await this.auditService.record({
      action: 'auth.2fa_verified',
      userId: user.id,
      ipAddress,
    });

    return this.completeLogin(
      user.id,
      reply,
      ipAddress ?? challenge.ipAddress,
      userAgent ?? challenge.userAgent,
    );
  }

  async logout(
    token: string | undefined,
    reply: FastifyReply,
    ipAddress?: string,
  ): Promise<MessageResponse> {
    if (token) {
      const userId = await this.sessionService.revokeByToken(token);

      if (userId) {
        await this.auditService.record({
          action: 'auth.logout',
          userId,
          ipAddress,
        });
      }
    }

    this.sessionService.clearSessionCookie(reply);
    return { message: 'Logged out' };
  }

  async verifyEmail(
    input: VerifyEmailRequest,
    ipAddress?: string,
  ): Promise<MessageResponse> {
    const tokenHash = hashToken(input.token);
    const record = await this.prisma.emailVerificationToken.findUnique({
      where: { tokenHash },
      include: { user: true },
    });

    if (!record || record.usedAt || record.expiresAt <= new Date()) {
      throw new BadRequestException('Invalid or expired verification token');
    }

    await this.prisma.$transaction([
      this.prisma.user.update({
        where: { id: record.userId },
        data: { emailVerifiedAt: new Date() },
      }),
      this.prisma.emailVerificationToken.update({
        where: { id: record.id },
        data: { usedAt: new Date() },
      }),
    ]);

    await this.auditService.record({
      action: 'auth.email_verified',
      userId: record.userId,
      ipAddress,
    });

    return { message: 'Email verified successfully' };
  }

  async resendVerification(userId: string, ipAddress?: string): Promise<MessageResponse> {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    if (user.emailVerifiedAt) {
      return { message: 'Email is already verified' };
    }

    await this.createEmailVerificationToken(user.id, user.email);

    await this.auditService.record({
      action: 'auth.verification_resent',
      userId,
      ipAddress,
    });

    return { message: 'Verification email sent' };
  }

  async forgotPassword(
    input: ForgotPasswordRequest,
    ipAddress?: string,
  ): Promise<MessageResponse> {
    const email = input.email.trim().toLowerCase();
    const user = await this.prisma.user.findUnique({ where: { email } });

    if (user) {
      const token = generateSecureToken();
      const tokenHash = hashToken(token);

      await this.prisma.passwordResetToken.create({
        data: {
          userId: user.id,
          tokenHash,
          expiresAt: new Date(Date.now() + PASSWORD_RESET_TTL_MS),
        },
      });

      await this.enqueueEmail({
        type: 'password_reset',
        to: user.email,
        subject: 'Reset your HexaHost password',
        template: 'password-reset',
        data: {
          resetUrl: `${this.config.APP_URL}/reset-password?token=${token}`,
        },
      });

      await this.auditService.record({
        action: 'auth.password_reset_requested',
        userId: user.id,
        ipAddress,
      });
    }

    return {
      message:
        'If an account exists for this email, a password reset link has been sent.',
    };
  }

  async resetPassword(
    input: ResetPasswordRequest,
    ipAddress?: string,
  ): Promise<MessageResponse> {
    const tokenHash = hashToken(input.token);
    const record = await this.prisma.passwordResetToken.findUnique({
      where: { tokenHash },
    });

    if (!record || record.usedAt || record.expiresAt <= new Date()) {
      throw new BadRequestException('Invalid or expired reset token');
    }

    const passwordHash = await hashPassword(input.newPassword);

    await this.prisma.$transaction([
      this.prisma.userCredential.updateMany({
        where: { userId: record.userId },
        data: { passwordHash },
      }),
      this.prisma.passwordResetToken.update({
        where: { id: record.id },
        data: { usedAt: new Date() },
      }),
      this.prisma.userSession.updateMany({
        where: { userId: record.userId, revokedAt: null },
        data: { revokedAt: new Date() },
      }),
      this.prisma.user.update({
        where: { id: record.userId },
        data: { failedLoginAttempts: 0, lockedUntil: null },
      }),
    ]);

    await this.auditService.record({
      action: 'auth.password_reset_completed',
      userId: record.userId,
      ipAddress,
    });

    return { message: 'Password reset successfully' };
  }

  async setupTwoFactor(userId: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    const secret = generateTotpSecret();

    await this.prisma.totpCredential.upsert({
      where: { userId },
      create: {
        userId,
        secret,
        enabledAt: null,
      },
      update: {
        secret,
        enabledAt: null,
      },
    });

    await this.prisma.recoveryCode.deleteMany({
      where: {
        totpCredential: { userId },
      },
    });

    return {
      secret,
      qrUrl: buildOtpAuthUrl(this.config.APP_NAME, user.email, secret),
    };
  }

  async confirmTwoFactor(
    userId: string,
    input: TwoFactorConfirmRequest,
    ipAddress?: string,
  ) {
    const credential = await this.prisma.totpCredential.findUnique({
      where: { userId },
    });

    if (!credential) {
      throw new BadRequestException('Two-factor setup has not been started');
    }

    if (!verifyTotpCode(credential.secret, input.code)) {
      throw new BadRequestException('Invalid authenticator code');
    }

    const recoveryCodes = generateRecoveryCodes();

    await this.prisma.$transaction(async (tx) => {
      await tx.totpCredential.update({
        where: { id: credential.id },
        data: { enabledAt: new Date() },
      });

      await tx.recoveryCode.deleteMany({
        where: { totpCredentialId: credential.id },
      });

      await tx.recoveryCode.createMany({
        data: recoveryCodes.map((code) => ({
          totpCredentialId: credential.id,
          codeHash: hashRecoveryCode(code),
        })),
      });
    });

    await this.auditService.record({
      action: 'auth.2fa_enabled',
      userId,
      ipAddress,
    });

    return {
      message: 'Two-factor authentication enabled',
      recoveryCodes,
    };
  }

  async disableTwoFactor(
    userId: string,
    input: TwoFactorDisableRequest,
    ipAddress?: string,
  ): Promise<MessageResponse> {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      include: {
        credentials: { take: 1, orderBy: { createdAt: 'desc' } },
        totpCredential: true,
      },
    });

    if (!user?.totpCredential?.enabledAt) {
      throw new BadRequestException('Two-factor authentication is not enabled');
    }

    const credential = user.credentials[0];
    if (!credential || !(await verifyPassword(input.password, credential.passwordHash))) {
      throw new UnauthorizedException('Invalid password');
    }

    const code = input.code.replace(/\s+/gu, '');
    let verified = verifyTotpCode(user.totpCredential.secret, code);

    if (!verified) {
      verified = await this.tryRecoveryCode(user.totpCredential.id, code);
    }

    if (!verified) {
      throw new UnauthorizedException('Invalid two-factor code');
    }

    await this.prisma.totpCredential.delete({
      where: { id: user.totpCredential.id },
    });

    await this.auditService.record({
      action: 'auth.2fa_disabled',
      userId,
      ipAddress,
    });

    return { message: 'Two-factor authentication disabled' };
  }

  toAuthUser(user: {
    id: string;
    email: string;
    username: string;
    displayName: string | null;
    emailVerifiedAt: Date | null;
    platformRoles?: Array<{ role: { name: string } }>;
  }): AuthUser {
    const authenticated = this.sessionService.toAuthenticatedUser({
      ...user,
      platformRoles: user.platformRoles ?? [],
    });

    return {
      id: authenticated.id,
      email: authenticated.email,
      username: authenticated.username,
      displayName: authenticated.displayName,
      emailVerified: authenticated.emailVerified,
      roles: authenticated.roles,
    };
  }

  private async completeLogin(
    userId: string,
    reply: FastifyReply,
    ipAddress?: string,
    userAgent?: string,
  ) {
    const { token, expiresAt } = await this.sessionService.createSession(
      userId,
      ipAddress,
      userAgent,
    );

    this.sessionService.setSessionCookie(reply, token, expiresAt);

    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      include: {
        platformRoles: { include: { role: true } },
      },
    });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    await this.auditService.record({
      action: 'auth.login',
      userId,
      ipAddress,
    });

    return { user: this.toAuthUser(user) };
  }

  private async ensureUniqueUsername(baseUsername: string): Promise<string> {
    let candidate = baseUsername;
    let suffix = 0;

    while (true) {
      const existing = await this.prisma.user.findUnique({
        where: { username: candidate },
        select: { id: true },
      });

      if (!existing) {
        return candidate;
      }

      suffix += 1;
      const suffixText = String(suffix);
      candidate = `${baseUsername.slice(0, Math.max(1, 32 - suffixText.length))}${suffixText}`;
    }
  }

  private async createEmailVerificationToken(userId: string, email: string): Promise<void> {
    const token = generateSecureToken();
    const tokenHash = hashToken(token);

    await this.prisma.emailVerificationToken.create({
      data: {
        userId,
        tokenHash,
        expiresAt: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS),
      },
    });

    await this.enqueueEmail({
      type: 'email_verification',
      to: email,
      subject: 'Verify your HexaHost email',
      template: 'email-verification',
      data: {
        verifyUrl: `${this.config.APP_URL}/verify-email?token=${token}`,
      },
    });
  }

  private async enqueueEmail(payload: NotificationEmailPayload): Promise<void> {
    await this.notificationsQueue.add('send-email', payload, {
      removeOnComplete: true,
      removeOnFail: 100,
    });
  }

  private challengeKey(challengeId: string): string {
    return `hgc:login-challenge:${challengeId}`;
  }

  private async tryRecoveryCode(
    totpCredentialId: string,
    code: string,
  ): Promise<boolean> {
    const recoveryCodes = await this.prisma.recoveryCode.findMany({
      where: {
        totpCredentialId,
        usedAt: null,
      },
    });

    for (const recoveryCode of recoveryCodes) {
      if (verifyRecoveryCode(code, recoveryCode.codeHash)) {
        await this.prisma.recoveryCode.update({
          where: { id: recoveryCode.id },
          data: { usedAt: new Date() },
        });
        return true;
      }
    }

    return false;
  }
}
