#include "gamesettings.hpp"

#include <QDir>
#include <QProgressDialog>
#include <QRegularExpression>

#include <components/files/configurationmanager.hpp>
#include <components/files/qtconversion.hpp>
#include <components/misc/utf8qtextstream.hpp>

const char Config::GameSettings::sArchiveKey[] = "fallback-archive";
const char Config::GameSettings::sContentKey[] = "content";
const char Config::GameSettings::sDirectoryKey[] = "data";

namespace
{
    template <typename T>
    QList<T> reverse(QList<T> values)
    {
        std::reverse(values.begin(), values.end());
        return values;
    }
}

Config::GameSettings::GameSettings(const Files::ConfigurationManager& cfg)
    : mCfgMgr(cfg)
{
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
    // this needs calling once so Qt can see its stream operators, which it needs when dragging and dropping
    // it's automatic with Qt 6
    qRegisterMetaTypeStreamOperators<SettingValue>("Config::SettingValue");
#endif
}

void Config::GameSettings::validatePaths()
{
    QList<SettingValue> paths = mSettings.values(QString("data"));

    mDataDirs.clear();

    QProgressDialog progressBar("Validating paths", {}, 0, static_cast<int>(paths.size() + 1));
    progressBar.setWindowModality(Qt::WindowModal);
    progressBar.setValue(0);

    for (const auto& dataDir : paths)
    {
        if (QDir(dataDir.value).exists())
        {
            SettingValue copy = dataDir;
            copy.value = QDir(dataDir.value).canonicalPath();
            mDataDirs.append(copy);
        }
        progressBar.setValue(progressBar.value() + 1);
    }

    // Do the same for data-local
    const QString& local = mSettings.value(QString("data-local")).value;

    progressBar.setValue(progressBar.value() + 1);

    if (!local.isEmpty() && QDir(local).exists())
    {
        mDataLocal = QDir(local).canonicalPath();
    }
}

QString Config::GameSettings::getResourcesVfs() const
{
    QString resources = mSettings.value(QString("resources"), { "./resources", "", "" }).value;
    resources += "/vfs";
    return QFileInfo(resources).canonicalFilePath();
}

QList<Config::SettingValue> Config::GameSettings::values(
    const QString& key, const QList<SettingValue>& defaultValues) const
{
    if (!mSettings.values(key).isEmpty())
        return mSettings.values(key);
    return defaultValues;
}

bool Config::GameSettings::containsValue(const QString& key, const QString& value) const
{
    auto [itr, end] = mSettings.equal_range(key);
    while (itr != end)
    {
        if (itr->value == value)
            return true;
        ++itr;
    }
    return false;
}

bool Config::GameSettings::readFile(QTextStream& stream, const QString& context, bool ignoreContent)
{
    if (readFile(stream, mSettings, context, ignoreContent))
    {
        mContexts.push_back(context);
        return true;
    }
    return false;
}

bool Config::GameSettings::readUserFile(QTextStream& stream, const QString& context, bool ignoreContent)
{
    return readFile(stream, mUserSettings, context, ignoreContent);
}

bool Config::GameSettings::readFile(
    QTextStream& stream, QMultiMap<QString, SettingValue>& settings, const QString& context, bool ignoreContent)
{
    QMultiMap<QString, SettingValue> cache;
    QRegularExpression replaceRe("^\\s*replace\\s*=\\s*(.+)$");
    QRegularExpression keyRe("^([^=]+)\\s*=\\s*(.+)$");

    auto initialPos = stream.pos();

    while (!stream.atEnd())
    {
        QString line = stream.readLine();

        if (line.isEmpty() || line.startsWith("#"))
            continue;

        QRegularExpressionMatch match = replaceRe.match(line);
        if (match.hasMatch())
        {
            QString key = match.captured(1).trimmed();
            // Replace composing entries with a replace= line
            if (key == QLatin1String("config") || key == QLatin1String("replace") || key == QLatin1String("data")
                || key == QLatin1String("fallback-archive") || key == QLatin1String("content")
                || key == QLatin1String("groundcover") || key == QLatin1String("fallback"))
                settings.remove(key);
        }
    }

    stream.seek(initialPos);

    while (!stream.atEnd())
    {
        QString line = stream.readLine();

        if (line.isEmpty() || line.startsWith("#"))
            continue;

        QRegularExpressionMatch match = keyRe.match(line);
        if (match.hasMatch())
        {
            QString key = match.captured(1).trimmed();
            SettingValue value{ match.captured(2).trimmed(), value.value, context };

            // Don't remove composing entries
            if (key != QLatin1String("config") && key != QLatin1String("replace") && key != QLatin1String("data")
                && key != QLatin1String("fallback-archive") && key != QLatin1String("content")
                && key != QLatin1String("groundcover") && key != QLatin1String("fallback"))
                settings.remove(key);

            if (key == QLatin1String("config") || key == QLatin1String("user-data") || key == QLatin1String("resources")
                || key == QLatin1String("data") || key == QLatin1String("data-local")
                || key == QLatin1String("load-savegame"))
            {
                // Path line (e.g. 'data=...'), so needs processing to deal with ampersands and quotes
                // The following is based on boost::io::detail::quoted_manip.hpp, but we don't actually use
                // boost::filesystem::path anymore, and use a custom class MaybeQuotedPath which uses Boost-like quoting
                // rules but internally stores as a std::filesystem::path.
                // Caution: This is intentional behaviour to duplicate how Boost and what we replaced it with worked,
                // and we rely on that.
                QChar delim = '\"';
                QChar escape = '&';

                if (value.value.at(0) == delim)
                {
                    QString valueOriginal = value.value;
                    value.value = "";

                    for (QString::const_iterator it = valueOriginal.begin() + 1; it != valueOriginal.end(); ++it)
                    {
                        if (*it == escape)
                            ++it;
                        else if (*it == delim)
                            break;
                        value.value += *it;
                    }
                    value.originalRepresentation = value.value;
                }

                value = processPathSettingValue(value);
            }
            if (ignoreContent && (key == QLatin1String("content") || key == QLatin1String("data")))
                continue;

            auto containsValue = [&](const QMultiMap<QString, SettingValue>& map) {
                for (auto [itr, end] = map.equal_range(key); itr != end; ++itr)
                {
                    if (itr->value == value.value)
                        return true;
                }
                return false;
            };

            if (!containsValue(cache) && !containsValue(settings))
            {
                cache.insert(key, value);
            }
        }
    }

    if (settings.isEmpty())
    {
        settings = cache; // This is the first time we read a file
        validatePaths();
        return true;
    }

    // Merge the changed keys with those which didn't
    settings.unite(cache);
    validatePaths();

    return true;
}

bool Config::GameSettings::writeFile(QTextStream& stream)
{
    // Iterate in reverse order to preserve insertion order
    auto i = mUserSettings.end();
    while (i != mUserSettings.begin())
    {
        i--;

        // path lines (e.g. 'data=...') need quotes and ampersands escaping to match how boost::filesystem::path uses
        // boost::io::quoted
        // We don't actually use boost::filesystem::path anymore, but use a custom class MaybeQuotedPath which uses
        // Boost-like quoting rules but internally stores as a std::filesystem::path.
        // Caution: This is intentional behaviour to duplicate how Boost and what we replaced it with worked, and we
        // rely on that.
        if (i.key() == QLatin1String("config") || i.key() == QLatin1String("user-data")
            || i.key() == QLatin1String("resources") || i.key() == QLatin1String("data")
            || i.key() == QLatin1String("data-local") || i.key() == QLatin1String("load-savegame"))
        {
            stream << i.key() << "=";

            // Equivalent to stream << std::quoted(i.value(), '"', '&'), which won't work on QStrings.
            QChar delim = '\"';
            QChar escape = '&';
            QString string = i.value().originalRepresentation;

            stream << delim;
            for (auto& it : string)
            {
                if (it == delim || it == escape)
                    stream << escape;
                stream << it;
            }
            stream << delim;

            stream << '\n';
            continue;
        }

        stream << i.key() << "=" << i.value().originalRepresentation << "\n";
    }

    return true;
}

bool Config::GameSettings::isOrderedLine(const QString& line)
{
    return line.contains(QRegularExpression("^\\s*fallback-archive\\s*="))
        || line.contains(QRegularExpression("^\\s*fallback\\s*="))
        || line.contains(QRegularExpression("^\\s*data\\s*="))
        || line.contains(QRegularExpression("^\\s*data-local\\s*="))
        || line.contains(QRegularExpression("^\\s*resources\\s*="))
        || line.contains(QRegularExpression("^\\s*groundcover\\s*="))
        || line.contains(QRegularExpression("^\\s*content\\s*="));
}

// Policy:
//
// - Always ignore a line beginning with '#' or empty lines; added above a config
//   entry.
//
// - If a line in file exists with matching key and value, then replace the line with that of mUserSettings.
// - else if only the key matches, remove comment
//
// - If there is no corresponding line in file, add at the end
//
// - Removed content items are saved as comments if the item had any comments.
//   Content items prepended with '##' are considered previously removed.
//
bool Config::GameSettings::writeFileWithComments(QFile& file)
{
    QTextStream stream(&file);
    Misc::ensureUtf8Encoding(stream);

    // slurp
    std::vector<QString> fileCopy;
    QString line = stream.readLine();
    while (!line.isNull())
    {
        fileCopy.push_back(line);
        line = stream.readLine();
    }
    stream.seek(0);

    // empty file, no comments to keep
    if (fileCopy.empty())
        return writeFile(stream);

    QMultiMap<QString, SettingValue> existingSettings;
    QString context = QFileInfo(file).absoluteDir().path();
    if (readFile(stream, existingSettings, context))
    {
        // don't use QMultiMap operator== as mUserSettings may have blank context fields
        // don't use one std::equal with custom predicate as (until Qt 6.4) there was no key-value iterator
        if (std::equal(existingSettings.keyBegin(), existingSettings.keyEnd(), mUserSettings.keyBegin(),
                mUserSettings.keyEnd())
            && std::equal(existingSettings.cbegin(), existingSettings.cend(), mUserSettings.cbegin(),
                [](const SettingValue& l, const SettingValue& r) {
                    return l.originalRepresentation == r.originalRepresentation;
                }))
        {
            // The existing file already contains what we need, don't risk scrambling comments and formatting
            return true;
        }
    }
    stream.seek(0);

    // start
    //   |
    //   |    +----------------------------------------------------------+
    //   |    |                                                          |
    //   v    v                                                          |
    // skip non-"ordered" lines (remove "ordered" lines)                 |
    //   |              ^                                                |
    //   |              |                                                |
    //   |      non-"ordered" line, write saved comments                 |
    //   |              ^                                                |
    //   v              |                                                |
    // blank or comment line, save in temp buffer <--------+             |
    //        |                |                           |             |
    //        |                +------- comment line ------+             |
    //        v                    (special processing '##')             |
    //  "ordered" line                                                   |
    //        |                                                          |
    //        v                                                          |
    // save in a separate map of comments keyed by "ordered" line        |
    //        |                                                          |
    //        +----------------------------------------------------------+
    //
    //
    QRegularExpression settingRegex("^([^=]+)\\s*=\\s*(.+?)\\s*$");
    std::vector<QString> comments;
    auto commentStart = fileCopy.end();
    std::map<QString, std::vector<QString>> commentsMap;
    for (auto iter = fileCopy.begin(); iter != fileCopy.end(); ++iter)
    {
        if (isOrderedLine(*iter))
        {
            // save in a separate map of comments keyed by "ordered" line
            if (!comments.empty())
            {
                QRegularExpressionMatch match = settingRegex.match(*iter);
                if (match.hasMatch())
                {
                    commentsMap[match.captured(1) + "=" + match.captured(2)] = comments;
                    comments.clear();
                    commentStart = fileCopy.end();
                }
                // else do nothing, malformed line
            }

            *iter = QString(); // "ordered" lines to be removed later
        }
        else if ((*iter).isEmpty() || (*iter).contains(QRegularExpression("^\\s*#")))
        {
            // comment line, save in temp buffer
            if (comments.empty())
                commentStart = iter;

            // special removed content processing
            if ((*iter).contains(QRegularExpression("^##content\\s*=")))
            {
                if (!comments.empty())
                {
                    commentsMap[*iter] = comments;
                    comments.clear();
                    commentStart = fileCopy.end();
                }
            }
            else
                comments.push_back(*iter);

            *iter = QString(); // assume to be deleted later
        }
        else
        {
            QRegularExpressionMatch match = settingRegex.match(*iter);

            // blank or non-"ordered" line, write saved comments
            if (!comments.empty() && match.hasMatch() && settingRegex.captureCount() >= 2
                && mUserSettings.find(match.captured(1)) != mUserSettings.end())
            {
                if (commentStart == fileCopy.end())
                    throw std::runtime_error(
                        "Config::GameSettings: failed to parse settings - iterator is past of end of settings file");

                for (const auto& comment : comments)
                {
                    *commentStart = comment;
                    ++commentStart;
                }
                comments.clear();
                commentStart = fileCopy.end();
            }

            // keep blank lines and non-"ordered" lines other than comments

            // look for a key in the line
            if (!match.hasMatch() || settingRegex.captureCount() < 2)
            {
                // no key or no value found in line, replace with a null string which will be removed later
                *iter = QString();
                comments.clear();
                commentStart = fileCopy.end();
                continue;
            }

            // look for a matching key in user settings
            *iter = QString(); // assume no match
            QString key = match.captured(1);
            QString keyVal = match.captured(1) + "=" + match.captured(2);
            QMultiMap<QString, SettingValue>::const_iterator i = mUserSettings.find(key);
            while (i != mUserSettings.end() && i.key() == key)
            {
                // todo: does this need to handle paths?
                QString settingLine = i.key() + "=" + i.value().originalRepresentation;
                QRegularExpressionMatch keyMatch = settingRegex.match(settingLine);
                if (keyMatch.hasMatch())
                {
                    if ((keyMatch.captured(1) + "=" + keyMatch.captured(2)) == keyVal)
                    {
                        *iter = std::move(settingLine);
                        break;
                    }
                }
                ++i;
            }
        }
    }

    // comments at top of file
    for (auto& iter : fileCopy)
    {
        if (iter.isNull())
            continue;

        // Below is based on readFile() code, if that changes corresponding change may be
        // required (for example duplicates may be inserted if the rules don't match)
        if (/*(*iter).isEmpty() ||*/ iter.contains(QRegularExpression("^\\s*#")))
        {
            stream << iter << "\n";
            continue;
        }
    }

    // Iterate in reverse order to preserve insertion order
    QString settingLine;
    auto it = mUserSettings.end();
    while (it != mUserSettings.begin())
    {
        it--;

        // path lines (e.g. 'data=...') need quotes and ampersands escaping to match how boost::filesystem::path uses
        // boost::io::quoted
        // We don't actually use boost::filesystem::path anymore, but use a custom class MaybeQuotedPath which uses
        // Boost-like quoting rules but internally stores as a std::filesystem::path.
        // Caution: This is intentional behaviour to duplicate how Boost and what we replaced it with worked, and we
        // rely on that.
        if (it.key() == QLatin1String("config") || it.key() == QLatin1String("user-data")
            || it.key() == QLatin1String("resources") || it.key() == QLatin1String("data")
            || it.key() == QLatin1String("data-local") || it.key() == QLatin1String("load-savegame"))
        {
            settingLine = it.key() + "=";

            // Equivalent to settingLine += std::quoted(it.value(), '"', '&'), which won't work on QStrings.
            QChar delim = '\"';
            QChar escape = '&';
            QString string = it.value().originalRepresentation;

            settingLine += delim;
            for (auto& iter : string)
            {
                if (iter == delim || iter == escape)
                    settingLine += escape;
                settingLine += iter;
            }
            settingLine += delim;
        }
        else
            settingLine = it.key() + "=" + it.value().originalRepresentation;

        QRegularExpressionMatch match = settingRegex.match(settingLine);
        if (match.hasMatch())
        {
            auto i = commentsMap.find(match.captured(1) + "=" + match.captured(2));

            // check if previous removed content item with comments
            if (i == commentsMap.end())
                i = commentsMap.find("##" + match.captured(1) + "=" + match.captured(2));

            if (i != commentsMap.end())
            {
                std::vector<QString> cLines = i->second;
                for (const auto& cLine : cLines)
                    stream << cLine << "\n";

                commentsMap.erase(i);
            }
        }

        stream << settingLine << "\n";
    }

    // flush any removed settings
    if (!commentsMap.empty())
    {
        auto i = commentsMap.begin();
        for (; i != commentsMap.end(); ++i)
        {
            if (i->first.contains(QRegularExpression("^\\s*content\\s*=")))
            {
                std::vector<QString> cLines = i->second;
                for (const auto& cLine : cLines)
                    stream << cLine << "\n";

                // mark the content line entry for future preocessing
                stream << "##" << i->first << "\n";

                // commentsMap.erase(i);
            }
        }
    }

    // flush any end comments
    if (!comments.empty())
    {
        for (const auto& comment : comments)
            stream << comment << "\n";
    }

    file.resize(file.pos());

    return true;
}

bool Config::GameSettings::hasMaster()
{
    bool result = false;
    QList<SettingValue> content = mSettings.values(QString(Config::GameSettings::sContentKey));
    for (int i = 0; i < content.count(); ++i)
    {
        if (content.at(i).value.endsWith(QLatin1String(".omwgame"), Qt::CaseInsensitive)
            || content.at(i).value.endsWith(QLatin1String(".esm"), Qt::CaseInsensitive))
        {
            result = true;
            break;
        }
    }

    return result;
}

void Config::GameSettings::setContentList(
    const QList<SettingValue>& dirNames, const QList<SettingValue>& archiveNames, const QStringList& fileNames)
{
    remove(sDirectoryKey);
    for (auto const& item : dirNames)
        setMultiValue(sDirectoryKey, item);
    remove(sArchiveKey);
    for (auto const& item : archiveNames)
        setMultiValue(sArchiveKey, item);
    remove(sContentKey);
    for (auto const& item : fileNames)
        setMultiValue(sContentKey, { item });
}

QList<Config::SettingValue> Config::GameSettings::getDataDirs() const
{
    return reverse(mDataDirs);
}

QList<Config::SettingValue> Config::GameSettings::getArchiveList() const
{
    // QMap returns multiple rows in LIFO order, so need to reverse
    return reverse(values(sArchiveKey));
}

QList<Config::SettingValue> Config::GameSettings::getContentList() const
{
    // QMap returns multiple rows in LIFO order, so need to reverse
    return reverse(values(sContentKey));
}

bool Config::GameSettings::isUserSetting(const SettingValue& settingValue) const
{
    return settingValue.context.isEmpty() || settingValue.context == getUserContext();
}

Config::SettingValue Config::GameSettings::processPathSettingValue(const SettingValue& value)
{
    std::filesystem::path path = Files::pathFromQString(value.value);
    std::filesystem::path basePath = Files::pathFromQString(value.context.isEmpty() ? getUserContext() : value.context);
    mCfgMgr.processPath(path, basePath);
    return SettingValue{ Files::pathToQString(path), value.originalRepresentation, value.context };
}

void Config::GameSettings::clear()
{
    mSettings.clear();
    mContexts.clear();
    mUserSettings.clear();
    mDataDirs.clear();
    mDataLocal.clear();
}

QDataStream& Config::operator<<(QDataStream& out, const SettingValue& settingValue)
{
    out << settingValue.value;
    out << settingValue.originalRepresentation;
    out << settingValue.context;
    return out;
}

QDataStream& Config::operator>>(QDataStream& in, SettingValue& settingValue)
{
    in >> settingValue.value;
    in >> settingValue.originalRepresentation;
    in >> settingValue.context;
    return in;
}
