// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd.
//
// SPDX-License-Identifier: GPL-3.0-or-later

#include <QTest>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDateTime>
#include <QCoreApplication>
#include <QDir>
#include <QFile>

#include <dfm-search/semanticsearcher.h>

#include "semantic/semanticruleengine.h"
#include "semantic/intentparser.h"
#include "semantic/ruleconfigloader.h"
#include "semantic/extractors/keywordextractor.h"
#include "semantic/semanticquerybuilder.h"
#include "semantic/extractors/locationextractor.h"

using namespace DFMSEARCH;

static bool buildGroupFromJson(const QByteArray &json, RuleGroup &outGroup)
{
    QJsonDocument doc = QJsonDocument::fromJson(json);
    if (!doc.isObject()) {
        return false;
    }
    QJsonObject root = doc.object();
    QJsonArray groups = root.value("groups").toArray();
    if (groups.isEmpty()) {
        return false;
    }
    return SemanticRuleEngine::parseRuleGroupStatic(groups.at(0).toObject(), outGroup);
}

// Helper: build a simple rule group JSON string
static QByteArray makeRuleJson(const QString &groupName, const QString &ruleId,
                               const QString &pattern, int priority,
                               const QVariantMap &metadata = {})
{
    QJsonObject ruleObj;
    ruleObj["id"] = ruleId;
    ruleObj["pattern"] = pattern;
    ruleObj["enabled"] = true;
    ruleObj["priority"] = priority;
    if (!metadata.isEmpty()) {
        ruleObj["metadata"] = QJsonObject::fromVariantMap(metadata);
    }

    QJsonObject ruleGroupObj;
    ruleGroupObj["name"] = groupName;
    ruleGroupObj["version"] = "1.0.0";
    ruleGroupObj["rules"] = QJsonArray({ ruleObj });

    QJsonObject root;
    root["groups"] = QJsonArray({ ruleGroupObj });

    return QJsonDocument(root).toJson(QJsonDocument::Compact);
}

// ===== tst_RuleEngine =====

class tst_RuleEngine : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void parseValidGroup();
    void parseEmptyGroup();
    void parsePriorityOrdering();
    void matchReturnsHighestPriority();
    void matchAllReturnsAll();
    void ruleMetadataAccess();
    void hasGroupCheck();
};

void tst_RuleEngine::parseValidGroup()
{
    QByteArray json = makeRuleJson("test", "r1", "hello", 100);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.name, QString("test"));
    QCOMPARE(group.rules.size(), 1);
    QCOMPARE(group.rules[0].id, QString("r1"));
    QVERIFY(group.rules[0].regex.isValid());
}

void tst_RuleEngine::parseEmptyGroup()
{
    QByteArray json = R"=====({"groups": [{"name": "empty", "rules": []}]})=====";

    RuleGroup group;
    QVERIFY(!buildGroupFromJson(json, group));
}

void tst_RuleEngine::parsePriorityOrdering()
{
    QJsonObject r1, r2, r3;
    r1["id"] = "low";
    r1["pattern"] = "test";
    r1["priority"] = 10;
    r2["id"] = "high";
    r2["pattern"] = "test";
    r2["priority"] = 200;
    r3["id"] = "mid";
    r3["pattern"] = "test";
    r3["priority"] = 100;

    QJsonObject ruleGroup;
    ruleGroup["name"] = "prio";
    ruleGroup["version"] = "1.0.0";
    ruleGroup["rules"] = QJsonArray({ r1, r2, r3 });

    QJsonObject root;
    root["groups"] = QJsonArray({ ruleGroup });

    RuleGroup group;
    QVERIFY(SemanticRuleEngine::parseRuleGroupStatic(ruleGroup, group));
    QCOMPARE(group.rules.size(), 3);

    QStringList ids;
    for (const Rule &r : group.rules) {
        ids.append(r.id);
    }
    QVERIFY(ids.contains("low"));
    QVERIFY(ids.contains("mid"));
    QVERIFY(ids.contains("high"));
}

void tst_RuleEngine::matchReturnsHighestPriority()
{
    QByteArray json = makeRuleJson("test_match", "r1", "abc", 200,
                                   { { "level", "high" } });

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].regex.match("abc").hasMatch());
}

void tst_RuleEngine::matchAllReturnsAll()
{
    QJsonObject r1, r2, r3;
    r1["id"] = "r1";
    r1["pattern"] = "cat";
    r1["priority"] = 100;
    r2["id"] = "r2";
    r2["pattern"] = "dog";
    r2["priority"] = 100;
    r3["id"] = "r3";
    r3["pattern"] = "bird";
    r3["priority"] = 50;

    QJsonObject ruleGroup;
    ruleGroup["name"] = "test_all";
    ruleGroup["version"] = "1.0.0";
    ruleGroup["rules"] = QJsonArray({ r1, r2, r3 });

    QJsonObject root;
    root["groups"] = QJsonArray({ ruleGroup });

    RuleGroup group;
    QVERIFY(SemanticRuleEngine::parseRuleGroupStatic(ruleGroup, group));
    QCOMPARE(group.rules.size(), 3);
}

void tst_RuleEngine::ruleMetadataAccess()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "today";
    QByteArray json = makeRuleJson("test_meta", "m1", "test", 100, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("type").toString(), QString("preset"));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("today"));
}

void tst_RuleEngine::hasGroupCheck()
{
    SemanticRuleEngine engine;
    QVERIFY(!engine.hasGroup("time"));
    QVERIFY(!engine.hasGroup("filetype"));
    QCOMPARE(engine.groupNames().size(), 0);
}

// ===== tst_TimeExtraction =====

class tst_TimeExtraction : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void presetToday();
    void presetYesterday();
    void presetThisWeek();
    void presetThisMonth();
    void presetThisYear();
    void presetLastYear();
    void customYear();
    void customYearMonth();
    void customFullDate();
    void noMatch();
    // Compound preset time rules (preset year+month, preset week+day, preset month+day)
    void presetLastYearMonth();
    void presetThisYearMonth();
    void presetThisWeekDay();
    void presetLastWeekDay();
    void presetThisMonthDay();
    void presetLastMonthDay();
};

void tst_TimeExtraction::presetToday()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "today";
    QByteArray json = makeRuleJson("time", "time_today", "today", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("today"));
    QVERIFY(group.rules[0].regex.isValid());
    QVERIFY(group.rules[0].regex.match("today").hasMatch());
    QVERIFY(!group.rules[0].regex.match("yesterday").hasMatch());
}

void tst_TimeExtraction::presetYesterday()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "yesterday";
    QByteArray json = makeRuleJson("time", "time_yesterday", "yesterday", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("yesterday"));
    QVERIFY(group.rules[0].regex.match("yesterday").hasMatch());
}

void tst_TimeExtraction::presetThisWeek()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "this_week";
    QByteArray json = makeRuleJson("time", "time_this_week", "this_week", 190, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("this_week"));
}

void tst_TimeExtraction::presetThisMonth()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "this_month";
    QByteArray json = makeRuleJson("time", "time_this_month", "this_month", 180, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("this_month"));
}

void tst_TimeExtraction::presetThisYear()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "this_year";
    QByteArray json = makeRuleJson("time", "time_this_year", "this_year", 170, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("this_year"));
}

void tst_TimeExtraction::presetLastYear()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "last_year";
    QByteArray json = makeRuleJson("time", "time_last_year", "last_year", 170, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("preset").toString(), QString("last_year"));
}

void tst_TimeExtraction::customYear()
{
    // Use programmatic JSON to avoid raw string delimiter conflict with regex patterns
    QVariantMap meta;
    meta["type"] = "custom";
    meta["format"] = "year";
    QByteArray json = makeRuleJson("time", "time_exact_year",
                                   "(?<year>\\d{2,4})year", 160, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto match = group.rules[0].regex.match("2025year");
    QVERIFY(match.hasMatch());
    QCOMPARE(match.captured("year"), QString("2025"));
}

void tst_TimeExtraction::customYearMonth()
{
    QVariantMap meta;
    meta["type"] = "custom";
    meta["format"] = "year_month";
    QByteArray json = makeRuleJson("time", "time_exact_year_month",
                                   "(?<year>\\d{2,4})[年\\./\\-](?<month>\\d{1,2})(?:月份?)?", 150, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto match1 = group.rules[0].regex.match("2025年12月");
    QVERIFY(match1.hasMatch());
    QCOMPARE(match1.captured("year"), QString("2025"));
    QCOMPARE(match1.captured("month"), QString("12"));

    auto match2 = group.rules[0].regex.match("2025年12");
    QVERIFY(match2.hasMatch());
    QCOMPARE(match2.captured("year"), QString("2025"));
    QCOMPARE(match2.captured("month"), QString("12"));

    auto match3 = group.rules[0].regex.match("2025年12月份");
    QVERIFY(match3.hasMatch());
    QCOMPARE(match3.captured("year"), QString("2025"));
    QCOMPARE(match3.captured("month"), QString("12"));

    auto match4 = group.rules[0].regex.match("2025-12");
    QVERIFY(match4.hasMatch());
    QCOMPARE(match4.captured("year"), QString("2025"));
    QCOMPARE(match4.captured("month"), QString("12"));

    auto match5 = group.rules[0].regex.match("25.12");
    QVERIFY(match5.hasMatch());
    QCOMPARE(match5.captured("year"), QString("25"));
    QCOMPARE(match5.captured("month"), QString("12"));
}

void tst_TimeExtraction::customFullDate()
{
    QVariantMap meta;
    meta["type"] = "custom";
    meta["format"] = "full_date";
    QByteArray json = makeRuleJson("time", "time_exact_full_date",
                                   "(?<year>\\d{2,4})[年\\./\\-](?<month>\\d{1,2})[月\\./\\-](?<day>\\d{1,2})[日号]?",
                                   140, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto match1 = group.rules[0].regex.match("2025年12月5日");
    QVERIFY(match1.hasMatch());
    QCOMPARE(match1.captured("year"), QString("2025"));
    QCOMPARE(match1.captured("month"), QString("12"));
    QCOMPARE(match1.captured("day"), QString("5"));

    auto match2 = group.rules[0].regex.match("2025年12月5");
    QVERIFY(match2.hasMatch());
    QCOMPARE(match2.captured("year"), QString("2025"));
    QCOMPARE(match2.captured("month"), QString("12"));
    QCOMPARE(match2.captured("day"), QString("5"));

    auto match3 = group.rules[0].regex.match("2025-03-15");
    QVERIFY(match3.hasMatch());
    QCOMPARE(match3.captured("year"), QString("2025"));
    QCOMPARE(match3.captured("month"), QString("03"));
    QCOMPARE(match3.captured("day"), QString("15"));

    auto match4 = group.rules[0].regex.match("2025/12/5");
    QVERIFY(match4.hasMatch());
    QCOMPARE(match4.captured("year"), QString("2025"));
    QCOMPARE(match4.captured("month"), QString("12"));
    QCOMPARE(match4.captured("day"), QString("5"));
}

void tst_TimeExtraction::noMatch()
{
    QVariantMap meta;
    meta["type"] = "preset";
    meta["preset"] = "today";
    QByteArray json = makeRuleJson("time", "time_today", "today", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(!group.rules[0].regex.match("random text without match").hasMatch());
}

void tst_TimeExtraction::presetLastYearMonth()
{
    // "去年12月" matches time_last_year_month, priority 175 > time_last_year(170)
    QVariantMap meta;
    meta["type"] = "preset_year_month";
    meta["preset"] = "last_year";
    QMap<QString, QVariant> dig;
    dig["零"] = 0; dig["一"] = 1; dig["二"] = 2; dig["三"] = 3;
    dig["四"] = 4; dig["五"] = 5; dig["六"] = 6; dig["七"] = 7;
    dig["八"] = 8; dig["九"] = 9; dig["十"] = 10;
    meta["digit_map"] = dig;
    meta["tens_unit"] = QString("十");
    QByteArray json = makeRuleJson("time", "time_last_year_month",
        "(?:去年|上一年)(?<month>(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))月份?",
        175, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    // Arabic month
    {
        auto m = group.rules[0].regex.match("去年12月");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("month"), QString("12"));
    }
    // Arabic month + 份
    {
        auto m = group.rules[0].regex.match("去年3月份");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("month"), QString("3"));
    }
    // Chinese numeral month
    {
        auto m = group.rules[0].regex.match("去年十二月");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("month"), QString("十二"));
    }
    // Should NOT match: no month suffix
    QVERIFY(!group.rules[0].regex.match("去年一整年").hasMatch());
    QVERIFY(!group.rules[0].regex.match("去年的").hasMatch());
}

void tst_TimeExtraction::presetThisYearMonth()
{
    QVariantMap meta;
    meta["type"] = "preset_year_month";
    meta["preset"] = "this_year";
    QMap<QString, QVariant> dig;
    dig["零"] = 0; dig["一"] = 1; dig["二"] = 2; dig["三"] = 3;
    dig["四"] = 4; dig["五"] = 5; dig["六"] = 6; dig["七"] = 7;
    dig["八"] = 8; dig["九"] = 9; dig["十"] = 10;
    meta["digit_map"] = dig;
    meta["tens_unit"] = QString("十");
    QByteArray json = makeRuleJson("time", "time_this_year_month",
        "(?:今年|本年|这年)(?<month>(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))月份?",
        175, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    {
        auto m = group.rules[0].regex.match("今年3月");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("month"), QString("3"));
    }
    {
        auto m = group.rules[0].regex.match("本年十二月");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("month"), QString("十二"));
    }
    // should not match plain year-only preset
    QVERIFY(!group.rules[0].regex.match("今年").hasMatch());
}

void tst_TimeExtraction::presetThisWeekDay()
{
    QVariantMap meta;
    meta["type"] = "preset_week_day";
    meta["preset"] = "this_week";
    QMap<QString, QVariant> wd;
    wd["一"] = 1; wd["二"] = 2; wd["三"] = 3; wd["四"] = 4;
    wd["五"] = 5; wd["六"] = 6; wd["日"] = 7; wd["天"] = 7;
    meta["weekday_map"] = wd;
    QByteArray json = makeRuleJson("time", "time_this_week_day",
        "(?:本周|这周|这个星期|这一个星期)(?<weekday>[一二三四五六日天])",
        175, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto m1 = group.rules[0].regex.match("本周三");
    QVERIFY(m1.hasMatch());
    QCOMPARE(m1.captured("weekday"), QString("三"));

    auto m2 = group.rules[0].regex.match("这周五");
    QVERIFY(m2.hasMatch());
    QCOMPARE(m2.captured("weekday"), QString("五"));

    auto m3 = group.rules[0].regex.match("这个星期天");
    QVERIFY(m3.hasMatch());
    QCOMPARE(m3.captured("weekday"), QString("天"));

    // Should NOT match plain week preset without weekday
    QVERIFY(!group.rules[0].regex.match("本周").hasMatch());
}

void tst_TimeExtraction::presetLastWeekDay()
{
    QVariantMap meta;
    meta["type"] = "preset_week_day";
    meta["preset"] = "last_week";
    QMap<QString, QVariant> wd;
    wd["一"] = 1; wd["二"] = 2; wd["三"] = 3; wd["四"] = 4;
    wd["五"] = 5; wd["六"] = 6; wd["日"] = 7; wd["天"] = 7;
    meta["weekday_map"] = wd;
    QByteArray json = makeRuleJson("time", "time_last_week_day",
        "(?:上周|上个星期|上星期|上一个星期)(?<weekday>[一二三四五六日天])",
        175, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto m1 = group.rules[0].regex.match("上周一");
    QVERIFY(m1.hasMatch());
    QCOMPARE(m1.captured("weekday"), QString("一"));

    auto m2 = group.rules[0].regex.match("上个星期五");
    QVERIFY(m2.hasMatch());
    QCOMPARE(m2.captured("weekday"), QString("五"));
}

void tst_TimeExtraction::presetThisMonthDay()
{
    QVariantMap meta;
    meta["type"] = "preset_month_day";
    meta["preset"] = "this_month";
    QMap<QString, QVariant> dig;
    dig["零"] = 0; dig["一"] = 1; dig["二"] = 2; dig["三"] = 3;
    dig["四"] = 4; dig["五"] = 5; dig["六"] = 6; dig["七"] = 7;
    dig["八"] = 8; dig["九"] = 9; dig["十"] = 10;
    meta["digit_map"] = dig;
    meta["tens_unit"] = QString("十");
    QByteArray json = makeRuleJson("time", "time_this_month_day",
        "(?:本月|这个月|当月)(?<day>(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))[日号]",
        175, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    {
        auto m = group.rules[0].regex.match("本月5号");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("day"), QString("5"));
    }
    {
        auto m = group.rules[0].regex.match("当月十五日");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("day"), QString("十五"));
    }
    {
        auto m = group.rules[0].regex.match("这个月31号");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("day"), QString("31"));
    }
    // should NOT match without day suffix
    QVERIFY(!group.rules[0].regex.match("本月").hasMatch());
}

void tst_TimeExtraction::presetLastMonthDay()
{
    QVariantMap meta;
    meta["type"] = "preset_month_day";
    meta["preset"] = "last_month";
    QMap<QString, QVariant> dig;
    dig["零"] = 0; dig["一"] = 1; dig["二"] = 2; dig["三"] = 3;
    dig["四"] = 4; dig["五"] = 5; dig["六"] = 6; dig["七"] = 7;
    dig["八"] = 8; dig["九"] = 9; dig["十"] = 10;
    meta["digit_map"] = dig;
    meta["tens_unit"] = QString("十");
    QByteArray json = makeRuleJson("time", "time_last_month_day",
        "(?:上个月|上月)(?<day>(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))[日号]",
        175, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    {
        auto m = group.rules[0].regex.match("上个月15号");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("day"), QString("15"));
    }
    {
        auto m = group.rules[0].regex.match("上月三号");
        QVERIFY(m.hasMatch());
        QCOMPARE(m.captured("day"), QString("三"));
    }
}

// ===== tst_FileTypeExtraction =====

class tst_FileTypeExtraction : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void precisePdf();
    void preciseWord();
    void preciseExcel();
    void precisePpt();
    void imageType();
    void videoType();
    void audioType();
    void genericDocument();
    void genericSpreadsheet();
    void genericPresentation();
    void archiveType();
    void extensionsList();
    void generalFlag();
};

void tst_FileTypeExtraction::precisePdf()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "pdf" });
    QByteArray json = makeRuleJson("filetype", "filetype_pdf", "pdf", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].regex.match("pdf").hasMatch());
    QCOMPARE(group.rules[0].metadata.value("extensions").toStringList(), QStringList({ "pdf" }));
}

void tst_FileTypeExtraction::preciseWord()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "doc", "docx" });
    meta["fileTypes"] = QStringList({ "doc" });
    QByteArray json = makeRuleJson("filetype", "filetype_word", "word|doc|docx", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].regex.match("word").hasMatch());
    QVERIFY(group.rules[0].regex.match("docx").hasMatch());
    QVERIFY(!group.rules[0].regex.match("pdf").hasMatch());
}

void tst_FileTypeExtraction::preciseExcel()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "xls", "xlsx" });
    QByteArray json = makeRuleJson("filetype", "filetype_excel", "excel|xls|xlsx", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].regex.match("excel").hasMatch());
    QVERIFY(group.rules[0].regex.match("xlsx").hasMatch());
}

void tst_FileTypeExtraction::precisePpt()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "ppt", "pptx" });
    QByteArray json = makeRuleJson("filetype", "filetype_ppt", "ppt|pptx", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].regex.match("ppt").hasMatch());
    QVERIFY(group.rules[0].regex.match("pptx").hasMatch());
}

void tst_FileTypeExtraction::imageType()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "jpg", "png", "gif" });
    meta["fileTypes"] = QStringList({ "pic" });
    QByteArray json = makeRuleJson("filetype", "filetype_image", "image", 150, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("fileTypes").toStringList(), QStringList({ "pic" }));
}

void tst_FileTypeExtraction::videoType()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "mp4", "avi", "mkv" });
    meta["fileTypes"] = QStringList({ "video" });
    QByteArray json = makeRuleJson("filetype", "filetype_video", "video", 150, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("fileTypes").toStringList(), QStringList({ "video" }));
}

void tst_FileTypeExtraction::audioType()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "mp3", "wav", "flac" });
    meta["fileTypes"] = QStringList({ "audio" });
    QByteArray json = makeRuleJson("filetype", "filetype_audio", "audio", 150, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("fileTypes").toStringList(), QStringList({ "audio" }));
}

void tst_FileTypeExtraction::genericDocument()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "doc", "docx", "pdf", "txt" });
    meta["fileTypes"] = QStringList({ "doc" });
    meta["general"] = true;
    QByteArray json = makeRuleJson("filetype", "filetype_document_general", "document", 100, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].metadata.value("general").toBool());
    QCOMPARE(group.rules[0].metadata.value("extensions").toStringList().size(), 4);
}

void tst_FileTypeExtraction::genericSpreadsheet()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "xls", "xlsx", "csv" });
    meta["general"] = true;
    QByteArray json = makeRuleJson("filetype", "filetype_spreadsheet_general", "spreadsheet", 100, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].metadata.value("general").toBool());
}

void tst_FileTypeExtraction::genericPresentation()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "ppt", "pptx", "dps" });
    meta["general"] = true;
    QByteArray json = makeRuleJson("filetype", "filetype_presentation_general", "presentation", 100, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].metadata.value("general").toBool());
}

void tst_FileTypeExtraction::archiveType()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "zip", "tar", "rar", "7z" });
    QByteArray json = makeRuleJson("filetype", "filetype_archive", "archive", 150, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("extensions").toStringList().size(), 4);
}

void tst_FileTypeExtraction::extensionsList()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "a", "b", "c", "d", "e" });
    QByteArray json = makeRuleJson("filetype", "ft", "test", 100, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("extensions").toStringList().size(), 5);
}

void tst_FileTypeExtraction::generalFlag()
{
    QVariantMap meta;
    meta["extensions"] = QStringList({ "pdf" });
    QByteArray json = makeRuleJson("filetype", "ft_precise", "pdf", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(!group.rules[0].metadata.value("general").toBool());
}

// ===== tst_KeywordExtraction =====

class tst_KeywordExtraction : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void containsPattern();
    void namedPattern();
    void contentHasPattern();
    void noMatch();
    void captureGroup();
    void multiKeywordFlag();
};

void tst_KeywordExtraction::containsPattern()
{
    QVariantMap meta;
    meta["capture_group"] = 1;
    meta["multi_keyword"] = true;
    QByteArray json = makeRuleJson("keyword", "keyword_contains",
                                   "contains(.+?)(?:of|$)", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto match = group.rules[0].regex.match("contains meeting notes");
    QVERIFY(match.hasMatch());
    QCOMPARE(match.captured(1), QString(" meeting notes"));
}

void tst_KeywordExtraction::namedPattern()
{
    QVariantMap meta;
    meta["capture_group"] = 1;
    meta["multi_keyword"] = false;
    QByteArray json = makeRuleJson("keyword", "keyword_named",
                                   "named (.+?)(?: of|$)", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto match = group.rules[0].regex.match("named report of");
    QVERIFY(match.hasMatch());
    QCOMPARE(match.captured(1), QString("report"));
}

void tst_KeywordExtraction::contentHasPattern()
{
    QVariantMap meta;
    meta["capture_group"] = 1;
    meta["multi_keyword"] = true;
    QByteArray json = makeRuleJson("keyword", "keyword_content_has",
                                   "content(?: has| contains| includes)(.+?)(?: of|$)",
                                   200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));

    auto match = group.rules[0].regex.match("content includes budget data");
    QVERIFY(match.hasMatch());
    QCOMPARE(match.captured(1), QString(" budget data"));
}

void tst_KeywordExtraction::noMatch()
{
    QVariantMap meta;
    meta["capture_group"] = 1;
    QByteArray json = makeRuleJson("keyword", "keyword_contains",
                                   "contains(.+?)(?:of|$)", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(!group.rules[0].regex.match("no keyword pattern here").hasMatch());
}

void tst_KeywordExtraction::captureGroup()
{
    QVariantMap meta;
    meta["capture_group"] = 1;
    QByteArray json = makeRuleJson("keyword", "k1", "contains(.+?)(?:of|$)", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QCOMPARE(group.rules[0].metadata.value("capture_group").toInt(), 1);
}

void tst_KeywordExtraction::multiKeywordFlag()
{
    QVariantMap meta;
    meta["capture_group"] = 1;
    meta["multi_keyword"] = true;
    QByteArray json = makeRuleJson("keyword", "k1", "contains(.+?)(?:of|$)", 200, meta);

    RuleGroup group;
    QVERIFY(buildGroupFromJson(json, group));
    QVERIFY(group.rules[0].metadata.value("multi_keyword").toBool());
}

// ===== tst_ParsedIntent =====

class tst_ParsedIntent : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void defaultState();
    void timeConstraintDefault();
    void timeConstraintPreset();
    void matchSpanValidity();
};

void tst_ParsedIntent::defaultState()
{
    ParsedIntent intent;
    QVERIFY(intent.timeConstraint().kind() == TimeConstraintKind::None);
    QVERIFY(intent.fileExtensions().isEmpty());
    QVERIFY(intent.keywords().isEmpty());
    QVERIFY(intent.consumedSpans().isEmpty());
}

void tst_ParsedIntent::timeConstraintDefault()
{
    TimeConstraint tc;
    QVERIFY(!tc.isValid());
    QCOMPARE(tc.kind(), TimeConstraintKind::None);
}

void tst_ParsedIntent::timeConstraintPreset()
{
    TimeConstraint tc;
    tc.setKind(TimeConstraintKind::Preset);
    tc.setPreset(TimePreset::Today);
    QVERIFY(tc.isValid());
}

void tst_ParsedIntent::matchSpanValidity()
{
    MatchSpan span;
    QVERIFY(!span.isValid());

    span.setStart(0);
    span.setEnd(5);
    span.setRuleId("test_rule");
    QVERIFY(span.isValid());
}

// ===== tst_IsSemanticQuery =====

namespace {

// Resolve the source tree rule directory relative to TEST_SOURCE_DIR.
// Falls back to a heuristic path if TEST_SOURCE_DIR is not defined.
QString sourceRulesDir()
{
    QString base = QString::fromUtf8(TEST_SOURCE_DIR);
    if (base.isEmpty()) {
        base = QCoreApplication::applicationDirPath() + "/../../..";
    }
    return base + "/src/dfm-search/dfm-search-lib/semantic/rules/zh_CN";
}

// Check whether the source tree rule files exist and are loadable.
bool sourceRulesAvailable()
{
    const QString dir = sourceRulesDir();
    return QDir(dir).exists()
            && !QDir(dir).entryList({ "*.json" }, QDir::Files).isEmpty();
}

// Replicate isSemanticQuery() logic using internal components with source-tree rules.
bool checkIsSemanticQuery(SemanticRuleEngine *engine, IntentParser *parser,
                          const QString &input)
{
    if (input.trimmed().isEmpty()) {
        return false;
    }

    ParsedIntent intent;
    parser->parse(input, intent);

    // Replicate isSemanticIntent() — keep in sync with semanticsearcher.cpp.
    // 维度计数法：任何单维度都不算语义查询，至少 2 个维度组合才算。

    // keyword_* 规则 span 单独成立
    for (const MatchSpan &span : intent.consumedSpans()) {
        if (span.ruleId().startsWith("keyword_")) {
            return true;
        }
    }

    int dimensionCount = 0;
    if (!intent.searchDirectories().isEmpty()) ++dimensionCount;
    if (intent.hiddenOnly()) ++dimensionCount;
    if (intent.recentOnly()) ++dimensionCount;
    if (intent.timeConstraint().isValid()) ++dimensionCount;
    if (intent.sizeConstraint().isValid()) ++dimensionCount;
    if (!intent.fileExtensions().isEmpty()) ++dimensionCount;
    if (!intent.keywords().isEmpty()) ++dimensionCount;
    bool hasTargetRule = false;
    for (const MatchSpan &span : intent.consumedSpans()) {
        if (span.ruleId().startsWith("target_")) {
            hasTargetRule = true;
            break;
        }
    }
    if (hasTargetRule) ++dimensionCount;

    bool hasSpecificFt = false;
    bool hasGeneralFt = false;
    for (const MatchSpan &span : intent.consumedSpans()) {
        const QString rid = span.ruleId();
        if (rid.startsWith("filetype_")) {
            if (rid.endsWith("_general"))
                hasGeneralFt = true;
            else
                hasSpecificFt = true;
        }
    }
    if (hasSpecificFt && hasGeneralFt) ++dimensionCount;

    return dimensionCount >= 2;
}

}   // namespace

class tst_IsSemanticQuery : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void initTestCase();
    void emptyInput();
    void whitespaceOnly();
    void plainKeyword();
    void plainChineseKeyword();
    void todayKeyword();
    void yesterdayKeyword();
    void thisWeekKeyword();
    void lastMonthKeyword();
    void fileTypePdf();
    void fileTypeImage();
    void fileTypeDocument();
    void locationDesktop();
    void locationDownloads();
    void sizeLarge();
    void sizeSmall();
    void sizeDynamic();
    void timeAndFileType();
    void singleTimeOnly();
    void singleSizeOnly();
    void singleFileTypeOnly();
    void singleRecentOnly();
    void timePlusTarget();
    void timePlusFileType();
    void fileTypePlusTarget();
    void specificAndGeneralFiletype();
    void sizePlusFileType();
    void recentPlusTarget();
    void locationAndTime();
    void keywordOnlyNoMatch();
    void consecutiveCalls();
    void noiseWordsOnly();
    void hiddenFile();
    void structuredKeywordRule();
    void locationOnlyRejected();
    void locationPlusDimensionAccepted();
    // Compound preset time rules (preset year+month, week+weekday, month+day)
    void compoundTimeLastYearMonth();
    void compoundTimeThisYearMonth();
    void compoundTimeThisWeekDay();
    void compoundTimeLastWeekDay();
    void compoundTimeThisMonthDay();
    void compoundTimeLastMonthDay();
    void compoundTimeNoKeywordLeak();

private:
    SemanticRuleEngine *m_engine = nullptr;
    IntentParser *m_parser = nullptr;
};

void tst_IsSemanticQuery::initTestCase()
{
    if (!sourceRulesAvailable()) {
        QSKIP("Rule files not found in source tree, skipping isSemanticQuery tests");
    }

    m_engine = new SemanticRuleEngine(this);
    const QString dir = sourceRulesDir();
    const QStringList ruleFiles = QDir(dir).entryList(
            { "*.json" }, QDir::Files, QDir::Name);
    for (const QString &filename : ruleFiles) {
        QString path = dir + "/" + filename;
        if (!m_engine->loadRuleFile(path)) {
            qWarning() << "Failed to load rule file:" << path;
        }
    }

    m_parser = new IntentParser(m_engine);
}

void tst_IsSemanticQuery::emptyInput()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, QString()));
}

void tst_IsSemanticQuery::whitespaceOnly()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "   "));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "\t\n"));
}

void tst_IsSemanticQuery::plainKeyword()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "hello"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "meeting notes"));
}

void tst_IsSemanticQuery::plainChineseKeyword()
{
    // Pure Chinese text without any semantic triggers.
    // Avoid words that match filetype/location/time/size rules
    // (e.g. "报告" matches filetype_document_general, "音乐" matches filetype_audio).
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "蓝天白云"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "春夏秋冬"));
}

void tst_IsSemanticQuery::todayKeyword()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "今天的文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "今日份报告"));
}

void tst_IsSemanticQuery::yesterdayKeyword()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "昨天的报告"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "昨晚的截图"));
}

void tst_IsSemanticQuery::thisWeekKeyword()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "本周的文档"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "这周修改的"));
}

void tst_IsSemanticQuery::lastMonthKeyword()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "上个月的文件"));
}

void tst_IsSemanticQuery::fileTypePdf()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "pdf文档"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "找一下pdf"));
}

void tst_IsSemanticQuery::fileTypeImage()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "图片"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "截图"));
}

void tst_IsSemanticQuery::fileTypeDocument()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "文档"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "报告"));
}

void tst_IsSemanticQuery::locationDesktop()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "桌面的文件"));
}

void tst_IsSemanticQuery::locationDownloads()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "下载的文件"));
}

void tst_IsSemanticQuery::sizeLarge()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "大文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "几个G的文件"));
}

void tst_IsSemanticQuery::sizeSmall()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "小文件"));
}

void tst_IsSemanticQuery::sizeDynamic()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "大于500M的文件"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "小于100K"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "小于100K文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "10M以上的表格"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "1G以内的文档"));
}

void tst_IsSemanticQuery::timeAndFileType()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "今天的pdf"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "本周的图片"));
}

void tst_IsSemanticQuery::singleTimeOnly()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "去年"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "去年的"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "今天的"));
}

void tst_IsSemanticQuery::singleSizeOnly()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "大于10M"));
}

void tst_IsSemanticQuery::singleFileTypeOnly()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "PPT"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "文档"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "报告"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "图片"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "截图"));
}

void tst_IsSemanticQuery::singleRecentOnly()
{
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "打开过的"));
}

void tst_IsSemanticQuery::timePlusTarget()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "去年的文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "去年的预算"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "今天的文件"));
}

void tst_IsSemanticQuery::timePlusFileType()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "去年的报告"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "本周的图片"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "今天的pdf"));
}

void tst_IsSemanticQuery::fileTypePlusTarget()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "PPT文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "pdf文件"));
}

void tst_IsSemanticQuery::specificAndGeneralFiletype()
{
    // specific filetype + general filetype word (文档/表格/幻灯片) should be
    // recognized as semantic (≥2 dimensions via the specific+general combo rule).
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "PPT文档"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "pdf文档"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "xls表格"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "PPT幻灯片"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "Word文档"));
}

void tst_IsSemanticQuery::sizePlusFileType()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "大于10M的文档"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "10M以上的表格"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "1G以内的文档"));
}

void tst_IsSemanticQuery::recentPlusTarget()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "打开过的预算"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "打开过的文件"));
}

void tst_IsSemanticQuery::locationAndTime()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "桌面今天的文件"));
}

void tst_IsSemanticQuery::keywordOnlyNoMatch()
{
    // Text that does not match any semantic rule pattern
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "xyzabc123"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "随便什么文字"));
}

void tst_IsSemanticQuery::consecutiveCalls()
{
    // Multiple calls with the same input should return consistent results
    QString input = "今天的pdf";
    bool first = checkIsSemanticQuery(m_engine, m_parser, input);
    bool second = checkIsSemanticQuery(m_engine, m_parser, input);
    bool third = checkIsSemanticQuery(m_engine, m_parser, input);
    QCOMPARE(first, second);
    QCOMPARE(second, third);
    QVERIFY(first);

    QString plain = "hello world";
    bool p1 = checkIsSemanticQuery(m_engine, m_parser, plain);
    bool p2 = checkIsSemanticQuery(m_engine, m_parser, plain);
    QCOMPARE(p1, p2);
    QVERIFY(!p1);
}

void tst_IsSemanticQuery::noiseWordsOnly()
{
    // Noise words alone (search action words) without any semantic dimension
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "搜索"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "查找"));
}

void tst_IsSemanticQuery::hiddenFile()
{
    // hiddenOnly + target "文件" = 2 维度 → 通过
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "隐藏文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "隐藏的文件"));
    // 纯属性槽单维度 → 不通过（与"打开过的"、"隐藏的"一致）
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "藏起来的"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "看不到的"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "隐藏的"));
    // time + hiddenOnly = 2 维度 → 通过
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "今天的隐藏文件"));
}

void tst_IsSemanticQuery::structuredKeywordRule()
{
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "包含测试的文件"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "文件名包含测试的文档"));
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "文件内容包含配置的文档"));
}

void tst_IsSemanticQuery::locationOnlyRejected()
{
    // 仅命中位置槽、无其他维度 —— 不应通过语义查询检查。
    // 这类查询本质是"列出某目录所有文件"，indexedstrategy 没有列全部文件的能力，
    // 由 guardNonSemantic 拦下报 InvalidQuery，避免进入 doSearch 后空关键字报错。
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "下载"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "微信"));
    QVERIFY(!checkIsSemanticQuery(m_engine, m_parser, "桌面"));
}

void tst_IsSemanticQuery::locationPlusDimensionAccepted()
{
    // 位置槽 + 其他维度 —— 应通过语义查询检查。
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "下载的隐藏文件"));   // location + hiddenOnly
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "下载的文档"));         // location + fileExtensions
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "下载的pdf"));          // location + fileExtensions
    QVERIFY(checkIsSemanticQuery(m_engine, m_parser, "桌面的今天的文件"));   // location + time + target
}

void tst_IsSemanticQuery::compoundTimeLastYearMonth()
{
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    ParsedIntent intent;
    m_parser->parse(QString::fromUtf8("去年12月创建的表格"), intent);

    // The compound rule should have consumed “去年12月” as a single time span.
    bool hasCompound = false;
    for (const auto &span : intent.consumedSpans()) {
        if (span.ruleId() == QLatin1String("time_last_year_month")) {
            hasCompound = true;
            break;
        }
    }
    QVERIFY(hasCompound);

    // Time constraint should be Custom (not Preset lastYear),
    // and the month component should NOT leak into keywords.
    QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom);
    QVERIFY(!intent.keywords().contains(QString::fromUtf8("12月")));

    // The Custom range should cover exactly December of last year.
    const int thisYear = QDate::currentDate().year();
    QCOMPARE(intent.timeConstraint().customStart().date(),
              QDate(thisYear - 1, 12, 1));
    QCOMPARE(intent.timeConstraint().customEnd().date(),
              QDate(thisYear - 1, 12, 31));
}

void tst_IsSemanticQuery::compoundTimeThisYearMonth()
{
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    ParsedIntent intent;
    m_parser->parse(QString::fromUtf8("今年3月修改的文档"), intent);

    bool hasCompound = false;
    for (const auto &span : intent.consumedSpans()) {
        if (span.ruleId() == QLatin1String("time_this_year_month")) {
            hasCompound = true;
            break;
        }
    }
    QVERIFY(hasCompound);

    QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom);
    QVERIFY(!intent.keywords().contains(QString::fromUtf8("3月")));

    const int thisYear = QDate::currentDate().year();
    QCOMPARE(intent.timeConstraint().customStart().date(),
              QDate(thisYear, 3, 1));
    QCOMPARE(intent.timeConstraint().customEnd().date(),
              QDate(thisYear, 3, 31));
}

void tst_IsSemanticQuery::compoundTimeThisWeekDay()
{
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    ParsedIntent intent;
    m_parser->parse(QString::fromUtf8("本周三的文件"), intent);

    bool hasCompound = false;
    for (const auto &span : intent.consumedSpans()) {
        if (span.ruleId() == QLatin1String("time_this_week_day")) {
            hasCompound = true;
            break;
        }
    }
    QVERIFY(hasCompound);

    QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom);
    QVERIFY(!intent.keywords().contains(QString::fromUtf8("三")));

    // Verify the target date is this week's Wednesday.
    const QDate today = QDate::currentDate();
    const QDate mondayOfThisWeek = today.addDays(1 - today.dayOfWeek());
    const QDate expectedDate = mondayOfThisWeek.addDays(3 - 1);   // Wed = 3
    QCOMPARE(intent.timeConstraint().customStart().date(), expectedDate);
    QCOMPARE(intent.timeConstraint().customEnd().date(), expectedDate);
}

void tst_IsSemanticQuery::compoundTimeLastWeekDay()
{
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    ParsedIntent intent;
    m_parser->parse(QString::fromUtf8("上周五的文档"), intent);

    bool hasCompound = false;
    for (const auto &span : intent.consumedSpans()) {
        if (span.ruleId() == QLatin1String("time_last_week_day")) {
            hasCompound = true;
            break;
        }
    }
    QVERIFY(hasCompound);

    QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom);
    QVERIFY(!intent.keywords().contains(QString::fromUtf8("五")));

    const QDate today = QDate::currentDate();
    const QDate mondayOfThisWeek = today.addDays(1 - today.dayOfWeek());
    const QDate expectedDate = mondayOfThisWeek.addDays(-7 + (5 - 1));   // last week Fri
    QCOMPARE(intent.timeConstraint().customStart().date(), expectedDate);
    QCOMPARE(intent.timeConstraint().customEnd().date(), expectedDate);
}

void tst_IsSemanticQuery::compoundTimeThisMonthDay()
{
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    ParsedIntent intent;
    m_parser->parse(QString::fromUtf8("本月15号的文件"), intent);

    bool hasCompound = false;
    for (const auto &span : intent.consumedSpans()) {
        if (span.ruleId() == QLatin1String("time_this_month_day")) {
            hasCompound = true;
            break;
        }
    }
    QVERIFY(hasCompound);

    QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom);
    QVERIFY(!intent.keywords().contains(QString::fromUtf8("15号")));

    const QDate today = QDate::currentDate();
    const QDate expectedDate = QDate(today.year(), today.month(), 15);
    QCOMPARE(intent.timeConstraint().customStart().date(), expectedDate);
    QCOMPARE(intent.timeConstraint().customEnd().date(), expectedDate);
}

void tst_IsSemanticQuery::compoundTimeLastMonthDay()
{
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    ParsedIntent intent;
    m_parser->parse(QString::fromUtf8("上个月8号的文档"), intent);

    bool hasCompound = false;
    for (const auto &span : intent.consumedSpans()) {
        if (span.ruleId() == QLatin1String("time_last_month_day")) {
            hasCompound = true;
            break;
        }
    }
    QVERIFY(hasCompound);

    QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom);
    QVERIFY(!intent.keywords().contains(QString::fromUtf8("8号")));

    const QDate today = QDate::currentDate();
    const QDate lastMonth = today.addMonths(-1);
    const QDate expectedDate = QDate(lastMonth.year(), lastMonth.month(), 8);
    QCOMPARE(intent.timeConstraint().customStart().date(), expectedDate);
    QCOMPARE(intent.timeConstraint().customEnd().date(), expectedDate);
}

void tst_IsSemanticQuery::compoundTimeNoKeywordLeak()
{
    // The core regression test: compound time input should NOT leak
    // the secondary time component (e.g. “12月”) into keywords.
    if (!sourceRulesAvailable()) QSKIP("Rule files not found");

    const QStringList inputs = {
        QString::fromUtf8("去年12月创建的表格"),
        QString::fromUtf8("今年3月修改的文档"),
        QString::fromUtf8("本周三的文件"),
        QString::fromUtf8("上周五的文档"),
        QString::fromUtf8("本月15号的文件"),
        QString::fromUtf8("上个月8号的文档"),
    };

    for (const QString &input : inputs) {
        ParsedIntent intent;
        m_parser->parse(input, intent);

        // Extract only the numeric month/day substring that was previously leaking.
        // For “去年12月创建的表格” the old code left “12月” in keywords.
        QVERIFY2(!intent.keywords().contains(QString::fromUtf8("12月")),
                 qUtf8Printable("12月 leaked into keywords for: " + input));
        QVERIFY2(!intent.keywords().contains(QString::fromUtf8("3月")),
                 qUtf8Printable("3月 leaked into keywords for: " + input));
        QVERIFY2(!intent.keywords().contains(QString::fromUtf8("三")),
                 qUtf8Printable("三 leaked into keywords for: " + input));
        QVERIFY2(!intent.keywords().contains(QString::fromUtf8("五")),
                 qUtf8Printable("五 leaked into keywords for: " + input));
        QVERIFY2(!intent.keywords().contains(QString::fromUtf8("15号")),
                 qUtf8Printable("15号 leaked into keywords for: " + input));
        QVERIFY2(!intent.keywords().contains(QString::fromUtf8("8号")),
                 qUtf8Printable("8号 leaked into keywords for: " + input));
    }
}

// ===== tst_SearchTarget =====

class tst_SearchTarget : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void initTestCase();
    void defaultIsAll();
    void filenameContains();
    void filenameNamed();
    void contentContains();
    void genericContainsStaysAll();
    void unconsumedTextStaysAll();

private:
    SemanticRuleEngine *m_engine = nullptr;
    KeywordExtractor *m_extractor = nullptr;
};

void tst_SearchTarget::initTestCase()
{
    if (!sourceRulesAvailable()) {
        QSKIP("Rule files not found in source tree, skipping search target tests");
    }

    m_engine = new SemanticRuleEngine(this);
    const QString dir = sourceRulesDir();
    const QStringList ruleFiles = QDir(dir).entryList(
            { "*.json" }, QDir::Files, QDir::Name);
    for (const QString &filename : ruleFiles) {
        QString path = dir + "/" + filename;
        if (!m_engine->loadRuleFile(path)) {
            qWarning() << "Failed to load rule file:" << path;
        }
    }

    m_extractor = new KeywordExtractor(m_engine);
}

void tst_SearchTarget::defaultIsAll()
{
    ParsedIntent intent;
    m_extractor->extract("蓝天白云", intent);
    QCOMPARE(intent.searchTarget(), SearchTarget::All);
}

void tst_SearchTarget::filenameContains()
{
    ParsedIntent intent;
    m_extractor->extract("文件名包含测试的文档", intent);
    QCOMPARE(intent.searchTarget(), SearchTarget::FileNameOnly);
    QCOMPARE(intent.keywords().size(), 1);
    QCOMPARE(intent.keywords().first(), QString("测试"));
}

void tst_SearchTarget::filenameNamed()
{
    ParsedIntent intent;
    m_extractor->extract("名为报告的文件", intent);
    QCOMPARE(intent.searchTarget(), SearchTarget::FileNameOnly);
    QCOMPARE(intent.keywords().size(), 1);
    QCOMPARE(intent.keywords().first(), QString("报告"));
}

void tst_SearchTarget::contentContains()
{
    ParsedIntent intent;
    m_extractor->extract("文件内容包含配置的文档", intent);
    QCOMPARE(intent.searchTarget(), SearchTarget::ContentOnly);
    QCOMPARE(intent.keywords().size(), 1);
    QCOMPARE(intent.keywords().first(), QString("配置"));
}

void tst_SearchTarget::genericContainsStaysAll()
{
    ParsedIntent intent;
    m_extractor->extract("包含测试的文件", intent);
    QCOMPARE(intent.searchTarget(), SearchTarget::All);
    QVERIFY(!intent.keywords().isEmpty());
}

void tst_SearchTarget::unconsumedTextStaysAll()
{
    // No structured keyword rule matches → unconsumed text extraction
    ParsedIntent intent;
    m_extractor->extract("项目计划书", intent);
    QCOMPARE(intent.searchTarget(), SearchTarget::All);
    QVERIFY(!intent.keywords().isEmpty());
}

// ===== tst_LocationExtraction =====

class tst_LocationExtraction : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    // Static method tests — no source-tree rule dependency
    void expandGlobNoMatch();
    void expandGlobSingleMatch();
    void expandGlobMultipleMatches();
    void expandGlobNestedDirs();
    void expandNonGlob();
    void expandGlobQuestionMark();

    // Integration tests — inline rules only
    void extractWechatTriggerWords();
    void extractCustomPathResolved();
};

void tst_LocationExtraction::expandGlobNoMatch()
{
    // Glob matching nothing → empty result (no crash)
    QStringList result = LocationExtractor::expandCustomPath("/tmp", "nonexistent/*/data");
    QVERIFY(result.isEmpty());
}

void tst_LocationExtraction::expandGlobSingleMatch()
{
    QString tmpBase = QDir::temp().filePath("tst_loc_" + QString::number(QDateTime::currentSecsSinceEpoch()));
    QDir().mkpath(tmpBase + "/user_a/sub/folder");

    QStringList result = LocationExtractor::expandCustomPath(tmpBase, "user_*/sub/folder");
    QCOMPARE(result.size(), 1);
    QVERIFY(result.first().endsWith("/user_a/sub/folder"));

    QDir(tmpBase).removeRecursively();
}

void tst_LocationExtraction::expandGlobMultipleMatches()
{
    QString tmpBase = QDir::temp().filePath("tst_loc_" + QString::number(QDateTime::currentSecsSinceEpoch()));
    QDir().mkpath(tmpBase + "/wxid_abc/msg/file");
    QDir().mkpath(tmpBase + "/wxid_def/msg/file");
    QDir().mkpath(tmpBase + "/wxid_ghi/msg/file");

    QStringList result = LocationExtractor::expandCustomPath(tmpBase, "wxid_*/msg/file");
    // At least our 3 test dirs should be present
    QVERIFY(result.size() >= 3);
    bool hasA = false, hasD = false, hasG = false;
    for (const QString &p : result) {
        if (p.contains("/wxid_abc/msg/file")) hasA = true;
        if (p.contains("/wxid_def/msg/file")) hasD = true;
        if (p.contains("/wxid_ghi/msg/file")) hasG = true;
    }
    QVERIFY(hasA && hasD && hasG);

    QDir(tmpBase).removeRecursively();
}

void tst_LocationExtraction::expandGlobNestedDirs()
{
    // Pattern: a/b/*/c/d → a/<user>/c/d
    QString tmpBase = QDir::temp().filePath("tst_loc_" + QString::number(QDateTime::currentMSecsSinceEpoch()));
    QDir().mkpath(tmpBase + "/docs/users/u1/c/d");
    QDir().mkpath(tmpBase + "/docs/users/u2/c/d");

    QStringList result = LocationExtractor::expandCustomPath(tmpBase, "docs/users/*/c/d");
    QVERIFY(result.size() >= 2);

    QDir(tmpBase).removeRecursively();
}

void tst_LocationExtraction::expandNonGlob()
{
    // Non-glob path is returned as-is, appended to base
    QStringList result = LocationExtractor::expandCustomPath("/home/test", "Downloads/myapp/data");
    QCOMPARE(result, QStringList { "/home/test/Downloads/myapp/data" });
}

void tst_LocationExtraction::expandGlobQuestionMark()
{
    // ? matches single character
    QString tmpBase = QDir::temp().filePath("tst_loc_" + QString::number(QDateTime::currentMSecsSinceEpoch()));
    QDir().mkpath(tmpBase + "/dir_a/data");
    QDir().mkpath(tmpBase + "/dir_b/data");
    QDir().mkpath(tmpBase + "/dir_abc/data");   // won't match "?_" pattern

    QStringList result = LocationExtractor::expandCustomPath(tmpBase, "dir_?/data");
    QVERIFY(result.size() >= 2);

    bool hasA = false, hasB = false;
    for (const QString &p : result) {
        if (p.endsWith("/dir_a/data")) hasA = true;
        if (p.endsWith("/dir_b/data")) hasB = true;
    }
    QVERIFY(hasA && hasB);

    QDir(tmpBase).removeRecursively();
}

void tst_LocationExtraction::extractWechatTriggerWords()
{
    // Build inline rule engine to avoid source-tree / local filesystem dependency.
    // Directory must be under QDir::homePath() so expandCustomPath can find it.
    const QString suffix = QString::number(QDateTime::currentSecsSinceEpoch());
    const QString baseName = "tst_wechat_" + suffix;
    const QString tmpDir = QDir::home().filePath(baseName);
    QDir().mkpath(tmpDir);

    QByteArray json = makeRuleJson("location", "loc_test_wechat", "微信|vx|wechat", 180,
                                   { { "custom_path", baseName },
                                     { "include_hidden", false } });

    QFile f(QDir::temp().filePath("tst_wechat_inline.json"));
    QVERIFY(f.open(QIODevice::WriteOnly));
    f.write(json);
    f.close();

    SemanticRuleEngine engine;
    QVERIFY(engine.loadRuleFile(f.fileName()));

    LocationExtractor extractor(&engine);
    const QStringList inputs = { "微信的文件", "vx收到的", "wechat文件" };
    for (const QString &input : inputs) {
        ParsedIntent intent;
        extractor.extract(input, intent);
        QVERIFY2(!intent.consumedSpans().isEmpty(),
                 qUtf8Printable("Expected consumed span for: " + input));
        QVERIFY2(!intent.searchDirectories().isEmpty(),
                 qUtf8Printable("Expected searchDirectory for: " + input));
    }

    f.remove();
    QDir(tmpDir).rmdir(tmpDir);
}

void tst_LocationExtraction::extractCustomPathResolved()
{
    // Verify extract() resolves custom_path and appends to searchDirectories.
    // Directory must live under QDir::homePath() so expandCustomPath can find it.
    const QString suffix = QString::number(QDateTime::currentSecsSinceEpoch());
    const QString baseName = "tst_custom_" + suffix;
    // Create under home, not QDir::temp() (which may be /tmp, outside home path)
    const QString tmpDir = QDir::home().filePath(baseName);
    QDir().mkpath(tmpDir + "/data");

    QByteArray json = makeRuleJson("location", "loc_test_cp", "我的目录", 180,
                                   { { "custom_path", baseName + "/data" },
                                     { "include_hidden", false } });

    QFile f(QDir::temp().filePath("tst_cp_inline.json"));
    QVERIFY(f.open(QIODevice::WriteOnly));
    f.write(json);
    f.close();

    SemanticRuleEngine engine;
    QVERIFY(engine.loadRuleFile(f.fileName()));

    LocationExtractor extractor(&engine);
    ParsedIntent intent;
    extractor.extract("我的目录的文件", intent);

    QVERIFY2(!intent.searchDirectories().isEmpty(),
             "Expected at least one resolved search directory");
    // Path must end with /data
    bool hasData = false;
    for (const QString &path : intent.searchDirectories()) {
        if (path.endsWith("/data")) hasData = true;
    }
    QVERIFY(hasData);

    f.remove();
    QDir(tmpDir).removeRecursively();
}

// ===== tst_SemanticQueryBuilderTarget =====

class tst_SemanticQueryBuilderTarget : public QObject
{
    Q_OBJECT

private Q_SLOTS:
    void defaultTarget();
    void fileNameOnlyTarget();
    void contentOnlyTarget();
    void hiddenOnlyNoKeyword();
    void noKeywordWithFileExtensions();

private:
    ParsedIntent makeIntent(const QStringList &keywords, SearchTarget target) const;
};

ParsedIntent tst_SemanticQueryBuilderTarget::makeIntent(
        const QStringList &keywords, SearchTarget target) const
{
    ParsedIntent intent;
    intent.setKeywords(keywords);
    intent.setSearchTarget(target);
    return intent;
}

void tst_SemanticQueryBuilderTarget::defaultTarget()
{
    SemanticQueryBuilder builder;
    ParsedIntent intent = makeIntent({ "测试" }, SearchTarget::All);
    SemanticSearchPlan plan = builder.build(intent);

    // All three paths should produce queries
    QVERIFY(!plan.fileNameQuery.keyword().isEmpty());
    QVERIFY(plan.contentQuery.has_value());
    QVERIFY(plan.ocrQuery.has_value());
}

void tst_SemanticQueryBuilderTarget::fileNameOnlyTarget()
{
    SemanticQueryBuilder builder;
    ParsedIntent intent = makeIntent({ "测试" }, SearchTarget::FileNameOnly);
    SemanticSearchPlan plan = builder.build(intent);

    // Only filename query should be built
    QVERIFY(!plan.fileNameQuery.keyword().isEmpty());
    QVERIFY(!plan.contentQuery.has_value());
    QVERIFY(!plan.ocrQuery.has_value());
}

void tst_SemanticQueryBuilderTarget::contentOnlyTarget()
{
    SemanticQueryBuilder builder;
    ParsedIntent intent = makeIntent({ "测试" }, SearchTarget::ContentOnly);
    SemanticSearchPlan plan = builder.build(intent);

    // Filename should NOT be built; content and ocr should
    QVERIFY(plan.fileNameQuery.keyword().isEmpty());
    QVERIFY(plan.contentQuery.has_value());
    QVERIFY(plan.ocrQuery.has_value());
}

void tst_SemanticQueryBuilderTarget::hiddenOnlyNoKeyword()
{
    // "下载的隐藏文件" 这类仅含位置槽 + 属性槽、无关键字槽的语义搜索：
    // 解析后 hiddenOnly=true、includeHidden=true、searchTarget=FileNameOnly、keywords=[]。
    // 预期 plan 仍应构建 fileName 路径（空关键字），由 indexedstrategy 的
    // hiddenOnlyQuery (kIsHidden:Y) 作为主查询条件，pathPrefixQuery 限制到下载目录。
    SemanticQueryBuilder builder;
    ParsedIntent intent;
    intent.setKeywords({});                  // 无关键字
    intent.setHiddenOnly(true);
    intent.setIncludeHidden(true);
    intent.setSearchTarget(SearchTarget::FileNameOnly);
    intent.setSearchDirectories({ QStringLiteral("/home/test/Downloads") });

    SemanticSearchPlan plan = builder.build(intent);

    // plan 顶层标志必须正确（SemanticSearcher.prepareOptions 会据此透传到 SearchOptions）
    QVERIFY(plan.hiddenOnly);
    QVERIFY(plan.includeHidden);
    QVERIFY(plan.searchDirectories == QStringList { QStringLiteral("/home/test/Downloads") });

    // 无关键字时改用 wildcard "*" 匹配所有文件名 ——
    // indexedstrategy Wildcard 分支 buildWildcardQuery("*") 生成匹配所有文件名的 WildcardQuery，
    // 让 pathPrefixQuery / hiddenOnlyQuery 作为真正的过滤器叠加。
    QCOMPARE(plan.fileNameQuery.keyword(), QStringLiteral("*"));
    QCOMPARE(plan.fileNameQuery.type(), SearchQuery::Type::Wildcard);

    // content/ocr 不应启用（hiddenOnly 强制 FileNameOnly，且无关键字）
    QVERIFY(!plan.contentQuery.has_value());
    QVERIFY(!plan.ocrQuery.has_value());
}

void tst_SemanticQueryBuilderTarget::noKeywordWithFileExtensions()
{
    // "今天的图片"、"下载的pdf" 这类有 fileExtensions 但无 keyword 的查询：
    // fileNameQuery 必须保持空 Simple（不是 wildcard "*"），否则 determineSearchType
    // 会把 wildcard "*" 误导进 Combined 分支用 buildSimpleQuery("*") 走 NGram —— 错误。
    // 空 Simple 让 determineSearchType 走 Combined(ext only)，由 extQuery 作主查询条件。
    SemanticQueryBuilder builder;
    ParsedIntent intent;
    intent.setKeywords({});
    intent.setFileExtensions({ "png", "jpg" });
    intent.setSearchTarget(SearchTarget::All);

    SemanticSearchPlan plan = builder.build(intent);

    QVERIFY(plan.fileNameQuery.keyword().isEmpty());
    QCOMPARE(plan.fileNameQuery.type(), SearchQuery::Type::Simple);
}

// ===== Factory functions =====

QObject *create_tst_RuleEngine()
{
    return new tst_RuleEngine();
}
QObject *create_tst_TimeExtraction()
{
    return new tst_TimeExtraction();
}
QObject *create_tst_FileTypeExtraction()
{
    return new tst_FileTypeExtraction();
}
QObject *create_tst_KeywordExtraction()
{
    return new tst_KeywordExtraction();
}
QObject *create_tst_ParsedIntent()
{
    return new tst_ParsedIntent();
}
QObject *create_tst_IsSemanticQuery()
{
    return new tst_IsSemanticQuery();
}
QObject *create_tst_SearchTarget()
{
    return new tst_SearchTarget();
}
QObject *create_tst_SemanticQueryBuilderTarget()
{
    return new tst_SemanticQueryBuilderTarget();
}
QObject *create_tst_LocationExtraction()
{
    return new tst_LocationExtraction();
}

#include "tst_semantic_search.moc"
