import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { createHash, randomInt } from 'crypto';
import * as bcrypt from 'bcrypt';

import { UploadService } from '@/modules/upload/upload.service';
import { ResendMailService } from '../../mail/resend-mail.service';
import { UsersRepository } from './users.repository';
import { UpdateUserDto } from './dto/update-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { RequestEmailChangeDto } from './dto/request-email-change.dto';
import { VerifyEmailChangeDto } from './dto/verify-email-change.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { AssignRolesDto } from './dto/assign-roles.dto';
import { AssignPermissionsDto } from './dto/assign-permissions.dto';
import { RolesRepository } from '@/modules/admin/roles/roles.repository';
import { PermissionsRepository } from '@/modules/admin/permissions/permissions.repository';
import { CaslAbilityFactory } from '@/casl/casl-ability.factory';

const SALT_ROUNDS = 10;
const EMAIL_OTP_EXPIRY_MS = 15 * 60 * 1000;

const ALLOWED_AVATAR_MIME_TYPES = [
  'image/jpeg',
  'image/jpg',
  'image/png',
  'image/webp',
  'image/svg+xml',
];

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  constructor(
    private readonly users: UsersRepository,
    private readonly mail: ResendMailService,
    private readonly uploads: UploadService,
    private readonly roles: RolesRepository,
    private readonly permissions: PermissionsRepository,
    private readonly casl: CaslAbilityFactory,
  ) {}

  async getProfile(userId: number) {
    const user = await this.users.findProfileById(userId);

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

    return user;
  }

  async getAllUsers() {
    const users = await this.users.findMany();
    return users.map((user) => this.formatUserForResponse(user));
  }

  async getUserById(userId: number) {
    const user = await this.users.findActiveById(userId);

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

    return this.formatUserForResponse(user);
  }

  // session as lastLogin
  private formatUserForResponse(
    user: {
      roles?: { role: { id: number; name: string } }[];
      sessions?: {
        ipAddress: string | null;
        userAgent: string | null;
        deviceType: string | null;
        browser: string | null;
        os: string | null;
        createdAt: Date;
        loggedOutAt: Date | null;
      }[];
    } & Record<string, unknown>,
  ) {
    const { roles, sessions, ...rest } = user;
    return {
      ...rest,
      roles: (roles ?? []).map((entry) => entry.role),
      lastLogin: sessions && sessions.length > 0 ? sessions[0] : null,
    };
  }

  // Reject the request unless every roleId resolves to a live (non-deleted) role.
  private async ensureRolesExist(roleIds: number[]) {
    const found = await this.roles.findManyByIds(roleIds);
    if (found.length !== roleIds.length) {
      throw new NotFoundException('One or more roles were not found');
    }
  }

  async getUserLoginTrails(userId: number) {
    const user = await this.users.findById(userId);

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

    const trails = await this.users.findLoginTrailsByUserId(userId);

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
      trails,
    };
  }

  private async uploadAvatar(avatarFile?: Express.Multer.File) {
    if (!avatarFile) {
      return undefined;
    }

    const { url } = await this.uploads.save(avatarFile, 'avatars', {
      allowedMimeTypes: ALLOWED_AVATAR_MIME_TYPES,
    });

    return url;
  }

  async createUser(dto: CreateUserDto, avatarFile?: Express.Multer.File) {
    const email = dto.email.toLowerCase().trim();

    const existing = await this.users.findByEmail(email);

    if (existing && !existing.deletedAt) {
      throw new ConflictException('Email already in use');
    }

    if (existing && existing.deletedAt) {
      throw new ConflictException(
        'This email belongs to a deleted user. Please use another email.',
      );
    }

    const roleIds = dto.roleIds ? [...new Set(dto.roleIds)] : [];
    if (roleIds.length > 0) {
      await this.ensureRolesExist(roleIds);
    }

    const password = await bcrypt.hash(dto.password, SALT_ROUNDS);
    const uploadedAvatar = await this.uploadAvatar(avatarFile);

    const created = await this.users.create({
      email,
      password,
      isActive: true,
      deletedAt: null,
      ...(dto.name !== undefined && { name: dto.name.trim() }),
      ...(dto.position !== undefined && { position: dto.position.trim() }),
      ...(uploadedAvatar !== undefined && { avatar: uploadedAvatar }),
      ...(!uploadedAvatar &&
        dto.avatar !== undefined && { avatar: dto.avatar }),
    });

    if (roleIds.length > 0) {
      await this.users.addRoles(created.id, roleIds);
    }

    const user = await this.users.findActiveById(created.id);
    if (!user) {
      throw new NotFoundException('User not found');
    }

    return {
      message: 'User created successfully.',
      user: this.formatUserForResponse(user),
    };
  }

  async updateUser(
    userId: number,
    dto: UpdateUserDto,
    avatarFile?: Express.Multer.File,
  ) {
    const user = await this.users.findById(userId);

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

    const data: {
      name?: string;
      position?: string;
      avatar?: string;
    } = {};

    if (dto.name !== undefined) {
      data.name = dto.name.trim();
    }

    if (dto.position !== undefined) {
      data.position = dto.position.trim();
    }

    const uploadedAvatar = await this.uploadAvatar(avatarFile);

    if (uploadedAvatar) {
      data.avatar = uploadedAvatar;
    } else if (dto.avatar !== undefined) {
      data.avatar = dto.avatar;
    }

    if (Object.keys(data).length === 0) {
      throw new BadRequestException('No fields provided to update');
    }

    const previousAvatar = user.avatar;
    const updated = await this.users.updateUser(userId, data);

    if (
      data.avatar !== undefined &&
      previousAvatar &&
      previousAvatar !== data.avatar
    ) {
      // remove() logs failures itself and never throws, so a disk problem
      // cannot break a user update that already saved to the database.
      await this.uploads.remove(previousAvatar);
    }

    return {
      message: 'Profile updated successfully.',
      user: updated,
    };
  }

  async adminUpdateUser(
    userId: number,
    dto: UpdateUserDto,
    avatarFile?: Express.Multer.File,
  ) {
    const user = await this.users.findById(userId);

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

    const data: {
      name?: string;
      email?: string;
      password?: string;
      position?: string;
      avatar?: string;
      isActive?: boolean;
    } = {};

    if (dto.name !== undefined) {
      data.name = dto.name.trim();
    }

    if (dto.email !== undefined) {
      const email = dto.email.toLowerCase().trim();
      const existing = await this.users.findByEmail(email);

      if (existing && existing.id !== userId) {
        throw new ConflictException('Email already in use');
      }

      data.email = email;
    }

    if (dto.password !== undefined && dto.password.trim()) {
      data.password = await bcrypt.hash(dto.password.trim(), SALT_ROUNDS);
    }

    if (dto.position !== undefined) {
      data.position = dto.position.trim();
    }

    if (dto.isActive !== undefined) {
      data.isActive = dto.isActive;
    }

    const uploadedAvatar = await this.uploadAvatar(avatarFile);

    if (uploadedAvatar) {
      data.avatar = uploadedAvatar;
    } else if (dto.avatar !== undefined) {
      data.avatar = dto.avatar;
    }

    const hasFieldUpdates = Object.keys(data).length > 0;
    const hasRoleUpdate = dto.roleIds !== undefined;

    if (!hasFieldUpdates && !hasRoleUpdate) {
      throw new BadRequestException('No fields provided to update');
    }

    const previousAvatar = user.avatar;

    if (hasFieldUpdates) {
      await this.users.updateUser(userId, data);
    }

    if (hasRoleUpdate) {
      const roleIds = [...new Set(dto.roleIds ?? [])];
      if (roleIds.length > 0) {
        await this.ensureRolesExist(roleIds);
      }
      await this.users.setRoles(userId, roleIds);
    }

    if (dto.isActive === false) {
      await this.users.closeActiveSessions(userId);
    }

    if (
      data.avatar !== undefined &&
      previousAvatar &&
      previousAvatar !== data.avatar
    ) {
      // remove() logs failures itself and never throws, so a disk problem
      // cannot break a user update that already saved to the database.
      await this.uploads.remove(previousAvatar);
    }

    const updated = await this.users.findActiveById(userId);
    if (!updated) {
      throw new NotFoundException('User not found');
    }

    return {
      message: 'User updated successfully.',
      user: this.formatUserForResponse(updated),
    };
  }

  async deleteUser(userId: number, currentUserId: number) {
    if (userId === currentUserId) {
      throw new BadRequestException('You cannot delete your own account');
    }

    const user = await this.users.findById(userId);

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

    if (user.deletedAt) {
      return {
        message: 'User already deleted.',
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
          deletedAt: user.deletedAt,
        },
      };
    }

    const deleted = await this.users.softDelete(userId);

    return {
      message: 'User deleted successfully.',
      user: deleted,
    };
  }

  async changePassword(userId: number, dto: ChangePasswordDto) {
    if (dto.currentPassword === dto.newPassword) {
      throw new BadRequestException(
        'New password must be different from the current password',
      );
    }

    const user = await this.users.findById(userId);

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

    const valid = await bcrypt.compare(dto.currentPassword, user.password);

    if (!valid) {
      throw new UnauthorizedException('Current password is incorrect');
    }

    const hashed = await bcrypt.hash(dto.newPassword, SALT_ROUNDS);

    await this.users.updatePassword(userId, hashed);
    await this.mail.sendPasswordChangedEmail(user.email, user.name);

    return {
      message: 'Password changed successfully.',
    };
  }

  async requestEmailChange(userId: number, dto: RequestEmailChangeDto) {
    const newEmail = dto.newEmail.toLowerCase().trim();

    const user = await this.users.findById(userId);

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

    if (newEmail === user.email.toLowerCase()) {
      throw new BadRequestException(
        'New email must be different from the current email',
      );
    }

    const valid = await bcrypt.compare(dto.currentPassword, user.password);

    if (!valid) {
      throw new UnauthorizedException('Current password is incorrect');
    }

    const existing = await this.users.findByEmail(newEmail);

    if (existing && !existing.deletedAt) {
      throw new ConflictException('Email already in use');
    }

    const otp = randomInt(0, 1_000_000).toString().padStart(6, '0');
    const hashedOtp = this.hashToken(otp);
    const expiresAt = new Date(Date.now() + EMAIL_OTP_EXPIRY_MS);

    await this.users.setPendingEmail(userId, newEmail, hashedOtp, expiresAt);
    await this.mail.sendEmailChangeOtpEmail(newEmail, otp, user.name);

    return {
      message:
        'Verification code sent to the new email. Confirm within 15 minutes.',
    };
  }

  async verifyEmailChange(userId: number, dto: VerifyEmailChangeDto) {
    const user = await this.users.findById(userId);

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

    const hashedOtp = this.hashToken(dto.otp);

    if (
      !user.pendingEmail ||
      !user.emailVerificationToken ||
      !user.emailVerificationExpiresAt ||
      user.emailVerificationToken !== hashedOtp ||
      user.emailVerificationExpiresAt <= new Date()
    ) {
      throw new BadRequestException('Invalid or expired verification code');
    }

    const stillFree = await this.users.findByEmail(user.pendingEmail);

    if (stillFree && stillFree.id !== userId && !stillFree.deletedAt) {
      await this.users.clearPendingEmail(userId);

      throw new ConflictException('Email is no longer available');
    }

    const oldEmail = user.email;
    const updated = await this.users.applyEmailChange(
      userId,
      user.pendingEmail,
    );

    await this.mail.sendEmailChangedNotice(oldEmail, updated.email);

    return {
      message: 'Email address changed successfully.',
      user: updated,
    };
  }

  // --- Role & permission assignment (RBAC) ---

  // Assign one or more roles to a user (user_has_roles).
  async assignRoles(userId: number, dto: AssignRolesDto) {
    await this.ensureUserExists(userId);

    const ids = [...new Set(dto.roleIds)];
    const found = await this.roles.findManyByIds(ids);
    if (found.length !== ids.length) {
      throw new NotFoundException('One or more roles were not found');
    }

    await this.users.addRoles(userId, ids);
    return {
      message: 'Roles assigned to user.',
      grants: await this.casl.getGrantsForUser(userId),
    };
  }

  // Remove a single role from a user.
  async removeRole(userId: number, roleId: number) {
    await this.ensureUserExists(userId);

    const link = await this.users.findUserRole(userId, roleId);
    if (!link) {
      throw new NotFoundException('Role is not assigned to this user');
    }

    await this.users.removeRole(userId, roleId);
    return { message: 'Role removed from user.' };
  }

  // Grant one or more permissions directly to a user (user_has_permissions).
  async assignPermissions(userId: number, dto: AssignPermissionsDto) {
    await this.ensureUserExists(userId);

    const ids = [...new Set(dto.permissionIds)];
    const found = await this.permissions.findManyByIds(ids);
    if (found.length !== ids.length) {
      throw new NotFoundException('One or more permissions were not found');
    }

    await this.users.addPermissions(userId, ids);
    return {
      message: 'Permissions granted to user.',
      grants: await this.casl.getGrantsForUser(userId),
    };
  }

  // Remove a single direct permission from a user.
  async removePermission(userId: number, permissionId: number) {
    await this.ensureUserExists(userId);

    const link = await this.users.findUserPermission(userId, permissionId);
    if (!link) {
      throw new NotFoundException('Permission is not granted to this user');
    }

    await this.users.removePermission(userId, permissionId);
    return { message: 'Permission removed from user.' };
  }

  // user exists (and not soft-deleted)?
  private async ensureUserExists(userId: number) {
    const user = await this.users.findById(userId);
    if (!user || user.deletedAt) {
      throw new NotFoundException('User not found');
    }
    return user;
  }

  private hashToken(token: string): string {
    return createHash('sha256').update(token).digest('hex');
  }
}
