import {
  BadRequestException,
  ConflictException,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createHash, randomInt } from 'crypto';
import * as bcrypt from 'bcrypt';
import { UAParser } from 'ua-parser-js';

import { PrismaService } from '@/prisma/prisma.service';
import { CaslAbilityFactory } from '@/casl/casl-ability.factory';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { ResendMailService } from '../mail/resend-mail.service';

const SALT_ROUNDS = 10;

// Auto-logout the user after this many minutes of no authenticated requests.
// Every authenticated request slides `expiresAt` forward by this amount.
// Override via SESSION_IDLE_TIMEOUT_MINUTES in .env (default: 120 = 2 hours).
export const SESSION_IDLE_TIMEOUT_MINUTES = (() => {
  const raw = process.env.SESSION_IDLE_TIMEOUT_MINUTES;
  const parsed = raw ? Number(raw) : NaN;
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
})();

export const SESSION_IDLE_TIMEOUT_MS = SESSION_IDLE_TIMEOUT_MINUTES * 60 * 1000;

// Shape of `req.user` populated by JwtStrategy.validate().
export interface AuthUser {
  id: number;
  email: string;
  name: string | null;
  createdAt: Date;
  sessionId: string;
}

// Request-derived context captured when a session is created (device log).
export interface SessionContext {
  ipAddress?: string;
  userAgent?: string;
}

// The readable device details we pull out of a raw User-Agent string.
interface ParsedDevice {
  deviceType: string | null; // e.g. "desktop", "mobile", "tablet"
  browser: string | null; // e.g. "Chrome", "Safari"
  os: string | null; // e.g. "macOS", "Windows", "iOS"
}

// Break a raw User-Agent string into readable parts using ua-parser-js.
// ua-parser-js leaves the device type empty for laptops/desktops, so we label
// those as "desktop" ourselves. Everything is null when there is no agent.
function parseUserAgent(userAgent?: string): ParsedDevice {
  if (!userAgent) {
    return { deviceType: null, browser: null, os: null };
  }

  const parsedResult = new UAParser(userAgent).getResult();

  return {
    deviceType: parsedResult.device.type ?? 'desktop',
    browser: parsedResult.browser.name ?? null,
    os: parsedResult.os.name ?? null,
  };
}

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwt: JwtService,
    private readonly mail: ResendMailService,
    private readonly casl: CaslAbilityFactory,
  ) {}

  // Return the authenticated user's full profile, their roles ({id, name}) and
  // effective permissions (parsed into {action, subject}), plus the current
  // session — the payload a frontend bootstraps from.
  async getMe(user: AuthUser) {
    const dbUser = await this.prisma.user.findFirst({
      where: { id: user.id, deletedAt: null },
      select: {
        id: true,
        email: true,
        name: true,
        avatar: true,
        position: true,
        isActive: true,
        roles: {
          select: {
            role: {
              select: {
                id: true,
                name: true,
                guardName: true,
                deletedAt: true,
                permissions: {
                  select: {
                    permission: { select: { name: true, deletedAt: true } },
                  },
                },
              },
            },
          },
        },
        permissions: {
          select: {
            permission: { select: { name: true, deletedAt: true } },
          },
        },
      },
    });

    if (!dbUser) {
      throw new UnauthorizedException();
    }

    const roles: { id: number; name: string }[] = [];
    const permissions: { action: string; subject: string }[] = [];
    const seenRole = new Set<number>();
    const seenPermission = new Set<string>();
    let guardName = 'web'; // the app's auth guard (single-guard for now)

    // A permission name is "<action> <subject>" (e.g. "manage all").
    const addPermission = (name: string) => {
      const [action, ...rest] = name.trim().split(/\s+/);
      if (!action) return;
      const subject = rest.join(' ') || 'all';
      const key = `${action} ${subject}`;
      if (seenPermission.has(key)) return;
      seenPermission.add(key);
      permissions.push({ action, subject });
    };

    // Roles + the permissions they grant.
    for (const userRole of dbUser.roles) {
      if (userRole.role.deletedAt) continue;
      if (!seenRole.has(userRole.role.id)) {
        seenRole.add(userRole.role.id);
        roles.push({ id: userRole.role.id, name: userRole.role.name });
        guardName = userRole.role.guardName;
      }
      for (const rolePermission of userRole.role.permissions) {
        if (rolePermission.permission.deletedAt) continue;
        addPermission(rolePermission.permission.name);
      }
    }

    // Permissions granted directly to the user.
    for (const userPermission of dbUser.permissions) {
      if (userPermission.permission.deletedAt) continue;
      addPermission(userPermission.permission.name);
    }

    return {
      user: {
        id: dbUser.id,
        email: dbUser.email,
        name: dbUser.name,
        avatar: dbUser.avatar,
        position: dbUser.position,
        isActive: dbUser.isActive,
        guardName,
        roles,
        permissions,
      },
      session: { id: user.sessionId },
    };
  }

  async register(dto: RegisterDto, sessionContext: SessionContext = {}) {
    const email = dto.email.toLowerCase().trim();

    const existing = await this.prisma.user.findUnique({
      where: { email },
    });

    if (existing) {
      throw new ConflictException('Email already in use');
    }

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

    const user = await this.prisma.user.create({
      data: {
        email,
        name: dto.name,
        password,
      },
    });

    return this.createSessionAndToken(user.id, sessionContext);
  }

  async login(dto: LoginDto, sessionContext: SessionContext = {}) {
    const email = dto.email.toLowerCase().trim();

    const user = await this.prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

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

    if (!valid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    // Block deactivated or soft-deleted accounts from authenticating. Checked
    // after the password so we don't reveal account state to anonymous probes.
    if (user.deletedAt || !user.isActive) {
      throw new UnauthorizedException(
        'This account is inactive. Contact an administrator.',
      );
    }

    const { accessToken } = await this.createSessionAndToken(
      user.id,
      sessionContext,
    );

    return {
      accessToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        avatar: user.avatar,
        position: user.position,
        isActive: user.isActive,
      },
    };
  }

  async forgotPassword(dto: ForgotPasswordDto) {
    const email = dto.email.toLowerCase().trim();

    const user = await this.prisma.user.findUnique({
      where: { email },
    });

    const response = {
      message: 'If this email exists, a password reset OTP has been sent.',
    };

    if (!user) {
      return response;
    }

    const otp = randomInt(0, 1_000_000).toString().padStart(6, '0');
    const hashedOtp = this.hashResetToken(otp);

    const expiresAt = new Date(Date.now() + 15 * 60 * 1000);

    await this.prisma.user.update({
      where: { id: user.id },
      data: {
        passwordResetToken: hashedOtp,
        passwordResetExpiresAt: expiresAt,
      },
    });

    await this.mail.sendResetPasswordOtpEmail(user.email, otp);

    return response;
  }

  async resetPassword(dto: ResetPasswordDto) {
    const email = dto.email.toLowerCase().trim();
    const hashedOtp = this.hashResetToken(dto.otp);

    const user = await this.prisma.user.findUnique({
      where: { email },
    });

    if (
      !user ||
      !user.passwordResetToken ||
      !user.passwordResetExpiresAt ||
      user.passwordResetToken !== hashedOtp ||
      user.passwordResetExpiresAt <= new Date()
    ) {
      throw new BadRequestException('Invalid or expired OTP');
    }

    const password = await bcrypt.hash(dto.newPassword, SALT_ROUNDS);
    const now = new Date();

    await this.prisma.$transaction([
      this.prisma.user.update({
        where: { id: user.id },
        data: {
          password,
          passwordResetToken: null,
          passwordResetExpiresAt: null,
        },
      }),
      this.prisma.userSession.updateMany({
        where: { userId: user.id, loggedOutAt: null },
        data: { loggedOutAt: now },
      }),
    ]);

    return {
      message: 'Password has been reset successfully.',
    };
  }

  async logout(sessionId: string) {
    await this.prisma.userSession.updateMany({
      where: { id: sessionId, loggedOutAt: null },
      data: { loggedOutAt: new Date() },
    });

    return { message: 'Logged out successfully.' };
  }

  private async createSessionAndToken(
    userId: number,
    sessionContext: SessionContext = {},
  ) {
    const now = new Date();

    // Turn the raw User-Agent into readable device columns we can show later.
    const device = parseUserAgent(sessionContext.userAgent);

    const session = await this.prisma.userSession.create({
      data: {
        userId,
        ipAddress: sessionContext.ipAddress ?? null,
        userAgent: sessionContext.userAgent ?? null,
        deviceType: device.deviceType,
        browser: device.browser,
        os: device.os,
        lastActivityAt: now,
        expiresAt: new Date(now.getTime() + SESSION_IDLE_TIMEOUT_MS),
      },
    });

    const accessToken = this.jwt.sign({
      userId,
      sessionId: session.id,
    });

    return {
      accessToken,
    };
  }

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