/*
 * SPDX-FileCopyrightText: 2016 Kåre Särs <kare.sars@iki.fi>
 * SPDX-FileCopyrightText: 2024 Christoph Cullmann <cullmann@kde.org>
 *
 * SPDX-License-Identifier: LGPL-2.0-or-later
 */

#include <QCommandLineParser>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QRegularExpression>
#include <QSet>
#include <QString>
#include <QXmlStreamReader>

void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
    QByteArray localMsg = msg.toLocal8Bit();
    const char *file = context.file ? context.file : "";
    const char *function = context.function ? context.function : "";
    switch (type) {
    case QtDebugMsg:
        fprintf(stderr, "Debug: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
        break;
    case QtInfoMsg:
        fprintf(stderr, "Info: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
        break;
    case QtWarningMsg:
        fprintf(stderr, "Warning: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
        break;
    case QtCriticalMsg:
        fprintf(stderr, "Critical: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
        break;
    case QtFatalMsg:
        fprintf(stderr, "Fatal: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
        break;
    }
}

/**
 * Check if this file is a duplicate of an other on, dies then.
 * @param fileName file to check
 */
static void checkForDuplicates(const QString &fileName)
{
    // get full content for dupe checking
    QFile in(fileName);
    if (!in.open(QIODevice::ReadOnly)) {
        qFatal() << "failed to open" << in.fileName() << "for duplication checking";
    }

    // simplify content to catch files that just have spacing diffs
    // that should not matter for SVGs
    const auto fullContent = in.readAll().simplified();
    QCryptographicHash hasher{QCryptographicHash::Sha256};

    hasher.addData(fullContent);
    auto hash = hasher.result();

    // see if we did have this content already and die
    static QHash<QByteArray, QString> contentToFileName;
    if (const auto it = contentToFileName.find(hash); it != contentToFileName.end()) {
        qFatal() << "file" << fileName << "is a duplicate of file" << it.value();
    }
    contentToFileName.insert(hash, fileName);
}

/**
 * Validate the XML, dies on errors.
 * @param fileName file to validate
 */
static void validateXml(const QString &fileName)
{
    // read once and bail out on errors
    QFile in(fileName);
    if (!in.open(QIODevice::ReadOnly)) {
        qFatal() << "failed to open" << in.fileName() << "for XML validation";
    }
    QXmlStreamReader xml(&in);
    while (!xml.atEnd()) {
        xml.readNext();
    }
    if (xml.hasError()) {
        qFatal() << "XML error " << xml.errorString() << "in file" << in.fileName() << "at line" << xml.lineNumber();
    }
}

/**
 * Given directories and a file, resolve real links & pseudo symlinks we get from Git on Windows.
 * Does some consistency checks, will die if they fail.
 *
 * @param indirs directories that might contain link targets
 * @param indir input directory the current file we handle is inside
 * @param fileName full file name of the file to resolve, if some link
 * @return target of the link or empty string if no link
 */
static QString resolveLink(const QStringList &indirs, const QString &indir, const QString &fileName)
{
    // real link or lnk?
    bool isLink = false;
    QString symlinkTarget;
    if (QFileInfo fi(fileName); fi.isSymLink() || fi.isSymbolicLink() || fi.isShortcut()) {
        // this will return the absolute path (even if non-existing)
        symlinkTarget = fi.symLinkTarget();
        isLink = true;
    }

    // windows pseudo git link?
    else {
        QFile in(fileName);
        if (!in.open(QIODevice::ReadOnly)) {
            qFatal() << "resolveLink: failed to open" << fileName << "to check for Windows Git symlink.";
        }

        const QString firstLine = QString::fromUtf8(in.readLine());
        if (firstLine.isEmpty()) {
            return QString();
        }
        QRegularExpression fNameReg(QStringLiteral("(.*\\.svg)$"));
        QRegularExpressionMatch match = fNameReg.match(firstLine);
        if (!match.hasMatch()) {
            return QString();
        }

        // construct absolute link target, like we would get from symLinkTarget()
        symlinkTarget = QFileInfo(fi.path() + QLatin1Char('/') + match.captured(1)).absoluteFilePath();
        isLink = true;
    }

    // no link? nothing to do
    if (!isLink)
        return QString();

    // if we failed to get any target, bad
    if (symlinkTarget.isEmpty()) {
        qFatal() << "Invalid link" << fileName << "with empty target found!";
    }

    // make link relative to current input dir
    const auto relativeLink = QDir(indir).relativeFilePath(symlinkTarget);

    // search if seen in any input directories
    QString foundTarget, usedInDir;
    QStringList triedTargets;
    for (const auto &dir : indirs) {
        const auto targetFile = QDir(dir).absoluteFilePath(relativeLink);
        triedTargets << targetFile;
        if (QFile::exists(targetFile)) {
            foundTarget = targetFile;
            usedInDir = dir;
            break;
        }
    }

    // we failed to find the target of the link
    if (foundTarget.isEmpty()) {
        qFatal() << "Invalid link" << fileName << "with non-existing target" << relativeLink << "found, tried the targets" << triedTargets;
    }

    // recurse if needed
    if (const QString aliasLink = resolveLink(indirs, usedInDir, foundTarget); !aliasLink.isEmpty()) {
        return aliasLink;
    }

    // else just return the current target
    return foundTarget;
}

/**
 * Generates for the given directories a resource file with the full icon theme.
 * Does some consistency checks, will die if they fail.
 *
 * @param indirs directories that contains the icons of the theme, the first is the versioned stuff,
 *               the remainings contain generated icons
 * @param outfile QRC file to generate
 */
static void generateQRCAndCheckInputs(const QStringList &indirs, const QString &outfile)
{
    // loop over the inputs, remember if we do look at generated stuff for checks
    // if we get just one dir, that is a generated one
    bool generatedIcons = indirs.size() == 1;
    QSet<QString> checkedFiles, filesInResource;
    QList<QString> lines;
    bool themeFileFound = false, icons24Seen = false;
    QStringList absIndirs;
    for (const auto &indir : indirs) {
        const QFileInfo dirInfo(indir);
        absIndirs << dirInfo.canonicalFilePath();
    }
    for (const auto &indir : absIndirs) {
        // go to input dir to have proper relative paths
        if (!QDir::setCurrent(indir)) {
            qFatal() << "Failed to switch to input directory" << indir;
        }

        // we look at all interesting files in the indir and create a qrc with resolved symlinks
        // we need QDir::System to get broken links for checking
        QDirIterator it(QStringLiteral("."), {QStringLiteral("*.theme"), QStringLiteral("*.svg")}, QDir::Files | QDir::System, QDirIterator::Subdirectories);
        while (it.hasNext()) {
            // ensure nice path without ./ and Co.
            const auto file = QDir::current().relativeFilePath(it.next());
            const QFileInfo fileInfo(file);

            // icons name shall not contain any kind of space
            for (const auto &c : file) {
                if (c.isSpace()) {
                    qFatal() << "Invalid file" << file << "with spaces in the name in input directory" << indir;
                }
            }

            // per default we write the relative name as alias and the full path to pack in
            // allows to generate the resource out of source
            auto fullPath = fileInfo.absoluteFilePath();

            // real symlink resolving for Unices, the rcc compiler ignores such files in -project mode
            // we need to look them up in all input dirs
            // handles pseudo link files generated by Git on Windows, too
            if (const auto aliasLink = resolveLink(absIndirs, indir, fullPath); !aliasLink.isEmpty()) {
                fullPath = aliasLink;
            }

            // do some checks for SVGs
            // do checks just once, if we encounter this multiple times because of aliasing
            if (fullPath.endsWith(QLatin1String(".svg")) && !checkedFiles.contains(fullPath)) {
                // fill our guard
                checkedFiles.insert(fullPath);

                // validate it as XML if it is an SVG
                validateXml(fullPath);

                // do duplicate check for non-generated icons
                if (!generatedIcons) {
                    checkForDuplicates(fullPath);
                }
            } else if (fullPath.endsWith(QLatin1String(".theme"))) {
                themeFileFound = true;
            }

            lines.append(QStringLiteral("    <file alias=\"%1\">%2</file>\n").arg(file, fullPath));

            // remember for checks below
            filesInResource.insert(file);
            if (!icons24Seen) {
                icons24Seen = file.contains(QLatin1String("/24/"));
            }
        }

        // starting with the second directory we look at generated icons, in all cases
        generatedIcons = true;
    }

    // Write the resource file
    QFile out(outfile);
    if (!out.open(QIODevice::WriteOnly)) {
        qFatal() << "Failed to create" << outfile;
    }
    out.write("<!DOCTYPE RCC><RCC version=\"1.0\">\n");
    out.write("<qresource>\n");

    std::sort(lines.begin(), lines.end());
    for (const auto &line : lines) {
        out.write(line.toUtf8());
    }

    out.write("</qresource>\n");
    out.write("</RCC>\n");

    if (!themeFileFound) {
        // without any theme file the icon theme will not work at runtime
        qFatal() << "No theme file found!";
    }

    // ensure some 24 links are there, if any 24 stuff got generated
    // see https://invent.kde.org/frameworks/breeze-icons/-/merge_requests/467
    if (icons24Seen) {
        for (const QString &knownIcon : {QStringLiteral("actions/24/list-remove-symbolic.svg")}) {
            if (!filesInResource.contains(knownIcon)) {
                qFatal() << "Generated 24px icon link" << knownIcon << "missing!";
            }
        }
    }
}

int main(int argc, char *argv[])
{
    qInstallMessageHandler(myMessageOutput);
    QCoreApplication app(argc, argv);

    QCommandLineParser parser;
    QCommandLineOption outOption(QStringList() << QLatin1String("o") << QLatin1String("outfile"), QStringLiteral("Output qrc file"), QStringLiteral("outfile"));
    parser.setApplicationDescription(QLatin1String("Create a resource file from the given input directories handling symlinks and pseudo symlink files."));
    parser.addHelpOption();
    parser.addVersionOption();
    parser.addOption(outOption);
    parser.process(app);

    // do the generation and checks, will die on errors
    generateQRCAndCheckInputs(parser.positionalArguments(), parser.value(outOption));
    return 0;
}
