#include "dandanplayprovider.h"

#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QMap>
#include <QFileInfo>
#include <QUrl>
#include <QUrlQuery>
#include <algorithm>
#include <cstdlib>
#include <exception>
#include <stdexcept>
#include <utility>

namespace {

QString firstNonEmpty(std::initializer_list<QString> values)
{
    for (const QString &value : values) {
        if (!value.trimmed().isEmpty()) {
            return value.trimmed();
        }
    }
    return {};
}

QString stringField(const QJsonObject &obj,
                    std::initializer_list<const char *> keys)
{
    for (const char *key : keys) {
        const QString value = obj.value(QLatin1String(key)).toVariant().toString();
        if (!value.trimmed().isEmpty()) {
            return value.trimmed();
        }
    }
    return {};
}

int intField(const QJsonObject &obj,
             std::initializer_list<const char *> keys,
             int defaultValue = 0)
{
    for (const char *key : keys) {
        const QJsonValue value = obj.value(QLatin1String(key));
        if (!value.isUndefined() && !value.isNull()) {
            return value.toVariant().toInt();
        }
    }
    return defaultValue;
}

qint64 longField(const QJsonObject &obj,
                 std::initializer_list<const char *> keys,
                 qint64 defaultValue = 0)
{
    for (const char *key : keys) {
        const QJsonValue value = obj.value(QLatin1String(key));
        if (!value.isUndefined() && !value.isNull()) {
            return value.toVariant().toLongLong();
        }
    }
    return defaultValue;
}

QColor parseColor(const QJsonValue &value)
{
    if (value.isString()) {
        const QColor color(value.toString());
        if (color.isValid()) {
            return color;
        }
        bool ok = false;
        const uint rgb = value.toString().toUInt(&ok, 10);
        if (ok) {
            return QColor::fromRgb(rgb);
        }
    }

    bool ok = false;
    const uint rgb = value.toVariant().toUInt(&ok);
    if (ok) {
        return QColor::fromRgb(rgb);
    }
    return QColor(Qt::white);
}

QString providerIdValue(const QVariantMap &providerIds,
                        std::initializer_list<const char *> keys)
{
    for (const char *key : keys) {
        const QString value =
            providerIds.value(QString::fromLatin1(key)).toString().trimmed();
        if (!value.isEmpty()) {
            return value;
        }
    }
    return {};
}

QString normalizedHost(const QString &baseUrl)
{
    const QUrl url = QUrl::fromUserInput(baseUrl.trimmed());
    return url.host().trimmed().toLower();
}

bool isOfficialDandanplayEndpoint(const DanmakuProviderConfig &config)
{
    return normalizedHost(config.baseUrl) == QStringLiteral("api.dandanplay.net");
}

QString missingCredentialsMessage()
{
    return QCoreApplication::translate(
        "DandanplayProvider",
        "DandanPlay Open API now requires App ID and App Secret. Configure them in Danmaku Server settings.");
}

bool isMissingCredentialsErrorMessage(const QString &message)
{
    return message.trimmed() == missingCredentialsMessage();
}

void ensureOfficialAuthentication(const DanmakuProviderConfig &config,
                                  const QString &apiPath)
{
    if (!isOfficialDandanplayEndpoint(config)) {
        return;
    }

    const QString appId = config.appId.trimmed();
    const QString appSecret = config.appSecret.trimmed();
    if (!appId.isEmpty() && !appSecret.isEmpty()) {
        return;
    }

    qWarning().noquote()
        << "[Danmaku][DandanPlay] Missing App credentials for official endpoint"
        << "| path:" << apiPath
        << "| baseUrl:" << config.baseUrl;
    throw std::runtime_error(missingCredentialsMessage().toStdString());
}

QMap<QString, QString> buildHeaders(const DanmakuProviderConfig &config,
                                    const QString &apiPath)
{
    QMap<QString, QString> headers;
    headers.insert(QStringLiteral("Accept"), QStringLiteral("application/json"));
    headers.insert(QStringLiteral("User-Agent"),
                   QStringLiteral("qEmby/1.0 (Danmaku)"));

    const QString appId = config.appId.trimmed();
    const QString appSecret = config.appSecret.trimmed();
    if (appId.isEmpty() || appSecret.isEmpty()) {
        return headers;
    }

    const QString unixTimestamp =
        QString::number(QDateTime::currentSecsSinceEpoch());
    const QByteArray raw = (appId + unixTimestamp + apiPath + appSecret)
                               .toUtf8();
    const QString signature = QString::fromLatin1(
        QCryptographicHash::hash(raw, QCryptographicHash::Sha256).toBase64());

    headers.insert(QStringLiteral("X-AppId"), appId);
    headers.insert(QStringLiteral("X-Timestamp"), unixTimestamp);
    headers.insert(QStringLiteral("X-Signature"), signature);
    return headers;
}

QString buildUrl(const DanmakuProviderConfig &config,
                 const QString &apiPath,
                 const QList<QPair<QString, QString>> &queryItems)
{
    QUrl url(config.baseUrl.trimmed().isEmpty()
                 ? QStringLiteral("https://api.dandanplay.net")
                 : config.baseUrl.trimmed());
    QString path = url.path();
    if (!path.endsWith('/')) {
        path.append('/');
    }
    const QString normalizedApiPath =
        apiPath.startsWith('/') ? apiPath.mid(1) : apiPath;
    path += normalizedApiPath;
    url.setPath(path);

    QUrlQuery query;
    for (const auto &item : queryItems) {
        if (!item.second.trimmed().isEmpty()) {
            query.addQueryItem(item.first, item.second.trimmed());
        }
    }
    url.setQuery(query);
    return url.toString();
}

double titleScore(const QString &lhs, const QString &rhs)
{
    if (lhs.isEmpty() || rhs.isEmpty()) {
        return 0.0;
    }

    const QString rawLhs = lhs.trimmed().toLower();
    const QString rawRhs = rhs.trimmed().toLower();
    if (rawLhs == rawRhs) {
        return 1.0;
    }

    auto cleaned = [](const QString &s) -> QString {
        QString result = s;
        const int fromIdx = result.lastIndexOf(QStringLiteral(" from "));
        if (fromIdx > 0) {
            result = result.left(fromIdx).trimmed();
        }
        int pos = 0;
        while (pos < result.size()) {
            const QChar ch = result.at(pos);
            if (ch == QChar(0x3010) || ch == QLatin1Char('[')) {
                const QChar closeChar =
                    ch == QChar(0x3010) ? QChar(0x3011) : QLatin1Char(']');
                const int closeIdx = result.indexOf(closeChar, pos + 1);
                if (closeIdx > pos) {
                    result.remove(pos, closeIdx - pos + 1);
                    continue;
                }
            }
            ++pos;
        }
        result = result.trimmed();
        for (int i = result.size() - 1; i >= 0; --i) {
            const QChar ch = result.at(i);
            if (ch == QLatin1Char(')') || ch == QChar(0xFF09)) {
                const QChar openChar =
                    ch == QLatin1Char(')') ? QLatin1Char('(') : QChar(0xFF08);
                const int openIdx = result.lastIndexOf(openChar, i - 1);
                if (openIdx >= 0 && i - openIdx == 5) {
                    bool isYear = true;
                    for (int j = openIdx + 1; j < i; ++j) {
                        if (!result.at(j).isDigit()) {
                            isYear = false;
                            break;
                        }
                    }
                    if (isYear) {
                        result.remove(openIdx, i - openIdx + 1);
                        result = result.trimmed();
                    }
                }
                break;
            }
            if (!ch.isSpace()) {
                break;
            }
        }
        return result.trimmed();
    };

    const QString cleanLhs = cleaned(rawLhs);
    const QString cleanRhs = cleaned(rawRhs);
    if (!cleanLhs.isEmpty() && !cleanRhs.isEmpty() &&
        cleanLhs == cleanRhs) {
        return 0.98;
    }

    const QString &shorter =
        cleanLhs.size() <= cleanRhs.size() ? cleanLhs : cleanRhs;
    const QString &longer =
        cleanLhs.size() <= cleanRhs.size() ? cleanRhs : cleanLhs;
    if (!shorter.isEmpty() && !longer.isEmpty()) {
        if (longer.startsWith(shorter)) {
            const double ratio = shorter.size() /
                                 static_cast<double>(longer.size());
            return 0.70 + ratio * 0.22;
        }
        if (longer.contains(shorter)) {
            const double ratio = shorter.size() /
                                 static_cast<double>(longer.size());
            return 0.30 + ratio * 0.30;
        }
    }

    if (rawLhs.contains(rawRhs) || rawRhs.contains(rawLhs)) {
        return 0.45;
    }

    int overlap = 0;
    for (const QChar ch : cleanLhs.isEmpty() ? rawLhs : cleanLhs) {
        const QString &target = cleanRhs.isEmpty() ? rawRhs : cleanRhs;
        if (!ch.isSpace() && target.contains(ch)) {
            ++overlap;
        }
    }
    const int base = cleanLhs.isEmpty() ? rawLhs.size()
                                        : cleanLhs.size();
    return base == 0
               ? 0.0
               : qBound(0.0, overlap / static_cast<double>(base), 0.35);
}

int extractYear(const QString &title)
{
    const int parenIdx = title.lastIndexOf('(');
    if (parenIdx < 0) {
        return 0;
    }
    const int closeIdx = title.indexOf(')', parenIdx + 1);
    if (closeIdx < 0 || closeIdx - parenIdx != 5) {
        return 0;
    }
    bool ok = false;
    const int year =
        title.mid(parenIdx + 1, 4).toInt(&ok);
    return (ok && year >= 1900 && year <= 2100) ? year : 0;
}


int chineseDigitsToInt(const QString &digits)
{
    if (digits.isEmpty()) {
        return 0;
    }

    bool ok = false;
    const int direct = digits.toInt(&ok);
    if (ok && direct > 0) {
        return direct;
    }

    static const QHash<QChar, int> digitMap = {
        {QChar(0x4E00), 1}, {QChar(0x4E8C), 2}, {QChar(0x4E09), 3},
        {QChar(0x56DB), 4}, {QChar(0x4E94), 5}, {QChar(0x516D), 6},
        {QChar(0x4E03), 7}, {QChar(0x516B), 8}, {QChar(0x4E5D), 9}};
    static const QChar shi(0x5341); 

    int total = 0;
    int currentDigit = 0;
    for (const QChar ch : digits) {
        if (ch == shi) {
            total += currentDigit == 0 ? 10 : currentDigit * 10;
            currentDigit = 0;
        } else if (digitMap.contains(ch)) {
            currentDigit = digitMap.value(ch);
        }
    }
    total += currentDigit;
    return total;
}


QString chineseOrdinalString(int value)
{
    if (value <= 0) {
        return {};
    }

    static const QChar digits[] = {
        QChar(0x4E00), QChar(0x4E8C), QChar(0x4E09),
        QChar(0x56DB), QChar(0x4E94), QChar(0x516D),
        QChar(0x4E03), QChar(0x516B), QChar(0x4E5D)};
    static const QChar shi(0x5341);

    if (value < 10) {
        return QString(digits[value - 1]);
    }
    if (value == 10) {
        return QString(shi);
    }
    if (value < 20) {
        return QString(shi) + digits[value - 10 - 1];
    }
    if (value < 100) {
        const int tens = value / 10;
        const int ones = value % 10;
        QString result = QString(digits[tens - 1]) + shi;
        if (ones > 0) {
            result += digits[ones - 1];
        }
        return result;
    }
    return QString::number(value);
}





int extractSeasonNumber(const QString &title)
{
    const QString trimmed = title.trimmed();
    if (trimmed.isEmpty()) {
        return 0;
    }

    static const QRegularExpression cnPattern(
        QStringLiteral(R"(第\s*([\x{4E00}-\x{9FFF}0-9]+)\s*[季部])"));
    QRegularExpressionMatch match = cnPattern.match(trimmed);
    if (match.hasMatch()) {
        const int parsed = chineseDigitsToInt(match.captured(1).trimmed());
        if (parsed > 0) {
            return parsed;
        }
    }

    static const QRegularExpression enPattern(
        QStringLiteral(R"((?:^|[^A-Za-z])(?:Season|S)\s*0*(\d{1,2})(?:[^A-Za-z0-9]|$))"),
        QRegularExpression::CaseInsensitiveOption);
    match = enPattern.match(trimmed);
    if (match.hasMatch()) {
        const int parsed = match.captured(1).toInt();
        if (parsed > 0) {
            return parsed;
        }
    }

    static const QRegularExpression romanPattern(
        QStringLiteral(R"((?:^|\s)(VIII|VII|VI|IV|IX|III|II|V|X)\s*$)"));
    match = romanPattern.match(trimmed);
    if (match.hasMatch()) {
        const QString roman = match.captured(1).toUpper();
        static const QHash<QString, int> romanMap = {
            {QStringLiteral("II"), 2},   {QStringLiteral("III"), 3},
            {QStringLiteral("IV"), 4},   {QStringLiteral("V"), 5},
            {QStringLiteral("VI"), 6},   {QStringLiteral("VII"), 7},
            {QStringLiteral("VIII"), 8}, {QStringLiteral("IX"), 9},
            {QStringLiteral("X"), 10}};
        const int parsed = romanMap.value(roman, 0);
        if (parsed > 1) {
            return parsed;
        }
    }

    return 0;
}

double computeScore(const DanmakuMediaContext &context,
                    const DanmakuMatchCandidate &candidate,
                    const QString &queryKeyword)
{
    double score = 0.0;
    const QString subjectTitle =
        context.isEpisode() ? context.seriesName : context.title;
    const QString &candidateSubject = candidate.subtitle.isEmpty()
                                          ? candidate.title
                                          : candidate.subtitle;
    score += titleScore(subjectTitle, candidateSubject) * 55.0;
    score += titleScore(context.originalTitle, candidateSubject) * 18.0;
    score += titleScore(context.title, candidate.title) * 18.0;
    score += titleScore(queryKeyword, candidate.displayText()) * 12.0;

    if (context.isEpisode() && context.episodeNumber > 0 &&
        candidate.episodeNumber > 0 &&
        context.episodeNumber == candidate.episodeNumber) {
        score += 24.0;
    }

    
    
    
    if (context.isEpisode() && context.seasonNumber > 0) {
        const int candidateSeason = extractSeasonNumber(candidateSubject);
        if (candidateSeason > 0) {
            if (candidateSeason == context.seasonNumber) {
                score += 30.0; 
            } else {
                score -= 30.0; 
            }
        } else if (context.seasonNumber == 1) {
            
            score += 6.0;
        } else {
            
            score -= 8.0;
        }
    }

    if (context.durationMs > 0 && candidate.durationMs > 0) {
        const qint64 diff = std::llabs(context.durationMs - candidate.durationMs);
        if (diff <= 30 * 1000) {
            score += 12.0;
        } else if (diff <= 90 * 1000) {
            score += 6.0;
        }
    }

    if (candidate.commentCount > 0) {
        score += std::min(10.0, candidate.commentCount / 80.0);
    }

    if (context.productionYear > 0) {
        const int candidateYear = extractYear(candidateSubject);
        if (candidateYear > 0) {
            const int yearDiff = std::abs(context.productionYear - candidateYear);
            if (yearDiff == 0) {
                score += 18.0;
            } else if (yearDiff <= 1) {
                score += 8.0;
            } else if (yearDiff <= 3) {
                score += 3.0;
            }
        }
    }

    return score;
}

QList<DanmakuMatchCandidate> parseSearchResponse(const QJsonObject &response,
                                                 const DanmakuMediaContext &context,
                                                 const QString &queryKeyword)
{
    QList<DanmakuMatchCandidate> candidates;
    auto parseArray = [&](const QJsonArray &animeArray) {
        for (const QJsonValue &animeValue : animeArray) {
            const QJsonObject animeObj = animeValue.toObject();
            const QString animeTitle = firstNonEmpty(
                {stringField(animeObj, {"animeTitle", "title", "name"}),
                 stringField(animeObj, {"animeTitleCN", "animeTitleJP"})});

            QJsonArray episodes = animeObj.value(QStringLiteral("episodes")).toArray();
            if (episodes.isEmpty() &&
                !animeObj.value(QStringLiteral("episodeId")).isUndefined()) {
                episodes.append(animeObj);
            }
            if (episodes.isEmpty() &&
                (!animeObj.value(QStringLiteral("animeId")).isUndefined() ||
                 !animeObj.value(QStringLiteral("bangumiId")).isUndefined())) {
                QJsonObject fallbackEpisodeObj = animeObj;
                const QString animeIdStr = stringField(
                    animeObj, {"animeId", "bangumiId"});
                if (!animeIdStr.isEmpty()) {
                    fallbackEpisodeObj.insert(
                        QStringLiteral("episodeId"), animeIdStr);
                }
                if (fallbackEpisodeObj.value(
                        QStringLiteral("episodeTitle")).isUndefined()) {
                    fallbackEpisodeObj.insert(
                        QStringLiteral("episodeTitle"), animeTitle);
                }
                episodes.append(fallbackEpisodeObj);
            }

            for (const QJsonValue &episodeValue : episodes) {
                const QJsonObject episodeObj = episodeValue.toObject();
                DanmakuMatchCandidate candidate;
                candidate.provider = QStringLiteral("dandanplay");
                candidate.targetId =
                    stringField(episodeObj, {"episodeId", "id", "episodeID"});
                candidate.title = stringField(
                    episodeObj, {"episodeTitle", "title", "name", "episodeName"});
                candidate.subtitle = animeTitle;
                candidate.episodeNumber =
                    intField(episodeObj, {"episodeNumber", "episode", "sort"}, -1);
                candidate.durationMs = longField(
                    episodeObj, {"durationMs", "duration", "videoDuration"}, 0);
                if (candidate.durationMs > 0 && candidate.durationMs < 1000) {
                    candidate.durationMs *= 1000;
                }
                candidate.commentCount = intField(
                    episodeObj, {"commentCount", "comments", "danmakuCount"}, 0);
                candidate.score = computeScore(context, candidate, queryKeyword);
                candidate.matchReason = QStringLiteral("search");
                if (candidate.isValid()) {
                    candidates.append(candidate);
                }
            }
        }
    };

    parseArray(response.value(QStringLiteral("animes")).toArray());
    if (!candidates.isEmpty()) {
        return candidates;
    }

    parseArray(response.value(QStringLiteral("data")).toArray());
    return candidates;
}

QList<DanmakuComment> parseCommentsResponse(const QJsonObject &response)
{
    QList<DanmakuComment> comments;
    QJsonArray array = response.value(QStringLiteral("comments")).toArray();
    if (array.isEmpty()) {
        array = response.value(QStringLiteral("data")).toArray();
    }
    if (array.isEmpty()) {
        array = response.value(QStringLiteral("result")).toArray();
    }

    comments.reserve(array.size());
    for (const QJsonValue &value : array) {
        const QJsonObject obj = value.toObject();
        DanmakuComment comment;
        comment.text = firstNonEmpty(
            {stringField(obj, {"m", "text", "comment", "content"})});

        const QString p = stringField(obj, {"p"});
        if (!p.isEmpty()) {
            const QStringList parts = p.split(',', Qt::KeepEmptyParts);
            if (!parts.isEmpty()) {
                comment.timeMs =
                    static_cast<qint64>(parts[0].toDouble() * 1000.0);
            }
            if (parts.size() > 1) {
                bool ok = false;
                const int parsedMode = parts[1].toInt(&ok);
                if (ok) {
                    comment.mode = parsedMode;
                }
            }
            if (parts.size() > 2) {
                comment.color = parseColor(parts[2]);
            }
            if (parts.size() > 3) {
                bool ok = false;
                const int parsedFontLevel = parts[3].toInt(&ok);
                if (ok) {
                    comment.fontLevel = parsedFontLevel;
                }
            }
        } else {
            comment.timeMs = longField(obj, {"timeMs", "time", "position"}, 0);
            if (comment.timeMs > 0 && comment.timeMs < 1000) {
                comment.timeMs *= 1000;
            }
            comment.mode = intField(obj, {"mode", "positionType"}, 1);
            comment.color = parseColor(obj.value(QStringLiteral("color")));
            comment.fontLevel = intField(obj, {"size", "fontLevel"}, 25);
        }

        comment.sender =
            stringField(obj, {"sender", "user", "author", "nickname"});
        const QString createdAtStr =
            stringField(obj, {"dateTime", "createdAt", "date", "timeStamp"});
        comment.createdAt =
            QDateTime::fromString(createdAtStr, Qt::ISODate);

        if (comment.isValid()) {
            comments.append(comment);
        }
    }
    return comments;
}

QList<DanmakuMatchCandidate> deduplicateCandidates(
    const QList<DanmakuMatchCandidate> &candidates)
{
    QHash<QString, DanmakuMatchCandidate> bestById;
    for (const DanmakuMatchCandidate &candidate : candidates) {
        const auto existing = bestById.constFind(candidate.targetId);
        if (existing == bestById.constEnd() ||
            candidate.score > existing->score) {
            bestById.insert(candidate.targetId, candidate);
        }
    }

    QList<DanmakuMatchCandidate> deduplicated = bestById.values();
    std::sort(deduplicated.begin(), deduplicated.end(),
              [](const DanmakuMatchCandidate &lhs,
                 const DanmakuMatchCandidate &rhs) {
                  if (!qFuzzyCompare(lhs.score, rhs.score)) {
                      return lhs.score > rhs.score;
                  }
                  return lhs.commentCount > rhs.commentCount;
              });
    return deduplicated;
}

QList<DanmakuMatchCandidate> parseMatchResponse(
    const QJsonObject &response,
    const DanmakuMediaContext &context,
    const QString &fileName)
{
    QList<DanmakuMatchCandidate> candidates;
    const bool isMatched = response.value(QStringLiteral("isMatched")).toBool();
    QJsonArray matches = response.value(QStringLiteral("matches")).toArray();
    if (matches.isEmpty()) {
        matches = response.value(QStringLiteral("animes")).toArray();
    }
    if (matches.isEmpty()) {
        matches = response.value(QStringLiteral("data")).toArray();
    }

    for (const QJsonValue &matchValue : matches) {
        const QJsonObject matchObj = matchValue.toObject();
        DanmakuMatchCandidate candidate;
        candidate.provider = QStringLiteral("dandanplay");
        candidate.targetId =
            stringField(matchObj, {"episodeId", "id", "episodeID"});
        candidate.title = stringField(
            matchObj, {"episodeTitle", "title", "name", "episodeName"});
        candidate.subtitle = stringField(
            matchObj, {"animeTitle", "animeTitleCN", "animeName"});
        candidate.episodeNumber =
            intField(matchObj, {"episodeNumber", "episode", "sort"}, -1);
        candidate.durationMs = longField(
            matchObj, {"durationMs", "duration", "videoDuration"}, 0);
        if (candidate.durationMs > 0 && candidate.durationMs < 1000) {
            candidate.durationMs *= 1000;
        }
        candidate.commentCount = intField(
            matchObj, {"commentCount", "comments", "danmakuCount"}, 0);

        if (candidate.targetId.isEmpty()) {
            const QString animeIdStr =
                stringField(matchObj, {"animeId", "bangumiId"});
            if (!animeIdStr.isEmpty()) {
                candidate.targetId = animeIdStr;
            }
        }
        if (candidate.title.isEmpty()) {
            candidate.title = candidate.subtitle;
        }

        candidate.score = computeScore(context, candidate, fileName);
        if (isMatched) {
            candidate.score += 30.0;
        }
        candidate.matchReason = QStringLiteral("match");
        if (candidate.isValid()) {
            candidates.append(candidate);
        }
    }

    std::sort(candidates.begin(), candidates.end(),
              [](const DanmakuMatchCandidate &lhs,
                 const DanmakuMatchCandidate &rhs) {
                  return lhs.score > rhs.score;
              });
    return candidates;
}

} 

DandanplayProvider::DandanplayProvider(NetworkManager *networkManager)
    : m_networkManager(networkManager)
{
}

QCoro::Task<QList<DanmakuMatchCandidate>> DandanplayProvider::searchCandidates(
    DanmakuMediaContext context,
    DanmakuProviderConfig config,
    QString manualKeyword) const
{
    QList<DanmakuMatchCandidate> allCandidates;
    if (!m_networkManager) {
        co_return allCandidates;
    }

    bool hadSuccessfulSearchResponse = false;
    QString lastSearchError;

    
    
    
    
    
    
    
    QStringList keywords;
    if (!manualKeyword.trimmed().isEmpty()) {
        keywords << manualKeyword.trimmed();
    } else if (context.isEpisode()) {
        const QString trimmedSeries = context.seriesName.trimmed();
        const QString trimmedOriginal = context.originalTitle.trimmed();
        const QString trimmedTitle = context.title.trimmed();
        if (!trimmedSeries.isEmpty()) {
            
            if (context.seasonNumber > 1) {
                const QString ordinal = chineseOrdinalString(context.seasonNumber);
                if (!ordinal.isEmpty()) {
                    keywords << QStringLiteral("%1 第%2季")
                                    .arg(trimmedSeries, ordinal);
                }
            }
            keywords << trimmedSeries;
            if (!trimmedOriginal.isEmpty() && trimmedOriginal != trimmedSeries) {
                keywords << trimmedOriginal;
            }
        } else {
            
            keywords << trimmedTitle << trimmedOriginal;
        }
    } else {
        keywords << context.title << context.originalTitle;
    }
    QStringList normalizedKeywords;
    normalizedKeywords.reserve(keywords.size());
    for (const QString &keyword : std::as_const(keywords)) {
        const QString trimmed = keyword.trimmed();
        if (!trimmed.isEmpty()) {
            normalizedKeywords.append(trimmed);
        }
    }
    keywords = normalizedKeywords;
    keywords.removeDuplicates();

    const QString tmdbId = providerIdValue(
        context.providerIds, {"Tmdb", "tmdb", "TMDb", "tmdbid"});

    const QString filePath = context.path.trimmed();
    const QString fileName =
        filePath.isEmpty() ? QString()
                           : QFileInfo(filePath).fileName().trimmed();

    qDebug().noquote()
        << "[Danmaku][DandanPlay] Search start"
        << "| media:" << context.displayTitle()
        << "| mediaId:" << context.mediaId
        << "| isEpisode:" << context.isEpisode()
        << "| seriesName:" << context.seriesName.trimmed()
        << "| seasonNumber:" << context.seasonNumber
        << "| episodeNumber:" << context.episodeNumber
        << "| contentScope:" << config.contentScope
        << "| fileName:" << fileName
        << "| keywords:" << keywords.join(QStringLiteral(" | "));

    if (!fileName.isEmpty() && manualKeyword.trimmed().isEmpty()) {
        try {
            const QString matchPath = QStringLiteral("/api/v2/match");
            ensureOfficialAuthentication(config, matchPath);
            QJsonObject payload;
            payload.insert(QStringLiteral("fileName"), fileName);
            payload.insert(QStringLiteral("fileHash"), QString());
            payload.insert(QStringLiteral("fileSize"), 0);
            payload.insert(QStringLiteral("matchMode"),
                           QStringLiteral("fileNameOnly"));
            const QString matchUrl = buildUrl(config, matchPath, {});
            qDebug().noquote()
                << "[Danmaku][DandanPlay] Match request"
                << "| fileName:" << fileName
                << "| url:" << matchUrl;
            const QJsonObject matchResponse = co_await m_networkManager->post(
                matchUrl, buildHeaders(config, matchPath), payload);
            hadSuccessfulSearchResponse = true;
            const QList<DanmakuMatchCandidate> matchCandidates =
                parseMatchResponse(matchResponse, context, fileName);
            qDebug().noquote()
                << "[Danmaku][DandanPlay] Match result"
                << "| fileName:" << fileName
                << "| isMatched:"
                << matchResponse.value(QStringLiteral("isMatched")).toBool()
                << "| count:" << matchCandidates.size();
            if (!matchCandidates.isEmpty()) {
                allCandidates.append(matchCandidates);
            }
        } catch (const std::exception &e) {
            const QString errorMessage =
                QString::fromUtf8(e.what()).trimmed();
            if (isMissingCredentialsErrorMessage(errorMessage)) {
                throw;
            }
            lastSearchError = errorMessage;
            qWarning().noquote()
                << "[Danmaku][DandanPlay] Match request failed"
                << "| fileName:" << fileName
                << "| error:" << errorMessage;
        }
    }

    for (const QString &keyword : std::as_const(keywords)) {
        QList<DanmakuMatchCandidate> keywordCandidates;
        try {
            const QString apiPath = QStringLiteral("/api/v2/search/episodes");
            ensureOfficialAuthentication(config, apiPath);
            QList<QPair<QString, QString>> queryItems;
            queryItems.append({QStringLiteral("anime"), keyword});
            if (context.isEpisode()) {
                if (context.episodeNumber > 0) {
                    queryItems.append({QStringLiteral("episode"),
                                       QString::number(context.episodeNumber)});
                }
                if (!tmdbId.isEmpty()) {
                    queryItems.append({QStringLiteral("tmdbId"), tmdbId});
                }
            }

            const QString url = buildUrl(config, apiPath, queryItems);
            qDebug().noquote()
                << "[Danmaku][DandanPlay] Search request"
                << "| path:" << apiPath
                << "| keyword:" << keyword
                << "| url:" << url;
            const QJsonObject response =
                co_await m_networkManager->get(url, buildHeaders(config, apiPath));
            hadSuccessfulSearchResponse = true;
            keywordCandidates =
                parseSearchResponse(response, context, keyword);
            if (!keywordCandidates.isEmpty()) {
                qDebug().noquote()
                    << "[Danmaku][DandanPlay] Search hit"
                    << "| path:" << apiPath
                    << "| keyword:" << keyword
                    << "| count:" << keywordCandidates.size();
                allCandidates.append(keywordCandidates);
            }
        } catch (const std::exception &e) {
            const QString errorMessage = QString::fromUtf8(e.what()).trimmed();
            if (isMissingCredentialsErrorMessage(errorMessage)) {
                throw;
            }
            lastSearchError = errorMessage;
            qWarning().noquote()
                << "[Danmaku][DandanPlay] Search request failed"
                << "| keyword:" << keyword
                << "| path: /api/v2/search/episodes"
                << "| error:" << errorMessage;
        }

        if (!keywordCandidates.isEmpty()) {
            
            
            
            if (context.isEpisode() && context.seasonNumber > 0 &&
                context.episodeNumber > 0) {
                bool hasConfidentMatch = false;
                for (const DanmakuMatchCandidate &c : keywordCandidates) {
                    const QString candidateSubject =
                        c.subtitle.isEmpty() ? c.title : c.subtitle;
                    const int candidateSeason =
                        extractSeasonNumber(candidateSubject);
                    const bool seasonHit =
                        candidateSeason == context.seasonNumber ||
                        (candidateSeason == 0 && context.seasonNumber == 1);
                    const bool episodeHit =
                        c.episodeNumber == context.episodeNumber;
                    if (seasonHit && episodeHit && c.score >= 60.0) {
                        hasConfidentMatch = true;
                        break;
                    }
                }
                if (hasConfidentMatch) {
                    qDebug().noquote()
                        << "[Danmaku][DandanPlay] Confident match found,"
                           " skipping remaining keywords"
                        << "| keyword:" << keyword
                        << "| seasonNumber:" << context.seasonNumber
                        << "| episodeNumber:" << context.episodeNumber;
                    break;
                }
            }
            continue;
        }

        const QString fallbackPath = QStringLiteral("/api/v2/search/anime");
        const QString fallbackUrl = buildUrl(
            config, fallbackPath, {{QStringLiteral("keyword"), keyword}});
        try {
            ensureOfficialAuthentication(config, fallbackPath);
            qDebug().noquote()
                << "[Danmaku][DandanPlay] Search fallback request"
                << "| path:" << fallbackPath
                << "| keyword:" << keyword
                << "| url:" << fallbackUrl;
            const QJsonObject fallbackResponse =
                co_await m_networkManager->get(
                    fallbackUrl, buildHeaders(config, fallbackPath));
            hadSuccessfulSearchResponse = true;
            const QList<DanmakuMatchCandidate> fallbackCandidates =
                parseSearchResponse(fallbackResponse, context, keyword);
            qDebug().noquote()
                << "[Danmaku][DandanPlay] Search fallback result"
                << "| keyword:" << keyword
                << "| count:" << fallbackCandidates.size();
            allCandidates.append(fallbackCandidates);
        } catch (const std::exception &e) {
            const QString errorMessage = QString::fromUtf8(e.what()).trimmed();
            if (isMissingCredentialsErrorMessage(errorMessage)) {
                throw;
            }
            lastSearchError = errorMessage;
            qWarning().noquote()
                << "[Danmaku][DandanPlay] Search fallback failed"
                << "| keyword:" << keyword
                << "| path:" << fallbackPath
                << "| error:" << errorMessage;
        }
    }

    if (allCandidates.isEmpty() && !hadSuccessfulSearchResponse &&
        !lastSearchError.isEmpty()) {
        throw std::runtime_error(lastSearchError.toStdString());
    }

    const QList<DanmakuMatchCandidate> deduplicated =
        deduplicateCandidates(allCandidates);
    qDebug().noquote()
        << "[Danmaku][DandanPlay] Search finished"
        << "| mediaId:" << context.mediaId
        << "| rawCount:" << allCandidates.size()
        << "| deduplicatedCount:" << deduplicated.size();

    
    if (context.isEpisode() && !deduplicated.isEmpty()) {
        const int topN = std::min<int>(deduplicated.size(), 5);
        for (int i = 0; i < topN; ++i) {
            const DanmakuMatchCandidate &c = deduplicated.at(i);
            const QString candidateSubject =
                c.subtitle.isEmpty() ? c.title : c.subtitle;
            const int candidateSeason = extractSeasonNumber(candidateSubject);
            qDebug().noquote()
                << "[Danmaku][DandanPlay] Search top candidate"
                << "| rank:" << (i + 1)
                << "| title:" << c.title
                << "| subtitle:" << c.subtitle
                << "| candidateSeason:" << candidateSeason
                << "| candidateEpisode:" << c.episodeNumber
                << "| seasonHit:"
                << (context.seasonNumber > 0 && candidateSeason > 0 &&
                    candidateSeason == context.seasonNumber)
                << "| episodeHit:"
                << (context.episodeNumber > 0 && c.episodeNumber > 0 &&
                    c.episodeNumber == context.episodeNumber)
                << "| score:" << c.score
                << "| commentCount:" << c.commentCount;
        }
    }
    co_return deduplicated;
}

QCoro::Task<QList<DanmakuComment>> DandanplayProvider::fetchComments(
    DanmakuMatchCandidate candidate,
    DanmakuProviderConfig config) const
{
    QList<DanmakuComment> comments;
    if (!m_networkManager || !candidate.isValid()) {
        co_return comments;
    }

    const QString apiPath =
        QStringLiteral("/api/v2/comment/%1").arg(candidate.targetId);
    ensureOfficialAuthentication(config, apiPath);
    const QString url = buildUrl(
        config, apiPath,
        {{QStringLiteral("withRelated"),
          config.withRelated ? QStringLiteral("true")
                             : QStringLiteral("false")}});
    qDebug().noquote()
        << "[Danmaku][DandanPlay] Fetch comments"
        << "| targetId:" << candidate.targetId
        << "| withRelated:" << config.withRelated
        << "| url:" << url;
    const QJsonObject response =
        co_await m_networkManager->get(url, buildHeaders(config, apiPath));
    comments = parseCommentsResponse(response);
    qDebug().noquote()
        << "[Danmaku][DandanPlay] Fetch comments result"
        << "| targetId:" << candidate.targetId
        << "| count:" << comments.size();
    co_return comments;
}
