import {
  Injectable,
  UnauthorizedException,
  BadRequestException,
  Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import * as speakeasy from 'speakeasy';
import { MagicLinkPurpose, NotificationChannel } from '@prisma/client';

import { PrismaService } from '../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { NotificationsService } from '../notifications/notifications.service';

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
    private readonly notifications: NotificationsService,
  ) {}

  async register(dto: RegisterDto) {
    const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
    if (existing) throw new BadRequestException('Email already registered');

    const rounds = parseInt(this.config.get<string>('BCRYPT_ROUNDS', '10'));
    const passwordHash = await bcrypt.hash(dto.password, rounds);

    const user = await this.prisma.user.create({
      data: {
        email: dto.email,
        phone: dto.phone,
        passwordHash,
        firstName: dto.firstName,
        lastName: dto.lastName,
        language: dto.language ?? 'fr',
      },
    });

    return this.buildTokenResponse(user.id, user.email);
  }

  async login(dto: LoginDto) {
    const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
    if (!user || user.status === 'DISABLED') {
      throw new UnauthorizedException('Invalid credentials');
    }
    const ok = await bcrypt.compare(dto.password, user.passwordHash);
    if (!ok) throw new UnauthorizedException('Invalid credentials');

    // MFA enforcement: if enabled, require valid TOTP code in `dto.mfaCode`
    if (user.mfaEnabled) {
      const code = (dto as any).mfaCode as string | undefined;
      if (!code) {
        return { mfaRequired: true, userId: user.id };
      }
      const verified = speakeasy.totp.verify({
        secret: user.mfaSecret!,
        encoding: 'base32',
        token: code,
        window: 1,
      });
      if (!verified) throw new UnauthorizedException('Invalid MFA code');
    }

    await this.prisma.user.update({
      where: { id: user.id },
      data: { lastLoginAt: new Date() },
    });

    return this.buildTokenResponse(user.id, user.email);
  }

  // ================= MFA (TOTP) =================

  async mfaEnroll(userId: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException();
    if (user.mfaEnabled) throw new BadRequestException('MFA already enabled');
    const secret = speakeasy.generateSecret({
      name: `SyndiClub (${user.email})`,
      length: 20,
    });
    await this.prisma.user.update({
      where: { id: userId },
      data: { mfaSecret: secret.base32 },
    });
    return {
      secret: secret.base32,
      otpauthUrl: secret.otpauth_url,
    };
  }

  async mfaActivate(userId: string, code: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user || !user.mfaSecret) throw new BadRequestException('Run enroll first');
    const ok = speakeasy.totp.verify({
      secret: user.mfaSecret,
      encoding: 'base32',
      token: code,
      window: 1,
    });
    if (!ok) throw new UnauthorizedException('Invalid MFA code');
    await this.prisma.user.update({
      where: { id: userId },
      data: { mfaEnabled: true },
    });
    return { enabled: true };
  }

  async mfaDisable(userId: string, code: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user || !user.mfaEnabled) throw new BadRequestException('MFA not enabled');
    const ok = speakeasy.totp.verify({
      secret: user.mfaSecret!,
      encoding: 'base32',
      token: code,
      window: 1,
    });
    if (!ok) throw new UnauthorizedException('Invalid MFA code');
    await this.prisma.user.update({
      where: { id: userId },
      data: { mfaEnabled: false, mfaSecret: null },
    });
    return { enabled: false };
  }

  async refresh(dto: RefreshTokenDto) {
    let payload: any;
    try {
      payload = this.jwt.verify(dto.refreshToken, {
        secret: this.config.get<string>('JWT_REFRESH_SECRET'),
      });
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }

    const hash = this.hashToken(dto.refreshToken);
    const session = await this.prisma.session.findFirst({
      where: { refreshTokenHash: hash, revokedAt: null, expiresAt: { gt: new Date() } },
    });
    if (!session) throw new UnauthorizedException('Session expired or revoked');

    // rotate
    await this.prisma.session.update({ where: { id: session.id }, data: { revokedAt: new Date() } });

    return this.buildTokenResponse(payload.sub, payload.email);
  }

  async logout(userId: string, refreshToken: string) {
    const hash = this.hashToken(refreshToken);
    await this.prisma.session.updateMany({
      where: { userId, refreshTokenHash: hash, revokedAt: null },
      data: { revokedAt: new Date() },
    });
    return { success: true };
  }

  async getProfile(userId: string) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      include: {
        roles: {
          include: { role: true, tenant: { select: { id: true, companyName: true } } },
        },
      },
    });
    if (!user) throw new UnauthorizedException();

    const { passwordHash: _ph, mfaSecret: _ms, ...safe } = user;
    return {
      ...safe,
      roles: user.roles.map((r) => ({
        code: r.role.code,
        label: r.role.label,
        tenantId: r.tenantId,
        tenant: r.tenant,
        scopeComplexId: r.scopeComplexId,
      })),
    };
  }

  async updateProfile(userId: string, dto: { firstName?: string; lastName?: string; phone?: string }) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException();
    const updated = await this.prisma.user.update({
      where: { id: userId },
      data: {
        firstName: dto.firstName !== undefined ? dto.firstName : undefined,
        lastName: dto.lastName !== undefined ? dto.lastName : undefined,
        phone: dto.phone !== undefined ? dto.phone : undefined,
      },
    });
    return { firstName: updated.firstName, lastName: updated.lastName, phone: updated.phone };
  }

  async changePassword(userId: string, currentPassword: string, newPassword: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException();
    const ok = await bcrypt.compare(currentPassword, user.passwordHash);
    if (!ok) throw new BadRequestException('Mot de passe actuel incorrect');
    if (newPassword.length < 8) throw new BadRequestException('Le nouveau mot de passe doit contenir au moins 8 caractères');
    const rounds = parseInt(this.config.get<string>('BCRYPT_ROUNDS', '10'));
    const passwordHash = await bcrypt.hash(newPassword, rounds);
    await this.prisma.user.update({ where: { id: userId }, data: { passwordHash } });
    return { success: true };
  }

  // ---------------- internal helpers ----------------

  private async buildTokenResponse(userId: string, email: string) {
    const roles = await this.prisma.userRole.findMany({
      where: { userId },
      include: { role: true },
    });

    const payload = {
      sub: userId,
      email,
      roles: roles.map((r) => ({
        code: r.role.code,
        tenantId: r.tenantId,
        scopeComplexId: r.scopeComplexId,
      })),
    };

    // Add a per-call random `jti` so two refreshes within the same second
    // produce DIFFERENT tokens (otherwise their hashes collide and the
    // newly-revoked session is still considered valid via the new row).
    const accessToken = this.jwt.sign({ ...payload, jti: randomBytes(8).toString('hex') });
    const refreshToken = this.jwt.sign(
      { sub: userId, email, jti: randomBytes(16).toString('hex') },
      {
        secret: this.config.get<string>('JWT_REFRESH_SECRET'),
        expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
      },
    );

    const refreshExpires = new Date();
    refreshExpires.setDate(refreshExpires.getDate() + 7);

    await this.prisma.session.create({
      data: {
        userId,
        refreshTokenHash: this.hashToken(refreshToken),
        expiresAt: refreshExpires,
      },
    });

    return {
      accessToken,
      refreshToken,
      tokenType: 'Bearer',
      user: { id: userId, email, roles: payload.roles },
    };
  }

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

  // ================= Magic Links (P0-1, P0-10) =================

  /**
   * Génère un lien magique. TTL 30 min pour LOGIN, 30 jours pour PRESTATAIRE_ACCESS.
   * Si l'email n'est pas connu, on retourne un succès silencieux pour éviter
   * l'énumération de comptes (OWASP A07).
   */
  async requestMagicLink(email: string, purpose: MagicLinkPurpose = MagicLinkPurpose.LOGIN) {
    const normalized = email.trim().toLowerCase();
    const user = await this.prisma.user.findUnique({ where: { email: normalized } });

    // On crée toujours une entrée seulement si l'utilisateur existe — sinon on
    // simule juste le délai, sans révéler la non-existence.
    if (!user || user.status === 'DISABLED') {
      this.logger.warn(`Magic link requested for unknown/disabled email ${normalized}`);
      return { sent: true };
    }

    const ttlMs =
      purpose === MagicLinkPurpose.PRESTATAIRE_ACCESS
        ? 30 * 24 * 60 * 60 * 1000 // 30 jours
        : 30 * 60 * 1000; // 30 minutes

    const rawToken = randomBytes(32).toString('hex');
    const tokenHash = this.hashToken(rawToken);
    const expiresAt = new Date(Date.now() + ttlMs);

    await this.prisma.magicLink.create({
      data: {
        email: normalized,
        userId: user.id,
        tokenHash,
        purpose,
        expiresAt,
      },
    });

    const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL', 'http://localhost:5173');
    const baseUrl = publicWebUrl.replace(/\/+$/, '');
    const url = `${baseUrl}/auth/magic/${rawToken}`;

    await this.notifications.send({
      tenantId: null,
      userId: user.id,
      channel: NotificationChannel.EMAIL,
      template: 'magic_link_login',
      recipient: normalized,
      subject: 'Votre lien de connexion SyndiClub',
      body: [
        'Bonjour,',
        '',
        'Voici votre lien de connexion SyndiClub :',
        url,
        '',
        purpose === MagicLinkPurpose.PRESTATAIRE_ACCESS
          ? 'Ce lien reste valable 30 jours.'
          : 'Ce lien reste valable 30 minutes et ne peut etre utilise qu une fois.',
        '',
        'Si vous n etes pas a l origine de cette demande, ignorez cet email.',
      ].join('\n'),
    });
    this.logger.log(`Magic link for ${normalized} (${purpose}): ${url}`);

    // Security: NEVER return the magic-link URL in HTTP response unless the operator
    // explicitly opts in via MAGIC_LINK_RETURN_URL=true (dev/test only).
    // Defense-in-depth: also blocked when NODE_ENV=production.
    const allowDevUrl =
      process.env.MAGIC_LINK_RETURN_URL === 'true' &&
      process.env.NODE_ENV !== 'production';
    return { sent: true, ...(allowDevUrl && { devUrl: url }) };
  }

  /**
   * Vérifie un token magique et retourne une session JWT complète.
   * LOGIN = usage unique. PRESTATAIRE_ACCESS = réutilisable jusqu'à expiration.
   */
  async verifyMagicLink(token: string, ip?: string) {
    if (!token || token.length < 32) throw new UnauthorizedException('Invalid token');
    const tokenHash = this.hashToken(token);
    const link = await this.prisma.magicLink.findUnique({ where: { tokenHash } });
    if (!link) throw new UnauthorizedException('Invalid or unknown token');
    if (link.expiresAt.getTime() < Date.now()) throw new UnauthorizedException('Token expired');

    if (link.purpose === MagicLinkPurpose.LOGIN && link.usedAt) {
      throw new UnauthorizedException('Token already used');
    }

    const user = link.userId
      ? await this.prisma.user.findUnique({ where: { id: link.userId } })
      : await this.prisma.user.findUnique({ where: { email: link.email } });
    if (!user || user.status === 'DISABLED') throw new UnauthorizedException();

    await this.prisma.magicLink.update({
      where: { id: link.id },
      data: { usedAt: new Date(), consumedIp: ip ?? null },
    });

    await this.prisma.user.update({
      where: { id: user.id },
      data: { lastLoginAt: new Date() },
    });

    return this.buildTokenResponse(user.id, user.email);
  }
}
