import { Server, LoginState, TFAOption, TFAType } from "@hibas123/openauth-internalapi";
import type { SessionContext } from "../index.js";
import Logging from "@hibas123/nodelogging";
import User, { IUser } from "../../../models/user.js";
import moment from "moment";
import crypto from "node:crypto";
import TwoFactor, { ITwoFactor, IWebAuthn } from "../../../models/twofactor.js";
import speakeasy from "speakeasy";
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
import config from "../../../config.js";

//FIXME: There are a lot of uneccessary database requests happening here. Since this is not a "hot" path, it should not matter to much, but it should be fixed nontheless.

export default class LoginService extends Server.LoginService<SessionContext> {
   private async getUser(username: string): Promise<IUser> {
      let user = await User.findOne({ username: username.toLowerCase() });
      if (!user) {
         throw new Error("User not found");
      }
      return user;
   }

   private async getLoginState(ctx: SessionContext): Promise<LoginState> {
      if (ctx.user && ctx.session.validated) {
         return {
            success: true
         }
      } else if (ctx.session.login_state) {
         //TODO: Check login_state expiration or so
         if (ctx.session.login_state.username) {
            let user = await this.getUser(ctx.session.login_state.username);
            if (!ctx.session.login_state.password_correct) {
               let passwordSalt = user.salt;
               return {
                  success: false,
                  username: ctx.session.login_state.username,
                  password: false,
                  passwordSalt: passwordSalt,
               }
            } else {
               let tfa = await this.getTwoFactors(await this.getUser(ctx.session.login_state.username))
               if (tfa.length <= 0) {
                  ctx.session.user_id = user._id.toString();
                  ctx.session.login_state = undefined;
                  Logging.warn("This should have been set somewhere else!");
                  return {
                     success: true,
                  }
               } else {
                  return {
                     success: false,
                     username: ctx.session.login_state.username,
                     password: true,
                     requireTwoFactor: tfa,
                  }
               }
            }
         }
      } else {
         return {
            success: false,
            username: undefined,
            password: false,
         }
      }
   }

   private async getTwoFactors(user: IUser): Promise<TFAOption[]> {
      let twofactors = await TwoFactor.find({
         user: user._id,
         valid: true
      })

      return twofactors.map<TFAOption>(tf => {
         return {
            id: tf._id.toString(),
            name: tf.name,
            tfatype: tf.type as number,
         }
      })

   }

   private async enableSession(ctx: SessionContext) {
      let user = await this.getUser(ctx.session.login_state.username);
      ctx.user = user;
      ctx.session.user_id = user._id.toString();
      ctx.session.login_state = undefined;
      ctx.session.validated = true;
   }

   GetState(ctx: SessionContext): Promise<LoginState> {
      return this.getLoginState(ctx);
   }

   async Start(username: string, ctx: SessionContext): Promise<LoginState> {
      let user = await this.getUser(username);

      ctx.session.login_state = {
         username: username,
         password_correct: false,
      }

      return this.getLoginState(ctx);
   }

   async UsePassword(password_hash: string, date: number, ctx: SessionContext): Promise<LoginState> {
      if (!ctx.session.login_state) {
         throw new Error("No login state. Call Start() first.");
      }

      let user = await this.getUser(ctx.session.login_state.username);

      if (date <= 0) {
         if (user.password !== password_hash) {
            throw new Error("Password incorrect");
         }
      } else {
         if (
            !moment(date).isBetween(
               moment().subtract(1, "minute"),
               moment().add(1, "minute")
            )
         ) {
            throw new Error("Date incorrect. Please check your devices time!");
         } else {
            let upw = crypto
               .createHash("sha512")
               .update(user.password + date.toString())
               .digest("hex");

            if (upw !== password_hash) {
               throw new Error("Password incorrect");
            }
         }
      }

      ctx.session.login_state.password_correct = true;

      let tfas = await this.getTwoFactors(user);
      if (tfas.length <= 0) {
         await this.enableSession(ctx);
      }

      return this.getLoginState(ctx);
   }


   private async getAndCheckTFA<T extends ITwoFactor>(id: string, shouldType: TFAType, ctx: SessionContext): Promise<T> {
      if (!ctx.session.login_state) {
         throw new Error("No login state. Call Start() first.");
      }

      let user = await this.getUser(ctx.session.login_state.username);

      let tfa = await TwoFactor.findById(id);
      if (!tfa || tfa.user.toString() != user._id.toString()) {
         throw new Error("Two factor not found");
      }

      if (tfa.type != shouldType as number) {
         throw new Error("Two factor is not the correct type!");
      }

      if (!tfa.valid) {
         throw new Error("Two factor is not valid");
      }

      if (tfa.expires && moment().isAfter(moment(tfa.expires))) {
         throw new Error("Two factor is expired");
      }

      return tfa as T;
   }

   async UseTOTP(id: string, code: string, ctx: SessionContext): Promise<LoginState> {
      let tfa = await this.getAndCheckTFA(id, TFAType.TOTP, ctx);

      let valid = speakeasy.totp.verify({
         secret: tfa.data,
         encoding: "base32",
         token: code,
      });

      if (!valid) {
         throw new Error("Code incorrect");
      }

      await this.enableSession(ctx);

      return this.getLoginState(ctx);
   }
   async UseBackupCode(id: string, code: string, ctx: SessionContext): Promise<LoginState> {
      let tfa = await this.getAndCheckTFA(id, TFAType.BACKUP_CODE, ctx);

      if (tfa.data.indexOf(code) < 0) {
         throw new Error("Code incorrect");
      }

      tfa.data = tfa.data.filter(c => c != code);

      await TwoFactor.save(tfa);
      //TODO: handle the case where the last backup code is used

      await this.enableSession(ctx);

      return this.getLoginState(ctx);
   }

   async GetWebAuthnChallenge(id: string, ctx: SessionContext): Promise<string> {
      let tfa = await this.getAndCheckTFA<IWebAuthn>(id, TFAType.WEBAUTHN, ctx);

      const rpID = new URL(config.core.url).hostname;

      let options = await generateAuthenticationOptions({
         timeout: 60000,
         userVerification: "discouraged",
         rpID,
         allowCredentials: [{
            id: typeof tfa.data.device.credentialID === "string" ? tfa.data.device.credentialID : Buffer.from(tfa.data.device.credentialID.buffer).toString("base64url"),
            transports: tfa.data.device.transports
         }]
      })

      ctx.session.login_state.webauthn_challenge = options.challenge;

      Logging.debug("Challenge", options, tfa, tfa.data.device.credentialID);

      return JSON.stringify(options);
   }

   async UseWebAuthn(id: string, response: string, ctx: SessionContext): Promise<LoginState> {
      let tfa = await this.getAndCheckTFA<IWebAuthn>(id, TFAType.WEBAUTHN, ctx);

      if (!ctx.session.login_state.webauthn_challenge) {
         throw new Error("No webauthn challenge");
      }

      let rpID = new URL(config.core.url).hostname;

      let verification = await verifyAuthenticationResponse({
         response: JSON.parse(response),
         credential: {
            id: typeof tfa.data.device.credentialID === "string" ? tfa.data.device.credentialID : Buffer.from(tfa.data.device.credentialID.buffer).toString("base64url"),
            publicKey: Buffer.from(tfa.data.device.credentialPublicKey.buffer),
            counter: tfa.data.device.counter,
            transports: tfa.data.device.transports
         },
         expectedChallenge: ctx.session.login_state.webauthn_challenge,
         expectedOrigin: config.core.url,
         expectedRPID: rpID,
         requireUserVerification: false
      })

      if (verification.verified) {
         tfa.data.device.counter = verification.authenticationInfo.newCounter;
         await TwoFactor.save(tfa);
      }

      await this.enableSession(ctx);

      return this.getLoginState(ctx);
   }
}
