import { ClientResponse } from '@sendgrid/mail';

import { FusionAuthClient, RegistrationResponse } from '@fusionauth/typescript-client';
import bodyParser from 'body-parser';
import { randomUUID } from 'crypto';
import dotenv from 'dotenv';
import express from 'express';
import passport from 'passport';
import { Strategy as CustomStrategy } from 'passport-custom';
import { shortenUrl } from '../controllers/urlShortener.js';
import { getUserById, getUserIdByEmail } from '../controllers/users.js';
import { sendDiscordFusionAuthMessage } from '../cron/notification.js';
import { sendIfAllRecipientsEmailable } from '../external-services/emailSender.js';
import {
  SG_TEMPLATE_PASSWORD_RESET,
  SG_TEMPLATE_VERIFY_EMAIL,
  SG_UNSUB_FORGOT_PASSWORD,
  SG_UNSUB_VERIFY_EMAIL,
} from '../external-services/sendgrid.js';
import expressAsync from '../server-utils/expressAsync.js';
import { logger } from '../server-utils/logger.js';
import query from '../server-utils/query.js';
import trackServerEvent, { ActivityType } from '../utils/trackServerEvent.js';
import { UrlParams } from '../utils/urlParamPassthrough.js';
import { verifyRecaptcha } from './recaptcha.js';
import runRegistrationAuthProvider, { runLoginAuthProvider } from './runAuthProvider.js';
import { newAccountsDisabled } from './newAccountsDisabled.js';

dotenv.config();
const landingUri = process.env.LANDING_URI || 'http://localhost:3000';
const shortUrlPrefix = process.env.URL_SHORTENER_PREFIX || 'http://localhost:3001/s';

const FUSIONAUTH_APPLICATION_ID = process.env.FUSIONAUTH_APPLICATION_ID;
const FUSIONAUTH_API_KEY = process.env.FUSIONAUTH_API_KEY;
const FUSIONAUTH_URI = process.env.FUSIONAUTH_URI || 'http://localhost:9011';
const fusionClient = new FusionAuthClient(FUSIONAUTH_API_KEY, FUSIONAUTH_URI);

const getFAUserById = async (userId: string): Promise<any> => {
  try {
    const existingUserCall = await fusionClient.retrieveUser(userId);
    if (existingUserCall.response.user) {
      logger.info('Existing user found');
      return existingUserCall.response.user;
    }
  } catch (e) {
    if (e.statusCode === 404) {
      logger.info('User not found in FusionAuth');
      return null;
    } else {
      logger.error(e);
      throw e;
    }
  }
};

const getFAUser = async (email: string): Promise<{ id: string; email: string } | null> => {
  try {
    const existingUserCall = await fusionClient.retrieveUserByEmail(email);
    if (existingUserCall.response.user) {
      logger.info('Existing user found', existingUserCall.response.user);
      return {
        id: existingUserCall.response.user.id,
        email: existingUserCall.response.user.email,
      };
    }
  } catch (e) {
    if (e.statusCode === 404) {
      logger.info('User not found in FusionAuth');
      return null;
    } else {
      logger.error(e);
      throw e;
    }
  }
};

const getFARegistration = async (userId: string, appId: string): Promise<RegistrationResponse | null> => {
  try {
    const userRegistration = await fusionClient.retrieveRegistration(userId, appId);
    return userRegistration.response;
  } catch (e) {
    if (e.statusCode === 404) {
      logger.info('User not registered with the app');
      return null;
    } else {
      logger.error(e);
      throw e;
    }
  }
};

const getFARegistrationByEmail = async (
  email: string,
  appId: string
): Promise<{
  registered: boolean;
  userId?: string;
  userData?: any;
}> => {
  let fusionAuthUserId = (await getFAUser(email))?.id;

  if (!fusionAuthUserId)
    return {
      registered: false,
    };

  let userRegistration = await getFARegistration(fusionAuthUserId, appId);

  if (!userRegistration)
    return {
      registered: false,
    };

  return {
    registered: true,
    userId: fusionAuthUserId,
    userData: userRegistration?.registration?.data,
  };
};

const getOrCreateFAUserId = async (email: string, name: string = '', password: string = ''): Promise<string> => {
  let fusionAuthUserId = (await getFAUser(email))?.id;
  if (!fusionAuthUserId) {
    logger.info('Creating new user');
    const createUser = await fusionClient.createUser(undefined, {
      // vertification is not performed by fusionauth.
      skipVerification: true,
      sendSetPasswordEmail: false,
      user: {
        email,
        password,
        data: {
          name: name,
        },
      },
    });
    fusionAuthUserId = createUser.response.user.id;
  }
  logger.info('User ID: ' + fusionAuthUserId);
  return fusionAuthUserId;
};

const registerUserWithFAApp = async (faUserId) => {
  if (newAccountsDisabled) return;
  await fusionClient.register(faUserId, {
    skipRegistrationVerification: true,
    // vertification is not performed by fusionauth.
    skipVerification: true,
    sendSetPasswordEmail: false,
    registration: {
      applicationId: FUSIONAUTH_APPLICATION_ID,
    },
  });
};

const checkEmailVerified = async (email: string): Promise<boolean> => {
  const results = await query(
    `
    SELECT * FROM email_verifications
    WHERE email = $1 AND verified = true
    LIMIT 1
  `,
    [email]
  );
  return results.rows.length > 0;
};

const createFakeVerification = async (email: string): Promise<void> => {
  await query(
    `
    INSERT INTO email_verifications (email, token, verified)
    VALUES ($1, $2, $3)
  `,
    [email, 'SOCIAL', true]
  );
};

const sendVerificationCode = async (
  email: string,
  name: string = '',
  urlParams: UrlParams = {}
): Promise<[ClientResponse, {}]> => {
  // Send email verification code
  logger.info('Sending verification email');
  // Generate verification code with letters and numbers
  const verificationCode = Math.random().toString(36).slice(2, 10).toUpperCase();
  // Save it into the database
  await query(`INSERT INTO email_verifications (email, token) VALUES ($1, $2)`, [email, verificationCode]);

  logger.info('Saved data, verification code: ' + verificationCode);
  const data = {
    // This will be used by the ChangePasswordModal on the frontend
    e: email,
    c: verificationCode,
  };
  // Base64 encode the data
  const encodedData = Buffer.from(JSON.stringify(data)).toString('base64');

  const stringParams = Object.entries(urlParams).reduce((acc, [key, value]) => {
    acc[key] = typeof value === 'string' ? value : String(value);
    return acc;
  }, {});

  const allParams: { [key: string]: string } = {
    ...stringParams,
    verifyEmail: encodedData,
  };

  const emailVerificationLink = `${landingUri}/?${new URLSearchParams(allParams)}`;

  const shortUrl = await shortenUrl(emailVerificationLink);

  // Send email to user
  return sendIfAllRecipientsEmailable({
    from: {
      email: 'hello@wavtool.com',
      name: 'WavTool',
    },
    replyTo: {
      email: 'hello@wavtool.com',
      name: 'WavTool Support',
    },
    personalizations: [
      {
        to: [
          {
            email,
          },
        ],
        dynamicTemplateData: {
          email_verification_link: shortUrl,
          first_name: name,
        },
      },
    ],
    templateId: SG_TEMPLATE_VERIFY_EMAIL,
    asm: {
      groupId: SG_UNSUB_VERIFY_EMAIL,
    },
  });
};

const registerUserHandler = async (req, res) => {
  /**
   * This function looks for a user on fusionauth first, and if not found, creates a new user.
   * Then it registers the user to the Application.
   * The reason it does this is because a user account can exist across multiple apps.
   * In our case, each environment is a separate app.
   * So if you change the password anywhere, it'll change on all environments.
   */
  const { email, password, name, token } = req.body;
  if (!email || !password || !name || !token) {
    res.status(400).send('Error: Missing required fields.');
    return;
  }

  if (await verifyRecaptcha(token)) {
    logger.info('Recaptcha verified');
  } else {
    logger.info('Recaptcha failed');
    res.status(400).send('Error: Could not create user.');
    return;
  }

  try {
    let fusionAuthUserId = await getOrCreateFAUserId(email, name, password);
    logger.info('Checking if registered with app');
    let userRegistration = await getFARegistration(fusionAuthUserId, FUSIONAUTH_APPLICATION_ID);
    if (userRegistration) {
      logger.info('User is registered with app');
      // Check if the user exists in the database
      const userId = await getUserIdByEmail(email);
      if (userId) {
        trackServerEvent(userId, {
          name: ActivityType.ExistingEmailSignupAttempt,
          properties: {},
        });
      }

      // Other option: lie to the user so they can't tell if the email is registered or not
      // return res.status(202).send('Success: User registered.');
      return res.status(204).send('Email address already registered.');
    } else {
      if (newAccountsDisabled) {
        logger.info('New accounts are disabled');
        return res.status(403).send('Error: New accounts are disabled.');
      }
      logger.info('User is not registered with app');
      logger.info('Registering user with app');
      await registerUserWithFAApp(fusionAuthUserId);
      // Check if the user exists in the database and log activity
      const userId = await getUserIdByEmail(email);
      if (userId) {
        // This doesn't use the user_identities table because it's just for logging
        trackServerEvent(userId, {
          name: ActivityType.AlternateProviderSignup,
          properties: {},
        });
      }
      logger.info('User registered with app');
    }

    const projectId = req.query.project_id;
    const backToLanding = req.query.back_to_landing;
    const urlParams = {};
    if (projectId) urlParams['project_id'] = projectId;
    if (!!backToLanding) urlParams['back_to_landing'] = 'true';

    // Send email to user
    sendVerificationCode(email, name, urlParams)
      .then(() => {
        logger.info('Verification code sent!');
        res.status(202).send('Success: User registered.');
      })
      .catch((e) => {
        console.error(e);
        sendDiscordFusionAuthMessage(`Error sending verification code for ${email}:\n${JSON.stringify(e)}`);
        res.status(500).send('Error: Could not send verification code.\n' + JSON.stringify(e));
      });
  } catch (e) {
    console.error(e);
    sendDiscordFusionAuthMessage(`Error registering user ${email}:\n${JSON.stringify(e)}`);
    res.status(500).send('Error: Could not create user.\n' + JSON.stringify(e));
  }
};

/**
 * This function checks if the user is registered with FusionAuth, and if not, registers them.
 * Then it sends a password reset email to the user.
 * It will only send the password reset email if the user is registered with the application or fusionauth
 * This allows people who are registered with the app but not fusionauth to still set a password
 * @param res The response object, used to send the result of the operation
 * @param unsafeEmail The email address of the user who needs to reset their password
 */
export const sendResetPasswordEmail = async (res, unsafeEmail: string) => {
  try {
    const userId = await getUserIdByEmail(unsafeEmail);
    if (!userId) {
      // Only proceed with reset logic if a user row exists (even though that user might not have FusionAuthed yet.)
      throw { statusCode: 404 };
    }

    const user = await getUserById(userId);

    // Check if user is registered for application first
    let userRegistered = false;
    let userFAId = null;
    const registered = await getFARegistrationByEmail(unsafeEmail, FUSIONAUTH_APPLICATION_ID);
    if (registered.registered) {
      userRegistered = true;
      userFAId = registered.userId;
    }

    const safeEmail = user?.email || (await getFAUser(unsafeEmail))?.email;

    if (!userRegistered) {
      logger.info('User has account in database, but not FusionAuth. Registering with FusionAuth');
      // If user has account, create account in FusionAuth
      trackServerEvent(userId, {
        name: ActivityType.AlternateProviderSignup,
        properties: {},
      });
      userFAId = await getOrCreateFAUserId(safeEmail, (await getUserById(userId))?.username, randomUUID());
      let userRegistration = await getFARegistration(userFAId, FUSIONAUTH_APPLICATION_ID);
      if (!userRegistration) {
        await registerUserWithFAApp(userFAId);
      }
      // Set user email as verified
      if (!(await checkEmailVerified(safeEmail))) {
        await createFakeVerification(safeEmail);
      }
    }

    let name = user?.username;
    if (!name) {
      const faUser = await getFAUserById(registered.userId);
      name = faUser?.data?.name || '';
    }

    logger.info('Sending password reset email for user', safeEmail);
    const forgotPasswordResponse = await fusionClient.forgotPassword({
      loginId: safeEmail,
      sendForgotPasswordEmail: false,
    });
    const changePasswordId = forgotPasswordResponse.response.changePasswordId;
    const data = {
      // This will be used by the ChangePasswordModal on the frontend
      e: safeEmail,
      c: changePasswordId,
    };
    // Base64 encode the data
    const encodedData = Buffer.from(JSON.stringify(data)).toString('base64');

    sendIfAllRecipientsEmailable({
      from: {
        email: 'hello@wavtool.com',
        name: 'WavTool',
      },
      replyTo: {
        email: 'hello@wavtool.com',
        name: 'WavTool Support',
      },
      personalizations: [
        {
          to: [
            {
              email: safeEmail,
            },
          ],
          dynamicTemplateData: {
            password_reset_link: `${landingUri}/?resetPassword=${encodedData}`,
            first_name: name,
          },
        },
      ],
      templateId: SG_TEMPLATE_PASSWORD_RESET,
      asm: {
        groupId: SG_UNSUB_FORGOT_PASSWORD,
      },
    })
      .then(() => {
        logger.info('Password reset email sent successfully!');
        res.status(200).send({ message: 'success' });
      })
      .catch((e) => {
        console.error(e);
        sendDiscordFusionAuthMessage(`Error sending password reset email for ${unsafeEmail}:\n${JSON.stringify(e)}`);
        res.status(500).send('Error: Could not send password reset email.\n' + JSON.stringify(e));
      });
  } catch (e) {
    if (e.statusCode === 404) {
      // User not found
      logger.info(`User with email ${unsafeEmail} not found`);
      sendDiscordFusionAuthMessage(`Unregistered user attempted password reset: ${unsafeEmail}`);
      res.status(200).send({ message: 'success' }); // Don't reveal if the user is registered or not
    } else {
      console.error(e);
      sendDiscordFusionAuthMessage(`Error sending password reset email for ${unsafeEmail}:\n${JSON.stringify(e)}`);
      res.status(500).send('Error: Could not send password reset email.\n' + JSON.stringify(e));
    }
  }
};

/**
 * This sends the reset password email to the user and is used by users who aren't logged in
 */
const forgotPasswordHandler = async (req, res) => {
  const { email: unsafeEmail, token } = req.body;
  if (!unsafeEmail || !token) {
    res.status(400).send('Error: Missing required fields.');
    return;
  }

  if (await verifyRecaptcha(token)) {
    logger.info('Recaptcha verified');
  } else {
    logger.info('Recaptcha failed');
    res.status(400).send('Error: Could not send password reset email.');
  }

  logger.info('Sending password reset email for user-entered email', unsafeEmail);
  sendResetPasswordEmail(res, unsafeEmail);
};

const resendEmailVerificationHandler = async (req, res) => {
  const { email, token } = req.body;
  if (!email || !token) {
    res.status(400).send('Error: Missing required fields.');
    return;
  }

  if (await verifyRecaptcha(token)) {
    logger.info('Recaptcha verified');
  } else {
    logger.info('Recaptcha failed');
    res.status(400).send('Error: Could not send verification email.');
    return;
  }

  const registered = await getFARegistrationByEmail(email, FUSIONAUTH_APPLICATION_ID);
  if (!registered.registered) {
    logger.info('User not registered with FusionAuth');
    // Note: this reveals if a user is registered
    res.status(404).send('Error: Could not send verification email.');
    return;
  }

  logger.info('Resending verification email for user', email);

  // Check if the user doesn't already have a successful verification
  const results = await query(
    `
    SELECT * FROM email_verifications
    WHERE email = $1 AND verified = true
  `,
    [email]
  );

  if (results.rows.length) {
    logger.info('User already verified');
    res.status(200).send({ message: 'success' });
    return;
  }

  // Check that there haven't been 3 attempts in the last 30 minutes.
  const results3 = await query(
    `
    SELECT * FROM email_verifications
    WHERE email = $1
    AND created_at > NOW() - INTERVAL '30 minutes'
  `,
    [email]
  );

  if (results3.rows.length >= 3) {
    logger.info('User has exceeded the number of attempts');
    res.status(429).send({ message: 'try again later' });
    return;
  }

  // Invalidate all existing verification codes for this email
  await query(
    `
    UPDATE email_verifications
    SET invalidated = true
    WHERE email = $1
  `,
    [email]
  );

  // Send email verification code
  const projectId = req.query.project_id;
  const urlParams = {};
  if (projectId) urlParams['project_id'] = projectId;
  sendVerificationCode(email, undefined, urlParams)
    .then(() => {
      logger.info('Verification email sent successfully!');
      res.status(200).send({ message: 'success' });
    })
    .catch((e) => {
      console.error(e);
      sendDiscordFusionAuthMessage(`Error resending verification email for ${email}:\n${JSON.stringify(e)}`);
      res.status(500).send('Error: Could not send verification email.');
    });
};

// This strategy assumes the user is already verified
const fusionAuthLoginStrategy = new CustomStrategy(async function (req, done) {
  // note: req.body is manually set to include only email & token (jwt) by checkUserAndVerifiedMiddleware.
  const { email, token } = req.body;
  if (!token) {
    return done('Error: Missing required fields.');
  }

  try {
    const retrieveUserResponse = await fusionClient.retrieveUserUsingJWT(token);
    logger.info('Retrieve user response: ' + JSON.stringify(retrieveUserResponse));
    const { user } = retrieveUserResponse.response;
    if (!user) {
      return done('Error: Could not login user.');
    }

    return runLoginAuthProvider('fusionauth', user, done);
  } catch (e) {
    sendDiscordFusionAuthMessage(`Error logging in user with provided email "${email}": ${JSON.stringify(e)}`);
    return done(JSON.stringify(e));
  }
});

const fusionAuthVerifyStrategy = new CustomStrategy(async function (req, done) {
  const { email, code } = req.body;
  if (!email || !code) {
    return done('Error: Missing required fields.');
  }

  // Check if there's an active verification code for this email
  const results = await query(
    `
    SELECT id, verified FROM email_verifications
      WHERE email = $1
      AND token = $2
      AND invalidated = false
      AND expires_at > NOW()
    ORDER BY expires_at ASC
    LIMIT 1;
    `,
    [email, code]
  );
  if (!results.rows.length) {
    logger.info('No verification code found');
    return done('Error: Could not verify email');
  } else if (results.rows[0].verified) {
    logger.info('Verification code already used');
    return done('Error: Verification code already used');
  }

  // Mark the verification code as used
  const verificationId = results.rows[0].id;
  await query(`UPDATE email_verifications SET verified = true WHERE id = $1`, [verificationId]);

  try {
    logger.info('Verifying user', email);
    const passwordlessCode = await fusionClient.startPasswordlessLogin({
      applicationId: FUSIONAUTH_APPLICATION_ID,
      loginId: email,
    });

    if (passwordlessCode.statusCode !== 200) {
      return done('Error: Could not get a code');
    }

    logger.info('Starting passwordless login');

    const { code: loginCode } = passwordlessCode.response;

    const loginResponse = await fusionClient.passwordlessLogin({
      code: loginCode,
      twoFactorTrustId: code,
    });

    if (loginResponse.statusCode !== 200) {
      return done('Error: Could not login');
    }

    const { token } = loginResponse.response;

    logger.info('Retrieving user from JWT');
    const retrieveUserResponse = await fusionClient.retrieveUserUsingJWT(token);
    logger.info('Retrieve user response: ' + JSON.stringify(retrieveUserResponse));
    const { user } = retrieveUserResponse.response;
    if (!user) {
      return done('Error: Could not login user.');
    }

    return runRegistrationAuthProvider('fusionauth', user, done);
  } catch (e) {
    logger.info(e);
    return done(JSON.stringify(e));
  }
});

const resetPasswordHandler = async (req, res) => {
  const { email, password, changePasswordId } = req.body;
  if (!email || !password || !changePasswordId) {
    res.status(400).send('Error: Missing required fields.');
    return;
  }
  logger.info('Resetting password for user', email);

  try {
    const changePasswordResponse = await fusionClient.changePassword(changePasswordId, {
      loginId: email,
      password,
      changePasswordId,
    });
    logger.info('Change password response: ' + JSON.stringify(changePasswordResponse));
    res.status(200).send({ message: 'success' });
  } catch (e) {
    console.error(e);
    sendDiscordFusionAuthMessage(`Error resetting password for ${email}:\n${JSON.stringify(e)}`);
    res.status(500).send('Error: Could not reset password.\n' + JSON.stringify(e));
  }
};

const checkUserAndVerifiedMiddleware = async (req, res, next) => {
  const { email, password, token } = req.body;
  if (!email || !password || !token) {
    return res.status(400).send('Error: Missing required fields.');
  }

  if (await verifyRecaptcha(token)) {
    logger.info('Recaptcha verified');
  } else {
    logger.info('Recaptcha failed');
    return res.status(404).send('Error: Could not login user.');
  }

  try {
    // All these checks are within fusionauth
    // Check if user is registered for application first
    const registered = await getFARegistrationByEmail(email, FUSIONAUTH_APPLICATION_ID);
    if (!registered.registered) {
      logger.info('User not registered with app');

      // Check if the user exists in the database
      const userId = await getUserIdByEmail(email);
      if (userId) {
        trackServerEvent(userId, {
          name: ActivityType.IncorrectProviderLogin,
          properties: {},
        });
      }

      return res.status(404).send('Error: Could not login user.');
    }

    logger.info('Trying to log in user', email);
    const loginResponse = await fusionClient.login({
      loginId: email,
      password,
      applicationId: FUSIONAUTH_APPLICATION_ID,
    });
    logger.info('Login response: ' + JSON.stringify(loginResponse));
    const { token: faToken } = loginResponse.response;
    if (!faToken) {
      return res.status(404).send('Error: Could not login user.');
    } else {
      // This is the only way I could figure out how to pass data to the next middleware,
      // which is the passport middleware
      req.body = {
        email,
        token: faToken,
      };
    }

    logger.info('Checking if user is verified');
    // Check if user is verified from database
    const verified = await checkEmailVerified(email);

    if (!verified) {
      logger.info('User not verified');
      return res.status(403).send('User not verified');
    }
    next();
  } catch (e) {
    if (e.statusCode === 404) {
      return res.status(404).send('Error: Could not login user.');
    }
    sendDiscordFusionAuthMessage(`Error logging in user ${email}: ${JSON.stringify(e)}`);
    return res.status(500).send('Internal Server Error');
  }
};

export default (app, failureRedirect, successRedirect) => {
  const postRouter = express.Router();
  postRouter.use(bodyParser.json());

  postRouter.post('/register', expressAsync(registerUserHandler));
  postRouter.post('/forgot', expressAsync(forgotPasswordHandler));
  postRouter.post('/resend-verification', expressAsync(resendEmailVerificationHandler));
  postRouter.post('/reset-password', expressAsync(resetPasswordHandler));
  app.use('/auth/email/', postRouter);

  // The login endpoint needs to use passport to set the cookie if the user successfully logs in
  passport.use('fusionauth-login', fusionAuthLoginStrategy);
  app.post(
    '/auth/email/login',
    expressAsync(checkUserAndVerifiedMiddleware),
    (req, res, next) => {
      passport.authenticate('fusionauth-login', { successRedirect: null, failureRedirect: failureRedirect })(
        req,
        res,
        next
      );
    },
    (req, res) => {
      logger.info('FusionAuth success');
      const duration = req.query.duration;
      let redirectUrl = successRedirect;
      const backToLanding = req.query.back_to_landing;
      res.send({
        redirected: backToLanding ? false : true,
        url: redirectUrl,
      });
    }
  );

  passport.use('fusionauth-verify', fusionAuthVerifyStrategy);
  app.post(
    '/auth/email/verify',
    passport.authenticate('fusionauth-verify', { successRedirect: null, failureRedirect: failureRedirect }),
    (req, res) => {
      logger.info('FusionAuth success');
      const duration = req.query.duration;
      let redirectUrl = successRedirect;
      const backToLanding = req.query.back_to_landing;
      if (backToLanding) {
        redirectUrl = `${landingUri}/logged-in`;
      }
      res.send({
        redirected: true,
        url: redirectUrl,
      });
    }
  );
};
