#include <scwx/qt/manager/download_manager.hpp>
#include <scwx/util/digest.hpp>
#include <scwx/util/logger.hpp>

#include <fstream>

#include <boost/asio/post.hpp>
#include <boost/asio/thread_pool.hpp>
#include <cpr/cpr.h>

namespace scwx
{
namespace qt
{
namespace manager
{

static const std::string logPrefix_ = "scwx::qt::manager::download_manager";
static const auto        logger_    = scwx::util::Logger::Create(logPrefix_);

class DownloadManager::Impl
{
public:
   explicit Impl(DownloadManager* self) : self_ {self} {}

   ~Impl() { threadPool_.join(); }

   void DownloadSync(const std::shared_ptr<request::DownloadRequest>& request);

   boost::asio::thread_pool threadPool_ {1u};

   DownloadManager* self_;
};

DownloadManager::DownloadManager() : p(std::make_unique<Impl>(this)) {}
DownloadManager::~DownloadManager() = default;

void DownloadManager::Download(
   const std::shared_ptr<request::DownloadRequest>& request)
{
   boost::asio::post(p->threadPool_,
                     [=, this]()
                     {
                        try
                        {
                           p->DownloadSync(request);
                        }
                        catch (const std::exception& ex)
                        {
                           logger_->error(ex.what());
                        }
                     });
}

void DownloadManager::Impl::DownloadSync(
   const std::shared_ptr<request::DownloadRequest>& request)
{
   // Prepare destination file
   const std::filesystem::path& destinationPath = request->destination_path();

   if (!destinationPath.has_parent_path())
   {
      logger_->error("Destination has no parent path: \"{}\"");

      Q_EMIT request->RequestComplete(
         request::DownloadRequest::CompleteReason::IOError);

      return;
   }

   const std::filesystem::path parentPath = destinationPath.parent_path();

   // Create directory if it doesn't exist
   if (!std::filesystem::exists(parentPath))
   {
      if (!std::filesystem::create_directories(parentPath))
      {
         logger_->error("Unable to create download directory: \"{}\"",
                        parentPath.string());

         Q_EMIT request->RequestComplete(
            request::DownloadRequest::CompleteReason::IOError);

         return;
      }
   }

   // Remove file if it exists
   if (std::filesystem::exists(destinationPath))
   {
      std::error_code error;
      if (!std::filesystem::remove(destinationPath, error))
      {
         logger_->error(
            "Unable to remove existing destination file ({}): \"{}\"",
            error.message(),
            destinationPath.string());

         Q_EMIT request->RequestComplete(
            request::DownloadRequest::CompleteReason::IOError);

         return;
      }
   }

   // Open file for writing
   std::ofstream ofs {destinationPath,
                      std::ios_base::out | std::ios_base::binary |
                         std::ios_base::trunc};
   if (!ofs.is_open() || !ofs.good())
   {
      logger_->error("Unable to open destination file for writing: \"{}\"",
                     destinationPath.string());

      Q_EMIT request->RequestComplete(
         request::DownloadRequest::CompleteReason::IOError);

      return;
   }

   std::chrono::system_clock::time_point lastUpdated {};
   cpr::cpr_off_t                        lastDownloadNow {};
   cpr::cpr_off_t                        lastDownloadTotal {};

   // Download file
   cpr::Response response = cpr::Get(
      cpr::Url {request->url()},
      cpr::ProgressCallback(
         [&](cpr::cpr_off_t downloadTotal,
             cpr::cpr_off_t downloadNow,
             cpr::cpr_off_t /* uploadTotal */,
             cpr::cpr_off_t /* uploadNow */,
             std::intptr_t /* userdata */)
         {
            using namespace std::chrono_literals;

            std::chrono::system_clock::time_point now =
               std::chrono::system_clock::now();

            // Only emit an update every 100ms
            if ((now > lastUpdated + 100ms || downloadNow == downloadTotal) &&
                (downloadNow != lastDownloadNow ||
                 downloadTotal != lastDownloadTotal))
            {
               logger_->trace(
                  "Downloaded: {} / {}", downloadNow, downloadTotal);

               Q_EMIT request->ProgressUpdated(downloadNow, downloadTotal);

               lastUpdated       = now;
               lastDownloadNow   = downloadNow;
               lastDownloadTotal = downloadTotal;
            }

            return !request->IsCanceled();
         }),
      cpr::WriteCallback(
         [&](const std::string_view& data, std::intptr_t /* userdata */)
         {
            // Write file
            ofs << data;
            return !request->IsCanceled();
         }));

   bool ofsGood = ofs.good();
   ofs.close();

   // Handle error response
   if (response.error.code != cpr::ErrorCode::OK || request->IsCanceled() ||
       !ofsGood)
   {
      request::DownloadRequest::CompleteReason reason =
         request::DownloadRequest::CompleteReason::IOError;

      if (request->IsCanceled())
      {
         logger_->info("Download request cancelled: {}", request->url());

         reason = request::DownloadRequest::CompleteReason::Canceled;
      }
      else if (response.error.code != cpr::ErrorCode::OK)
      {
         logger_->error("Error downloading file ({}): {}",
                        response.error.message,
                        request->url());

         reason = request::DownloadRequest::CompleteReason::RemoteError;
      }
      else if (!ofsGood)
      {
         logger_->error("File I/O error: {}", destinationPath.string());

         reason = request::DownloadRequest::CompleteReason::IOError;
      }

      std::error_code error;
      if (!std::filesystem::remove(destinationPath, error))
      {
         logger_->error("Unable to remove destination file: {}, {}",
                        destinationPath.string(),
                        error.message());
      }

      Q_EMIT request->RequestComplete(reason);

      return;
   }

   // Handle response
   const auto contentMd5 = response.header.find("content-md5");
   if (contentMd5 != response.header.cend() && !contentMd5->second.empty())
   {
      // Open file for reading
      std::ifstream is {destinationPath,
                        std::ios_base::in | std::ios_base::binary};
      if (!is.is_open() || !is.good())
      {
         logger_->error("Unable to open destination file for reading: {}",
                        destinationPath.string());

         Q_EMIT request->RequestComplete(
            request::DownloadRequest::CompleteReason::IOError);

         return;
      }

      // Compute MD5
      std::vector<std::uint8_t> digest {};
      if (!util::ComputeDigest(EVP_md5(), is, digest))
      {
         logger_->error("Failed to compute MD5: {}", destinationPath.string());

         Q_EMIT request->RequestComplete(
            request::DownloadRequest::CompleteReason::IOError);

         return;
      }

      // Compare calculated MD5 with digest in response header
      QByteArray expectedDigestArray =
         QByteArray::fromBase64(contentMd5->second.c_str());
      std::vector<std::uint8_t> expectedDigest(expectedDigestArray.cbegin(),
                                               expectedDigestArray.cend());

      if (digest != expectedDigest)
      {
         QByteArray calculatedDigest(reinterpret_cast<char*>(digest.data()),
                                     digest.size());

         logger_->error("Digest mismatch: {} != {}",
                        calculatedDigest.toBase64().toStdString(),
                        contentMd5->second);

         Q_EMIT request->RequestComplete(
            request::DownloadRequest::CompleteReason::DigestError);

         return;
      }
   }

   logger_->info("Download complete: {}", request->url());
   Q_EMIT request->RequestComplete(
      request::DownloadRequest::CompleteReason::OK);
}

std::shared_ptr<DownloadManager> DownloadManager::Instance()
{
   static std::weak_ptr<DownloadManager> downloadManagerReference_ {};
   static std::mutex                     instanceMutex_ {};

   std::unique_lock lock(instanceMutex_);

   std::shared_ptr<DownloadManager> downloadManager =
      downloadManagerReference_.lock();

   if (downloadManager == nullptr)
   {
      downloadManager           = std::make_shared<DownloadManager>();
      downloadManagerReference_ = downloadManager;
   }

   return downloadManager;
}

} // namespace manager
} // namespace qt
} // namespace scwx
