#include "mod_waygate_network.h"
#include "Chat.h"
#include "CommandScript.h"
#include "Config.h"
#include "Creature.h"
#include "DatabaseEnv.h"
#include "DataMap.h"
#include "GameObject.h"
#include "Log.h"
#include "ObjectAccessor.h"
#include "Opcodes.h"
#include "Player.h"
#include "ScriptedGossip.h"
#include "ScriptMgr.h"
#include "StringFormat.h"
#include "WorldPacket.h"
#include 
#include 
#include 
#include 

#ifdef MOD_PLAYERBOTS
#include "Playerbots.h"
#endif

using namespace Acore::ChatCommands;

namespace
{
    // Two gossip "pages" share the same menu/text id but are told apart by sender: continent list
    // first, then destinations within the chosen continent. Action 0 always means "close everything"
    // regardless of which page sent it; GOSSIP_ACTION_BACK is a page-2-only sentinel picked well
    // outside any real destination/continent id range.
    constexpr uint32 GOSSIP_SENDER_WAYGATE_CONTINENT = 1;
    constexpr uint32 GOSSIP_SENDER_WAYGATE_DESTINATION = 2;
    constexpr uint32 GOSSIP_ACTION_CANCEL = 0;
    constexpr uint32 GOSSIP_ACTION_BACK = std::numeric_limits::max();

    // Fixed display order; a destination's `continent` column must match one of these exactly (the
    // world-DB column is an ENUM constraining it to these 4 values). Action codes for continent
    // picks are 1-based indices into this list, not a per-request dynamic index, so they stay stable
    // regardless of which continents a given character actually has anything attuned in.
    std::vector const WaygateContinents =
    {
        "Eastern Kingdoms", "Kalimdor", "Outland", "Northrend"
    };

    uint32 GetContinentAction(std::string const& continent)
    {
        for (size_t i = 0; i < WaygateContinents.size(); ++i)
            if (WaygateContinents[i] == continent)
                return static_cast(i + 1);

        return GOSSIP_ACTION_CANCEL;
    }

    std::string const* GetContinentByAction(uint32 action)
    {
        if (!action || action > WaygateContinents.size())
            return nullptr;

        return &WaygateContinents[action - 1];
    }

    // Playerbots don't get a say in Waygate travel -- they don't need the QoL, and letting
    // hundreds of random bots attune/teleport would spam mod_waygate_network_discovered and chat
    // for no player-facing benefit.
    bool IsPlayerbot([[maybe_unused]] Player* player)
    {
#ifdef MOD_PLAYERBOTS
        return GET_PLAYERBOT_AI(player) != nullptr;
#else
        return false;
#endif
    }

    // Populated once at startup by WaygateNetwork_World::OnLoadCustomDatabaseTable, from
    // mod_waygate_network_locations (world DB) -- no longer hardcoded, so admins can add
    // destinations with a plain SQL insert instead of a code change + rebuild.
    std::vector WaygateDestinationList;

    void LoadWaygateDestinations()
    {
        WaygateDestinationList.clear();

        QueryResult result = WorldDatabase.Query(
            "SELECT id, name, continent, faction, map, zone, position_x, position_y, position_z, orientation "
            "FROM mod_waygate_network_locations ORDER BY id");

        if (!result)
        {
            LOG_WARN("server.loading", "mod-waygate-network: mod_waygate_network_locations is empty -- no travel destinations loaded.");
            return;
        }

        do
        {
            Field* row = result->Fetch();
            WaygateDestinationList.push_back(WaygateDestination
            {
                row[0].Get(),
                row[1].Get(),
                row[2].Get(),
                row[3].Get(),
                row[4].Get(),
                row[5].Get(),
                row[6].Get(),
                row[7].Get(),
                row[8].Get(),
                row[9].Get()
            });
        } while (result->NextRow());

        LOG_INFO("server.loading", "mod-waygate-network: loaded {} Waygate destination(s).", WaygateDestinationList.size());
    }

    // No Player::PlaySound helper exists in this core -- every use of SMSG_PLAY_SOUND (e.g.
    // Map::PlayDirectSoundToMap) builds the packet by hand, so this does the same for one player.
    void PlaySoundToPlayer(Player* player, uint32 soundId)
    {
        if (!soundId)
            return;

        WorldPacket data(SMSG_PLAY_SOUND, 4);
        data << uint32(soundId);
        player->SendDirectMessage(&data);
    }

    // Records a destination as attuned (mod_waygate_network_discovered), plays the unlock sound, and
    // announces it. Called from go_waygate_standing_stone::OnGossipHello -- interacting with a
    // destination's own Waygate Stone is the only way to attune it (see "Waygate Stones" in
    // README.md for why this replaced the earlier walk-into-the-zone auto-attunement). A no-op if
    // already attuned, so it's safe to call without a pre-check.
    void AttuneWaygateDestination(Player* player, WaygateDestination const& dest)
    {
        QueryResult result = CharacterDatabase.Query(
            "SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
            player->GetGUID().GetCounter(), dest.id);

        if (result)
            return;

        CharacterDatabase.Execute(
            "INSERT INTO mod_waygate_network_discovered (guid, waygate_id, discovered_time) VALUES ({}, {}, UNIX_TIMESTAMP())",
            player->GetGUID().GetCounter(), dest.id);

        PlaySoundToPlayer(player, sConfigMgr->GetOption("WaygateNetwork.UnlockSoundId", 1519));

        // One-shot cosmetic flash on the player -- GameObject has no SendPlaySpellVisual (that's a
        // Unit-only method), so this plays on the player rather than the stone itself. Reuses the
        // same real Teleport-family visual kit already relied on for the travel channel (kit 267
        // there is the sustained casting-loop; this is that family's ImpactKit, the flash that plays
        // when a real Teleport cast completes), keeping the two moments visually related without
        // inventing a new effect from scratch.
        if (uint32 visualId = sConfigMgr->GetOption("WaygateNetwork.AttuneVisualKitId", 3394))
            player->SendPlaySpellVisual(visualId);

        // One-shot animation to go with it -- EMOTE_ONESHOT_USE_STANDING is the real, generic
        // "reach out and use a standing object" gesture, purely cosmetic like the visual/sound above
        // (HandleEmoteCommand just broadcasts the animation, no gameplay side effect).
        player->HandleEmoteCommand(EMOTE_ONESHOT_USE_STANDING);

        ChatHandler chat(player->GetSession());
        chat.PSendSysMessage("Waygate attuned: {}", dest.name);

        QueryResult countResult = CharacterDatabase.Query(
            "SELECT COUNT(*) FROM mod_waygate_network_discovered WHERE guid = {}",
            player->GetGUID().GetCounter());

        // The row we just inserted makes this 1 on a character's very first-ever attunement.
        if (countResult && countResult->Fetch()[0].Get() == 1)
            chat.PSendSysMessage("Tip: type .waygate to open your travel menu -- you can even bind it to a macro, e.g. '/say .waygate'.");
    }

    // Debug mode lists every known destination instead of just attuned ones, and skips the
    // attunement check on teleport -- for reviewing/testing new mod_waygate_network_locations rows
    // without needing to physically visit each one first. Gated on GM security regardless of the
    // config flag, so flipping the config alone can't let a regular player skip attunement.
    bool IsWaygateDebugFor(Player* player)
    {
        return sConfigMgr->GetOption("WaygateNetwork.Debug", false)
            && player->GetSession()->GetSecurity() >= SEC_GAMEMASTER;
    }

    // A "Neutral" destination (sanctuary cities like Shattrath/Dalaran) is open to everyone;
    // "Alliance"/"Horde" destinations are only offered to/reachable by that faction's own players --
    // landing an Alliance character in the middle of Orgrimmar just to get jumped by guards isn't a
    // feature. Debug mode (IsWaygateDebugFor, GM-only) is the only override, matching the existing
    // "GM sees/travels everywhere regardless" bypass already used for attunement.
    bool WaygateFactionAllowed(Player* player, WaygateDestination const& dest)
    {
        if (dest.faction == "Neutral")
            return true;

        switch (player->GetTeamId())
        {
            case TEAM_ALLIANCE:
                return dest.faction == "Alliance";
            case TEAM_HORDE:
                return dest.faction == "Horde";
            default:
                return true;
        }
    }

    // Alternative to flight paths, not a replacement -- deliberately priced above a comparable
    // flight. Real Blizzard flight prices (extracted from this client's TaxiPath.dbc, 2026-08-15;
    // not derivable from a formula -- see PLAN.md) range ~50c (Stormwind <-> Ironforge) to ~12000c
    // for the longest single-continent legs. Default WaygateNetwork.CostPerYard (0.02) puts
    // Stormwind <-> Ironforge (~4217yd straight-line) at ~84c, roughly 1.7x that real 50c price --
    // the one real anchor point available. Longer/shorter routes will drift from that exact ratio
    // since straight-line distance isn't real flight-path distance and Blizzard's real prices
    // aren't a clean function of distance either; tune the config values against what you actually
    // see in-game rather than trusting these as precisely calibrated.
    uint32 CalculateWaygateCost(Player* player, WaygateDestination const& dest)
    {
        uint32 cost;

        if (player->GetMapId() != dest.mapId)
        {
            cost = sConfigMgr->GetOption("WaygateNetwork.CrossMapCostCopper", 20000);
        }
        else
        {
            float dx = player->GetPositionX() - dest.x;
            float dy = player->GetPositionY() - dest.y;
            float dz = player->GetPositionZ() - dest.z;
            float distance = std::sqrt(dx * dx + dy * dy + dz * dz);
            cost = static_cast(distance * sConfigMgr->GetOption("WaygateNetwork.CostPerYard", 0.02f));
        }

        return std::max(cost, sConfigMgr->GetOption("WaygateNetwork.MinCostCopper", 25));
    }

    std::string FormatWaygateCost(uint32 copper)
    {
        uint32 gold = copper / 10000;
        uint32 silver = (copper % 10000) / 100;
        copper %= 100;

        std::string text;
        if (gold)
            text += Acore::StringFormat("{}|TInterface\\MoneyFrame\\UI-GoldIcon:0|t ", gold);
        if (gold || silver)
            text += Acore::StringFormat("{}|TInterface\\MoneyFrame\\UI-SilverIcon:0|t ", silver);
        text += Acore::StringFormat("{}|TInterface\\MoneyFrame\\UI-CopperIcon:0|t", copper);

        return text;
    }

    Creature* SummonWaygatePortal(Player* player)
    {
        // See the SQL comment on waygate_network_portal_npc.sql for why a real (if short-lived) NPC
        // is needed here instead of sourcing gossip from the player directly.
        return player->SummonCreature(NPC_WAYGATE_PORTAL, player->GetPositionX(), player->GetPositionY(),
            player->GetPositionZ(), player->GetOrientation(), TEMPSUMMON_TIMED_DESPAWN, 15000);
    }

    // Validates, charges, and teleports -- called once the wait (see BeginWaygateChannel) actually
    // completes without the player moving away or losing eligibility in the meantime.
    void FinishWaygateChannel(Player* player, WaygateDestination const& dest,
        float startX, float startY, float startZ, uint32 startMap)
    {
        // The channel is ending one way or another the moment this runs (success or one of the
        // aborts below) -- clear the looping channel animation BeginWaygateChannel set, on every path.
        player->ClearEmoteState();

        float dx = player->GetPositionX() - startX;
        float dy = player->GetPositionY() - startY;
        float dz = player->GetPositionZ() - startZ;
        float moved = std::sqrt(dx * dx + dy * dy + dz * dz);

        if (player->GetMapId() != startMap || moved > 2.0f)
        {
            ChatHandler(player->GetSession()).PSendSysMessage("You moved and lost your focus on the waygate.");
            return;
        }

        if (player->IsInCombat())
        {
            ChatHandler(player->GetSession()).PSendSysMessage("The waygate falters -- you're in combat.");
            return;
        }

        // Re-check everything BeginWaygateChannel already checked -- the module could've been
        // disabled, destinations reloaded, or funds spent during the wait.
        if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true))
            return;

        QueryResult result = CharacterDatabase.Query(
            "SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
            player->GetGUID().GetCounter(), dest.id);
        if (!result)
            return;

        if (!WaygateFactionAllowed(player, dest))
            return;

        uint32 cost = CalculateWaygateCost(player, dest);
        if (!player->HasEnoughMoney(cost))
        {
            ChatHandler(player->GetSession()).PSendSysMessage(
                "You can't afford the {} it costs to travel to {}.", FormatWaygateCost(cost), dest.name);
            return;
        }

        player->ModifyMoney(-static_cast(cost));
        player->TeleportTo(dest.mapId, dest.x, dest.y, dest.z, dest.o);
    }

    // No real spell involved at all -- see PLAN.md's "v1.7"/"v1.8" for why the earlier "borrow a
    // real spell's cast bar" approach (Teleport, then Hearthstone) kept misbehaving in ways that
    // were hard to fully pin down or trust (missing cast bar, leaking the donor's real cooldown,
    // and finally the real hearth-home effect still firing some of the time despite
    // PreventHitEffect). This is a plain, fully server-controlled wait: a message, an optional
    // one-shot cosmetic visual burst (SendPlaySpellVisual -- a bare SMSG_PLAY_SPELL_VISUAL packet
    // with zero gameplay side effects: no aura, no cooldown, nothing to suppress or leak), a looping
    // "channeling" animation (SetEmoteState -- same story, purely visual), and a timer via the
    // player's own event scheduler. Cost is deliberately NOT charged here: only if the wait actually
    // completes (see FinishWaygateChannel).
    void BeginWaygateChannel(Player* player, WaygateDestination const& dest)
    {
        // Re-validate against the DB rather than trusting the gossip action alone -- the menu is
        // rebuilt from discovered rows each time, but this guards against a stale/replayed packet.
        QueryResult result = CharacterDatabase.Query(
            "SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
            player->GetGUID().GetCounter(), dest.id);
        if (!result)
            return;

        if (!WaygateFactionAllowed(player, dest))
            return;

        if (player->IsInCombat())
        {
            ChatHandler(player->GetSession()).PSendSysMessage("You can't open a waygate while in combat.");
            return;
        }

        uint32 cost = CalculateWaygateCost(player, dest);
        if (!player->HasEnoughMoney(cost))
        {
            ChatHandler(player->GetSession()).PSendSysMessage(
                "You can't afford the {} it costs to travel to {}.", FormatWaygateCost(cost), dest.name);
            return;
        }

        ChatHandler(player->GetSession()).PSendSysMessage("Hold still... the waygate reaches for you.");

        if (uint32 visualId = sConfigMgr->GetOption("WaygateNetwork.ChannelVisualKitId", 267))
            player->SendPlaySpellVisual(visualId);

        // Generic "channeling a spell" loop -- cleared by FinishWaygateChannel unconditionally, on
        // every path (success, moved-away, combat, etc.), since the channel is always over by then.
        player->SetEmoteState(EMOTE_STATE_SPELL_CHANNEL_OMNI);

        // Captured by GUID and re-resolved via ObjectAccessor when the timer fires, not by raw
        // Player* -- the player could log out, disconnect, or otherwise be destroyed during the
        // wait. dest is captured by value for the same reason: WaygateDestinationList can be
        // reloaded (.waygate reload) mid-wait, which would invalidate a pointer/reference into it.
        ObjectGuid guid = player->GetGUID();
        WaygateDestination destCopy = dest;
        float startX = player->GetPositionX();
        float startY = player->GetPositionY();
        float startZ = player->GetPositionZ();
        uint32 startMap = player->GetMapId();
        uint32 channelMs = sConfigMgr->GetOption("WaygateNetwork.ChannelMs", 4000);

        player->m_Events.AddEventAtOffset([guid, destCopy, startX, startY, startZ, startMap]()
        {
            if (Player* p = ObjectAccessor::FindPlayer(guid))
                FinishWaygateChannel(p, destCopy, startX, startY, startZ, startMap);
        }, Milliseconds(channelMs));
    }

    // Every destination the player could pick from at the destination page: attuned ones normally,
    // every known destination in debug mode (see IsWaygateDebugFor).
    std::vector GetSelectableDestinations(Player* player, bool debug)
    {
        std::vector destinations;

        if (debug)
        {
            for (WaygateDestination const& dest : WaygateDestinationList)
                destinations.push_back(&dest);

            return destinations;
        }

        QueryResult result = CharacterDatabase.Query(
            "SELECT waygate_id FROM mod_waygate_network_discovered WHERE guid = {} ORDER BY waygate_id",
            player->GetGUID().GetCounter());

        if (result)
        {
            do
            {
                Field* row = result->Fetch();
                if (WaygateDestination const* dest = GetWaygateDestinationById(row[0].Get()))
                    if (WaygateFactionAllowed(player, *dest))
                        destinations.push_back(dest);
            } while (result->NextRow());
        }

        return destinations;
    }

    // sender is a real Creature (the summoned portal), not the player -- see the SQL comment on
    // waygate_network_portal_npc.sql for why a player-self-sourced gossip menu doesn't work.
    void ShowWaygateContinentMenu(Player* player, Creature* sender)
    {
        ClearGossipMenuFor(player);

        std::vector destinations = GetSelectableDestinations(player, IsWaygateDebugFor(player));

        std::set present;
        for (WaygateDestination const* dest : destinations)
            present.insert(dest->continent);

        if (present.empty())
        {
            AddGossipItemFor(player, GOSSIP_ICON_CHAT,
                "You haven't attuned any Waygates yet. Visit a capital city to attune one.",
                GOSSIP_SENDER_WAYGATE_CONTINENT, GOSSIP_ACTION_CANCEL);
        }
        else
        {
            for (std::string const& continent : WaygateContinents)
                if (present.count(continent))
                    AddGossipItemFor(player, GOSSIP_ICON_TAXI, continent,
                        GOSSIP_SENDER_WAYGATE_CONTINENT, GetContinentAction(continent));
        }

        // Always present, even with just one continent -- keeps the menu at 2+ options so there's
        // never a single-choice list to worry about, and gives an explicit way out that closes the
        // menu and despawns the portal immediately instead of just walking away from it.
        AddGossipItemFor(player, GOSSIP_ICON_CHAT, "I changed my mind...", GOSSIP_SENDER_WAYGATE_CONTINENT,
            GOSSIP_ACTION_CANCEL);

        SendGossipMenuFor(player, WAYGATE_NETWORK_GOSSIP_TEXT, sender->GetGUID());
    }

    void ShowWaygateDestinationMenu(Player* player, Creature* sender, std::string const& continent)
    {
        ClearGossipMenuFor(player);

        bool debug = IsWaygateDebugFor(player);

        for (WaygateDestination const* dest : GetSelectableDestinations(player, debug))
        {
            if (dest->continent != continent)
                continue;

            std::string text = debug
                ? dest->name + " |cffff0000[debug]|r"
                : dest->name + " - " + FormatWaygateCost(CalculateWaygateCost(player, *dest));

            AddGossipItemFor(player, GOSSIP_ICON_TAXI, text, GOSSIP_SENDER_WAYGATE_DESTINATION, dest->id);
        }

        // "I changed my mind..." only lives on the continent page -- "« Back" already covers
        // backing out of this one, no need for two different-looking exits on the same page.
        AddGossipItemFor(player, GOSSIP_ICON_CHAT, "« Back", GOSSIP_SENDER_WAYGATE_DESTINATION, GOSSIP_ACTION_BACK);

        SendGossipMenuFor(player, WAYGATE_NETWORK_GOSSIP_TEXT, sender->GetGUID());
    }
}

WaygateDestination const* FindWaygateDestinationByZone(uint32 zoneId)
{
    for (WaygateDestination const& dest : WaygateDestinationList)
        if (dest.zoneId == zoneId)
            return &dest;

    return nullptr;
}

WaygateDestination const* GetWaygateDestinationById(uint32 id)
{
    for (WaygateDestination const& dest : WaygateDestinationList)
        if (dest.id == id)
            return &dest;

    return nullptr;
}

class WaygateNetworkCommandScript : public CommandScript
{
public:
    WaygateNetworkCommandScript() : CommandScript("WaygateNetworkCommandScript") { }

    ChatCommandTable GetCommands() const override
    {
        static ChatCommandTable waygateCommandTable =
        {
            { "reload", HandleWaygateReloadCommand, SEC_GAMEMASTER, Console::Yes },
            { "",       HandleWaygateCommand,       SEC_PLAYER,     Console::No  },
        };

        static ChatCommandTable commandTable =
        {
            { "waygate", waygateCommandTable },
        };

        return commandTable;
    }

    static bool HandleWaygateReloadCommand(ChatHandler* handler)
    {
        LoadWaygateDestinations();
        handler->PSendSysMessage("Waygate destinations reloaded ({} loaded).", WaygateDestinationList.size());
        return true;
    }

    static bool HandleWaygateCommand(ChatHandler* handler)
    {
        Player* player = handler->GetPlayer();
        if (!player || IsPlayerbot(player))
            return true;

        if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true))
        {
            handler->PSendSysMessage("The Waygate network is currently disabled.");
            return true;
        }

        // The intro quest chain ("The Ley Wardens") is a hard prerequisite for real use -- GM debug
        // mode is the only override, matching every other bypass in this module. Passive attunement
        // (OnPlayerUpdateZone) is untouched by this check, so finishing the chain doesn't leave a
        // character staring at an empty menu.
        if (sConfigMgr->GetOption("WaygateNetwork.RequireIntroQuest", true)
            && !IsWaygateDebugFor(player) && !player->GetQuestRewardStatus(QUEST_WAYGATE_REACTIVATION))
        {
            handler->PSendSysMessage("The waygate network lies dormant. Seek out Aemos in the Hillsbrad Foothills.");
            return true;
        }

        Creature* portal = SummonWaygatePortal(player);
        if (!portal)
        {
            handler->PSendSysMessage("The Waygate network is unavailable right now.");
            return true;
        }

        ShowWaygateContinentMenu(player, portal);
        return true;
    }
};

class npc_waygate_portal : public CreatureScript
{
public:
    npc_waygate_portal() : CreatureScript("npc_waygate_portal") { }

    bool OnGossipHello(Player* player, Creature* creature) override
    {
        ShowWaygateContinentMenu(player, creature);
        return true;
    }

    bool OnGossipSelect(Player* player, Creature* creature, uint32 sender, uint32 action) override
    {
        if (sender == GOSSIP_SENDER_WAYGATE_CONTINENT)
        {
            if (std::string const* continent = GetContinentByAction(action))
                ShowWaygateDestinationMenu(player, creature, *continent);
            else
            {
                CloseGossipMenuFor(player);
                creature->DespawnOrUnsummon();
            }

            return true;
        }

        if (sender != GOSSIP_SENDER_WAYGATE_DESTINATION)
            return false;

        if (action == GOSSIP_ACTION_BACK)
        {
            ShowWaygateContinentMenu(player, creature);
            return true;
        }

        CloseGossipMenuFor(player);

        if (action && sConfigMgr->GetOption("WaygateNetwork.Enable", true))
        {
            if (WaygateDestination const* dest = GetWaygateDestinationById(action))
            {
                if (IsWaygateDebugFor(player))
                {
                    // Debug mode stays instant and free -- it's for reviewing/testing locations
                    // quickly, not for simulating the real player experience. No attunement,
                    // combat, or cost checks; no cast.
                    player->TeleportTo(dest->mapId, dest->x, dest->y, dest->z, dest->o);
                }
                else
                {
                    BeginWaygateChannel(player, *dest);
                }
            }
        }

        creature->DespawnOrUnsummon();
        return true;
    }
};

// One of these is spawned permanently at every Waygate destination (entry = WAYGATE_STONE_ENTRY_BASE
// + destination id, see data/sql/db-world/updates/2026_08_15_11.sql), replacing the earlier
// walk-into-the-zone auto-attunement -- see "Waygate Stones" in README.md for why. A single shared
// ScriptName across all 74 spawns; which destination a given stone represents is resolved from its
// own template entry, not its position or spawn guid (real gameobject.guid values in this client
// already run into the millions, too real a collision risk to use as a deterministic lookup key).
// Attuning requires the Ley Wardens chain to be complete, same gate as `.waygate` itself -- before
// that, interacting shows in-world flavor text (WAYGATE_STONE_LOCKED_GOSSIP_TEXT) instead.
class go_waygate_standing_stone : public GameObjectScript
{
public:
    go_waygate_standing_stone() : GameObjectScript("go_waygate_standing_stone") { }

    bool OnGossipHello(Player* player, GameObject* go) override
    {
        if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true) || IsPlayerbot(player))
            return true;

        WaygateDestination const* dest = GetWaygateDestinationById(go->GetEntry() - WAYGATE_STONE_ENTRY_BASE);
        if (!dest)
            return true;

        // The stones themselves are gated the same way `.waygate` is -- reactivating the network is
        // what the Ley Wardens chain is *for*, so a stone shouldn't do anything until that's done
        // (GM debug mode is the only bypass, matching every other gate in this module). Rather than a
        // plain chat line, this opens a real gossip popup with in-world flavor text -- deliberately
        // vague about *who* to seek out (the `.waygate` command's own message already names Aemos
        // directly; this is often the first hint a character gets, before they've ever typed the
        // command, so it stays a level more mysterious).
        if (sConfigMgr->GetOption("WaygateNetwork.RequireIntroQuest", true)
            && !IsWaygateDebugFor(player) && !player->GetQuestRewardStatus(QUEST_WAYGATE_REACTIVATION))
        {
            ClearGossipMenuFor(player);
            SendGossipMenuFor(player, WAYGATE_STONE_LOCKED_GOSSIP_TEXT, go->GetGUID());
            return true;
        }

        AttuneWaygateDestination(player, *dest);
        return true;
    }
};

// Debounce state for the zone-entry hint below, held on Player::CustomData (the standard
// AzerothCore idiom for a module to attach per-player transient state without touching core
// headers) rather than anything persisted -- this only needs to survive the current session.
struct WaygateHintState : public DataMap::Base
{
    uint32 lastHintedDestinationId = 0;
};

class WaygateNetwork_Player : public PlayerScript
{
public:
    WaygateNetwork_Player() : PlayerScript("WaygateNetwork_Player") { }

    // No longer attunes on its own -- entering the zone just hints that a Waygate Stone is
    // somewhere nearby, so there's something to go on besides a blind search. Actual attunement
    // only happens by interacting with the stone itself (go_waygate_standing_stone::OnGossipHello).
    //
    // Two guards keep this from spamming: skipped entirely while on a taxi (IsInFlight()) -- a
    // single flight can cross several un-attuned destination zones back to back, which is what was
    // actually producing the "3 messages in a row" reports, not a single zone re-triggering -- and
    // deduped against the last destination actually hinted, so re-entering the *same* zone (zone
    // line flicker while walking, or the hook firing more than once for one real entry) doesn't
    // repeat the message. Still repeats across *different* un-attuned zones on foot, which is the
    // intended nudge-not-once-only behavior.
    void OnPlayerUpdateZone(Player* player, uint32 newZone, uint32 /*newArea*/) override
    {
        if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true) || IsPlayerbot(player) || player->IsInFlight())
            return;

        WaygateDestination const* dest = FindWaygateDestinationByZone(newZone);
        if (!dest)
            return;

        WaygateHintState* hintState = player->CustomData.GetDefault("WaygateNetworkHint");
        if (hintState->lastHintedDestinationId == dest->id)
            return;

        QueryResult result = CharacterDatabase.Query(
            "SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
            player->GetGUID().GetCounter(), dest->id);

        if (result)
            return;

        hintState->lastHintedDestinationId = dest->id;
        ChatHandler(player->GetSession()).PSendSysMessage("You sense a waygate stone somewhere nearby...");
    }

    void OnPlayerDeleteFromDB(CharacterDatabaseTransaction trans, uint32 guid) override
    {
        // Otherwise a deleted character's guid can be recycled and the new character on that guid
        // would inherit stale attunements it never earned.
        trans->Append("DELETE FROM mod_waygate_network_discovered WHERE guid = {}", guid);
    }
};

class WaygateNetwork_World : public WorldScript
{
public:
    WaygateNetwork_World() : WorldScript("WaygateNetwork_World") { }

    void OnLoadCustomDatabaseTable() override
    {
        LoadWaygateDestinations();
    }
};

void AddSC_mod_waygate_network()
{
    new WaygateNetwork_Player();
    new WaygateNetworkCommandScript();
    new npc_waygate_portal();
    new go_waygate_standing_stone();
    new WaygateNetwork_World();
}