/*
 * Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
 * released under GNU AGPL v3 license:
 * https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
 */

#include "PlayerGuideMgr.h"

#include "../IndividualProgression.h"

#include "Config.h"
#include "DatabaseEnv.h"
#include "Field.h"
#include "Log.h"
#include "Player.h"
#include "QueryResult.h"
#include "QuestDef.h"

#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <sstream>
#include <unordered_set>

namespace MoonWell::PlayerGuide
{
    namespace
    {
        std::string EscapeJson(std::string const& in)
        {
            std::string out;
            out.reserve(in.size() + 2);
            for (unsigned char c : in)
            {
                switch (c)
                {
                    case '"':  out += "\\\""; break;
                    case '\\': out += "\\\\"; break;
                    case '\b': out += "\\b";  break;
                    case '\f': out += "\\f";  break;
                    case '\n': out += "\\n";  break;
                    case '\r': out += "\\r";  break;
                    case '\t': out += "\\t";  break;
                    default:
                        if (c < 0x20)
                        {
                            char buf[8];
                            std::snprintf(buf, sizeof(buf), "\\u%04x", c);
                            out += buf;
                        }
                        else
                            out += static_cast<char>(c);
                        break;
                }
            }
            return out;
        }

        // Writes "key":number when value > 0.
        void WriteOptUInt(std::ostringstream& s, char const* key, uint32 v,
                          bool& first)
        {
            if (!v)
                return;
            if (!first) s << ',';
            s << '"' << key << "\":" << v;
            first = false;
        }

        void WriteString(std::ostringstream& s, char const* key,
                         std::string const& v, bool& first)
        {
            if (!first) s << ',';
            s << '"' << key << "\":\"" << EscapeJson(v) << '"';
            first = false;
        }

        void WriteUInt(std::ostringstream& s, char const* key, uint32 v,
                       bool& first)
        {
            if (!first) s << ',';
            s << '"' << key << "\":" << v;
            first = false;
        }

        void WriteBool(std::ostringstream& s, char const* key, bool v,
                       bool& first)
        {
            if (!first) s << ',';
            s << '"' << key << "\":" << (v ? "true" : "false");
            first = false;
        }

        // Maps an IPP ProgressionState into a target stage (1..N).
        // The stage ids are kept stable so the client can key
        // localisations off of them.
        struct StageInfo
        {
            uint32 id;
            char const* name;
            char const* description;
        };

        StageInfo const STAGES[] =
        {
            { 1,  "Начало пути",
                  "Завершите низкоуровневое содержимое родного континента." },
            { 2,  "Огненные Недра",
                  "Победите Рагнароса в Огненных Недрах." },
            { 3,  "Логово Ониксии",
                  "Победите Ониксию." },
            { 4,  "Логово Крыла Тьмы",
                  "Победите Нефариана в Логове Крыла Тьмы." },
            { 5,  "Подготовка к АQ",
                  "Завершите подготовку к открытию ворот Ан'Киража." },
            { 6,  "Военный поход АQ",
                  "Окажите помощь военным усилиям в Силитусе." },
            { 7,  "Храм Ан'Киража",
                  "Победите Ц'Туна в Храме Ан'Киража." },
            { 8,  "Наксрамас (40)",
                  "Победите КелТузада в Наксрамасе." },
            { 9,  "Подготовка к Запределью",
                  "Откройте Тёмный портал и подготовьтесь к TBC." },
            { 10, "Каражан и Гру'ул",
                  "Завершите первый ярус рейдов TBC." },
            { 11, "Пещера Змеиных Недр / Око Бури",
                  "Завершите рейды второго яруса TBC." },
            { 12, "Вершина Хиджала и Чёрный храм",
                  "Победите Иллидана в Чёрном храме." },
            { 13, "Зул'Аман",
                  "Получите доступ к Зул'Аману и завершите его." },
            { 14, "Плато Солнечного Колодца",
                  "Победите Кил'джедена на Плато Солнечного Колодца." },
            { 15, "Наксрамас (Нордскол)",
                  "Завершите рейды первого яруса WotLK." },
            { 16, "Ульдуар",
                  "Победите Йогг-Сарона в Ульдуаре." },
            { 17, "Испытание крестоносца",
                  "Завершите Испытание крестоносца." },
            { 18, "Ледяная Корона",
                  "Победите Короля-лича." },
            { 19, "Рубиновое святилище",
                  "Победите Халиона в Рубиновом святилище." }
        };

        StageInfo const* FindStage(uint32 id)
        {
            for (StageInfo const& s : STAGES)
                if (s.id == id)
                    return &s;
            return nullptr;
        }

        // Tiered dungeon recommendations matched to the player level/stage.
        struct DungeonRec
        {
            uint32      stage;       // recommended starting from this stage
            uint32      maxStage;    // hidden after this stage (inclusive)
            uint8       minLevel;
            uint8       maxLevel;
            uint32      mapId;
            char const* title;
        };

        DungeonRec const DUNGEONS[] =
        {
            { 1, 1,  15, 21, 36,  "Мёртвые копи" },
            { 1, 1,  17, 24, 33,  "Курганы Иглошкурых" },
            { 1, 2,  22, 30, 47,  "Шахта Каражан" },
            { 1, 2,  24, 32, 70,  "Ульдаман" },
            { 1, 3,  27, 38, 109, "Затонувший храм" },
            { 1, 3,  30, 42, 209, "Зул'Фаррак" },
            { 1, 4,  46, 54, 229, "Верхняя часть Чёрной горы" },
            { 1, 4,  55, 60, 230, "Глубины Чёрной горы" },
            { 1, 4,  58, 60, 533, "Наксрамас" },
            { 9, 12, 58, 70, 540, "Кузня Крови" },
            { 9, 12, 60, 70, 542, "Цитадель Адского пламени" },
            { 9, 12, 65, 70, 553, "Ботаника" },
            { 9, 12, 67, 70, 555, "Гробница Ткача Смерти" },
            {14, 19, 68, 80, 574, "Утгард" },
            {14, 19, 70, 80, 600, "Драк'Тарон" },
            {14, 19, 72, 80, 619, "Ан'Кахет: Старое Королевство" },
            {14, 19, 74, 80, 595, "Чертоги Камней" },
            {14, 19, 76, 80, 658, "Очищение Стратхольма" }
        };
    }

    Mgr* Mgr::instance()
    {
        static Mgr inst;
        return &inst;
    }

    void Mgr::LoadConfig()
    {
        _enabled     = sConfigMgr->GetOption<bool>(
            "IndividualProgression.PlayerGuide.Enable", true);
        _logPayloads = sConfigMgr->GetOption<bool>(
            "IndividualProgression.PlayerGuide.LogPayloads", false);
        _chunkSize   = sConfigMgr->GetOption<uint32>(
            "IndividualProgression.PlayerGuide.ChunkSize", 230);

        // The addon channel hard-caps a single message body to 255
        // characters. We leave room for the framing header.
        if (_chunkSize < 64)  _chunkSize = 64;
        if (_chunkSize > 240) _chunkSize = 240;

        ReloadEncounterJournalCache();
    }

    void Mgr::ReloadEncounterJournalCache()
    {
        _ejByMapId.clear();

        // mod-encounter-journal may be disabled or its table absent.
        // The query then yields no result and we keep an empty cache;
        // recommendations that would have used EJ ids are skipped.
        QueryResult result = WorldDatabase.Query(
            "SELECT `id`, `map_id`, `name` "
            "FROM `custom_ej_instance`");
        if (!result)
        {
            LOG_INFO("server.loading",
                ">> PlayerGuide: no custom_ej_instance rows found. "
                "Recommendations will be empty.");
            return;
        }

        do
        {
            Field* f = result->Fetch();
            EjInstanceRef ref;
            ref.ejId = f[0].Get<uint32>();
            uint32 mapId = f[1].Get<uint32>();
            ref.name = f[2].Get<std::string>();

            // First entry wins. If the EJ has duplicates for the same
            // map (e.g. heroic variants) the lowest id is taken via the
            // INSERT order; we don't reorder.
            _ejByMapId.emplace(mapId, std::move(ref));
        } while (result->NextRow());

        LOG_INFO("server.loading",
            ">> PlayerGuide: cached {} EJ instance entries by mapId.",
            _ejByMapId.size());
    }

    std::string Mgr::StageName(uint32 stage) const
    {
        StageInfo const* s = FindStage(stage);
        return s ? s->name : "Этап";
    }

    std::string Mgr::StageDescription(uint32 stage) const
    {
        StageInfo const* s = FindStage(stage);
        return s ? s->description : "";
    }

    void Mgr::AppendLevelObjective(Payload& out, Player* player,
                                   uint32 requiredLevel, uint32 order) const
    {
        Objective o;
        o.id        = order;
        o.type      = "level";
        char buf[64];
        std::snprintf(buf, sizeof(buf), "Достигнуть %u уровня",
            requiredLevel);
        o.title     = buf;
        o.progress  = player->GetLevel();
        o.required  = requiredLevel;
        o.completed = o.progress >= o.required;
        o.targetId  = requiredLevel;
        o.order     = order;
        out.objectives.push_back(std::move(o));
    }

    void Mgr::AppendQuestObjective(Payload& out, Player* player,
                                   uint32 questId,
                                   std::string const& title,
                                   uint32 order) const
    {
        QuestStatus st = player->GetQuestStatus(questId);
        Objective o;
        o.id        = order;
        o.type      = "quest";
        o.title     = title;
        o.required  = 1;
        o.progress  = (st == QUEST_STATUS_REWARDED ||
                       st == QUEST_STATUS_COMPLETE) ? 1 : 0;
        o.completed = (st == QUEST_STATUS_REWARDED);
        o.targetId  = questId;
        o.questId   = questId;
        o.order     = order;
        out.objectives.push_back(std::move(o));
    }

    void Mgr::AppendDungeonObjective(Payload& out, uint32 mapId,
                                     std::string const& title,
                                     uint32 order) const
    {
        auto it = _ejByMapId.find(mapId);
        if (it == _ejByMapId.end())
            return; // no EJ entry -> skip rather than emit a stub.

        Objective o;
        o.id           = order;
        o.type         = "dungeon";
        o.title        = it->second.name.empty() ? title : it->second.name;
        o.required     = 1;
        o.targetId     = it->second.ejId;     // primary id for click
        o.ejInstanceId = it->second.ejId;     // JOURNALINSTANCE key
        o.mapId        = mapId;               // WoW map id (auxiliary)
        o.order        = order;
        out.objectives.push_back(std::move(o));
    }

    void Mgr::AppendRaidObjective(Payload& out, uint32 mapId,
                                  std::string const& title,
                                  uint32 order) const
    {
        auto it = _ejByMapId.find(mapId);
        if (it == _ejByMapId.end())
            return;

        Objective o;
        o.id           = order;
        o.type         = "raid";
        o.title        = it->second.name.empty() ? title : it->second.name;
        o.required     = 1;
        o.targetId     = it->second.ejId;
        o.ejInstanceId = it->second.ejId;
        o.mapId        = mapId;
        o.order        = order;
        out.objectives.push_back(std::move(o));
    }

    void Mgr::AppendRecommendations(Payload& out, Player* player) const
    {
        uint8 level = player->GetLevel();
        uint32 stage = out.stageId;

        std::unordered_set<uint32> seen;
        uint32 recId = 1;

        for (DungeonRec const& d : DUNGEONS)
        {
            if (stage < d.stage || stage > d.maxStage)
                continue;
            if (level + 2 < d.minLevel || level > d.maxLevel + 1)
                continue;

            // Resolve EJ id from custom_ej_instance. If the EJ does
            // not know about this map, we cannot link to a card, so
            // we skip the recommendation entirely. This keeps the
            // contract that recommendations.instanceID is always a
            // valid EJ id.
            auto it = _ejByMapId.find(d.mapId);
            if (it == _ejByMapId.end())
                continue;
            if (!seen.insert(it->second.ejId).second)
                continue;

            Recommendation r;
            r.id           = recId++;
            r.type         = "dungeon";
            r.title        = it->second.name.empty() ? d.title
                                                     : it->second.name;
            r.ejInstanceId = it->second.ejId;   // JOURNALINSTANCE key
            r.mapId        = d.mapId;           // WoW map id (auxiliary)
            r.priority     = 100 -
                static_cast<uint32>(std::abs(int32(level) -
                    int32((d.minLevel + d.maxLevel) / 2)));
            r.description  = "Подходит для вашего текущего этапа.";
            out.recommendations.push_back(std::move(r));

            if (out.recommendations.size() >= 6)
                break;
        }
    }

    static uint32 ProgressFor(uint32 stage, uint8 level)
    {
        // Rough %: scaled across the three expansion bands.
        uint32 p = 0;
        if (stage < PROGRESSION_PRE_TBC)
            p = std::min<uint32>(60u, (level * 60u) / 60u);
        else if (stage < PROGRESSION_WOTLK_TIER_1)
            p = 60u + std::min<uint32>(20u,
                ((level > 60 ? level - 60 : 0) * 20u) / 10u);
        else
            p = 80u + std::min<uint32>(20u,
                ((level > 70 ? level - 70 : 0) * 20u) / 10u);
        // Add a small bump per cleared major raid stage above 1.
        if (stage > 1)
            p = std::min<uint32>(100u, p + (stage - 1) * 2u);
        return p;
    }

    Payload Mgr::BuildFor(Player* player) const
    {
        Payload out;
        out.version       = PAYLOAD_VERSION;
        out.characterGuid = player->GetGUID().GetRawValue();

        // ProgressionState as set by IPP. Default is 0 (start of vanilla).
        uint32 ipStage = player->GetPlayerSetting(
            "mod-individual-progression", SETTING_PROGRESSION_STATE).value;

        // Player's *next* stage is what the guide should be helping with.
        // STAGES use 1-based ids where stage id = ProgressionState+1.
        out.stageId          = std::min<uint32>(ipStage + 1, 19);
        out.stageName        = StageName(out.stageId);
        out.stageDescription = StageDescription(out.stageId);
        out.progress         = ProgressFor(ipStage, player->GetLevel());

        uint32 order = 1;

        switch (ipStage)
        {
            case PROGRESSION_START:
                AppendLevelObjective(out, player, 60, order++);
                AppendRaidObjective(out, 409,
                    "Победить Рагнароса в Огненных Недрах", order++);
                break;

            case PROGRESSION_MOLTEN_CORE:
                AppendLevelObjective(out, player, 60, order++);
                AppendRaidObjective(out, 249,
                    "Победить Ониксию", order++);
                AppendRaidObjective(out, 469,
                    "Победить Нефариана в Логове Крыла Тьмы", order++);
                break;

            case PROGRESSION_ONYXIA:
                AppendRaidObjective(out, 469,
                    "Победить Нефариана в Логове Крыла Тьмы", order++);
                AppendRaidObjective(out, 309,
                    "Зул'Гуруб (опционально)", order++);
                break;

            case PROGRESSION_BLACKWING_LAIR:
                AppendQuestObjective(out, player, MIGHT_OF_KALIMDOR,
                    "Сдать ресурсы для военного похода", order++);
                AppendQuestObjective(out, player, BANG_A_GONG,
                    "Открыть ворота Ан'Киража", order++);
                break;

            case PROGRESSION_PRE_AQ:
                AppendQuestObjective(out, player, CHAOS_AND_DESTRUCTION,
                    "Завершить полевые задания в Силитусе", order++);
                AppendRaidObjective(out, 509,
                    "Руины Ан'Киража", order++);
                break;

            case PROGRESSION_AQ_WAR:
                AppendRaidObjective(out, 531,
                    "Победить Ц'Туна в Храме Ан'Киража", order++);
                break;

            case PROGRESSION_AQ:
                AppendRaidObjective(out, 533,
                    "Победить КелТузада в Наксрамасе", order++);
                break;

            case PROGRESSION_NAXX40:
                AppendQuestObjective(out, player, INTO_THE_BREACH,
                    "Открыть Тёмный портал", order++);
                AppendLevelObjective(out, player, 70, order++);
                break;

            case PROGRESSION_PRE_TBC:
                AppendLevelObjective(out, player, 70, order++);
                AppendRaidObjective(out, 532,
                    "Каражан", order++);
                AppendRaidObjective(out, 565,
                    "Логово Груула", order++);
                AppendRaidObjective(out, 544,
                    "Логово Магтеридона", order++);
                break;

            case PROGRESSION_TBC_TIER_1:
                AppendRaidObjective(out, 548,
                    "Пещера Змеиных Недр", order++);
                AppendRaidObjective(out, 550,
                    "Око Бури — Крепость Бурь", order++);
                break;

            case PROGRESSION_TBC_TIER_2:
                AppendRaidObjective(out, 534,
                    "Битва за гору Хиджал", order++);
                AppendRaidObjective(out, 564,
                    "Чёрный храм", order++);
                break;

            case PROGRESSION_TBC_TIER_3:
                AppendRaidObjective(out, 568,
                    "Зул'Аман", order++);
                break;

            case PROGRESSION_TBC_TIER_4:
                AppendRaidObjective(out, 580,
                    "Плато Солнечного Колодца", order++);
                break;

            case PROGRESSION_TBC_TIER_5:
                AppendLevelObjective(out, player, 80, order++);
                AppendRaidObjective(out, 533,
                    "Наксрамас (Нордскол)", order++);
                AppendRaidObjective(out, 616,
                    "Око Вечности", order++);
                AppendRaidObjective(out, 615,
                    "Камера Сокровищ Обсидиановой драконьей стаи",
                    order++);
                break;

            case PROGRESSION_WOTLK_TIER_1:
                AppendRaidObjective(out, 603,
                    "Победить Йогг-Сарона в Ульдуаре", order++);
                break;

            case PROGRESSION_WOTLK_TIER_2:
                AppendRaidObjective(out, 649,
                    "Испытание крестоносца", order++);
                break;

            case PROGRESSION_WOTLK_TIER_3:
                AppendRaidObjective(out, 631,
                    "Цитадель Ледяной Короны", order++);
                break;

            case PROGRESSION_WOTLK_TIER_4:
                AppendRaidObjective(out, 724,
                    "Рубиновое святилище", order++);
                break;

            default:
                break;
        }

        AppendRecommendations(out, player);
        return out;
    }

    std::string Mgr::SerializeJson(Objective const& o) const
    {
        std::ostringstream s;
        s << '{';
        bool first = true;
        WriteUInt(s, "id", o.id, first);
        WriteString(s, "type", o.type, first);
        WriteString(s, "title", o.title, first);
        if (!o.description.empty())
            WriteString(s, "description", o.description, first);
        WriteBool(s, "completed", o.completed, first);
        WriteUInt(s, "progress", o.progress, first);
        WriteUInt(s, "required", o.required, first);
        WriteUInt(s, "targetID", o.targetId, first);
        WriteUInt(s, "order", o.order, first);

        WriteOptUInt(s, "questID",        o.questId,        first);
        WriteOptUInt(s, "questChainID",   o.questChainId,   first);
        WriteOptUInt(s, "ej_instanceID",  o.ejInstanceId,   first);
        WriteOptUInt(s, "mapID",          o.mapId,          first);
        WriteOptUInt(s, "encounterID",    o.encounterId,    first);
        WriteOptUInt(s, "achievementID",  o.achievementId,  first);
        WriteOptUInt(s, "factionID",      o.factionId,      first);
        WriteOptUInt(s, "professionID",   o.professionId,   first);
        WriteOptUInt(s, "itemID",         o.itemId,         first);
        WriteOptUInt(s, "currencyID",     o.currencyId,     first);
        s << '}';
        return s.str();
    }

    std::string Mgr::SerializeJson(Payload const& p) const
    {
        std::ostringstream s;
        s << '{';
        s << "\"version\":"        << p.version
          << ",\"characterGuid\":" << p.characterGuid
          << ",\"stageID\":"       << p.stageId
          << ",\"stageName\":\""   << EscapeJson(p.stageName) << '"'
          << ",\"stageDescription\":\""
                                   << EscapeJson(p.stageDescription) << '"'
          << ",\"progress\":"      << std::min<uint32>(p.progress, 100u);

        // Objectives: collapse duplicate ids and clamp progress.
        s << ",\"objectives\":[";
        std::set<uint32> seenIds;
        bool firstObj = true;
        for (Objective const& src : p.objectives)
        {
            if (!seenIds.insert(src.id).second)
                continue;
            Objective o = src;
            if (o.required && o.progress > o.required)
                o.progress = o.required;
            if (!firstObj) s << ',';
            s << SerializeJson(o);
            firstObj = false;
        }
        s << "]";

        // Recommendations.
        s << ",\"recommendations\":[";
        bool firstRec = true;
        for (Recommendation const& r : p.recommendations)
        {
            if (!firstRec) s << ',';
            s << '{';
            bool f = true;
            WriteUInt(s, "id", r.id, f);
            WriteString(s, "type", r.type, f);
            WriteString(s, "title", r.title, f);
            if (!r.description.empty())
                WriteString(s, "description", r.description, f);
            WriteOptUInt(s, "ej_instanceID", r.ejInstanceId, f);
            WriteOptUInt(s, "mapID",         r.mapId,        f);
            WriteOptUInt(s, "encounterID",   r.encounterId,  f);
            WriteOptUInt(s, "questID",       r.questId,      f);
            WriteOptUInt(s, "itemID",        r.itemId,       f);
            WriteUInt(s, "priority", r.priority, f);
            s << '}';
            firstRec = false;
        }
        s << "]";

        // Rewards.
        s << ",\"rewards\":[";
        bool firstRew = true;
        for (Reward const& r : p.rewards)
        {
            if (!firstRew) s << ',';
            s << '{';
            bool f = true;
            WriteString(s, "type", r.type, f);
            WriteOptUInt(s, "itemID",     r.itemId,     f);
            WriteOptUInt(s, "currencyID", r.currencyId, f);
            WriteUInt(s, "count", std::max<uint32>(r.count, 1), f);
            WriteOptUInt(s, "amount", r.amount, f);
            s << '}';
            firstRew = false;
        }
        s << "]";
        s << '}';
        return s.str();
    }
}
