// This file Copyright © 2009-2023 Mnemosyne LLC.
// It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only),
// or any future license endorsed by Mnemosyne LLC.
// License text can be found in the licenses/ folder.

#include "Application.h"

#include <algorithm>
#include <array>
#include <ctime>
#include <memory>
#include <utility>

#include <QIcon>
#include <QLibraryInfo>
#include <QMessageBox>
#include <QProcess>
#include <QRect>
#include <QSystemTrayIcon>
#include <QtDebug>

#ifdef QT_DBUS_LIB
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusReply>
#endif

#include <libtransmission/transmission.h>

#include <libtransmission/tr-getopt.h>
#include <libtransmission/utils.h>
#include <libtransmission/version.h>

#include "AddData.h"
#include "Formatter.h"
#include "InteropHelper.h"
#include "MainWindow.h"
#include "OptionsDialog.h"
#include "Prefs.h"
#include "Session.h"
#include "TorrentModel.h"
#include "WatchDir.h"

namespace
{

std::array<tr_option, 8> const Opts = {
    tr_option{ 'g', "config-dir", "Where to look for configuration files", "g", true, "<path>" },
    { 'm', "minimized", "Start minimized in system tray", "m", false, nullptr },
    { 'p', "port", "Port to use when connecting to an existing session", "p", true, "<port>" },
    { 'r', "remote", "Connect to an existing session at the specified hostname", "r", true, "<host>" },
    { 'u', "username", "Username to use when connecting to an existing session", "u", true, "<username>" },
    { 'v', "version", "Show version number and exit", "v", false, nullptr },
    { 'w', "password", "Password to use when connecting to an existing session", "w", true, "<password>" },
    { 0, nullptr, nullptr, nullptr, false, nullptr }
};

char const* getUsage()
{
    return "Usage:\n"
           "  transmission [OPTIONS...] [torrent files]";
}

auto constexpr StatsRefreshIntervalMsec = int{ 3000 };
auto constexpr SessionRefreshIntervalMsec = int{ 3000 };
auto constexpr ModelRefreshIntervalMsec = int{ 3000 };

bool loadTranslation(QTranslator& translator, QString const& name, QLocale const& locale, QStringList const& search_directories)
{
    for (QString const& directory : search_directories)
    {
        if (translator.load(locale, name, QStringLiteral("_"), directory))
        {
            return true;
        }
    }

    return false;
}

[[nodiscard]] auto makeWindowIcon()
{
    // first, try to load it from the system theme
    if (auto icon = QIcon::fromTheme(QStringLiteral("transmission")); !icon.isNull())
    {
        return icon;
    }

    // if that fails, use our own as the fallback
    return QIcon{ QStringLiteral(":/icons/transmission.svg") };
}

} // namespace

Application::Application(int& argc, char** argv)
    : QApplication(argc, argv)
    , config_name_{ QStringLiteral("transmission") }
    , display_name_{ QStringLiteral("transmission-qt") }
    , start_now_regex_{ QRegularExpression(QStringLiteral(R"rgx(start-now\((\d+)\))rgx")) }
{
    setApplicationName(config_name_);
    loadTranslations();

#if defined(_WIN32) || defined(__APPLE__)

    if (QIcon::themeName().isEmpty())
    {
        QIcon::setThemeName(QStringLiteral("Faenza"));
    }

#endif

    setWindowIcon(makeWindowIcon());

#ifdef __APPLE__
    setAttribute(Qt::AA_DontShowIconsInMenus);
#endif

    // parse the command-line arguments
    int c = 0;
    bool minimized = false;
    char const* optarg = nullptr;
    QString host;
    QString port;
    QString username;
    QString password;
    QString config_dir;
    QStringList filenames;

    while ((c = tr_getopt(getUsage(), argc, const_cast<char const**>(argv), Opts.data(), &optarg)) != TR_OPT_DONE)
    {
        switch (c)
        {
        case 'g':
            config_dir = QString::fromUtf8(optarg);
            break;

        case 'p':
            port = QString::fromUtf8(optarg);
            break;

        case 'r':
            host = QString::fromUtf8(optarg);
            break;

        case 'u':
            username = QString::fromUtf8(optarg);
            break;

        case 'w':
            password = QString::fromUtf8(optarg);
            break;

        case 'm':
            minimized = true;
            break;

        case 'v':
            qInfo() << qPrintable(display_name_) << LONG_VERSION_STRING;
            quitLater();
            return;

        case TR_OPT_ERR:
            qWarning() << qPrintable(QObject::tr("Invalid option"));
            tr_getopt_usage(qPrintable(display_name_), getUsage(), Opts.data());
            quitLater();
            return;

        default:
            filenames.append(QString::fromUtf8(optarg));
            break;
        }
    }

    // try to delegate the work to an existing copy of Transmission
    // before starting ourselves...
    InteropHelper const interop_client;

    if (interop_client.isConnected())
    {
        bool delegated = false;

        for (QString const& filename : filenames)
        {
            auto const a = AddData(filename);
            QString metainfo;

            switch (a.type)
            {
            case AddData::URL:
                metainfo = a.url.toString();
                break;

            case AddData::MAGNET:
                metainfo = a.magnet;
                break;

            case AddData::FILENAME:
            case AddData::METAINFO:
                metainfo = QString::fromUtf8(a.toBase64());
                break;

            default:
                break;
            }

            if (!metainfo.isEmpty() && interop_client.addMetainfo(metainfo))
            {
                delegated = true;
            }
        }

        if (delegated)
        {
            quitLater();
            return;
        }
    }

    // set the fallback config dir
    if (config_dir.isNull())
    {
        config_dir = QString::fromStdString(tr_getDefaultConfigDir("transmission"));
    }

    // ensure our config directory exists
    QDir const dir(config_dir);

    if (!dir.exists())
    {
        dir.mkpath(config_dir);
    }

    // is this the first time we've run transmission?
    bool const first_time = !dir.exists(QStringLiteral("settings.json"));

    // initialize the prefs
    prefs_ = std::make_unique<Prefs>(config_dir);

    if (!host.isNull())
    {
        prefs_->set(Prefs::SESSION_REMOTE_HOST, host);
    }

    if (!port.isNull())
    {
        prefs_->set(Prefs::SESSION_REMOTE_PORT, port.toUInt());
    }

    if (!username.isNull())
    {
        prefs_->set(Prefs::SESSION_REMOTE_USERNAME, username);
    }

    if (!password.isNull())
    {
        prefs_->set(Prefs::SESSION_REMOTE_PASSWORD, password);
    }

    if (!host.isNull() || !port.isNull() || !username.isNull() || !password.isNull())
    {
        prefs_->set(Prefs::SESSION_IS_REMOTE, true);
    }

    if (prefs_->getBool(Prefs::START_MINIMIZED))
    {
        minimized = true;
    }

    // start as minimized only if the system tray present
    if (!prefs_->getBool(Prefs::SHOW_TRAY_ICON))
    {
        minimized = false;
    }

    session_ = std::make_unique<Session>(config_dir, *prefs_);
    model_ = std::make_unique<TorrentModel>(*prefs_);
    window_ = std::make_unique<MainWindow>(*session_, *prefs_, *model_, minimized);
    watch_dir_ = std::make_unique<WatchDir>(*model_);

    connect(this, &QCoreApplication::aboutToQuit, this, &Application::saveGeometry);
    connect(model_.get(), &TorrentModel::torrentsAdded, this, &Application::onTorrentsAdded);
    connect(model_.get(), &TorrentModel::torrentsCompleted, this, &Application::onTorrentsCompleted);
    connect(model_.get(), &TorrentModel::torrentsEdited, this, &Application::onTorrentsEdited);
    connect(model_.get(), &TorrentModel::torrentsNeedInfo, this, &Application::onTorrentsNeedInfo);
    connect(prefs_.get(), &Prefs::changed, this, &Application::refreshPref);
    connect(session_.get(), &Session::sourceChanged, this, &Application::onSessionSourceChanged);
    connect(session_.get(), &Session::torrentsRemoved, model_.get(), &TorrentModel::removeTorrents);
    connect(session_.get(), &Session::torrentsUpdated, model_.get(), &TorrentModel::updateTorrents);
    connect(watch_dir_.get(), &WatchDir::torrentFileAdded, this, qOverload<QString const&>(&Application::addWatchdirTorrent));

    // init from preferences
    for (auto const key : { Prefs::DIR_WATCH })
    {
        refreshPref(key);
    }

    QTimer* timer = &model_timer_;
    connect(timer, &QTimer::timeout, this, &Application::refreshTorrents);
    timer->setSingleShot(false);
    timer->setInterval(ModelRefreshIntervalMsec);
    timer->start();

    timer = &stats_timer_;
    connect(timer, &QTimer::timeout, session_.get(), &Session::refreshSessionStats);
    timer->setSingleShot(false);
    timer->setInterval(StatsRefreshIntervalMsec);
    timer->start();

    timer = &session_timer_;
    connect(timer, &QTimer::timeout, session_.get(), &Session::refreshSessionInfo);
    timer->setSingleShot(false);
    timer->setInterval(SessionRefreshIntervalMsec);
    timer->start();

    maybeUpdateBlocklist();

    if (!first_time)
    {
        session_->restart();
    }
    else
    {
        window_->openSession();
    }

    if (!prefs_->getBool(Prefs::USER_HAS_GIVEN_INFORMED_CONSENT))
    {
        auto* dialog = new QMessageBox(
            QMessageBox::Information,
            QString(),
            tr("<b>Transmission is a file sharing program.</b>"),
            QMessageBox::Ok | QMessageBox::Cancel,
            window_.get());
        dialog->setInformativeText(
            tr("When you run a torrent, its data will be made available to others by means of upload. "
               "Any content you share is your sole responsibility."));
        dialog->button(QMessageBox::Ok)->setText(tr("I &Agree"));
        dialog->setDefaultButton(QMessageBox::Ok);
        dialog->setModal(true);

        connect(dialog, &QDialog::finished, this, &Application::consentGiven);

        dialog->setAttribute(Qt::WA_DeleteOnClose);
        dialog->show();
    }

    // torrent files passed in on the command line
    for (QString const& filename : filenames)
    {
        addTorrent(AddData{ filename });
    }

    InteropHelper::registerObject(this);

#ifdef QT_DBUS_LIB
    QDBusConnection bus = QDBusConnection::sessionBus();
    if (bus.isConnected())
    {
        bus.connect(
            fdo_notifications_service_name_,
            fdo_notifications_path_,
            fdo_notifications_interface_name_,
            QLatin1String("ActionInvoked"),
            this,
            SLOT(onNotificationActionInvoked(quint32, QString)));
    }

#endif
}

void Application::loadTranslations()
{
    auto const qt_qm_dirs = QStringList() <<
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
        QLibraryInfo::path(QLibraryInfo::TranslationsPath) <<
#else
        QLibraryInfo::location(QLibraryInfo::TranslationsPath) <<
#endif
#ifdef TRANSLATIONS_DIR
        QStringLiteral(TRANSLATIONS_DIR) <<
#endif
        (applicationDirPath() + QStringLiteral("/translations"));

    QStringList const app_qm_dirs = QStringList() <<
#ifdef TRANSLATIONS_DIR
        QStringLiteral(TRANSLATIONS_DIR) <<
#endif
        (applicationDirPath() + QStringLiteral("/translations"));

    auto const qt_file_name = QStringLiteral("qtbase");

    QLocale const locale;
    QLocale const english_locale(QLocale::English, QLocale::UnitedStates);

    if (loadTranslation(qt_translator_, qt_file_name, locale, qt_qm_dirs) ||
        loadTranslation(qt_translator_, qt_file_name, english_locale, qt_qm_dirs))
    {
        installTranslator(&qt_translator_);
    }

    if (loadTranslation(app_translator_, config_name_, locale, app_qm_dirs) ||
        loadTranslation(app_translator_, config_name_, english_locale, app_qm_dirs))
    {
        installTranslator(&app_translator_);
    }
}

void Application::quitLater() const
{
    QTimer::singleShot(0, this, SLOT(quit()));
}

void Application::onTorrentsEdited(torrent_ids_t const& torrent_ids) const
{
    // the backend's tr_info has changed, so reload those fields
    session_->initTorrents(torrent_ids);
}

QStringList Application::getNames(torrent_ids_t const& torrent_ids) const
{
    QStringList names;
    for (auto const& id : torrent_ids)
    {
        names.push_back(model_->getTorrentFromId(id)->name());
    }

    names.sort();
    return names;
}

void Application::onTorrentsAdded(torrent_ids_t const& torrent_ids) const
{
    if (!prefs_->getBool(Prefs::SHOW_NOTIFICATION_ON_ADD))
    {
        return;
    }

    for (auto id : torrent_ids)
    {
        notifyTorrentAdded(model_->getTorrentFromId(id));
    }
}

void Application::onTorrentsCompleted(torrent_ids_t const& torrent_ids) const
{
    if (prefs_->getBool(Prefs::SHOW_NOTIFICATION_ON_COMPLETE))
    {
        auto const title = tr("Torrent(s) Completed", nullptr, static_cast<int>(std::size(torrent_ids)));
        auto const body = getNames(torrent_ids).join(QStringLiteral("\n"));
        notifyApp(title, body);
    }

    if (prefs_->getBool(Prefs::COMPLETE_SOUND_ENABLED))
    {
#if defined(Q_OS_WIN) || defined(Q_OS_MAC)
        beep();
#else
        auto args = prefs_->get<QStringList>(Prefs::COMPLETE_SOUND_COMMAND);
        auto const command = args.takeFirst();
        QProcess::execute(command, args);
#endif
    }
}

void Application::onTorrentsNeedInfo(torrent_ids_t const& torrent_ids) const
{
    if (!torrent_ids.empty())
    {
        session_->initTorrents(torrent_ids);
    }
}

void Application::notifyTorrentAdded(Torrent const* tor) const
{
    QStringList actions;
    actions << QString(QLatin1String("start-now(%1)")).arg(tor->id()) << QObject::tr("Start Now");
    notifyApp(tr("Torrent Added"), tor->name(), actions);
}

/***
****
***/

void Application::consentGiven(int result) const
{
    if (result == QMessageBox::Ok)
    {
        prefs_->set<bool>(Prefs::USER_HAS_GIVEN_INFORMED_CONSENT, true);
    }
    else
    {
        quit();
    }
}

void Application::saveGeometry() const
{
    if (prefs_ != nullptr && window_ != nullptr)
    {
        auto const geometry = window_->geometry();
        prefs_->set(Prefs::MAIN_WINDOW_HEIGHT, std::max(100, geometry.height()));
        prefs_->set(Prefs::MAIN_WINDOW_WIDTH, std::max(100, geometry.width()));
        prefs_->set(Prefs::MAIN_WINDOW_X, geometry.x());
        prefs_->set(Prefs::MAIN_WINDOW_Y, geometry.y());
    }
}

/***
****
***/

void Application::refreshPref(int key) const
{
    switch (key)
    {
    case Prefs::BLOCKLIST_UPDATES_ENABLED:
        maybeUpdateBlocklist();
        break;

    case Prefs::DIR_WATCH:
    case Prefs::DIR_WATCH_ENABLED:
        watch_dir_->setPath(prefs_->getString(Prefs::DIR_WATCH), prefs_->getBool(Prefs::DIR_WATCH_ENABLED));
        break;

    default:
        break;
    }
}

void Application::maybeUpdateBlocklist() const
{
    if (!prefs_->getBool(Prefs::BLOCKLIST_UPDATES_ENABLED))
    {
        return;
    }

    QDateTime const last_updated_at = prefs_->getDateTime(Prefs::BLOCKLIST_DATE);
    QDateTime const next_update_at = last_updated_at.addDays(7);
    QDateTime const now = QDateTime::currentDateTime();

    if (now < next_update_at)
    {
        session_->updateBlocklist();
        prefs_->set(Prefs::BLOCKLIST_DATE, now);
    }
}

void Application::onSessionSourceChanged() const
{
    session_->initTorrents();
    session_->refreshSessionStats();
    session_->refreshSessionInfo();
}

void Application::refreshTorrents()
{
    // usually we just poll the torrents that have shown recent activity,
    // but we also periodically ask for updates on the others to ensure
    // nothing's falling through the cracks.
    time_t const now = time(nullptr);

    if (last_full_update_time_ + 60 >= now)
    {
        session_->refreshActiveTorrents();
    }
    else
    {
        last_full_update_time_ = now;
        session_->refreshAllTorrents();
    }
}

/***
****
***/

void Application::addWatchdirTorrent(QString const& filename) const
{
    auto add_data = AddData{ filename };
    auto const disposal = prefs_->getBool(Prefs::TRASH_ORIGINAL) ? AddData::FilenameDisposal::Delete :
                                                                   AddData::FilenameDisposal::Rename;
    add_data.setFileDisposal(disposal);
    addTorrent(std::move(add_data));
}

void Application::addTorrent(AddData addme) const
{
    if (addme.type == addme.NONE)
    {
        return;
    }

    // if there's not already a disposal action set,
    // then honor the `trash original` preference setting
    if (!addme.fileDisposal() && prefs_->getBool(Prefs::TRASH_ORIGINAL))
    {
        addme.setFileDisposal(AddData::FilenameDisposal::Delete);
    }

    if (!prefs_->getBool(Prefs::OPTIONS_PROMPT))
    {
        session_->addTorrent(addme);
    }
    else
    {
        auto* o = new OptionsDialog(*session_, *prefs_, addme, window_.get());
        o->show();
    }

    raise();
}

/***
****
***/

void Application::raise() const
{
    alert(window_.get());
}

bool Application::notifyApp(QString const& title, QString const& body, QStringList const& actions) const
{
#ifdef QT_DBUS_LIB
    if (auto bus = QDBusConnection::sessionBus(); bus.isConnected())
    {
        QDBusMessage m = QDBusMessage::createMethodCall(
            fdo_notifications_service_name_,
            fdo_notifications_path_,
            fdo_notifications_interface_name_,
            QStringLiteral("Notify"));
        QVariantList args;
        args.append(QStringLiteral("Transmission")); // app_name
        args.append(0U); // replaces_id
        args.append(QStringLiteral("transmission")); // icon
        args.append(title); // summary
        args.append(body); // body
        args.append(actions);
        args.append(QVariantMap({
            std::make_pair(QStringLiteral("category"), QVariant(QStringLiteral("transfer.complete"))),
        })); // hints
        args.append(static_cast<int32_t>(-1)); // use the default timeout period
        m.setArguments(args);
        QDBusReply<quint32> const reply_msg = bus.call(m);

        if (reply_msg.isValid() && reply_msg.value() > 0)
        {
            return true;
        }
    }

#endif

    window_->trayIcon().showMessage(title, body);
    return true;
}

#ifdef QT_DBUS_LIB
void Application::onNotificationActionInvoked(quint32 /* notification_id */, QString action_key)
{
    auto const match = start_now_regex_.match(action_key);
    if (match.hasMatch())
    {
        int const torrent_id = match.captured(1).toInt();
        session_->startTorrentsNow({ torrent_id });
    }
}
#endif

FaviconCache& Application::faviconCache()
{
    return favicons_;
}

/***
****
***/

int tr_main(int argc, char** argv)
{
    tr_locale_set_global("");

    InteropHelper::initialize();

    Application const app(argc, argv);
    return QApplication::exec();
}
