/*
 * Copyright 2020 Eike K. & Contributors
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

package docspell.backend.auth

import cats.data.{EitherT, OptionT}
import cats.effect._
import cats.implicits._

import docspell.backend.auth.Login._
import docspell.common._
import docspell.store.Store
import docspell.store.queries.QLogin
import docspell.store.records._
import docspell.totp.{OnetimePassword, Totp}

import org.log4s.getLogger
import org.mindrot.jbcrypt.BCrypt
import scodec.bits.ByteVector

trait Login[F[_]] {

  def loginExternal(config: Config)(accountId: AccountId): F[Result]

  def loginSession(config: Config)(sessionKey: String): F[Result]

  def loginUserPass(config: Config)(up: UserPass): F[Result]

  def loginSecondFactor(config: Config)(sf: SecondFactor): F[Result]

  def loginRememberMe(config: Config)(token: String): F[Result]

  def loginSessionOrRememberMe(
      config: Config
  )(sessionKey: String, rememberId: Option[String]): F[Result]

  def removeRememberToken(token: String): F[Int]
}

object Login {
  private[this] val logger = getLogger

  case class Config(
      serverSecret: ByteVector,
      sessionValid: Duration,
      rememberMe: RememberMe
  )

  case class RememberMe(enabled: Boolean, valid: Duration) {
    val disabled = !enabled
  }

  case class UserPass(user: String, pass: String, rememberMe: Boolean) {
    def hidePass: UserPass =
      if (pass.isEmpty) copy(pass = "<none>")
      else copy(pass = "***")
  }

  final case class SecondFactor(
      token: AuthToken,
      rememberMe: Boolean,
      otp: OnetimePassword
  )

  sealed trait Result {
    def toEither: Either[String, AuthToken]
  }
  object Result {
    case class Ok(session: AuthToken, rememberToken: Option[RememberToken])
        extends Result {
      val toEither = Right(session)
    }
    case object InvalidAuth extends Result {
      val toEither = Left("Authentication failed.")
    }
    case object InvalidTime extends Result {
      val toEither = Left("Authentication failed.")
    }
    case object InvalidFactor extends Result {
      val toEither = Left("Authentication requires second factor.")
    }

    def ok(session: AuthToken, remember: Option[RememberToken]): Result =
      Ok(session, remember)
    def invalidAuth: Result = InvalidAuth
    def invalidTime: Result = InvalidTime
    def invalidFactor: Result = InvalidFactor
  }

  def apply[F[_]: Async](store: Store[F], totp: Totp): Resource[F, Login[F]] =
    Resource.pure[F, Login[F]](new Login[F] {

      private val logF = Logger.log4s(logger)

      def loginExternal(config: Config)(accountId: AccountId): F[Result] =
        for {
          data <- store.transact(QLogin.findUser(accountId))
          _ <- logF.trace(s"Account lookup: $data")
          res <-
            if (data.exists(checkNoPassword(_, Set(AccountSource.OpenId))))
              doLogin(config, accountId, false)
            else Result.invalidAuth.pure[F]
        } yield res

      def loginSession(config: Config)(sessionKey: String): F[Result] =
        AuthToken.fromString(sessionKey) match {
          case Right(at) =>
            if (at.sigInvalid(config.serverSecret))
              logF.warn("Cookie signature invalid!") *> Result.invalidAuth.pure[F]
            else if (at.isExpired(config.sessionValid))
              logF.debug("Auth Cookie expired") *> Result.invalidTime.pure[F]
            else if (at.requireSecondFactor)
              logF.debug("Auth requires second factor!") *> Result.invalidFactor.pure[F]
            else Result.ok(at, None).pure[F]
          case Left(err) =>
            logF.debug(s"Invalid session token: $err") *> Result.invalidAuth.pure[F]
        }

      def loginUserPass(config: Config)(up: UserPass): F[Result] =
        AccountId.parse(up.user) match {
          case Right(acc) =>
            for {
              data <- store.transact(QLogin.findUser(acc))
              _ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
              res <-
                if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe)
                else Result.invalidAuth.pure[F]
            } yield res
          case Left(_) =>
            logF.info(s"User authentication failed for: ${up.hidePass}") *>
              Result.invalidAuth.pure[F]
        }

      def loginSecondFactor(config: Config)(sf: SecondFactor): F[Result] = {
        val okResult: F[Result] =
          for {
            _ <- store.transact(RUser.updateLogin(sf.token.account))
            newToken <- AuthToken.user(sf.token.account, false, config.serverSecret)
            rem <- OptionT
              .whenF(sf.rememberMe && config.rememberMe.enabled)(
                insertRememberToken(store, sf.token.account, config)
              )
              .value
          } yield Result.ok(newToken, rem)

        val validateToken: EitherT[F, Result, Unit] = for {
          _ <- EitherT
            .cond[F](sf.token.sigValid(config.serverSecret), (), Result.invalidAuth)
            .leftSemiflatTap(_ =>
              logF.warn("OTP authentication token signature invalid!")
            )
          _ <- EitherT
            .cond[F](sf.token.notExpired(config.sessionValid), (), Result.invalidTime)
            .leftSemiflatTap(_ => logF.info("OTP Token expired."))
          _ <- EitherT
            .cond[F](sf.token.requireSecondFactor, (), Result.invalidAuth)
            .leftSemiflatTap(_ =>
              logF.warn("OTP received for token that is not allowed for 2FA!")
            )
        } yield ()

        (for {
          _ <- validateToken
          key <- EitherT.fromOptionF(
            store.transact(RTotp.findEnabledByLogin(sf.token.account, true)),
            Result.invalidAuth
          )
          now <- EitherT.right[Result](Timestamp.current[F])
          _ <- EitherT.cond[F](
            totp.checkPassword(key.secret, sf.otp, now.value),
            (),
            Result.invalidAuth
          )
        } yield ()).swap.getOrElseF(okResult)
      }

      def loginRememberMe(config: Config)(token: String): F[Result] = {
        def okResult(acc: AccountId) =
          for {
            _ <- store.transact(RUser.updateLogin(acc))
            token <- AuthToken.user(acc, false, config.serverSecret)
          } yield Result.ok(token, None)

        def doLogin(rid: Ident) =
          (for {
            now <- OptionT.liftF(Timestamp.current[F])
            minTime = now - config.rememberMe.valid
            data <- OptionT(store.transact(QLogin.findByRememberMe(rid, minTime).value))
            _ <- OptionT.liftF(
              logF.info(s"Account lookup via remember me: $data")
            )
            res <- OptionT.liftF(
              if (checkNoPassword(data, AccountSource.all.toList.toSet))
                logF.info("RememberMe auth successful") *> okResult(data.account)
              else
                logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F]
            )
          } yield res).getOrElseF(
            logF.info("RememberMe not found in database.") *> Result.invalidAuth.pure[F]
          )

        if (config.rememberMe.disabled)
          logF.info(
            "Remember me auth tried, but disabled in config."
          ) *> Result.invalidAuth.pure[F]
        else
          RememberToken.fromString(token) match {
            case Right(rt) =>
              if (rt.sigInvalid(config.serverSecret))
                logF.warn(
                  s"RememberMe cookie signature invalid ($rt)!"
                ) *> Result.invalidAuth
                  .pure[F]
              else if (rt.isExpired(config.rememberMe.valid))
                logF.info(s"RememberMe cookie expired ($rt).") *> Result.invalidTime
                  .pure[F]
              else doLogin(rt.rememberId)
            case Left(err) =>
              logF.info(s"RememberMe cookie was invalid: $err") *> Result.invalidAuth
                .pure[F]
          }
      }

      def loginSessionOrRememberMe(
          config: Config
      )(sessionKey: String, rememberToken: Option[String]): F[Result] =
        loginSession(config)(sessionKey).flatMap {
          case success @ Result.Ok(_, _) => (success: Result).pure[F]
          case fail =>
            rememberToken match {
              case Some(token) =>
                loginRememberMe(config)(token)
              case None =>
                fail.pure[F]
            }
        }

      def removeRememberToken(token: String): F[Int] =
        RememberToken.fromString(token) match {
          case Right(rt) =>
            store.transact(RRememberMe.delete(rt.rememberId))
          case Left(_) =>
            0.pure[F]
        }

      private def doLogin(
          config: Config,
          acc: AccountId,
          rememberMe: Boolean
      ): F[Result] =
        for {
          require2FA <- store.transact(RTotp.isEnabled(acc))
          _ <-
            if (require2FA) ().pure[F]
            else store.transact(RUser.updateLogin(acc))
          token <- AuthToken.user(acc, require2FA, config.serverSecret)
          rem <- OptionT
            .whenF(!require2FA && rememberMe && config.rememberMe.enabled)(
              insertRememberToken(store, acc, config)
            )
            .value
        } yield Result.ok(token, rem)

      private def insertRememberToken(
          store: Store[F],
          acc: AccountId,
          config: Config
      ): F[RememberToken] =
        for {
          rme <- RRememberMe.generate[F](acc)
          _ <- store.transact(RRememberMe.insert(rme))
          token <- RememberToken.user(rme.id, config.serverSecret)
        } yield token

      private def check(given: String)(data: QLogin.Data): Boolean = {
        val passOk = BCrypt.checkpw(given, data.password.pass)
        checkNoPassword(data, Set(AccountSource.Local)) && passOk
      }

      def checkNoPassword(
          data: QLogin.Data,
          expectedSources: Set[AccountSource]
      ): Boolean = {
        val collOk = data.collectiveState == CollectiveState.Active ||
          data.collectiveState == CollectiveState.ReadOnly
        val userOk =
          data.userState == UserState.Active && expectedSources.contains(data.source)
        collOk && userOk
      }
    })
}
