import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
import {randomBytes} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
import * as React from 'react';

import {
  DASHBOARD_URI,
  DISABLE_SIGNUPS,
  EMAIL_VERIFICATION_RATE_LIMIT,
  EMAIL_VERIFICATION_RATE_WINDOW,
  GITHUB_OAUTH_ENABLED,
  GOOGLE_OAUTH_ENABLED,
  LANDING_URI,
  PASSWORD_RESET_RATE_LIMIT,
  PLUNK_ENABLED,
  TOKEN_EXPIRY_SECONDS,
  VERIFY_EMAIL_ON_SIGNUP,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
import {jwt, parseJwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js';
import {CatchAsync} from '../utils/asyncHandler.js';

@Controller('auth')
export class Auth {
  @Post('login')
  @CatchAsync
  public async login(req: Request, res: Response, _next: NextFunction) {
    const {email, password} = AuthenticationSchemas.login.parse(req.body);

    const user = await UserService.email(email);

    if (!user) {
      return res.json({success: false, data: 'Incorrect email or password'});
    }

    if (user.type === 'PASSWORD' && !user.password) {
      return res.json({success: 'redirect', redirect: `/auth/reset?id=${user.id}`});
    }

    const verified = await AuthService.verifyCredentials(email, password);

    if (!verified) {
      return res.json({success: false, data: 'Incorrect email or password'});
    }

    await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60);

    const token = jwt.sign(user.id);
    const cookie = UserService.cookieOptions();

    return res
      .cookie(UserService.COOKIE_NAME, token, cookie)
      .json({success: true, data: {id: user.id, email: user.email}});
  }

  @Post('signup')
  @CatchAsync
  public async signup(req: Request, res: Response, _next: NextFunction) {
    // Check if signups are disabled
    if (DISABLE_SIGNUPS) {
      return res.json({
        success: false,
        data: 'New user signups are currently disabled',
      });
    }

    const {email, password} = AuthenticationSchemas.login.parse(req.body);

    // Verify email is valid and not disposable/plus-addressed (if verification enabled)
    if (VERIFY_EMAIL_ON_SIGNUP) {
      const verification = await EmailVerificationService.verifyEmail(email);

      if (
        verification.isDisposable ||
        verification.isPlusAddressed ||
        !verification.domainExists ||
        !verification.hasMxRecords
      ) {
        // Build list of reasons for notification
        const reasons: string[] = [];
        if (verification.isDisposable) reasons.push('disposable email');
        if (verification.isPlusAddressed) reasons.push('plus addressing');
        if (!verification.domainExists) reasons.push('domain does not exist');
        if (!verification.hasMxRecords) reasons.push('no MX records');

        // Send notification about failed signup attempt
        await NtfyService.notifyFailedSignupAttempt(email, reasons);

        return res.json({
          success: false,
          data: 'This email address cannot be used for signup',
        });
      }
    }

    const user = await UserService.email(email);

    if (user) {
      return res.json({
        success: false,
        data: 'That email is already associated with another user',
      });
    }

    const created_user = await prisma.user.create({
      data: {
        email,
        password: await AuthService.generateHash(password),
        type: 'PASSWORD',
        // Auto-verify email if platform emails are disabled
        emailVerified: !PLUNK_ENABLED,
      },
    });

    await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);

    // Send notification about new user signup
    await NtfyService.notifyUserSignup(created_user.email, created_user.id);

    // Send email verification if platform emails are enabled
    if (PLUNK_ENABLED) {
      const verificationToken = randomBytes(32).toString('hex');
      await redis.setex(
        Keys.User.emailVerificationToken(verificationToken),
        TOKEN_EXPIRY_SECONDS,
        JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
      );

      const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
      await sendPlatformEmail(
        created_user.email,
        'Verify your email address',
        React.createElement(EmailVerificationEmail, {
          email: created_user.email,
          verificationUrl,
          landingUrl: LANDING_URI,
        }),
      );
    }

    const token = jwt.sign(created_user.id);
    const cookie = UserService.cookieOptions();

    return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
      success: true,
      data: {id: created_user.id, email: created_user.email},
    });
  }

  @Get('logout')
  public logout(req: Request, res: Response) {
    res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
    return res.json(true);
  }

  @Get('oauth-config')
  public oauthConfig(req: Request, res: Response) {
    return res.json({
      success: true,
      data: {
        github: GITHUB_OAUTH_ENABLED,
        google: GOOGLE_OAUTH_ENABLED,
      },
    });
  }

  @Post('verify-email')
  @CatchAsync
  public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
    const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);

    // Look up token in Redis
    const data = await redis.get(Keys.User.emailVerificationToken(token));

    if (!data) {
      throw new BadRequest('Invalid or expired verification token');
    }

    const {userId} = JSON.parse(data);

    // Update user
    await prisma.user.update({
      where: {id: userId},
      data: {emailVerified: true},
    });

    // Delete token (single use) and invalidate cache
    await redis.del(Keys.User.emailVerificationToken(token));
    await redis.del(Keys.User.id(userId));

    return res.json({success: true, data: {message: 'Email verified successfully'}});
  }

  @Post('request-verification')
  @CatchAsync
  public async requestVerification(req: Request, res: Response, _next: NextFunction) {
    const userId = parseJwt(req);
    const user = await UserService.id(userId);

    if (!user) {
      throw new NotAuthenticated();
    }

    if (user.emailVerified) {
      return res.json({success: true, data: {message: 'Email already verified'}});
    }

    // Check rate limit
    const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
    const count = await redis.get(rateLimitKey);

    if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
      throw new RateLimitError('Too many verification emails sent. Please try again later.');
    }

    // Generate token
    const token = randomBytes(32).toString('hex');
    await redis.setex(
      Keys.User.emailVerificationToken(token),
      TOKEN_EXPIRY_SECONDS,
      JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
    );

    // Send email
    const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
    await sendPlatformEmail(
      user.email,
      'Verify your email address',
      React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
    );

    // Increment rate limit
    if (count) {
      await redis.incr(rateLimitKey);
    } else {
      await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
    }

    return res.json({success: true, data: {message: 'Verification email sent'}});
  }

  @Post('request-password-reset')
  @CatchAsync
  public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
    const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);

    // Check rate limit
    const rateLimitKey = Keys.User.passwordResetRateLimit(email);
    const count = await redis.get(rateLimitKey);

    if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
      // Still return success to prevent enumeration
      return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
    }

    // Look up user
    const user = await UserService.email(email);

    // Only send email if user exists and is PASSWORD type
    if (user && user.type === 'PASSWORD') {
      const token = randomBytes(32).toString('hex');
      await redis.setex(
        Keys.User.passwordResetToken(token),
        TOKEN_EXPIRY_SECONDS,
        JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
      );

      const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
      await sendPlatformEmail(
        user.email,
        'Reset your password',
        React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
      );

      // Increment rate limit
      if (count) {
        await redis.incr(rateLimitKey);
      } else {
        await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
      }
    }

    // Always return success (prevent enumeration)
    return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
  }

  @Post('reset-password')
  @CatchAsync
  public async resetPassword(req: Request, res: Response, _next: NextFunction) {
    const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);

    // Look up token
    const data = await redis.get(Keys.User.passwordResetToken(token));

    if (!data) {
      throw new BadRequest('Invalid or expired reset token');
    }

    const {userId} = JSON.parse(data);

    // Get user and verify type
    const user = await prisma.user.findUnique({where: {id: userId}});

    if (!user || user.type !== 'PASSWORD') {
      throw new BadRequest('Invalid reset token');
    }

    // Hash new password and update
    const hashedPassword = await AuthService.generateHash(newPassword);
    await prisma.user.update({
      where: {id: userId},
      data: {password: hashedPassword},
    });

    // Delete token and invalidate cache
    await redis.del(Keys.User.passwordResetToken(token));
    await redis.del(Keys.User.id(userId));

    return res.json({success: true, data: {message: 'Password reset successfully'}});
  }
}
