// SPDX-FileCopyrightText: 2018 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT

#define QT_NO_CAST_FROM_ASCII  // avoids unexpected implicit casts
#include "common/LinkParser.hpp"

#include "common/QLogging.hpp"
#include "util/QCompareTransparent.hpp"

#include <QFile>
#include <QString>
#include <QStringView>
#include <QTextStream>

#include <set>

namespace {

using namespace chatterino;

using TldSet = std::set<QString, QCompareCaseInsensitive>;

TldSet &tlds()
{
    static TldSet tlds = [] {
        QFile file(QStringLiteral(":/tlds.txt"));
        bool ok = file.open(QFile::ReadOnly);
        if (!ok)
        {
            assert(false && "Resources not available");
            qCWarning(chatterinoApp) << "Resources not available";
        }
        QTextStream stream(&file);

#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
        // Default encoding of QTextStream is already UTF-8, at least in Qt6
#else
        stream.setCodec("UTF-8");
#endif

        TldSet set;

        while (!stream.atEnd())
        {
            set.emplace(stream.readLine());
        }

        return set;
    }();
    return tlds;
}

bool isValidTld(QStringView tld)
{
    return tlds().contains(tld);
}

bool isValidIpv4(QStringView host)
{
    // We don't care about the actual value,
    // we only want to verify the ip.

    char16_t sectionValue = 0;  // 0..256
    uint8_t octetNumber = 0;    // 0..4
    uint8_t sectionDigits = 0;  // 0..3
    bool lastWasDot = true;

    for (auto c : host)
    {
        char16_t current = c.unicode();
        if (current == '.')
        {
            if (lastWasDot || octetNumber == 3)
            {
                return false;
            }
            lastWasDot = true;
            octetNumber++;
            sectionValue = 0;
            sectionDigits = 0;
            continue;
        }
        lastWasDot = false;

        if (current > u'9' || current < u'0')
        {
            return false;
        }

        sectionValue = sectionValue * 10 + (current - u'0');
        sectionDigits++;
        if (sectionValue >= 256 || sectionDigits > 3)
        {
            return false;
        }
    }

    return octetNumber == 3 && !lastWasDot;
}

/**
 * @brief Checks if the string starts with a port number.
 * 
 * The value of the port number isn't checked. A port in this implementation
 * can be in the range 0..100'000.
 */
bool startsWithPort(QStringView string)
{
    for (qsizetype i = 0; i < std::min<qsizetype>(5, string.length()); i++)
    {
        char16_t c = string[i].unicode();
        if (i >= 1 && (c == u'/' || c == u'?' || c == u'#'))
        {
            return true;
        }

        if (!string[i].isDigit())
        {
            return false;
        }
    }
    return true;
}

/// @brief Strips ignored characters off @a source
///
/// As per https://github.github.com/gfm/#autolinks-extension-:
///
/// '<', '*', '_', '~', and '(' are ignored at the beginning
/// '>', '?', '!', '.', ',', ':', '*', '~', and ')' are ignored at the end.
///
/// A difference to GFM is that '_' isn't a valid suffix.
///
/// This might remove more than desired (e.g. "(a.com/(foo))" -> "a.com/(foo").
/// Parentheses are counted after recognizing a valid IP/host.
void strip(QStringView &source)
{
    while (!source.isEmpty())
    {
        auto c = source.first();
        if (c == u'<' || c == u'*' || c == u'_' || c == u'~' || c == u'(')
        {
            source = source.mid(1);
            continue;
        }
        break;
    }

    while (!source.isEmpty())
    {
        auto c = source.last();
        if (c == u'>' || c == u'?' || c == u'!' || c == u'.' || c == u',' ||
            c == u':' || c == u'*' || c == u'~' || c == u')')
        {
            source.chop(1);
            continue;
        }
        break;
    }
}

/// @brief Checks if @a c is valid in a domain
///
/// Valid characters are 0-9, A-Z, a-z, '-', '_', and '.' (like in GFM)
/// and all non-ASCII characters (unlike in GFM).
Q_ALWAYS_INLINE bool isValidDomainChar(char16_t c)
{
    return c >= 0x80 || (u'0' <= c && c <= u'9') || (u'A' <= c && c <= u'Z') ||
           (u'a' <= c && c <= u'z') || c == u'_' || c == u'-' || c == u'.';
}

}  // namespace

namespace chatterino::linkparser {

std::optional<Parsed> parse(QStringView source) noexcept
{
    using SizeType = QString::size_type;

    std::optional<Parsed> result;
    // This is not implemented with a regex to increase performance.

    QStringView link{source};
    strip(link);

    QStringView remaining = link;
    QStringView protocol;

    // Check protocol for https?://
    if (remaining.startsWith(u"http", Qt::CaseInsensitive) &&
        remaining.length() >= 4 + 3 + 1)  // 'http' + '://' + [any]
    {
        // optimistic view assuming there's a protocol (http or https)
        auto withProto = remaining.mid(4);  // 'http'

        if (withProto[0] == QChar(u's') || withProto[0] == QChar(u'S'))
        {
            withProto = withProto.mid(1);
        }

        if (withProto.startsWith(u"://"))
        {
            // there's really a protocol => consume it
            remaining = withProto.mid(3);
            protocol = {link.begin(), remaining.begin()};
        }
    }

    // Http basic auth `user:password` isn't supported for security reasons (misleading links)

    // Host `a.b.c.com`
    QStringView host = remaining;
    QStringView rest;
    bool lastWasDot = true;
    SizeType lastDotPos = -1;
    SizeType nDots = 0;

    // Extract the host
    for (SizeType i = 0; i < remaining.size(); i++)
    {
        char16_t currentChar = remaining[i].unicode();
        if (currentChar == u'.')
        {
            if (lastWasDot)  // no double dots ..
            {
                return result;
            }
            lastDotPos = i;
            lastWasDot = true;
            nDots++;
        }
        else
        {
            lastWasDot = false;
        }

        // found a port
        if (currentChar == u':')
        {
            host = remaining.mid(0, i);
            rest = remaining.mid(i);
            remaining = remaining.mid(i + 1);

            if (!startsWithPort(remaining))
            {
                return result;
            }

            break;
        }

        // we accept everything in the path/query/anchor
        if (currentChar == u'/' || currentChar == u'?' || currentChar == u'#')
        {
            host = remaining.mid(0, i);
            rest = remaining.mid(i);
            break;
        }

        if (!isValidDomainChar(currentChar))
        {
            return result;
        }
    }

    if (lastWasDot || lastDotPos <= 0)
    {
        return result;
    }

    // check host/tld
    if ((nDots == 3 && isValidIpv4(host)) ||
        isValidTld(host.mid(lastDotPos + 1)))
    {
        // scan for parentheses (only if there were characters excluded)
        if (link.end() != source.end() && !rest.empty())
        {
            size_t nestingLevel = 0;
            // position after the last closing brace (i.e. the minimum characters to include)
            const auto *lastClose = link.end();

            // scan source from rest until the end:
            //                            lastClose
            //                                v
            // (example.com/foo/bar/#baz_(qox)),
            //             ▏╌╌rest (before)╌ ▏
            //  ▏╌╌╌╌╌╌╌link (before)╌╌╌╌╌╌╌ ▏
            //             ▏╌╌rest (after)╌╌╌ ▏
            //  ▏╌╌╌╌╌╌╌link (after)╌╌╌╌╌╌╌╌╌ ▏
            // ▏╌╌╌╌╌╌╌╌╌╌╌╌╌source╌╌╌╌╌╌╌╌╌╌╌╌ ▏
            //             ▏╌╌╌╌╌╌╌search╌╌╌╌╌╌ ▏
            for (const auto *it = rest.begin(); it < source.end(); it++)
            {
                if (it->unicode() == u'(')
                {
                    nestingLevel++;
                    continue;
                }

                if (nestingLevel != 0 && it->unicode() == u')')
                {
                    nestingLevel--;
                    if (nestingLevel == 0)
                    {
                        lastClose = it + 1;
                    }
                }
            }
            link = QStringView{link.begin(), std::max(link.end(), lastClose)};
            rest = QStringView{rest.begin(), std::max(rest.end(), lastClose)};
        }
        result = Parsed{
            .protocol = protocol,
            .host = host,
            .rest = rest,
            .link = link,
        };
    }

    return result;
}

}  // namespace chatterino::linkparser
