import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { UserRepository } from '../models/repositories/UserRepository';
import { PasswordResetTokenRepository } from '../models/repositories/PasswordResetTokenRepository';
import { AuditLogRepository } from '../models/repositories/AuditLogRepository';
import { generateUuid } from '../utils/uuid';
import { AppError } from '../utils/AppError';
import { TimezoneService } from './TimezoneService';
import { AuthMailService } from './AuthMailService';
import { User } from '../models/entities/User.entity';
import { assertPasswordStrength } from '../utils/passwordPolicy';
import { generateResetToken, generateSecurePassword, hashResetToken } from '../utils/passwordTokens';
import { normalizeUsername } from '../utils/usernamePolicy';

const BCRYPT_ROUNDS = 12;
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;

export class AuthService {
  constructor(
    private userRepo = new UserRepository(),
    private resetTokenRepo = new PasswordResetTokenRepository(),
    private auditRepo = new AuditLogRepository(),
    private timezoneService = new TimezoneService(),
    private authMail = new AuthMailService(),
  ) {}

  issueAccessToken(user: User): string {
    return jwt.sign({ sub: user.id }, env.JWT_SECRET, {
      expiresIn: env.JWT_EXPIRES_IN,
    });
  }

  async register(input: {
    email: string;
    password?: string;
    fullName: string;
    username: string;
    timezone: string;
  }): Promise<{ user: User; passwordEmailed: boolean }> {
    await this.timezoneService.assertActiveTimezone(input.timezone);

    const email = input.email.trim().toLowerCase();
    const username = normalizeUsername(input.username);

    const existingEmail = await this.userRepo.findByEmail(email);
    if (existingEmail) {
      throw new AppError('validation_error', 400, 'Email already registered.');
    }

    const existingUsername = await this.userRepo.findByUsername(username);
    if (existingUsername) {
      throw new AppError('validation_error', 400, 'Username already taken.');
    }

    let plainPassword: string;
    let passwordEmailed = false;
    let mustChangePassword = false;

    if (input.password) {
      assertPasswordStrength(input.password);
      plainPassword = input.password;
    } else {
      plainPassword = generateSecurePassword();
      passwordEmailed = true;
      mustChangePassword = true;
    }

    const passwordHash = await bcrypt.hash(plainPassword, BCRYPT_ROUNDS);
    const now = new Date();
    const user = await this.userRepo.create({
      id: generateUuid(),
      email,
      passwordHash,
      authProvider: 'email',
      oauthSubject: null,
      fullName: input.fullName,
      username,
      timezone: input.timezone,
      emailVerified: false,
      mustChangePassword,
      passwordChangedAt: passwordEmailed ? null : now,
    });

    if (passwordEmailed) {
      try {
        await this.authMail.sendWelcomePassword(user.email, user.fullName, plainPassword);
      } catch (err) {
        await this.userRepo.deleteById(user.id);
        throw err;
      }
    }

    await this.auditRepo.log({
      userId: user.id,
      action: 'user.register',
      entityType: 'user',
      entityId: user.id,
    });

    return { user, passwordEmailed };
  }

  async login(email: string, password: string): Promise<{ user: User; accessToken: string }> {
    const user = await this.userRepo.findByEmail(email.trim().toLowerCase());
    if (!user || !user.passwordHash) {
      throw new AppError('unauthorized', 401, 'Invalid credentials.');
    }

    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) {
      throw new AppError('unauthorized', 401, 'Invalid credentials.');
    }

    const accessToken = this.issueAccessToken(user);

    await this.auditRepo.log({
      userId: user.id,
      action: 'user.login',
      entityType: 'user',
      entityId: user.id,
    });

    return { user, accessToken };
  }

  async forgotPassword(email: string): Promise<void> {
    const user = await this.userRepo.findByEmail(email.trim().toLowerCase());
    if (!user || !user.passwordHash) {
      return;
    }

    const rawToken = generateResetToken();
    const tokenHash = hashResetToken(rawToken);
    await this.resetTokenRepo.invalidateForUser(user.id);
    await this.resetTokenRepo.create({
      id: generateUuid(),
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS),
      usedAt: null,
    });

    const resetUrl = `${env.APP_URL}/reset-password?token=${rawToken}`;
    await this.authMail.sendPasswordResetLink(user.email, user.fullName, resetUrl);
  }

  async resetPassword(token: string, newPassword: string): Promise<void> {
    assertPasswordStrength(newPassword);
    const tokenHash = hashResetToken(token);
    const row = await this.resetTokenRepo.findValidByHash(tokenHash);
    if (!row?.user) {
      throw new AppError('validation_error', 400, 'Invalid or expired reset link.');
    }

    await this.setPassword(row.user, newPassword, { markResetTokenUsed: row.id });
  }

  async changePassword(
    userId: string,
    currentPassword: string | undefined,
    newPassword: string,
  ): Promise<void> {
    assertPasswordStrength(newPassword);
    const user = await this.userRepo.findById(userId);
    if (!user) {
      throw new AppError('not_found', 404, 'User not found.');
    }

    if (!user.mustChangePassword) {
      if (!currentPassword || !user.passwordHash) {
        throw new AppError('validation_error', 400, 'Current password is required.');
      }
      const valid = await bcrypt.compare(currentPassword, user.passwordHash);
      if (!valid) {
        throw new AppError('unauthorized', 401, 'Current password is incorrect.');
      }
    }

    await this.setPassword(user, newPassword);
  }

  private async setPassword(
    user: User,
    newPassword: string,
    options?: { markResetTokenUsed?: string },
  ): Promise<void> {
    const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
    await this.userRepo.update(user.id, {
      passwordHash,
      authProvider: user.authProvider === 'email' ? 'email' : user.authProvider,
      mustChangePassword: false,
      passwordChangedAt: new Date(),
    });

    if (options?.markResetTokenUsed) {
      await this.resetTokenRepo.markUsed(options.markResetTokenUsed);
    } else {
      await this.resetTokenRepo.invalidateForUser(user.id);
    }

    await this.authMail.sendPasswordChangedNotification(user.email, user.fullName);

    await this.auditRepo.log({
      userId: user.id,
      action: 'user.password.change',
      entityType: 'user',
      entityId: user.id,
    });
  }
}
