import {
  BadRequestException,
  Injectable,
  Logger,
  UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { LessThan, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { UsersService } from '../users/users.service';
import { User } from '../users/user.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { PasswordResetToken } from './entities/password-reset-token.entity';

export interface TokenPair {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}

export interface AuthContext {
  ip?: string;
  userAgent?: string;
}

const BCRYPT_ROUNDS = 12;

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

  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
    @InjectRepository(RefreshToken)
    private readonly refreshRepo: Repository<RefreshToken>,
    @InjectRepository(PasswordResetToken)
    private readonly resetRepo: Repository<PasswordResetToken>,
  ) {}

  // -------------------------------------------------------------------------
  // Login
  // -------------------------------------------------------------------------

  async validateUser(email: string, password: string): Promise<User | null> {
    const user = await this.usersService.findByEmail(email);
    if (!user || !user.passwordHash) return null;

    const matches = await bcrypt.compare(password, user.passwordHash);
    if (!matches) return null;

    // A deactivated account must not be able to obtain tokens.
    if (!user.isActive) {
      throw new UnauthorizedException('Account is deactivated');
    }
    return user;
  }

  async login(user: User, context: AuthContext = {}) {
    const full = await this.usersService.findById(user.id);
    const permissions = this.collectPermissions(full);
    const roles = (full?.roles || []).map((r) => r.name);

    const tokens = await this.issueTokens(user, permissions, context);

    return {
      ...tokens,
      user: {
        id: Number(user.id),
        email: user.email,
        displayName: user.displayName,
        roles,
        permissions,
      },
    };
  }

  async profile(userId: number) {
    const user = await this.usersService.findById(userId);
    if (!user) throw new UnauthorizedException('User no longer exists');
    return {
      id: Number(user.id),
      email: user.email,
      displayName: user.displayName,
      isActive: user.isActive,
      roles: (user.roles || []).map((r) => ({ id: Number(r.id), name: r.name })),
      permissions: this.collectPermissions(user),
    };
  }

  // -------------------------------------------------------------------------
  // Refresh / logout
  // -------------------------------------------------------------------------

  async refresh(rawToken: string, context: AuthContext = {}): Promise<TokenPair> {
    const tokenHash = this.hash(rawToken);
    const stored = await this.refreshRepo.findOne({
      where: { tokenHash },
      relations: ['user'],
    });

    if (!stored || stored.revokedAt || stored.expiresAt.getTime() < Date.now()) {
      throw new UnauthorizedException('Invalid or expired refresh token');
    }

    const user = await this.usersService.findById(stored.userId);
    if (!user || !user.isActive) {
      throw new UnauthorizedException('Account is unavailable');
    }

    // Rotate: the presented token can never be replayed.
    stored.revokedAt = new Date();
    await this.refreshRepo.save(stored);

    return this.issueTokens(user, this.collectPermissions(user), context);
  }

  async logout(rawToken?: string, userId?: number): Promise<void> {
    if (rawToken) {
      await this.refreshRepo.update(
        { tokenHash: this.hash(rawToken) },
        { revokedAt: new Date() },
      );
      return;
    }
    if (userId) {
      await this.refreshRepo.update(
        { userId, revokedAt: null as any },
        { revokedAt: new Date() },
      );
    }
  }

  // -------------------------------------------------------------------------
  // Password reset
  // -------------------------------------------------------------------------

  /**
   * Returns the raw reset token. Callers must deliver it out of band; it is
   * never persisted in plain text and never returned in production responses.
   */
  async createPasswordReset(email: string): Promise<string | null> {
    const user = await this.usersService.findByEmail(email);
    // Do not disclose whether the address exists.
    if (!user || !user.isActive) return null;

    const rawToken = crypto.randomBytes(48).toString('hex');
    const ttlMinutes = parseInt(process.env.PASSWORD_RESET_TTL_MINUTES || '60', 10);

    await this.resetRepo.save(
      this.resetRepo.create({
        userId: user.id,
        tokenHash: this.hash(rawToken),
        expiresAt: new Date(Date.now() + ttlMinutes * 60 * 1000),
      }),
    );

    return rawToken;
  }

  async resetPassword(rawToken: string, newPassword: string): Promise<void> {
    const record = await this.resetRepo.findOne({
      where: { tokenHash: this.hash(rawToken) },
    });

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

    await this.usersService.setPassword(record.userId, await this.hashPassword(newPassword));

    record.usedAt = new Date();
    await this.resetRepo.save(record);

    // Any session opened with the old password is no longer trustworthy.
    await this.logout(undefined, record.userId);
  }

  async changePassword(userId: number, currentPassword: string, newPassword: string) {
    const user = await this.usersService.findByEmailOrId(userId);
    if (!user || !user.passwordHash) throw new UnauthorizedException();

    const matches = await bcrypt.compare(currentPassword, user.passwordHash);
    if (!matches) throw new BadRequestException('Current password is incorrect');

    await this.usersService.setPassword(userId, await this.hashPassword(newPassword));
    await this.logout(undefined, userId);
  }

  hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, BCRYPT_ROUNDS);
  }

  /** Housekeeping for expired/revoked refresh tokens. */
  async pruneExpiredTokens(): Promise<number> {
    const result = await this.refreshRepo.delete({ expiresAt: LessThan(new Date()) });
    return result.affected || 0;
  }

  // -------------------------------------------------------------------------
  // Internals
  // -------------------------------------------------------------------------

  private async issueTokens(
    user: User,
    permissions: string[],
    context: AuthContext,
  ): Promise<TokenPair> {
    const expiresIn = parseInt(process.env.JWT_EXPIRES_IN_SECONDS || '3600', 10);

    const accessToken = this.jwtService.sign(
      { sub: Number(user.id), email: user.email, permissions },
      { expiresIn },
    );

    const rawRefresh = crypto.randomBytes(64).toString('hex');
    const refreshDays = parseInt(process.env.REFRESH_TOKEN_EXPIRES_DAYS || '30', 10);

    await this.refreshRepo.save(
      this.refreshRepo.create({
        userId: user.id,
        tokenHash: this.hash(rawRefresh),
        expiresAt: new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000),
        ip: context.ip,
        userAgent: context.userAgent?.slice(0, 512),
      }),
    );

    return { accessToken, refreshToken: rawRefresh, expiresIn };
  }

  private collectPermissions(user: User | null): string[] {
    if (!user?.roles) return [];
    const names = user.roles.flatMap((role) =>
      (role.permissions || []).map((permission) => permission.name),
    );
    return Array.from(new Set(names));
  }

  private hash(value: string): string {
    return crypto.createHash('sha256').update(value).digest('hex');
  }
}
