/**
 * Authentication Service
 *
 * Business logic for user authentication including:
 * - User registration with password hashing
 * - JWT token generation and validation
 * - Login with credential verification
 * - Token invalidation (logout)
 */

import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { prisma } from '../../shared/database/prisma.client';
import { blacklistToken, isTokenBlacklisted as checkTokenBlacklisted } from '../../shared/database/redis.client';
import { logger } from '../../shared/utils/logger';
import {
  ConflictError,
  AuthenticationError,
  NotFoundError,
  ValidationError,
} from '../../shared/types';
import { RegisterDto, LoginDto, JwtPayload } from './dtos';
import type { UserRole } from '@prisma/client';
import { telegramClient } from '../../modules/notification/telegram/telegram.client';
import { isTelegramEnabled } from '../../config/telegram';

/**
 * Token expiration times
 * - Access token: configurable via JWT_EXPIRES_IN env var (default: 15 minutes)
 * - Refresh token: 7 days
 */
const ACCESS_TOKEN_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
const SALT_ROUNDS = 10;
const PASSWORD_RESET_TOKEN_EXPIRES_HOURS = 1; // 1 hour expiration

/**
 * Response types for authentication operations
 */
export interface RegisterResponse {
  user: {
    id: string;
    email: string;
    username: string;
    isActive: boolean;
    createdAt: Date;
  };
  token: string;
  refreshToken: string;
  expiresIn: number;
  refreshTokenExpiresIn: number;
}

export interface LoginResponse {
  user: {
    id: string;
    email: string;
    username: string;
    isActive: boolean;
    lastLoginAt: Date | null;
  };
  token: string;
  refreshToken: string;
  expiresIn: number;
  refreshTokenExpiresIn: number;
}

export interface RefreshTokenResponse {
  token: string;
  refreshToken: string;
  expiresIn: number;
  refreshTokenExpiresIn: number;
}

export interface UserProfile {
  id: string;
  email: string;
  username: string;
  isActive: boolean;
  telegramChatId: string | null;
  createdAt: Date;
  updatedAt: Date;
  lastLoginAt: Date | null;
}

/**
 * Authentication Service
 */
export class AuthService {
  /**
   * Get JWT secret from environment
   */
  private getJwtSecret(): string {
    const secret = process.env.JWT_SECRET;
    if (!secret) {
      logger.error('JWT_SECRET not configured in environment');
      throw new Error('JWT_SECRET not configured');
    }
    return secret;
  }

  /**
   * Generate JWT access token for user (15 minutes)
   */
  private generateAccessToken(user: { id: string; email: string; username: string; role: UserRole | null }): string {
    const payload: JwtPayload = {
      userId: user.id,
      email: user.email,
      username: user.username,
      role: user.role ?? 'STUDENT',
    };

    const options = {
      expiresIn: ACCESS_TOKEN_EXPIRES_IN as unknown as NonNullable<jwt.SignOptions['expiresIn']>,
    } satisfies jwt.SignOptions;
    const token = jwt.sign(payload, this.getJwtSecret(), options as jwt.SignOptions);

    return token;
  }

  /**
   * Generate a secure refresh token string
   */
  private generateRefreshTokenString(): string {
    return crypto.randomBytes(64).toString('hex');
  }

  /**
   * Hash refresh token using SHA-256 for O(1) lookup
   * SHA-256 is fast and deterministic, allowing direct database lookup
   */
  private hashRefreshToken(token: string): string {
    return crypto.createHash('sha256').update(token).digest('hex');
  }

  /**
   * Calculate access token expiration in seconds (15 minutes)
   */
  private getAccessTokenExpirationSeconds(): number {
    return 15 * 60;
  }

  /**
   * Calculate refresh token expiration in seconds (7 days)
   */
  private getRefreshTokenExpirationSeconds(): number {
    return REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60;
  }

  /**
   * Create and store a refresh token in database
   * Uses SHA-256 hash for O(1) lookup instead of bcrypt
   */
  private async createRefreshToken(userId: string): Promise<string> {
    const tokenString = this.generateRefreshTokenString();
    const hashedToken = this.hashRefreshToken(tokenString);

    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);

    await prisma.refreshToken.create({
      data: {
        token: hashedToken,
        userId,
        expiresAt,
      },
    });

    logger.info({ userId }, 'Refresh token created');

    return tokenString;
  }

  /**
   * Verify refresh token and return associated user
   * O(1) lookup using SHA-256 hash instead of O(n) bcrypt comparison
   */
  private async verifyRefreshToken(tokenString: string): Promise<{ userId: string; tokenId: string }> {
    // Hash the input token with SHA-256 for direct lookup
    const hashedInput = this.hashRefreshToken(tokenString);

    const storedToken = await prisma.refreshToken.findUnique({
      where: {
        token: hashedInput,
      },
      select: {
        id: true,
        userId: true,
        revoked: true,
        expiresAt: true,
      },
    });

    if (!storedToken) {
      throw new AuthenticationError('Invalid or expired refresh token');
    }

    if (storedToken.revoked) {
      throw new AuthenticationError('Refresh token has been revoked');
    }

    if (storedToken.expiresAt < new Date()) {
      throw new AuthenticationError('Refresh token has expired');
    }

    return { userId: storedToken.userId, tokenId: storedToken.id };
  }

  /**
   * Revoke a refresh token by ID
   */
  private async revokeRefreshToken(tokenId: string): Promise<void> {
    await prisma.refreshToken.update({
      where: { id: tokenId },
      data: { revoked: true },
    });
    logger.info({ tokenId }, 'Refresh token revoked');
  }

  /**
   * Clean up expired or revoked refresh tokens for a user
   */
  private async cleanupUserRefreshTokens(userId: string): Promise<void> {
    const result = await prisma.refreshToken.deleteMany({
      where: {
        userId,
        OR: [
          { revoked: true },
          { expiresAt: { lt: new Date() } },
        ],
      },
    });
    logger.debug({ userId, count: result.count }, 'Cleaned up user refresh tokens');
  }

  /**
   * Hash password using bcrypt
   */
  private async hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, SALT_ROUNDS);
  }

  /**
   * Compare password with hash
   */
  private async comparePassword(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }

  /**
   * Validate email uniqueness
   */
  private async isEmailUnique(email: string): Promise<boolean> {
    const existingUser = await prisma.user.findUnique({
      where: { email },
      select: { id: true },
    });

    return !existingUser;
  }

  /**
   * Validate username uniqueness
   */
  private async isUsernameUnique(username: string): Promise<boolean> {
    const existingUser = await prisma.user.findUnique({
      where: { username },
      select: { id: true },
    });

    return !existingUser;
  }

  /**
   * Register a new user
   *
   * @throws {ConflictError} If email or username already exists
   */
  async register(dto: RegisterDto): Promise<RegisterResponse> {
    logger.info({ email: dto.email, username: dto.username }, 'Register attempt');

    // Check email uniqueness
    const emailUnique = await this.isEmailUnique(dto.email);
    if (!emailUnique) {
      logger.warn({ email: dto.email }, 'Registration failed: email already exists');
      throw new ConflictError('Email already registered', {
        field: 'email',
        message: 'An account with this email already exists',
      });
    }

    // Check username uniqueness
    const usernameUnique = await this.isUsernameUnique(dto.username);
    if (!usernameUnique) {
      logger.warn({ username: dto.username }, 'Registration failed: username already exists');
      throw new ConflictError('Username already taken', {
        field: 'username',
        message: 'This username is already taken',
      });
    }

    // Hash password
    const passwordHash = await this.hashPassword(dto.password);

    // Create user
    const user = await prisma.user.create({
      data: {
        email: dto.email,
        username: dto.username,
        passwordHash,
      },
      select: {
        id: true,
        email: true,
        username: true,
        role: true,
        isActive: true,
        createdAt: true,
      },
    });

    // Generate tokens
    const accessToken = this.generateAccessToken(user);
    const refreshToken = await this.createRefreshToken(user.id);
    const expiresIn = this.getAccessTokenExpirationSeconds();
    const refreshTokenExpiresIn = this.getRefreshTokenExpirationSeconds();

    logger.info(
      { userId: user.id, email: user.email },
      'User registered successfully'
    );

    return {
      user,
      token: accessToken,
      refreshToken,
      expiresIn,
      refreshTokenExpiresIn,
    };
  }

  /**
   * Login user with email and password
   *
   * @throws {AuthenticationError} If credentials are invalid
   * @throws {NotFoundError} If user doesn't exist
   */
  async login(dto: LoginDto): Promise<LoginResponse> {
    logger.info({ email: dto.email }, 'Login attempt');

    // Find user by email
    const user = await prisma.user.findUnique({
      where: { email: dto.email },
    });

    if (!user) {
      logger.warn({ email: dto.email }, 'Login failed: user not found');
      throw new AuthenticationError('Invalid credentials');
    }

    // Check if user is active
    if (!user.isActive) {
      logger.warn({ userId: user.id }, 'Login failed: user is inactive');
      throw new AuthenticationError('Account is inactive');
    }

    // Verify password
    const passwordValid = await this.comparePassword(dto.password, user.passwordHash);
    if (!passwordValid) {
      logger.warn({ userId: user.id }, 'Login failed: invalid password');
      throw new AuthenticationError('Invalid credentials');
    }

    // Update last login
    const updatedUser = await prisma.user.update({
      where: { id: user.id },
      data: { lastLoginAt: new Date() },
    });

    // Clean up old refresh tokens and generate new tokens
    await this.cleanupUserRefreshTokens(user.id);
    const accessToken = this.generateAccessToken(user);
    const refreshToken = await this.createRefreshToken(user.id);
    const expiresIn = this.getAccessTokenExpirationSeconds();
    const refreshTokenExpiresIn = this.getRefreshTokenExpirationSeconds();

    logger.info({ userId: user.id, email: user.email }, 'User logged in successfully');

    return {
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
        isActive: user.isActive,
        lastLoginAt: updatedUser.lastLoginAt,
      },
      token: accessToken,
      refreshToken,
      expiresIn,
      refreshTokenExpiresIn,
    };
  }

  /**
   * Get user profile by ID
   *
   * @throws {NotFoundError} If user doesn't exist
   */
  async getProfile(userId: string): Promise<UserProfile> {
    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
        username: true,
        isActive: true,
        telegramChatId: true,
        createdAt: true,
        updatedAt: true,
        lastLoginAt: true,
      },
    });

    if (!user) {
      logger.warn({ userId }, 'Profile not found');
      throw new NotFoundError('User');
    }

    return user;
  }

  /**
   * Verify JWT token and return payload
   *
   * @throws {AuthenticationError} If token is invalid or expired
   */
  verifyToken(token: string): JwtPayload {
    try {
      const decoded = jwt.verify(token, this.getJwtSecret()) as JwtPayload;
      return decoded;
    } catch (error) {
      if (error instanceof jwt.TokenExpiredError) {
        logger.warn({ reason: 'token expired' }, 'Token verification failed');
        throw new AuthenticationError('Token expired');
      } else if (error instanceof jwt.JsonWebTokenError) {
        logger.warn({ reason: 'invalid token' }, 'Token verification failed');
        throw new AuthenticationError('Invalid token');
      }
      logger.error({ error }, 'Unexpected token verification error');
      throw new AuthenticationError('Token verification failed');
    }
  }

  /**
   * Logout user by blacklisting access token and revoking refresh tokens
   *
   * @param accessToken - The JWT access token to blacklist
   * @param refreshToken - Optional refresh token to revoke
   */
  async logout(accessToken: string, refreshToken?: string): Promise<void> {
    // Blacklist access token in Redis (expires after 15 min)
    await blacklistToken(accessToken);

    // If refresh token provided, revoke it in database
    if (refreshToken) {
      try {
        const { tokenId } = await this.verifyRefreshToken(refreshToken);
        await this.revokeRefreshToken(tokenId);
      } catch (error) {
        // If refresh token is invalid, we still consider logout successful
        logger.debug({ error }, 'Refresh token already invalid during logout');
      }
    }

    logger.info({ tokenPrefix: accessToken.substring(0, 20) }, 'User logged out');
  }

  /**
   * Refresh access token using refresh token
   *
   * Validates refresh token, generates new access token, optionally rotates refresh token
   *
   * @param refreshTokenString - The refresh token provided by client
   * @returns New access token and optionally new refresh token
   * @throws {AuthenticationError} If refresh token is invalid or expired
   */
  async refreshAccessToken(refreshTokenString: string): Promise<RefreshTokenResponse> {
    logger.info({ tokenPrefix: refreshTokenString.substring(0, 10) }, 'Token refresh attempt');

    // Verify the refresh token
    const { userId, tokenId } = await this.verifyRefreshToken(refreshTokenString);

    // Get user to ensure they still exist and are active
    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
        username: true,
        role: true,
        isActive: true,
      },
    });

    if (!user || !user.isActive) {
      // Revoke the refresh token if user is inactive/deleted
      await this.revokeRefreshToken(tokenId);
      throw new AuthenticationError('User account is inactive or deleted');
    }

    // Generate new access token
    const accessToken = this.generateAccessToken(user);

    // Optionally rotate refresh token (revoke old, create new)
    // This provides better security by detecting potential token reuse attacks
    await this.revokeRefreshToken(tokenId);
    const newRefreshToken = await this.createRefreshToken(userId);

    const expiresIn = this.getAccessTokenExpirationSeconds();
    const refreshTokenExpiresIn = this.getRefreshTokenExpirationSeconds();

    logger.info({ userId }, 'Access token refreshed successfully');

    return {
      token: accessToken,
      refreshToken: newRefreshToken,
      expiresIn,
      refreshTokenExpiresIn,
    };
  }

  /**
   * Check if token is blacklisted in Redis
   */
  async isTokenBlacklisted(token: string): Promise<boolean> {
    return await checkTokenBlacklisted(token);
  }

  /**
   * Request password reset
   * Generates a reset token, saves it in DB, and sends via Telegram
   */
  async requestPasswordReset(email: string): Promise<void> {
    logger.info({ email }, 'Password reset requested');

    // Find user by email
    const user = await prisma.user.findUnique({
      where: { email },
      select: {
        id: true,
        email: true,
        username: true,
        telegramChatId: true,
      },
    });

    if (!user) {
      // Don't reveal if email exists or not - log but return success
      logger.info({ email }, 'Password reset requested for non-existent email');
      return;
    }

    // Invalidate any existing unused tokens for this user
    await prisma.passwordResetToken.updateMany({
      where: {
        userId: user.id,
        used: false,
      },
      data: {
        used: true, // Mark as used to invalidate
      },
    });

    // Generate secure random token
    const resetToken = crypto.randomBytes(32).toString('hex');

    // Calculate expiration time
    const expiresAt = new Date();
    expiresAt.setHours(expiresAt.getHours() + PASSWORD_RESET_TOKEN_EXPIRES_HOURS);

    // Save token in database
    await prisma.passwordResetToken.create({
      data: {
        token: resetToken,
        userId: user.id,
        expiresAt,
      },
    });

    logger.info({ userId: user.id }, 'Password reset token created');

    // Send reset token via Telegram
    await this.sendPasswordResetToken(user, resetToken);
  }

  /**
   * Send password reset token via Telegram
   * If user has telegramChatId, send directly. Otherwise, send to admin for forwarding.
   */
  private async sendPasswordResetToken(
    user: { id: string; email: string; username: string; telegramChatId: string | null },
    token: string
  ): Promise<void> {
    if (!isTelegramEnabled()) {
      logger.warn({ userId: user.id }, 'Telegram disabled - password reset token cannot be sent');
      return;
    }

    const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
    const message = `
🔐 <b>Password Reset Request</b>

User: <b>${user.username}</b> (${user.email})

Reset token: <code>${token}</code>

Reset URL: ${resetUrl}

⏰ This token expires in <b>${PASSWORD_RESET_TOKEN_EXPIRES_HOURS} hour</b>.
`;

    try {
      // If user has Telegram chat ID, send directly
      if (user.telegramChatId) {
        await telegramClient.getClient().sendMessage(user.telegramChatId, message, {
          parseMode: 'HTML',
          disableWebPagePreview: true,
        });
        logger.info({ userId: user.id, chatId: user.telegramChatId }, 'Password reset token sent to user via Telegram');
      } else {
        // Send to admin who can forward to the user
        await telegramClient.sendToAdmin(message);
        logger.info({ userId: user.id }, 'Password reset token sent to admin (user has no Telegram chat ID)');
      }
    } catch (error) {
      logger.error({ error, userId: user.id }, 'Failed to send password reset token via Telegram');
      // Don't throw - we don't want to reveal if the operation failed
    }
  }

  /**
   * Reset password with token
   * Verifies token validity, updates password, and marks token as used
   */
  async resetPassword(token: string, newPassword: string): Promise<void> {
    logger.info({ tokenPrefix: token.substring(0, 10) }, 'Password reset attempt');

    // Find the reset token
    const resetToken = await prisma.passwordResetToken.findUnique({
      where: { token },
      include: {
        user: {
          select: {
            id: true,
            email: true,
            username: true,
          },
        },
      },
    });

    if (!resetToken) {
      logger.warn({ tokenPrefix: token.substring(0, 10) }, 'Password reset token not found');
      throw new ValidationError('Invalid or expired reset token');
    }

    // Check if token has been used
    if (resetToken.used) {
      logger.warn({ tokenId: resetToken.id }, 'Password reset token already used');
      throw new ValidationError('Reset token has already been used');
    }

    // Check if token has expired
    if (resetToken.expiresAt < new Date()) {
      logger.warn({ tokenId: resetToken.id, expiresAt: resetToken.expiresAt }, 'Password reset token expired');
      throw new ValidationError('Reset token has expired');
    }

    // Hash the new password
    const passwordHash = await this.hashPassword(newPassword);

    // Update user password and mark token as used
    await prisma.$transaction([
      prisma.user.update({
        where: { id: resetToken.userId },
        data: { passwordHash },
      }),
      prisma.passwordResetToken.update({
        where: { id: resetToken.id },
        data: { used: true },
      }),
    ]);

    logger.info({ userId: resetToken.userId }, 'Password reset successful');

    // Notify user via Telegram about password change
    await this.notifyPasswordChanged(resetToken.user);
  }

  /**
   * Notify user about password change via Telegram
   */
  private async notifyPasswordChanged(
    user: { id: string; email: string; username: string }
  ): Promise<void> {
    if (!isTelegramEnabled()) {
      return;
    }

    // Get user's Telegram chat ID
    const fullUser = await prisma.user.findUnique({
      where: { id: user.id },
      select: { telegramChatId: true },
    });

    const message = `
✅ <b>Password Changed Successfully</b>

Your password for <b>${user.username}</b> has been reset.

If you did not request this change, please contact support immediately.
`;

    try {
      if (fullUser?.telegramChatId) {
        await telegramClient.getClient().sendMessage(fullUser.telegramChatId, message, {
          parseMode: 'HTML',
        });
        logger.info({ userId: user.id }, 'Password change notification sent to user');
      } else {
        // Notify admin
        await telegramClient.sendToAdmin(`
✅ <b>Password Changed</b>

User: <b>${user.username}</b> (${user.email})
Password has been reset successfully.

Please forward this message to the user if needed.
`);
        logger.info({ userId: user.id }, 'Password change notification sent to admin');
      }
    } catch (error) {
      logger.error({ error, userId: user.id }, 'Failed to send password change notification');
    }
  }
}

// Export singleton instance
export const authService = new AuthService();
