// For license of this file, see <project-root-folder>/LICENSE.md.

////////////////////////////////////////////////////////////////////////////////

//                                                                            //
// This file is part of QOAuth2.                                              //
// Copyright (c) 2014 Jacob Dawid <jacob@omg-it.works>                        //
//                                                                            //
// QOAuth2 is free software: you can redistribute it and/or modify            //
// it under the terms of the GNU Affero General Public License as             //
// published by the Free Software Foundation, either version 3 of the         //
// License, or (at your option) any later version.                            //
//                                                                            //
// QOAuth2 is distributed in the hope that it will be useful,                 //
// but WITHOUT ANY WARRANTY; without even the implied warranty of             //
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              //
// GNU Affero General Public License for more details.                        //
//                                                                            //
// You should have received a copy of the GNU Affero General Public           //
// License along with QOAuth2.                                                //
// If not, see <http://www.gnu.org/licenses/>.                                //
//                                                                            //
////////////////////////////////////////////////////////////////////////////////

#include "network-web/oauth2service.h"

#include "definitions/definitions.h"
#include "exceptions/applicationexception.h"
#include "miscellaneous/application.h"
#include "network-web/networkfactory.h"
#include "network-web/oauthhttphandler.h"
#include "network-web/webfactory.h"

#include <cstdlib>
#include <exception>
#include <utility>

#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRandomGenerator>
#include <QSystemTrayIcon>
#include <QTimer>
#include <QUrlQuery>

OAuth2Service::OAuth2Service(const QString& auth_url,
                             const QString& token_url,
                             const QString& client_id,
                             const QString& client_secret,
                             const QString& scope,
                             QObject* parent)
  : QObject(parent), m_id(QString::number(QRandomGenerator::global()->generate())), m_timerId(-1),
    m_redirectionHandler(new OAuthHttpHandler(tr("You can close this window now. Go back to %1.").arg(QSL(APP_NAME)),
                                              this)),
    m_tokenRequestRunning(false), m_loginFunctorGeneration(0),
    m_functorOnLogin(std::function<void()>()) {
  m_tokenGrantType = QSL("authorization_code");
  m_tokenUrl = QUrl(token_url);
  m_authUrl = auth_url;

  m_clientId = client_id;
  m_clientSecret = client_secret;
  m_clientSecretId = m_clientSecretSecret = QString();
  m_scope = scope;
  m_useHttpBasicAuthWithClientData = false;

  connect(&m_networkManager, &QNetworkAccessManager::finished, this, &OAuth2Service::tokenRequestFinished);
  connect(m_redirectionHandler, &OAuthHttpHandler::authGranted, [this](const QString& auth_code, const QString& id) {
    if (id.isEmpty() || id == m_id) {
      // We process this further only if handler (static singleton) responded to our original request.
      retrieveAccessToken(auth_code);
    }
  });
  connect(m_redirectionHandler,
          &OAuthHttpHandler::authRejected,
          [this](const QString& error_description, const QString& id) {
            Q_UNUSED(error_description)

            if (id.isEmpty() || id == m_id) {
              // We process this further only if handler (static singleton) responded to our original request.
              m_functorOnLogin = {};
              m_loginFunctorGeneration++;
              emit authFailed();
            }
          });
}

OAuth2Service::~OAuth2Service() {
  qDebugNN << LOGSEC_OAUTH << "Destroying OAuth2Service instance.";
}

QString OAuth2Service::bearer() {
  if (!isFullyLoggedIn()) {
    qApp->showGuiMessage(Notification::Event::LoginFailure,
                         {tr("You have to login first"),
                          tr("Click here to login."),
                          QSystemTrayIcon::MessageIcon::Critical},
                         {},
                         {tr("Login"), this, [this]() {
                            login();
                          }});
    return {};
  }
  else {
    return QSL("Bearer %1").arg(accessToken());
  }
}

bool OAuth2Service::isFullyLoggedIn() const {
  bool is_expiration_valid = tokensExpireIn() > QDateTime::currentDateTime();
  bool do_tokens_exist = !refreshToken().isEmpty() && !accessToken().isEmpty();

  return is_expiration_valid && do_tokens_exist;
}

void OAuth2Service::setOAuthTokenGrantType(QString grant_type) {
  m_tokenGrantType = std::move(grant_type);
}

QString OAuth2Service::oAuthTokenGrantType() {
  return m_tokenGrantType;
}

void OAuth2Service::timerEvent(QTimerEvent* event) {
  if (m_timerId >= 0 && event->timerId() == m_timerId) {
    event->accept();

    QDateTime window_about_expire = tokensExpireIn().addSecs(-60 * 15);

    if (window_about_expire < QDateTime::currentDateTime()) {
      // We try to refresh access token, because it probably expires soon.
      qDebugNN << LOGSEC_OAUTH << "Refreshing automatically access token.";
      refreshAccessToken();
    }
    else {
      qDebugNN << LOGSEC_OAUTH << "Access token is not expired yet.";
    }
  }

  QObject::timerEvent(event);
}

bool OAuth2Service::useHttpBasicAuthWithClientData() const {
  return m_useHttpBasicAuthWithClientData;
}

void OAuth2Service::setUseHttpBasicAuthWithClientData(bool use_auth) {
  m_useHttpBasicAuthWithClientData = use_auth;
}

QString OAuth2Service::clientSecretSecret() const {
  return m_clientSecretSecret;
}

void OAuth2Service::setClientSecretSecret(const QString& client_secret_secret) {
  m_clientSecretSecret = client_secret_secret;
}

QString OAuth2Service::clientSecretId() const {
  return m_clientSecretId;
}

void OAuth2Service::setClientSecretId(const QString& client_secret_id) {
  m_clientSecretId = client_secret_id;
}

QString OAuth2Service::id() const {
  return m_id;
}

void OAuth2Service::setId(const QString& id) {
  m_id = id;
}

void OAuth2Service::retrieveAccessToken(const QString& auth_code) {
  QNetworkRequest network_request;

  network_request.setUrl(m_tokenUrl);
  network_request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded");

  if (m_useHttpBasicAuthWithClientData) {
    auto basic_auth = NetworkFactory::generateBasicAuthHeader(NetworkFactory::NetworkAuthentication::Basic,
                                                              properClientId(),
                                                              properClientSecret());

    network_request.setRawHeader(basic_auth.first, basic_auth.second);
  }

  QUrlQuery query;

  query.addQueryItem(QSL("client_id"), properClientId());
  query.addQueryItem(QSL("client_secret"), properClientSecret());
  query.addQueryItem(QSL("code"), auth_code);
  query.addQueryItem(QSL("redirect_uri"), m_redirectionHandler->listenAddressPort());
  query.addQueryItem(QSL("grant_type"), m_tokenGrantType);

  const QString content = query.toString(QUrl::FullyEncoded);

  qDebugNN << LOGSEC_OAUTH << "Posting data for access token retrieval:" << QUOTE_W_SPACE_DOT(content);
  postTokenRequest(network_request, content);
}

void OAuth2Service::refreshAccessToken(const QString& refresh_token) {
  auto real_refresh_token = refresh_token.isEmpty() ? refreshToken() : refresh_token;
  QNetworkRequest network_request;

  network_request.setUrl(m_tokenUrl);
  network_request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded");

  if (m_useHttpBasicAuthWithClientData) {
    auto basic_auth = NetworkFactory::generateBasicAuthHeader(NetworkFactory::NetworkAuthentication::Basic,
                                                              properClientId(),
                                                              properClientSecret());

    network_request.setRawHeader(basic_auth.first, basic_auth.second);
  }

  QUrlQuery query;

  query.addQueryItem(QSL("client_id"), properClientId());
  query.addQueryItem(QSL("client_secret"), properClientSecret());
  query.addQueryItem(QSL("refresh_token"), real_refresh_token);
  query.addQueryItem(QSL("grant_type"), QSL("refresh_token"));

  const QString content = query.toString(QUrl::FullyEncoded);

  qApp->showGuiMessage(Notification::Event::LoginProgressOrSuccessful,
                       {tr("Logging in via OAuth 2.0..."),
                        tr("Refreshing login tokens for '%1'...").arg(m_tokenUrl.toString()),
                        QSystemTrayIcon::MessageIcon::Information},
                       {true, false, true});

  qDebugNN << LOGSEC_OAUTH << "Posting data for access token refreshing:" << QUOTE_W_SPACE_DOT(content);
  postTokenRequest(network_request, content);
}

void OAuth2Service::tokenRequestFinished(QNetworkReply* network_reply) {
  if (network_reply != m_tokenReply) {
    qWarningNN << LOGSEC_OAUTH << "Ignoring stale OAuth token reply.";
    network_reply->deleteLater();
    return;
  }

  m_tokenReply.clear();
  m_tokenRequestRunning = false;

  QByteArray repl = network_reply->readAll();
  QJsonDocument json_document = QJsonDocument::fromJson(repl);
  QJsonObject root_obj = json_document.object();

  qDebugNN << LOGSEC_OAUTH << "Token response:" << QUOTE_W_SPACE_DOT(QString::fromUtf8(json_document.toJson()));

  if (network_reply->error() != QNetworkReply::NetworkError::NoError) {
    qWarningNN << LOGSEC_OAUTH
               << "Network error when obtaining token response:" << QUOTE_W_SPACE_DOT(network_reply->error());

    m_functorOnLogin = {};
    m_loginFunctorGeneration++;
    emit tokensRetrieveError(QString(), NetworkFactory::networkErrorText(network_reply->error()));
  }
  else if (root_obj.keys().contains(QSL("error"))) {
    QString error = root_obj.value(QSL("error")).toString();
    QString error_description = root_obj.value(QSL("error_description")).toString();

    qWarningNN << LOGSEC_OAUTH << "JSON error when obtaining token response:" << QUOTE_W_SPACE(error)
               << QUOTE_W_SPACE_DOT(error_description);

    logout();

    m_functorOnLogin = {};
    m_loginFunctorGeneration++;
    emit tokensRetrieveError(error, error_description);
  }
  else {
    int expires = root_obj.value(QL1S("expires_in")).toInt();

    setTokensExpireIn(QDateTime::currentDateTime().addSecs(expires));
    setAccessToken(root_obj.value(QL1S("access_token")).toString());

    const QString refresh_token = root_obj.value(QL1S("refresh_token")).toString();

    if (!refresh_token.isEmpty()) {
      setRefreshToken(refresh_token);
    }

    qDebugNN << LOGSEC_OAUTH << "Obtained refresh token" << QUOTE_W_SPACE(refreshToken()) << "- expires on date/time"
             << QUOTE_W_SPACE_DOT(tokensExpireIn());

    emit tokensRetrieved(accessToken(), refreshToken(), expires);

    auto functor = std::exchange(m_functorOnLogin, {});

    scheduleLoginFunctor(functor);
  }

  network_reply->deleteLater();
}

void OAuth2Service::scheduleLoginFunctor(const std::function<void()>& functor) {
  if (!functor) {
    return;
  }

  const int generation = ++m_loginFunctorGeneration;

  QTimer::singleShot(0, this, [this, functor, generation]() {
    if (generation == m_loginFunctorGeneration) {
      runLoginFunctor(functor);
    }
  });
}

bool OAuth2Service::postTokenRequest(const QNetworkRequest& request, const QString& content) {
  if (m_tokenRequestRunning) {
    qWarningNN << LOGSEC_OAUTH << "Token request is already running, ignoring duplicate request.";
    return false;
  }

  m_tokenRequestRunning = true;
  m_tokenReply = m_networkManager.post(request, content.toUtf8());

  return true;
}

void OAuth2Service::runLoginFunctor(const std::function<void()>& functor) {
  if (!functor) {
    return;
  }

  qDebugNN << LOGSEC_OAUTH << "Running custom after-login code.";

  try {
    functor();
  }
  catch (const ApplicationException& ex) {
    qCriticalNN << LOGSEC_OAUTH << "Custom after-login code failed:" << QUOTE_W_SPACE_DOT(ex.message());

    qApp->showGuiMessage(Notification::Event::LoginFailure,
                         {tr("After-login action failed"), ex.message(), QSystemTrayIcon::MessageIcon::Critical},
                         GuiMessageDestination(true, true));
  }
  catch (const std::exception& ex) {
    const QString message = QString::fromLocal8Bit(ex.what());

    qCriticalNN << LOGSEC_OAUTH << "Custom after-login code failed:" << QUOTE_W_SPACE_DOT(message);

    qApp->showGuiMessage(Notification::Event::LoginFailure,
                         {tr("After-login action failed"), message, QSystemTrayIcon::MessageIcon::Critical},
                         GuiMessageDestination(true, true));
  }
  catch (...) {
    const QString message = tr("Unknown error.");

    qCriticalNN << LOGSEC_OAUTH << "Custom after-login code failed with unknown exception.";

    qApp->showGuiMessage(Notification::Event::LoginFailure,
                         {tr("After-login action failed"), message, QSystemTrayIcon::MessageIcon::Critical},
                         GuiMessageDestination(true, true));
  }
}

QString OAuth2Service::properClientId() const {
  return m_clientId.simplified().isEmpty() ? m_clientSecretId : m_clientId;
}

QString OAuth2Service::properClientSecret() const {
  return m_clientSecret.simplified().isEmpty() ? m_clientSecretSecret : m_clientSecret;
}

QString OAuth2Service::accessToken() const {
  return m_accessToken;
}

void OAuth2Service::setAccessToken(const QString& access_token) {
  m_accessToken = access_token;
}

QDateTime OAuth2Service::tokensExpireIn() const {
  return m_tokensExpireIn;
}

void OAuth2Service::setTokensExpireIn(const QDateTime& tokens_expire_in) {
  m_tokensExpireIn = tokens_expire_in;
}

QString OAuth2Service::clientSecret() const {
  return m_clientSecret;
}

void OAuth2Service::setClientSecret(const QString& client_secret) {
  m_clientSecret = client_secret;
}

QString OAuth2Service::clientId() const {
  return m_clientId;
}

void OAuth2Service::setClientId(const QString& client_id) {
  m_clientId = client_id;
}

QString OAuth2Service::redirectUrl() const {
  return m_redirectionHandler->listenAddressPort();
}

void OAuth2Service::setRedirectUrl(const QString& redirect_url, bool start_handler) {
  m_redirectionHandler->setListenAddressPort(redirect_url, start_handler);
}

QString OAuth2Service::refreshToken() const {
  return m_refreshToken;
}

void OAuth2Service::setRefreshToken(const QString& refresh_token) {
  killRefreshTimer();
  m_refreshToken = refresh_token;
  startRefreshTimer();
}

bool OAuth2Service::login(const std::function<void()>& functor_when_logged_in) {
  if (m_tokenRequestRunning) {
    qWarningNN << LOGSEC_OAUTH << "Cannot start OAuth login because token request is already running.";
    return false;
  }

  m_functorOnLogin = functor_when_logged_in;

  if (!m_redirectionHandler->isListening()) {
    qCriticalNN << LOGSEC_OAUTH << "Cannot log-in because OAuth redirection handler is not listening.";

    m_functorOnLogin = {};
    m_loginFunctorGeneration++;
    emit tokensRetrieveError(QString(),
                             tr("Failed to start OAuth "
                                "redirection listener. Maybe your "
                                "rights are not high enough."));

    return false;
  }

  bool should_refresh_token =
    tokensExpireIn().isNull() || tokensExpireIn() < QDateTime::currentDateTime().addSecs(120);
  bool does_token_exist = !refreshToken().isEmpty();

  // We refresh current tokens only if:
  //   1. We have some existing refresh token.
  //   AND
  //   2. We do not know its expiration date or it expires soon.
  if (does_token_exist && should_refresh_token) {
    refreshAccessToken();
    return false;
  }
  else if (!does_token_exist) {
    retrieveAuthCode();
    return false;
  }
  else {
    auto functor = std::exchange(m_functorOnLogin, {});

    scheduleLoginFunctor(functor);

    return true;
  }
}

void OAuth2Service::logout(bool stop_redirection_handler) {
  m_functorOnLogin = {};
  m_loginFunctorGeneration++;

  if (m_tokenReply != nullptr) {
    QNetworkReply* token_reply = m_tokenReply;

    m_tokenReply.clear();
    m_tokenRequestRunning = false;
    token_reply->abort();
  }

  setTokensExpireIn(QDateTime());
  setAccessToken(QString());
  setRefreshToken(QString());

  qDebugNN << LOGSEC_OAUTH << "Clearing tokens.";

  if (stop_redirection_handler) {
    m_redirectionHandler->stop();
  }
}

void OAuth2Service::startRefreshTimer() {
  if (!refreshToken().isEmpty()) {
    m_timerId = startTimer(1000 * 60 * 15, Qt::TimerType::VeryCoarseTimer);
  }
}

void OAuth2Service::killRefreshTimer() {
  if (m_timerId > 0) {
    killTimer(m_timerId);
  }
}

void OAuth2Service::retrieveAuthCode() {
  m_id = QString::number(QRandomGenerator::global()->generate());

  QUrl auth_url(m_authUrl);
  QUrlQuery query;

  query.addQueryItem(QSL("client_id"), properClientId());
  query.addQueryItem(QSL("scope"), m_scope);
  query.addQueryItem(QSL("redirect_uri"), m_redirectionHandler->listenAddressPort());
  query.addQueryItem(QSL("response_type"), QSL("code"));
  query.addQueryItem(QSL("state"), m_id);
  query.addQueryItem(QSL("prompt"), QSL("consent"));
  query.addQueryItem(QSL("duration"), QSL("permanent"));
  query.addQueryItem(QSL("access_type"), QSL("offline"));
  auth_url.setQuery(query);

  // We run login URL in external browser, response is caught by light HTTP server.
  qApp->web()->openUrlInExternalBrowser(auth_url);
}
