#include "xdg-app-database.hpp"
#include "environment.hpp"
#include "internal/wayland/xdg-activation.hpp"
#include "services/app-service/abstract-app-db.hpp"
#include "services/app-service/xdg/xdg-app.hpp"
#include "utils.hpp"
#include "xdgpp/desktop-entry/entry.hpp"
#include "xdgpp/desktop-entry/exec.hpp"
#include "xdgpp/desktop-entry/file.hpp"
#include "xdgpp/mime/iterator.hpp"
#include <algorithm>
#include <qstandardpaths.h>
#include <qtenvironmentvariables.h>
#include <ranges>
#include "xdgpp/xdg-terminal-exec/xdg-terminals-list.hpp"
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusReply>
#include <filesystem>
#include <memory>
#include <qcontainerfwd.h>
#include <qlogging.h>
#include <qsettings.h>

#include <queue>
#include <qurl.h>
#include <set>
#include <QDir>
#include <unordered_set>
#include <xdgpp/env/env.hpp>
#include <xdgpp/mime/mime-apps-list.hpp>

namespace fs = std::filesystem;

using AppPtr = XdgAppDatabase::AppPtr;

// This is non standard, and isn't set correctly in most environments
// This will be deprecated in favor of xdg-terminal-exec compliance
static constexpr auto FALLBACK_TERMINAL_MIME = "x-scheme-handler/terminal";

namespace {

// TryExec check
bool isExecutable(const AbstractApplication &entry) {
  constexpr auto programExists = [](const QString &text) {
    return !QStandardPaths::findExecutable(text).isEmpty();
  };
  auto &xdg = static_cast<const XdgApplication &>(entry);

  if (auto exec = xdg.data().tryExec(); exec && !programExists(qStringFromStdView(*exec))) return false;

  return programExists(entry.program());
}

bool revealInFileManager(const std::filesystem::path &path) {
  auto const fileUrl = QUrl::fromLocalFile(path.c_str()).toString();
  QDBusInterface iface("org.freedesktop.FileManager1", "/org/freedesktop/FileManager1",
                       "org.freedesktop.FileManager1", QDBusConnection::sessionBus());

  if (!iface.isValid()) { return false; }

  QDBusReply<void> const reply = iface.call("ShowItems", QStringList{fileUrl}, QString{});
  return reply.isValid();
}

std::optional<fs::path> containingFolderTarget(const fs::path &path) {
  std::error_code ec;

  if (!fs::exists(path, ec)) {
    auto parentPath = path.parent_path();
    if (parentPath.empty()) { return std::nullopt; }
    return parentPath;
  }

  if (fs::is_directory(path, ec)) {
    auto parentPath = path.parent_path();
    return parentPath.empty() ? std::optional(path) : std::optional(parentPath);
  }

  return path;
}

} // namespace

std::shared_ptr<AbstractApplication> XdgAppDatabase::defaultForMime(const QString &mime) const {
  for (const auto &list : m_mimeAppsLists) {
    for (const auto &appId : list.defaultAssociations(mime.toStdString())) {
      if (auto appIt = appMap.find(appId.c_str()); appIt != appMap.end() && isExecutable(*appIt->second)) {
        return appIt->second;
      }
    }
  }

  auto openers = findAssociations(mime);

  return openers.empty() ? nullptr : openers.front();
}

AppPtr XdgAppDatabase::findDefaultOpener(const QString &target) const {
  return defaultForMime(mimeNameForTarget(target));
}

bool XdgAppDatabase::scan() {
  appMap.clear();
  m_apps.clear();
  m_mimeAppsLists.clear();
  m_dataDirToApps.clear();
  m_mimeAppsLists = xdgpp::getAllMimeAppsLists();

  std::set<std::string> seen;

  for (const auto &dir : searchPaths()) {
    std::error_code ec;

    for (const auto &entry :
         fs::recursive_directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
      if (!entry.is_regular_file(ec)) continue;
      if (!entry.path().filename().string().ends_with(".desktop")) continue;

      std::string const id = xdgpp::DesktopFile::relativeId(entry.path(), dir);

      if (seen.contains(id)) continue;
      seen.insert(id);

      auto file = xdgpp::DesktopFile::fromFile(entry.path(), dir);

      // we no longer check TryExec here, we still want to track the app even if it's
      // not executable at scan time, because it may become executable later.
      if (file.deleted()) continue;

      if (file.errorMessage()) {
        qWarning() << "Desktop file" << file.path().c_str() << "is invalid" << *file.errorMessage();
        continue;
      }

      auto app = std::make_shared<XdgApplication>(file);

      m_apps.emplace_back(app);
      m_dataDirToApps[dir].emplace_back(app);
      appMap[app->id()] = app;

      for (const auto &action : app->actions()) {
        appMap[action->id()] = action;
      }
    }
  }

  return true;
}

AppPtr XdgAppDatabase::findDefaultTerminalFromSpec() const {
  std::unordered_set<std::string> seen;
  std::unordered_set<std::string> excluded;
  std::vector<std::string> selected;
  std::error_code ec;

  // we store app + action as "app.action" unlike the spec that uses "app:action"
  auto toMapKey = [](const xdgpp::XdgTerminalEntry &e) -> std::string {
    if (e.actionId) return std::string(e.entryId) + "." + std::string(*e.actionId);
    return std::string(e.entryId);
  };

  for (const auto &path : xdgpp::xdgTerminalsListPaths()) {
    if (!fs::is_regular_file(path, ec)) continue;

    xdgpp::parseXdgTerminalsList(path,
                                 [&](xdgpp::XdgTerminalEntry entry, xdgpp::XdgTerminalSelectionState state) {
                                   auto key = toMapKey(entry);
                                   if (seen.contains(key)) return;
                                   seen.emplace(key);

                                   switch (state) {
                                   case xdgpp::XdgTerminalSelectionState::Selected:
                                     selected.emplace_back(std::move(key));
                                     break;
                                   case xdgpp::XdgTerminalSelectionState::FallbackExcluded:
                                     excluded.emplace(std::move(key));
                                     break;
                                   case xdgpp::XdgTerminalSelectionState::FallbackProtected:
                                     break;
                                   }
                                 });
  }

  for (const auto &key : selected) {
    if (auto it = appMap.find(key.c_str()); it != appMap.end()) {
      // NOLINTNEXTLINE(cppcoreguidelines-pro-type-static-cast-downcast)
      auto &xdgApp = static_cast<XdgApplication &>(*it->second);
      if (xdgApp.isTerminalEmulator() && !xdgApp.data().deleted()) { return it->second; }
    }
  }

  for (const auto &app : m_apps) {
    if (!excluded.contains(app->id().toStdString()) && app->isTerminalEmulator() && !app->data().deleted() &&
        app->data().matchesCurrentDesktop()) {
      return app;
    }
  }

  return nullptr;
}

AppPtr XdgAppDatabase::terminalEmulator() const {
  if (auto emulator = findDefaultTerminalFromSpec()) return emulator;
  if (auto emulator = defaultForMime(FALLBACK_TERMINAL_MIME)) { return emulator; }

  auto terms = m_apps | std::views::filter([](auto &&app) { return app->isTerminalEmulator(); });
  auto findWithProg = [&](const QString &prog) -> std::optional<AppPtr> {
    if (auto it = std::ranges::find_if(terms, [&](auto &&app) { return app->program() == prog; });
        it != terms.end()) {
      return *it;
    }
    return std::nullopt;
  };

  if (Environment::isGnomeEnvironment()) {
    if (auto target = findWithProg("kgx")) { return *target; }
    if (auto target = findWithProg("gnome-terminal")) { return *target; }
  }

  else if (Environment::isPlasmaDesktop()) {
    if (auto target = findWithProg("konsole")) { return *target; }
  }

  else if (Environment::isCosmicDesktop()) {
    if (auto target = findWithProg("cosmic-term")) { return *target; }
  }

  if (!terms.empty()) { return terms.front(); }

  return nullptr;
}

AppPtr XdgAppDatabase::fileBrowser() const {
  if (auto browser = defaultForMime("inode/directory")) { return browser; }
  if (auto browser = findByCategory("FileManager")) { return browser; }

  for (const auto &app : m_apps) {
    if (app->data().supportsMime("inode/directory")) { return app; }
  }

  return nullptr;
}

AppPtr XdgAppDatabase::genericTextEditor() const {
  if (auto editor = defaultForMime("text/plain")) { return editor; }
  if (auto editor = findByCategory("TextEditor")) { return editor; }

  for (const auto &app : m_apps) {
    if (app->data().supportsMime("text/plain")) { return app; }
  }

  return nullptr;
}

AppPtr XdgAppDatabase::webBrowser() const {
  if (auto browser = defaultForMime("x-scheme-handler/https")) { return browser; }
  if (auto browser = defaultForMime("x-scheme-handler/http")) { return browser; }
  if (auto browser = defaultForMime("text/html")) { return browser; }
  if (auto browser = findByCategory("WebBrowser")) { return browser; }

  for (const auto &app : m_apps) {
    if (app->data().supportsMime("x-scheme-handler/https") ||
        app->data().supportsMime("x-scheme-handler/http")) {
      return app;
    }
  }

  return nullptr;
}

bool XdgAppDatabase::showInFileBrowser(const fs::path &path, bool select) const {
  auto const browser = fileBrowser();
  if (!browser) { return false; }

  if (!select) { return launch(*browser, {path.c_str()}); }

  std::error_code ec;
  if (fs::exists(path, ec) && revealInFileManager(path)) { return true; }

  auto target = containingFolderTarget(path);
  if (!target) { return false; }

  // These file managers don't work correctly when they're passed a file path.
  // We work around this by passing the parent folder path instead.
  static const std::set<QString> exceptions = {"org.kde.dolphin.desktop", "ranger.desktop"};

  if (exceptions.contains(browser->id())) {
    if (auto parentPath = target->parent_path(); !parentPath.empty() && *target == path) {
      return launch(*browser, {parentPath.c_str()});
    }
  }

  return launch(*browser, {target->c_str()});
}

bool XdgAppDatabase::openLocation(const AbstractApplication &app) const {
  auto path = QString::fromStdString(app.path());
  const auto opener = findDefaultOpener(path);

  if (!opener) return false;

  return launch(*opener, {std::move(path)});
}

AppPtr XdgAppDatabase::locationOpener(const AbstractApplication &app) const {
  return findDefaultOpener(QString::fromStdString(app.path()));
}

std::vector<fs::path> XdgAppDatabase::defaultSearchPaths() const { return xdgpp::appDirs(); }

AppPtr XdgAppDatabase::findById(const QString &id) const {
  if (auto it = appMap.find(id); it != appMap.end()) { return it->second; }
  if (auto it = appMap.find(id + ".desktop"); it != appMap.end()) { return it->second; }

  return nullptr;
}

std::vector<AppPtr> XdgAppDatabase::findOpeners(const QString &target) const {
  return findAssociations(mimeNameForTarget(target));
}

// https://specifications.freedesktop.org/mime-apps-spec/latest/associations.html
std::vector<AppPtr> XdgAppDatabase::findAssociations(const QString &mimeName) const {
  std::set<std::string> seen;
  std::vector<AppPtr> openers;
  std::queue<QString> mimeStack;

  mimeStack.emplace(mimeName);

  while (!mimeStack.empty()) {
    auto mime = mimeStack.front();
    mimeStack.pop();

    for (const auto &parent : m_mimeDb.mimeTypeForName(mime).parentMimeTypes()) {
      mimeStack.emplace(parent);
    }

    std::set<std::string> removed;

    // perform a full file tour to find the default if there is one
    for (const auto &list : m_mimeAppsLists) {
      for (const auto &appId : list.defaultAssociations(mime.toStdString())) {
        if (auto appIt = appMap.find(appId.c_str()); appIt != appMap.end() && isExecutable(*appIt->second)) {
          seen.insert(appIt->second->id().toStdString());
          openers.emplace_back(appIt->second);
          break;
        }
      }
    }

    for (const auto &list : m_mimeAppsLists) {
      for (const auto &appId : list.addedAssociations(mime.toStdString())) {
        if (removed.contains(appId) || seen.contains(appId)) continue;
        seen.insert(appId);
        if (auto appIt = appMap.find(appId.c_str()); appIt != appMap.end() && isExecutable(*appIt->second)) {
          openers.emplace_back(appIt->second);
        }
      }

      for (const auto &appId : list.removedAssociations(mime.toStdString())) {
        removed.insert(appId);
      }

      auto dataDir = list.path().parent_path();

      if (auto it = m_dataDirToApps.find(dataDir); it != m_dataDirToApps.end()) {
        for (const auto &app : it->second) {
          std::string const appId = app->id().toStdString();
          if (removed.contains(appId) || seen.contains(appId)) continue;
          if (app->data().supportsMime(mime.toStdString()) && isExecutable(*app)) {
            seen.insert(appId);
            openers.emplace_back(app);
          }
        }
      }
    }
  }

  return openers;
}

xdgpp::DesktopEntry::TerminalExec XdgAppDatabase::inferTermExec(const XdgApplication &app) const {
  using TExec = xdgpp::DesktopEntry::TerminalExec;

  if (app.program() == "gnome-terminal") { return TExec{.exec = "--"}; }
  // new gnome terminal
  if (app.program() == "kgx") { return TExec{.exec = "--", .title = "--title", .dir = "working-directory"}; }
  if (app.program() == "alacritty") {
    return {
        .exec = "-e",
        .appId = "--class",
        .title = "--title",
        .dir = "--working-directory",
        .hold = "--hold",
    };
  }
  if (app.program() == "cosmic-term") { return TExec{.exec = "-e"}; }
  if (app.program() == "konsole") {
    return {
        .exec = "-e",
        .dir = "--workdir",
        .hold = "--hold",
    };
  }
  if (app.program() == "foot" || app.program() == "footclient") {
    return {
        .exec = "-e",
        .appId = "--app-id",
        .title = "--title",
        .dir = "--working-directory",
        .hold = "--hold",
    };
  }
  if (app.program() == "mate-terminal") { return TExec{.exec = "-x"}; }
  if (app.program() == "xfce4-terminal") { return TExec{.exec = "-x"}; }

  return {.exec = "-e"};
}

xdgpp::DesktopEntry::TerminalExec XdgAppDatabase::getTermExec(const XdgApplication &app) const {
  return app.data().terminalExec().value_or(inferTermExec(app));
}

bool XdgAppDatabase::launchTerminalCommand(const std::vector<QString> &cmdline,
                                           const LaunchTerminalCommandOptions &opts) const {
  if (cmdline.empty()) return false;

  auto terminal = opts.emulator;

  if (!terminal) {
    if (auto term = terminalEmulator()) { terminal = term.get(); }
  }

  if (!terminal) {
    qWarning() << "No terminal is available to launch the command";
    return false;
  }

  auto xdgApp =
      static_cast<XdgApplication *>(terminal); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)

  auto exec = xdgApp->parseExec({}, m_launchPrefix);

  if (exec.empty()) return false;

  QStringList argv;
  std::ranges::for_each(exec | std::views::drop(1), [&](auto &&arg) { argv << arg; });
  auto texec = getTermExec(*xdgApp);

  // per the xdg-terminal-exec spec, a flag ending with '=' takes its value appended
  // to the same argument, without whitespace
  auto addFlag = [&argv](const std::string &flag, const QString &value) {
    if (flag.ends_with('=')) {
      argv << QString::fromStdString(flag) + value;
    } else {
      argv << flag.c_str() << value;
    }
  };

  if (texec.appId && opts.appId) { addFlag(*texec.appId, opts.appId.value()); }
  if (texec.title && opts.title) { addFlag(*texec.title, opts.title.value()); }
  if (texec.dir && opts.workingDirectory) { addFlag(*texec.dir, opts.workingDirectory.value()); }
  if (texec.hold && opts.hold) { argv << texec.hold->c_str(); }
  if (texec.exec) { argv << texec.exec->c_str(); }

  for (const auto &arg : cmdline) {
    argv << arg;
  }

  return launchProcess(exec.front(), argv, xdgApp->data().workingDirectory(), opts.appId.value_or(QString()));
}

bool XdgAppDatabase::launchProcess(const QString &prog, const QStringList &args,
                                   const std::optional<std::filesystem::path> &workingDirectory,
                                   const QString &appId) const {
  QProcess process;
  process.setProgram(prog);
  process.setArguments(args);
  process.setStandardOutputFile(QProcess::nullDevice());
  process.setStandardErrorFile(QProcess::nullDevice());

  if (workingDirectory) { process.setWorkingDirectory(workingDirectory->c_str()); }

  if (auto token = Wayland::XdgActivation::requestLaunchToken(appId)) {
    qDebug() << "Successfully minted xdg activation token for app" << appId;
    auto env = QProcessEnvironment::systemEnvironment();
    env.insert("XDG_ACTIVATION_TOKEN", *token);
    process.setProcessEnvironment(env);
  } else {
    qWarning() << "Unable to mint xdg activation token to launch" << appId;
  }

  QStringList cmdline;
  cmdline << prog << args;
  qInfo() << "App started with command line" << cmdline.join(' ');

  if (!process.startDetached()) {
    qWarning() << "Failed to start app:" << process.errorString();
    return false;
  }

  return true;
}

bool XdgAppDatabase::launch(const AbstractApplication &app, const std::vector<QString> &args) const {
  auto &xdgApp =
      static_cast<const XdgApplication &>(app); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)

  if (auto url = xdgApp.data().url().transform(QString::fromStdString)) {
    auto opener = findDefaultOpener(*url);

    if (!opener) {
      qWarning() << "No opener for link entry with url" << url;
      return false;
    }

    return launch(*opener, {*url});
  }

  if (xdgApp.isTerminalApp()) {
    auto wd = xdgApp.data().workingDirectory().transform(QString::fromStdString);
    // we pass a proper appId if the terminal allows it (according to xdg-terminal-exec spec)
    // so that we can identify the terminal app from its own wm class and not the one of the terminal
    // emulator. We don't alter title because it is usually dynamic and useful
    auto opts = LaunchTerminalCommandOptions{
        .appId = xdgApp.windowClass().value_or(xdgApp.id()),
        .workingDirectory = std::move(wd),
    };

    return launchTerminalCommand(xdgApp.parseExec(args), std::move(opts));
  }

  auto exec = xdgApp.parseExec(args, m_launchPrefix);

  if (exec.empty()) {
    qWarning() << "Failed to launch app" << app.id() << "exec command line empty";
    return false;
  }

  auto argv = exec | std::views::drop(1) | std::ranges::to<QStringList>();

  return launchProcess(exec.front(), argv, xdgApp.data().workingDirectory(),
                       xdgApp.windowClass().value_or(xdgApp.id()));
}

QString XdgAppDatabase::mimeNameForTarget(const QString &target) const {
  QString source = target;

  {
    QUrl const url(source);

    if (!url.scheme().isEmpty()) { return "x-scheme-handler/" + url.scheme(); }
  }

  if (m_mimeDb.mimeTypeForName(source).isValid()) { return source; }

  auto mime = m_mimeDb.mimeTypeForFile(source);

  return mime.isValid() ? mime.name() : source;
}

AppPtr XdgAppDatabase::findByCategory(const QString &category) const {
  auto pred = [&](const std::shared_ptr<XdgApplication> &app) {
    return app->data().hasCategory(category.toStdString());
  };
  if (auto it = std::ranges::find_if(m_apps, pred); it != m_apps.end()) { return *it; }
  return nullptr;
}

AppPtr XdgAppDatabase::findByClass(const QString &name) const {
  // we might need to use a map to speed that up
  if (auto it = std::ranges::find_if(m_apps, [&](auto &&app) { return app->matchesWindowClass(name); });
      it != m_apps.end()) {
    return *it;
  }

  return nullptr;
}

std::vector<AppPtr> XdgAppDatabase::list() const { return {m_apps.begin(), m_apps.end()}; }

PreferenceList XdgAppDatabase::preferences() const {
  auto defaultAction = Preference::makeDropdown(
      "defaultAction", {{tr("Focus window"), "focus"}, {tr("Launch app"), "launch"}});
  defaultAction.setDefaultValue("focus");
  defaultAction.setTitle(tr("Default action"));
  defaultAction.setDescription(tr("Action to perform when the return key is pressed. Always default to "
                                  "'launch' if the app has no open window."));

  auto launchPrefix = Preference::makeText("launchPrefix");
  launchPrefix.setTitle(tr("Launch Prefix"));
  launchPrefix.setDescription(
      tr("Custom app launcher to use. Affects applications as well as their sub-actions."));
  launchPrefix.setPlaceholder("uwsm app --");

  std::vector<QString> lockedPaths;
  auto defaults = defaultSearchPaths();
  lockedPaths.reserve(defaults.size());
  for (const auto &searchPath : defaults) {
    lockedPaths.emplace_back(QString::fromStdString(searchPath));
  }

  auto paths = Preference::directories("paths", std::move(lockedPaths));
  paths.setTitle(tr("Application directories"));
  paths.setDescription(
      tr("Directories applications are sourced from. The list cannot be modified directly. In order to do "
         "so, you need to append additonal paths to the <b>XDG_DATA_DIRS</b> environment variables."));
  paths.setReadOnly(true);

  return {defaultAction, launchPrefix, paths};
}

void XdgAppDatabase::applyPreferences(const QJsonObject &preferences) {
  auto val = preferences.value("launchPrefix").toString();
  if (val.isEmpty()) {
    m_launchPrefix = Environment::detectAppLauncher();
  } else {
    m_launchPrefix = val;
  }
}

XdgAppDatabase::XdgAppDatabase() { scan(); }
