#include "minecraftversion.h"

#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QObject>
#include <QRegularExpression>
#include <QSysInfo>
#include <algorithm>
#include <utility>

namespace {

// Больше десяти уровней inheritsFrom не бывает даже у Forge поверх Fabric-подобных сборок.
constexpr int kMaxInheritanceDepth = 10;

QString versionJsonPath(const QString &gameDir, const QString &id)
{
    return gameDir + "/versions/" + id + "/" + id + ".json";
}

QString versionJarPath(const QString &gameDir, const QString &id)
{
    return gameDir + "/versions/" + id + "/" + id + ".jar";
}

QString versionsRoot(const QString &gameDir)
{
    return gameDir + QStringLiteral("/versions");
}

// Идентификатор версии — это имя папки, поэтому «чинить» его как sanitizeId()
// нельзя: подстановка символов увела бы нас не в ту версию. Только проверяем.
bool isSafeVersionId(const QString &id)
{
    return !id.isEmpty() && !id.startsWith(u'.') && !id.contains(u'/') && !id.contains(u'\\');
}

// Верхний объект <id>.json без разворачивания цепочки: нужен только inheritsFrom.
QJsonObject readVersionObject(const QString &gameDir, const QString &id)
{
    QFile file(versionJsonPath(gameDir, id));
    if (!file.open(QIODevice::ReadOnly))
        return {};
    return QJsonDocument::fromJson(file.readAll()).object();
}

// org.lwjgl:lwjgl:3.3.1:natives-windows -> org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar
QString mavenToPath(const QString &name)
{
    QString coords = name;
    QString extension = QStringLiteral("jar");

    const int at = coords.indexOf(u'@');
    if (at >= 0) {
        extension = coords.mid(at + 1);
        coords.truncate(at);
    }

    const QStringList parts = coords.split(u':');
    if (parts.size() < 3)
        return {};

    QString file = parts[1] + u'-' + parts[2];
    if (parts.size() > 3)
        file += u'-' + parts[3];

    return QString(parts[0]).replace(u'.', u'/') + u'/' + parts[1] + u'/' + parts[2]
           + u'/' + file + u'.' + extension;
}

// Ключ для дедупликации: у наследника и родителя одна и та же библиотека часто
// разных версий, и побеждать должна версия наследника.
QString libraryKey(const QString &name)
{
    const QStringList parts = name.split(u':');
    if (parts.size() < 2)
        return name;
    QString key = parts[0] + u':' + parts[1];
    if (parts.size() > 3)
        key += u':' + parts[3];
    return key;
}

QString mavenClassifier(const QString &name)
{
    const QStringList parts = name.split(u':');
    if (parts.size() < 4)
        return {};
    QString classifier = parts[3];
    const int at = classifier.indexOf(u'@');
    if (at >= 0)
        classifier.truncate(at);
    return classifier;
}

bool ruleMatches(const QJsonObject &rule, const QSet<QString> &features)
{
    if (rule.contains(QStringLiteral("features"))) {
        const QJsonObject wanted = rule.value(QStringLiteral("features")).toObject();
        for (auto it = wanted.constBegin(); it != wanted.constEnd(); ++it) {
            if (it.value().toBool() != features.contains(it.key()))
                return false;
        }
    }

    const QJsonObject os = rule.value(QStringLiteral("os")).toObject();
    if (!os.isEmpty()) {
        if (os.contains(QStringLiteral("name"))
            && os.value(QStringLiteral("name")).toString() != VersionLoader::osName())
            return false;
        if (os.contains(QStringLiteral("arch"))
            && os.value(QStringLiteral("arch")).toString() != VersionLoader::osArch())
            return false;
        if (os.contains(QStringLiteral("version"))) {
            const QRegularExpression re(os.value(QStringLiteral("version")).toString());
            if (!re.isValid() || !re.match(QSysInfo::productVersion()).hasMatch())
                return false;
        }
    }

    return true;
}

void appendArguments(const QJsonArray &source, QStringList &target, const QSet<QString> &features)
{
    for (const QJsonValue &entry : source) {
        if (entry.isString()) {
            target << entry.toString();
            continue;
        }

        const QJsonObject object = entry.toObject();
        if (!VersionLoader::rulesAllow(object.value(QStringLiteral("rules")).toArray(), features))
            continue;

        const QJsonValue value = object.value(QStringLiteral("value"));
        if (value.isString())
            target << value.toString();
        else
            for (const QJsonValue &item : value.toArray())
                target << item.toString();
    }
}

MinecraftLibrary libraryFromDownload(const QString &name,
                                     const QJsonObject &download,
                                     const QString &fallbackPath)
{
    MinecraftLibrary library;
    library.name = name;
    library.path = download.value(QStringLiteral("path")).toString();
    if (library.path.isEmpty())
        library.path = fallbackPath;
    library.url = download.value(QStringLiteral("url")).toString();
    library.sha1 = download.value(QStringLiteral("sha1")).toString();
    library.size = static_cast<qint64>(download.value(QStringLiteral("size")).toDouble());
    return library;
}

void parseLibraries(const QJsonObject &root,
                    QList<MinecraftLibrary> &target,
                    const QSet<QString> &features)
{
    for (const QJsonValue &entry : root.value(QStringLiteral("libraries")).toArray()) {
        const QJsonObject object = entry.toObject();
        if (!VersionLoader::rulesAllow(object.value(QStringLiteral("rules")).toArray(), features))
            continue;

        const QString name = object.value(QStringLiteral("name")).toString();
        if (name.isEmpty())
            continue;

        const QString relativePath = mavenToPath(name);
        const QJsonObject downloads = object.value(QStringLiteral("downloads")).toObject();
        const QJsonObject extract = object.value(QStringLiteral("extract")).toObject();

        QStringList exclude;
        for (const QJsonValue &value : extract.value(QStringLiteral("exclude")).toArray())
            exclude << value.toString();

        // Основной артефакт. В новом формате (1.19+) натив-библиотеки — это
        // отдельные записи с классификатором natives-*, у них тоже есть artifact.
        if (downloads.contains(QStringLiteral("artifact")) || !downloads.contains(QStringLiteral("classifiers"))) {
            MinecraftLibrary library = libraryFromDownload(
                name, downloads.value(QStringLiteral("artifact")).toObject(), relativePath);

            // У Fabric у части библиотек нет блока downloads вообще — только
            // name и адрес Maven-репозитория, путь собираем из координат.
            if (library.url.isEmpty()) {
                const QString repository = object.value(QStringLiteral("url")).toString();
                if (!repository.isEmpty())
                    library.url = repository.endsWith(u'/') ? repository + library.path
                                                            : repository + u'/' + library.path;
            }

            if (!library.path.isEmpty()) {
                library.native = mavenClassifier(name).startsWith(QStringLiteral("natives-"));
                library.extractExclude = exclude;
                target << library;
            }
        }

        // Старый формат (до 1.18): отдельная карта natives + downloads.classifiers.
        const QJsonObject natives = object.value(QStringLiteral("natives")).toObject();
        if (natives.isEmpty())
            continue;

        QString classifier = natives.value(VersionLoader::osName()).toString();
        if (classifier.isEmpty())
            continue;
        classifier.replace(QStringLiteral("${arch}"),
                           VersionLoader::osArch() == QStringLiteral("x86") ? QStringLiteral("32")
                                                                           : QStringLiteral("64"));

        const QJsonObject classifiers = downloads.value(QStringLiteral("classifiers")).toObject();
        MinecraftLibrary library = libraryFromDownload(
            name + u':' + classifier,
            classifiers.value(classifier).toObject(),
            mavenToPath(name + u':' + classifier));
        if (library.path.isEmpty())
            continue;

        library.native = true;
        library.extractExclude = exclude;
        target << library;
    }
}

// Для 1.19+ в списке лежат natives-windows, natives-windows-x86 и natives-windows-arm64
// одновременно — правила фильтруют только по ОС. Оставляем по одной на артефакт.
void pickNativesForCurrentArch(QList<MinecraftLibrary> &libraries)
{
    const QString exact = VersionLoader::nativeClassifier();
    const QString osPart = VersionLoader::osName() == QStringLiteral("osx")
                               ? QStringLiteral("macos")
                               : VersionLoader::osName();
    const QString base = QStringLiteral("natives-") + osPart;

    QHash<QString, QString> chosen;   // group:artifact -> классификатор
    for (const MinecraftLibrary &library : libraries) {
        if (!library.native)
            continue;
        const QStringList parts = library.name.split(u':');
        if (parts.size() < 4)
            continue;
        const QString artifact = parts[0] + u':' + parts[1];
        const QString classifier = mavenClassifier(library.name);
        const QString current = chosen.value(artifact);
        if (current == exact)
            continue;
        if (classifier == exact || current.isEmpty() || classifier == base)
            chosen.insert(artifact, classifier);
    }

    libraries.removeIf([&](const MinecraftLibrary &library) {
        if (!library.native)
            return false;
        const QStringList parts = library.name.split(u':');
        if (parts.size() < 4)
            return false;
        return chosen.value(parts[0] + u':' + parts[1]) != mavenClassifier(library.name);
    });
}

} // namespace

QString VersionLoader::osName()
{
#if defined(Q_OS_WIN)
    return QStringLiteral("windows");
#elif defined(Q_OS_MACOS)
    return QStringLiteral("osx");
#else
    return QStringLiteral("linux");
#endif
}

QString VersionLoader::osArch()
{
    const QString arch = QSysInfo::currentCpuArchitecture();
    if (arch == QLatin1String("i386") || arch == QLatin1String("i686"))
        return QStringLiteral("x86");
    return arch;   // x86_64 / arm64 — совпадают с тем, что пишет Mojang
}

QString VersionLoader::nativeClassifier()
{
    const QString arch = osArch();
    const QString os = osName();

    if (os == QLatin1String("windows")) {
        if (arch == QLatin1String("arm64"))
            return QStringLiteral("natives-windows-arm64");
        if (arch == QLatin1String("x86"))
            return QStringLiteral("natives-windows-x86");
        return QStringLiteral("natives-windows");
    }
    if (os == QLatin1String("osx"))
        return arch == QLatin1String("arm64") ? QStringLiteral("natives-macos-arm64")
                                              : QStringLiteral("natives-macos");
    return arch == QLatin1String("arm64") ? QStringLiteral("natives-linux-arm64")
                                          : QStringLiteral("natives-linux");
}

bool VersionLoader::rulesAllow(const QJsonArray &rules, const QSet<QString> &features)
{
    if (rules.isEmpty())
        return true;

    bool allowed = false;
    for (const QJsonValue &entry : rules) {
        const QJsonObject rule = entry.toObject();
        if (ruleMatches(rule, features))
            allowed = rule.value(QStringLiteral("action")).toString() != QLatin1String("disallow");
    }
    return allowed;
}

QStringList VersionLoader::installedVersions(const QString &gameDir)
{
    QStringList result;
    const QDir versions(gameDir + QStringLiteral("/versions"));
    const QStringList entries = versions.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
    for (const QString &id : entries) {
        if (QFile::exists(versionJsonPath(gameDir, id)))
            result << id;
    }
    return result;
}

QStringList VersionLoader::dependentsOf(const QString &gameDir, const QString &versionId)
{
    if (versionId.isEmpty())
        return {};

    // Одного уровня хватает: профили модлоадеров наследуются от ванильных версий,
    // а не друг от друга.
    QStringList result;
    const QStringList installed = installedVersions(gameDir);
    for (const QString &id : installed) {
        if (id == versionId)
            continue;
        const QJsonObject object = readVersionObject(gameDir, id);
        if (object.value(QStringLiteral("inheritsFrom")).toString() == versionId)
            result << id;
    }
    return result;
}

qint64 VersionLoader::installedSize(const QString &gameDir, const QString &versionId)
{
    if (!isSafeVersionId(versionId))
        return 0;

    qint64 total = 0;
    QDirIterator it(versionsRoot(gameDir) + u'/' + versionId,
                    QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
                    QDirIterator::Subdirectories);
    while (it.hasNext()) {
        it.next();
        total += it.fileInfo().size();
    }
    return total;
}

bool VersionLoader::remove(const QString &gameDir, const QString &versionId, QString *error)
{
    const QString root = versionsRoot(gameDir);
    if (gameDir.isEmpty() || !isSafeVersionId(versionId)) {
        if (error)
            *error = QObject::tr("недопустимый идентификатор версии");
        return false;
    }

    // Удаляем только внутри <gameDir>/versions: проверка id уже не пропустит «..»,
    // но цена ошибки — чужая папка, а проверка стоит одной строки.
    const QString dir = QDir::cleanPath(root + u'/' + versionId);
    if (!dir.startsWith(root + u'/')) {
        if (error)
            *error = QObject::tr("недопустимый идентификатор версии");
        return false;
    }
    if (!QDir(dir).exists())
        return true;
    if (!QDir(dir).removeRecursively()) {
        if (error)
            *error = QObject::tr("не удалось удалить %1").arg(QDir::toNativeSeparators(dir));
        return false;
    }
    return true;
}

MinecraftVersion VersionLoader::load(const QString &gameDir,
                                     const QString &versionId,
                                     const QSet<QString> &features,
                                     QString *error)
{
    MinecraftVersion version;

    // Цепочка от самой производной версии к базовой: Fabric -> 1.20.1.
    QList<QJsonObject> chain;
    QStringList chainIds;
    QString currentId = versionId;

    while (!currentId.isEmpty()) {
        if (chainIds.contains(currentId)) {
            if (error)
                *error = QObject::tr("Циклическая зависимость inheritsFrom в версии «%1»").arg(currentId);
            return version;
        }
        if (chain.size() >= kMaxInheritanceDepth) {
            if (error)
                *error = QObject::tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(versionId);
            return version;
        }

        const QString path = versionJsonPath(gameDir, currentId);
        QFile file(path);
        if (!file.open(QIODevice::ReadOnly)) {
            if (error)
                *error = QObject::tr("Не найден файл версии: %1").arg(QDir::toNativeSeparators(path));
            return version;
        }

        QJsonParseError parseError;
        const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
        if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
            if (error)
                *error = QObject::tr("Повреждён файл версии %1: %2")
                             .arg(QDir::toNativeSeparators(path), parseError.errorString());
            return version;
        }

        const QJsonObject object = document.object();
        chain << object;
        chainIds << currentId;
        currentId = object.value(QStringLiteral("inheritsFrom")).toString();
    }

    version.id = versionId;

    // Скаляры: идём от базовой версии к производной, наследник перекрывает родителя.
    for (int i = chain.size() - 1; i >= 0; --i) {
        const QJsonObject &object = chain[i];

        if (object.contains(QStringLiteral("mainClass")))
            version.mainClass = object.value(QStringLiteral("mainClass")).toString();
        if (object.contains(QStringLiteral("type")))
            version.type = object.value(QStringLiteral("type")).toString();
        if (object.contains(QStringLiteral("assets")))
            version.assetsKind = object.value(QStringLiteral("assets")).toString();
        if (object.contains(QStringLiteral("assetIndex")))
            version.assetIndexId = object.value(QStringLiteral("assetIndex"))
                                       .toObject()
                                       .value(QStringLiteral("id"))
                                       .toString();
        if (object.contains(QStringLiteral("javaVersion")))
            version.javaMajor = object.value(QStringLiteral("javaVersion"))
                                    .toObject()
                                    .value(QStringLiteral("majorVersion"))
                                    .toInt(version.javaMajor);

        const QJsonObject logging = object.value(QStringLiteral("logging"))
                                        .toObject()
                                        .value(QStringLiteral("client"))
                                        .toObject();
        if (!logging.isEmpty()) {
            version.loggingArgument = logging.value(QStringLiteral("argument")).toString();
            const QString fileId = logging.value(QStringLiteral("file"))
                                       .toObject()
                                       .value(QStringLiteral("id"))
                                       .toString();
            if (!fileId.isEmpty())
                version.loggingConfigPath = gameDir + QStringLiteral("/assets/log_configs/") + fileId;
        }

        // minecraftArguments (до 1.13) заменяет список целиком, arguments.game — дополняет.
        if (object.contains(QStringLiteral("minecraftArguments"))) {
            version.gameArgs = object.value(QStringLiteral("minecraftArguments"))
                                   .toString()
                                   .split(QRegularExpression(QStringLiteral("\\s+")),
                                          Qt::SkipEmptyParts);
        }

        const QJsonObject arguments = object.value(QStringLiteral("arguments")).toObject();
        appendArguments(arguments.value(QStringLiteral("jvm")).toArray(), version.jvmArgs, features);
        appendArguments(arguments.value(QStringLiteral("game")).toArray(), version.gameArgs, features);

        const QByteArray raw = QJsonDocument(arguments.value(QStringLiteral("game")).toArray()).toJson();
        if (raw.contains("quickPlayMultiplayer"))
            version.supportsQuickPlay = true;
        if (raw.contains("resolution_width"))
            version.hasCustomResolutionArgs = true;
    }

    if (version.assetIndexId.isEmpty())
        version.assetIndexId = version.assetsKind;
    if (version.assetsKind.isEmpty())
        version.assetsKind = version.assetIndexId;
    if (version.type.isEmpty())
        version.type = QStringLiteral("release");

    // Библиотеки: наследник первым, дубли по group:artifact:classifier отбрасываем.
    QList<MinecraftLibrary> collected;
    for (const QJsonObject &object : chain)
        parseLibraries(object, collected, features);

    QSet<QString> seen;
    for (const MinecraftLibrary &library : std::as_const(collected)) {
        const QString key = libraryKey(library.name);
        if (seen.contains(key))
            continue;
        seen.insert(key);
        version.libraries << library;
    }
    pickNativesForCurrentArch(version.libraries);

    // До 1.13 jvm-аргументов в json нет — собираем их сами.
    const bool hasClasspathArg = std::any_of(
        version.jvmArgs.cbegin(), version.jvmArgs.cend(), [](const QString &argument) {
            return argument == QLatin1String("-cp") || argument == QLatin1String("-classpath");
        });
    if (!hasClasspathArg) {
        version.jvmArgs << QStringLiteral("-Djava.library.path=${natives_directory}")
                        << QStringLiteral("-Dminecraft.launcher.brand=${launcher_name}")
                        << QStringLiteral("-Dminecraft.launcher.version=${launcher_version}")
                        << QStringLiteral("-cp") << QStringLiteral("${classpath}");
    }

    // Клиентский jar: у Fabric-версии он свой, у Forge обычно только у родителя.
    for (const QString &id : std::as_const(chainIds)) {
        const QString jar = versionJarPath(gameDir, id);
        if (QFile::exists(jar)) {
            version.clientJarPath = jar;
            break;
        }
    }
    if (version.clientJarPath.isEmpty())
        version.clientJarPath = versionJarPath(gameDir, chainIds.constLast());

    if (version.mainClass.isEmpty() && error)
        *error = QObject::tr("В файле версии «%1» не указан mainClass").arg(versionId);

    return version;
}
