#include "javainstaller.h"

#include "launcherpaths.h"

#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcess>
#include <QSaveFile>
#include <QTimer>
#include <QtCore/private/qzipreader_p.h>

#include <utility>

// Читатель архива вместе с разобранным оглавлением: fileInfoList() каждый раз
// заново разбирает центральный каталог zip, а записей в JDK десятки тысяч.
struct ZipExtraction
{
    explicit ZipExtraction(const QString &path)
        : reader(path)
    {
    }

    QZipReader reader;
    QList<QZipReader::FileInfo> entries;
    int index = 0;
};

namespace {

constexpr int kTransferTimeoutMs = 30000;
constexpr int kMaxParallel = 4;
constexpr int kMaxAttempts = 3;
constexpr int kProgressIntervalMs = 100;

// Записей zip за один проход таймера. Сотня файлов по паре мегабайт — это
// заметная, но ещё не видимая глазом задержка кадра.
constexpr int kZipChunk = 24;

bool isTransient(QNetworkReply::NetworkError error, int status)
{
    if (status >= 500)
        return true;
    switch (error) {
    case QNetworkReply::TimeoutError:
    case QNetworkReply::TemporaryNetworkFailureError:
    case QNetworkReply::NetworkSessionFailedError:
    case QNetworkReply::ProxyTimeoutError:
    case QNetworkReply::UnknownNetworkError:
        return true;
    default:
        return false;
    }
}

// Путь из манифеста или архива не должен уводить за пределы папки сборки:
// «../../» в имени записи — известный способ подменить чужой файл.
bool isInside(const QString &root, const QString &path)
{
    const QString normalizedRoot = QDir::cleanPath(root) + u'/';
    const QString normalized = QDir::cleanPath(path);
    return normalized.startsWith(normalizedRoot);
}

void makeExecutable(const QString &path)
{
    const QFileDevice::Permissions current = QFile::permissions(path);
    QFile::setPermissions(path,
                          current | QFileDevice::ExeOwner | QFileDevice::ExeGroup
                              | QFileDevice::ExeOther);
}

} // namespace

JavaInstaller::JavaInstaller(QObject *parent)
    : QObject(parent)
    , m_network(new QNetworkAccessManager(this))
    , m_extractTimer(new QTimer(this))
    , m_progressTimer(new QTimer(this))
{
    m_extractTimer->setInterval(0);
    connect(m_extractTimer, &QTimer::timeout, this, &JavaInstaller::extractZipChunk);

    m_progressTimer->setInterval(kProgressIntervalMs);
    connect(m_progressTimer, &QTimer::timeout, this, &JavaInstaller::flushProgress);
}

JavaInstaller::~JavaInstaller() = default;

qint64 JavaInstaller::bytesDone() const
{
    qint64 total = m_bytesSettled;
    for (const auto &context : m_active)
        total += context->received;
    return total;
}

double JavaInstaller::fraction() const
{
    if (m_bytesTotal > 0)
        return qBound(0.0, double(bytesDone()) / double(m_bytesTotal), 1.0);
    if (m_stepsTotal > 0)
        return qBound(0.0, double(m_stepsDone) / double(m_stepsTotal), 1.0);
    return -1.0;
}

void JavaInstaller::install(const JavaRuntimeEntry &entry)
{
    if (m_running)
        return;
    if (!entry.isValid()) {
        emit failed(entry.version, tr("Сборка Java недоступна для загрузки"));
        return;
    }

    QString error;
    if (!LauncherPaths::ensureRootExists(&error)) {
        emit failed(entry.version, tr("Папка лаунчера недоступна: %1").arg(error));
        return;
    }

    m_entry = entry;
    m_label = tr("%1 %2").arg(javaKindTitle(entry.kind), entry.version);
    m_targetDir = JavaRuntimeStore::dirFor(entry.id);
    if (m_targetDir.isEmpty()) {
        emit failed(m_label, tr("Недопустимый идентификатор сборки Java"));
        return;
    }

    // Переустановка поверх недокачанной прошлой попытки: старое дерево мешает
    // и размером, и тем, что locateBinary нашла бы в нём чужой java.
    if (!JavaRuntimeStore::remove(entry.id, &error)) {
        emit failed(m_label, error);
        return;
    }
    if (!QDir().mkpath(m_targetDir)) {
        emit failed(m_label, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(m_targetDir)));
        return;
    }

    m_running = true;
    m_canceling = false;
    m_bytesSettled = 0;
    m_bytesTotal = 0;
    m_stepsDone = 0;
    m_stepsTotal = 0;
    m_currentFile.clear();
    m_links.clear();
    m_queue.clear();

    emit started(m_label);
    if (entry.archive == QLatin1String("mojang"))
        beginMojang();
    else
        beginArchive();
}

void JavaInstaller::cancel()
{
    if (!m_running || m_canceling)
        return;

    m_canceling = true;
    m_extractTimer->stop();

    if (m_reply)
        m_reply->abort();
    for (const auto &context : std::as_const(m_active))
        context->reply->abort();
    if (m_tar)
        m_tar->kill();

    // Прерванная установка оставляет дерево наполовину собранным: запускать из
    // него нечего, а locateBinary могла бы найти в нём java.
    if (m_active.isEmpty() && !m_reply && !m_tar) {
        const QString label = m_label;
        JavaRuntimeStore::remove(m_entry.id, nullptr);
        reset();
        emit canceled(label);
    }
}

// ── Temurin: один архив ─────────────────────────────────────────────────────

void JavaInstaller::beginArchive()
{
    if (m_entry.archive != QLatin1String("zip")
        && m_entry.archive != QLatin1String("tar.gz")) {
        fail(tr("Неизвестный формат архива: %1").arg(m_entry.archive));
        return;
    }

    const QString name = m_entry.url.fileName();
    m_archivePath = LauncherPaths::javaDownloadDir() + u'/'
                    + (name.isEmpty() ? m_entry.id + QStringLiteral(".archive") : name);
    m_bytesTotal = m_entry.size;
    setStage(tr("Загрузка Java %1").arg(m_entry.version));
    downloadArchive();
}

void JavaInstaller::downloadArchive()
{
    m_archiveFile = std::make_unique<QSaveFile>(m_archivePath);
    if (!m_archiveFile->open(QIODevice::WriteOnly)) {
        fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(m_archivePath)));
        return;
    }
    m_archiveHash.reset();
    m_currentFile = QFileInfo(m_archivePath).fileName();

    QNetworkRequest request{m_entry.url};
    request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
                         QNetworkRequest::NoLessSafeRedirectPolicy);
    request.setTransferTimeout(kTransferTimeoutMs);

    m_reply = m_network->get(request);
    connect(m_reply, &QNetworkReply::readyRead, this, [this] {
        const QByteArray chunk = m_reply->readAll();
        if (chunk.isEmpty())
            return;
        m_archiveHash.addData(chunk);
        if (m_archiveFile->write(chunk) != chunk.size())
            m_reply->abort();
    });
    connect(m_reply, &QNetworkReply::downloadProgress, this,
            [this](qint64 received, qint64 total) {
                m_bytesSettled = received;
                if (m_bytesTotal <= 0 && total > 0)
                    m_bytesTotal = total;
                scheduleProgress();
            });
    connect(m_reply, &QNetworkReply::finished, this,
            [this] { handleArchiveFinished(m_reply); });

    scheduleProgress();
}

void JavaInstaller::handleArchiveFinished(QNetworkReply *reply)
{
    reply->deleteLater();
    m_reply = nullptr;

    if (m_canceling) {
        m_archiveFile->cancelWriting();
        m_archiveFile.reset();
        const QString label = m_label;
        JavaRuntimeStore::remove(m_entry.id, nullptr);
        reset();
        emit canceled(label);
        return;
    }
    if (!m_running)
        return;

    if (reply->error() != QNetworkReply::NoError) {
        m_archiveFile->cancelWriting();
        m_archiveFile.reset();
        fail(tr("Не удалось скачать Java %1: %2").arg(m_entry.version, reply->errorString()));
        return;
    }

    if (!m_entry.checksum.isEmpty()) {
        const QString actual = QString::fromLatin1(m_archiveHash.result().toHex());
        if (actual.compare(m_entry.checksum, Qt::CaseInsensitive) != 0) {
            m_archiveFile->cancelWriting();
            m_archiveFile.reset();
            fail(tr("Контрольная сумма архива Java не совпала — загрузка повреждена"));
            return;
        }
    }

    if (!m_archiveFile->commit()) {
        m_archiveFile.reset();
        fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(m_archivePath)));
        return;
    }
    m_archiveFile.reset();

    startExtraction();
}

void JavaInstaller::startExtraction()
{
    // Байты кончились, дальше считаем шаги: панель по нулевому итогу перестанет
    // показывать мегабайты, а проценты продолжит.
    m_bytesTotal = 0;
    m_bytesSettled = 0;
    setStage(tr("Распаковка Java %1").arg(m_entry.version));

    if (m_entry.archive == QLatin1String("tar.gz")) {
        startTarExtraction();
        return;
    }

    m_zip = std::make_unique<ZipExtraction>(m_archivePath);
    if (!m_zip->reader.isReadable()) {
        m_zip.reset();
        fail(tr("Не удалось прочитать архив %1").arg(QDir::toNativeSeparators(m_archivePath)));
        return;
    }

    m_zip->entries = m_zip->reader.fileInfoList();
    m_stepsDone = 0;
    m_stepsTotal = m_zip->entries.size();
    m_extractTimer->start();
}

void JavaInstaller::extractZipChunk()
{
    if (m_canceling || !m_running || !m_zip) {
        m_extractTimer->stop();
        return;
    }

    const QList<QZipReader::FileInfo> &entries = m_zip->entries;
    const int end = qMin(m_zip->index + kZipChunk, entries.size());
    for (; m_zip->index < end; ++m_zip->index) {
        const QZipReader::FileInfo &info = entries.at(m_zip->index);
        const QString target = m_targetDir + u'/' + info.filePath;
        if (!isInside(m_targetDir, target)) {
            m_extractTimer->stop();
            fail(tr("Архив Java содержит недопустимый путь: %1").arg(info.filePath));
            return;
        }

        if (info.isDir) {
            QDir().mkpath(target);
            continue;
        }
        // Ссылок в zip-сборках Temurin не бывает — под Unix они отдают tar.gz.
        if (!info.isFile)
            continue;

        if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
            m_extractTimer->stop();
            fail(tr("Не удалось создать %1")
                     .arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
            return;
        }

        const QByteArray data = m_zip->reader.fileData(info.filePath);
        QFile file(target);
        if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
            m_extractTimer->stop();
            fail(tr("Не удалось распаковать %1").arg(info.filePath));
            return;
        }
        file.close();
        if (info.permissions != QFileDevice::Permissions())
            file.setPermissions(info.permissions);
    }

    m_stepsDone = m_zip->index;
    m_currentFile = m_zip->index > 0 ? entries.at(m_zip->index - 1).filePath : QString();
    scheduleProgress();

    if (m_zip->index >= entries.size()) {
        m_extractTimer->stop();
        m_zip.reset();
        complete();
    }
}

void JavaInstaller::startTarExtraction()
{
    // tar есть в любой macOS и практически в любом дистрибутиве Linux; своего
    // распаковщика gzip-tar в Qt нет, а тянуть ради него зависимость незачем.
    m_tar = new QProcess(this);
    m_tar->setWorkingDirectory(m_targetDir);
    m_tar->setProgram(QStringLiteral("tar"));
    m_tar->setArguments({QStringLiteral("-xzf"), m_archivePath,
                         QStringLiteral("-C"), m_targetDir});

    connect(m_tar, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
        if (!m_tar)
            return;
        const QString message = m_tar->errorString();
        m_tar->deleteLater();
        m_tar = nullptr;
        if (m_canceling) {
            const QString label = m_label;
            JavaRuntimeStore::remove(m_entry.id, nullptr);
            reset();
            emit canceled(label);
            return;
        }
        fail(tr("Не удалось распаковать архив Java: %1").arg(message));
    });

    connect(m_tar, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
        if (!m_tar)
            return;
        const QByteArray errorOutput = m_tar->readAllStandardError();
        m_tar->deleteLater();
        m_tar = nullptr;

        if (m_canceling) {
            const QString label = m_label;
            JavaRuntimeStore::remove(m_entry.id, nullptr);
            reset();
            emit canceled(label);
            return;
        }
        if (status != QProcess::NormalExit || exitCode != 0) {
            fail(tr("Распаковка архива Java не удалась: %1")
                     .arg(QString::fromLocal8Bit(errorOutput).trimmed()));
            return;
        }
        complete();
    });

    m_tar->start();
}

// ── Mojang: дерево файлов ───────────────────────────────────────────────────

void JavaInstaller::beginMojang()
{
    setStage(tr("Список файлов Java %1").arg(m_entry.version));
    m_currentFile = QStringLiteral("manifest.json");

    QNetworkRequest request{m_entry.url};
    request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
                         QNetworkRequest::NoLessSafeRedirectPolicy);
    request.setTransferTimeout(kTransferTimeoutMs);

    m_reply = m_network->get(request);
    connect(m_reply, &QNetworkReply::finished, this, [this] {
        QNetworkReply *reply = m_reply;
        reply->deleteLater();
        m_reply = nullptr;

        if (m_canceling) {
            const QString label = m_label;
            JavaRuntimeStore::remove(m_entry.id, nullptr);
            reset();
            emit canceled(label);
            return;
        }
        if (!m_running)
            return;
        if (reply->error() != QNetworkReply::NoError) {
            fail(tr("Не удалось получить состав рантайма: %1").arg(reply->errorString()));
            return;
        }
        handleManifest(reply->readAll());
    });
}

void JavaInstaller::handleManifest(const QByteArray &payload)
{
    const QJsonObject files = QJsonDocument::fromJson(payload)
                                  .object()
                                  .value(QStringLiteral("files"))
                                  .toObject();
    if (files.isEmpty()) {
        fail(tr("Состав рантайма Java не удалось разобрать"));
        return;
    }
    if (!prepareTree(files))
        return;

    setStage(tr("Загрузка Java %1").arg(m_entry.version));
    if (m_queue.isEmpty()) {
        if (applyLinks())
            complete();
        return;
    }
    pump();
}

bool JavaInstaller::prepareTree(const QJsonObject &files)
{
    m_bytesTotal = 0;

    for (auto it = files.constBegin(); it != files.constEnd(); ++it) {
        const QString target = m_targetDir + u'/' + it.key();
        if (!isInside(m_targetDir, target)) {
            fail(tr("Состав рантайма содержит недопустимый путь: %1").arg(it.key()));
            return false;
        }

        const QJsonObject node = it.value().toObject();
        const QString type = node.value(QStringLiteral("type")).toString();

        if (type == QLatin1String("directory")) {
            if (!QDir().mkpath(target)) {
                fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(target)));
                return false;
            }
            continue;
        }

        if (type == QLatin1String("link")) {
            // Ссылки создаём в конце: их цели ещё не скачаны.
            m_links << qMakePair(target, node.value(QStringLiteral("target")).toString());
            continue;
        }
        if (type != QLatin1String("file"))
            continue;

        const QJsonObject raw = node.value(QStringLiteral("downloads"))
                                    .toObject()
                                    .value(QStringLiteral("raw"))
                                    .toObject();
        const QUrl url(raw.value(QStringLiteral("url")).toString());
        if (url.scheme() != QLatin1String("https"))
            continue;

        JavaFileTask task;
        task.url = url;
        task.path = target;
        task.sha1 = raw.value(QStringLiteral("sha1")).toString();
        task.size = static_cast<qint64>(raw.value(QStringLiteral("size")).toDouble());
        task.executable = node.value(QStringLiteral("executable")).toBool();

        m_bytesTotal += task.size;
        m_queue.enqueue(task);
    }

    m_stepsTotal = m_queue.size();
    m_stepsDone = 0;
    return true;
}

void JavaInstaller::pump()
{
    if (!m_running || m_canceling)
        return;

    while (m_active.size() < kMaxParallel && !m_queue.isEmpty())
        startTask(m_queue.dequeue());

    if (m_active.isEmpty() && m_queue.isEmpty()) {
        if (applyLinks())
            complete();
    }
}

void JavaInstaller::startTask(JavaFileTask task)
{
    const QFileInfo info(task.path);
    if (!QDir().mkpath(info.absolutePath())) {
        fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath())));
        return;
    }

    auto context = std::make_shared<JavaActiveDownload>();
    context->task = task;
    context->file = std::make_unique<QSaveFile>(task.path);
    if (!context->file->open(QIODevice::WriteOnly)) {
        fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path)));
        return;
    }

    QNetworkRequest request{task.url};
    request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
                         QNetworkRequest::NoLessSafeRedirectPolicy);
    request.setTransferTimeout(kTransferTimeoutMs);

    context->reply = m_network->get(request);
    m_active.append(context);
    m_currentFile = QDir(m_targetDir).relativeFilePath(task.path);

    connect(context->reply, &QNetworkReply::readyRead, this, [this, context] {
        const QByteArray chunk = context->reply->readAll();
        if (chunk.isEmpty())
            return;
        context->hash.addData(chunk);
        if (context->file->write(chunk) != chunk.size())
            context->reply->abort();
    });
    connect(context->reply, &QNetworkReply::downloadProgress, this,
            [this, context](qint64 received, qint64) {
                context->received = received;
                scheduleProgress();
            });
    connect(context->reply, &QNetworkReply::finished, this,
            [this, context] { handleFileFinished(context); });

    scheduleProgress();
}

void JavaInstaller::handleFileFinished(const std::shared_ptr<JavaActiveDownload> &context)
{
    context->reply->deleteLater();
    m_active.removeOne(context);

    if (m_canceling) {
        context->file->cancelWriting();
        if (m_active.isEmpty()) {
            const QString label = m_label;
            JavaRuntimeStore::remove(m_entry.id, nullptr);
            reset();
            emit canceled(label);
        }
        return;
    }
    if (!m_running)
        return;

    const int status =
        context->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();

    if (context->reply->error() != QNetworkReply::NoError) {
        context->file->cancelWriting();
        const QString reason = context->reply->errorString();
        if (isTransient(context->reply->error(), status)) {
            if (!retryOrFail(context->task, reason))
                return;
        } else {
            fail(tr("Не удалось скачать %1: %2")
                     .arg(QDir(m_targetDir).relativeFilePath(context->task.path), reason));
            return;
        }
        pump();
        return;
    }

    if (!context->task.sha1.isEmpty()) {
        const QString actual = QString::fromLatin1(context->hash.result().toHex());
        if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) {
            context->file->cancelWriting();
            if (!retryOrFail(context->task, tr("контрольная сумма не совпала")))
                return;
            pump();
            return;
        }
    }

    if (!context->file->commit()) {
        fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path)));
        return;
    }
    if (context->task.executable)
        makeExecutable(context->task.path);

    m_bytesSettled += context->received;
    ++m_stepsDone;
    scheduleProgress();
    pump();
}

bool JavaInstaller::retryOrFail(JavaFileTask task, const QString &reason)
{
    if (++task.attempts >= kMaxAttempts) {
        fail(tr("Не удалось скачать %1: %2")
                 .arg(QDir(m_targetDir).relativeFilePath(task.path), reason));
        return false;
    }
    m_queue.enqueue(task);
    return true;
}

bool JavaInstaller::applyLinks()
{
    for (const auto &link : std::as_const(m_links)) {
        const QString linkPath = link.first;
        if (QFileInfo::exists(linkPath))
            continue;
        if (!QDir().mkpath(QFileInfo(linkPath).absolutePath()))
            continue;

        // Цель относительна самой ссылке — QFile::link кладёт её как есть.
        if (!QFile::link(link.second, linkPath)) {
            // Под Windows символических ссылок в рантаймах Mojang нет, но если
            // они появятся, копия работает не хуже.
            const QString source =
                QDir::cleanPath(QFileInfo(linkPath).absolutePath() + u'/' + link.second);
            if (!QFile::copy(source, linkPath)) {
                fail(tr("Не удалось создать ссылку %1")
                         .arg(QDir(m_targetDir).relativeFilePath(linkPath)));
                return false;
            }
        }
    }
    return true;
}

// ── Завершение ──────────────────────────────────────────────────────────────

void JavaInstaller::complete()
{
    setStage(tr("Проверка сборки Java"));

    const QString javaPath = JavaRuntimeStore::locateBinary(m_targetDir);
    if (javaPath.isEmpty()) {
        fail(tr("В скачанной сборке не нашлось исполняемого файла java"));
        return;
    }
#if !defined(Q_OS_WIN)
    // tar сохраняет права, а вот zip и файлы Mojang без флага executable — нет.
    makeExecutable(javaPath);
#endif

    InstalledJavaRuntime runtime;
    runtime.id = m_entry.id;
    runtime.kind = javaKindKey(m_entry.kind);
    runtime.major = m_entry.major;
    runtime.version = m_entry.version;
    runtime.javaPath = javaPath;
    runtime.size = m_entry.size > 0 ? m_entry.size : m_bytesTotal;

    QString error;
    if (!JavaRuntimeStore::writeMeta(runtime, &error)) {
        fail(error);
        return;
    }

    // Архив нужен был только на распаковку — двести мегабайт в кэше ни к чему.
    if (!m_archivePath.isEmpty())
        QFile::remove(m_archivePath);

    const QString id = m_entry.id;
    reset();
    emit finished(id, javaPath);
}

void JavaInstaller::fail(const QString &message)
{
    const QString label = m_label;
    m_extractTimer->stop();
    m_zip.reset();
    if (!m_archivePath.isEmpty())
        QFile::remove(m_archivePath);
    // Половина рантайма хуже, чем его отсутствие: следующая попытка начнётся с чистой папки.
    JavaRuntimeStore::remove(m_entry.id, nullptr);
    reset();
    emit failed(label, message);
}

void JavaInstaller::reset()
{
    m_running = false;
    m_canceling = false;
    m_progressTimer->stop();
    m_extractTimer->stop();
    m_zip.reset();
    m_queue.clear();
    m_active.clear();
    m_links.clear();
    m_archiveFile.reset();
    m_archivePath.clear();
    m_bytesSettled = 0;
    m_bytesTotal = 0;
    m_stepsDone = 0;
    m_stepsTotal = 0;
    m_stage.clear();
    m_currentFile.clear();
    m_entry = {};
    m_label.clear();
    emit progressChanged();
}

void JavaInstaller::setStage(const QString &stage)
{
    if (m_stage == stage)
        return;
    m_stage = stage;
    emit progressChanged();
}

void JavaInstaller::scheduleProgress()
{
    m_progressDirty = true;
    if (!m_progressTimer->isActive())
        m_progressTimer->start();
}

void JavaInstaller::flushProgress()
{
    if (!m_progressDirty) {
        m_progressTimer->stop();
        return;
    }
    m_progressDirty = false;
    emit progressChanged();
}
