/*
 * SPDX-License-Identifier: AGPL-3.0-or-later
 *
 * mod-taxi-builder: in-game GM commands for authoring custom taxi nodes
 * and flight paths. The custom_taxi_* tables in acore_world are the
 * source of truth; the moonwell-taxi Python CLI generates DBCs from them.
 */

#include "Chat.h"
#include "CommandScript.h"
#include "DBCStores.h"
#include "DatabaseEnv.h"
#include "GameObject.h"
#include "ObjectAccessor.h"
#include "ObjectMgr.h"
#include "Player.h"

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>

using namespace Acore::ChatCommands;

namespace
{
    // TaxiNode ids must stay <= 448. AzerothCore tracks per-node discovery
    // in a TaxiMask of size 14 uint32 = 448 bits (DBCStructure.h
    // `TaxiMaskSize = 14`). GetNearestTaxiNode skips any node whose
    // `(id - 1) / 32 >= TaxiMaskSize`, so nodes with id > 448 are silently
    // invisible to the flight-master logic — flights from those nodes loop
    // and drop the player at a graveyard.
    //
    // To squeeze more than 8 custom nodes out of this cap we also reuse
    // gaps inside the vanilla range (1..PREFERRED_MIN-1). Allocation prefers
    // the above-vanilla band first (PREFERRED_MIN..ABS_MAX) so vanilla gaps
    // stay reserved for spillover or for explicit GM picks; once that band
    // is full we walk vanilla gaps from low to high.
    constexpr uint32 CUSTOM_TAXI_NODE_ID_PREFERRED_MIN = 441;  // first id past stock vanilla (~440 here)
    constexpr uint32 CUSTOM_TAXI_NODE_ID_ABS_MAX       = 448;  // hard ceiling from TaxiMaskSize
    // TaxiPath ids are not limited by TaxiMask, but the 3.3.5a client can
    // allocate client-side path tables up to max id. Keep custom paths dense,
    // immediately after the stock 3.3.5a max id (1978 in this data set).
    constexpr uint32 CUSTOM_TAXI_PATH_ID_MIN           = 1979;

    // Backwards-compatible aliases used elsewhere in this file.
    constexpr uint32 CUSTOM_TAXI_NODE_ID_MIN = CUSTOM_TAXI_NODE_ID_PREFERRED_MIN;
    constexpr uint32 CUSTOM_TAXI_NODE_ID_MAX = CUSTOM_TAXI_NODE_ID_ABS_MAX;

    // Loads every id in `custom_taxi_nodes` (capped at the absolute max so
    // we don't drag in legacy out-of-range rows). The set is used as a
    // companion to `sTaxiNodesStore` when scanning for free slots — pending
    // custom nodes that haven't been merged into a DBC yet still live only
    // in the table.
    std::set<uint32> LoadCustomTaxiNodeIds()
    {
        std::set<uint32> ids;
        QueryResult r = WorldDatabase.Query(
            "SELECT id FROM custom_taxi_nodes WHERE id <= {}", CUSTOM_TAXI_NODE_ID_ABS_MAX);
        if (r)
            do { ids.insert(r->Fetch()[0].Get<uint32>()); } while (r->NextRow());
        return ids;
    }

    bool IsTaxiNodeIdInUse(uint32 id, std::set<uint32> const& customIds)
    {
        return sTaxiNodesStore.LookupEntry(id) != nullptr || customIds.count(id) != 0;
    }

    // Returns the next free TaxiNode id within [1, ABS_MAX], 0 if exhausted.
    // Preference: PREFERRED_MIN..ABS_MAX first (above-vanilla, always safe),
    // then vanilla gaps 1..PREFERRED_MIN-1.
    uint32 PickNextCustomTaxiNodeId()
    {
        std::set<uint32> const customIds = LoadCustomTaxiNodeIds();

        for (uint32 id = CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; id <= CUSTOM_TAXI_NODE_ID_ABS_MAX; ++id)
            if (!IsTaxiNodeIdInUse(id, customIds))
                return id;

        for (uint32 id = 1; id < CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; ++id)
            if (!IsTaxiNodeIdInUse(id, customIds))
                return id;

        return 0;
    }

    uint32 PickNextCustomId(char const* table, uint32 floor)
    {
        QueryResult result = WorldDatabase.Query(
            "SELECT MAX(id) FROM {} WHERE id >= {}", table, floor);

        if (!result)
            return floor;

        Field* fields = result->Fetch();
        if (fields[0].IsNull())
            return floor;

        return fields[0].Get<uint32>() + 1;
    }

    uint32 PickNextCustomTaxiPathId() { return PickNextCustomId("custom_taxi_paths", CUSTOM_TAXI_PATH_ID_MIN); }

    // Per-GM in-memory recording state. Cleared on worldserver restart; the
    // GM just reissues `.taxi record start <pathId>` to resume — already
    // recorded waypoints sit in custom_taxi_path_nodes, not here.
    struct RecordingSession
    {
        uint32 pathId;
        uint32 baselinePointCount;  // waypoints that existed at session start
    };
    std::unordered_map<ObjectGuid, RecordingSession> g_recording;

    // Banner Stand of Argent Crusade — a small, universally-loaded visible
    // GO. Override this if your world DB strips it.
    constexpr uint32 MARKER_GO_ENTRY        = 178559;
    constexpr uint32 MARKER_RESPAWN_SECONDS = 24 * 60 * 60;

    // Active marker GOs per path, addressed by path id (not per-GM, since
    // markers are world-state and shared across all GMs looking at the path).
    std::unordered_map<uint32, std::vector<ObjectGuid>> g_markers;

    void DespawnMarkersForPath(Player* anchor, uint32 pathId)
    {
        auto it = g_markers.find(pathId);
        if (it == g_markers.end())
            return;
        for (ObjectGuid const& guid : it->second)
        {
            if (GameObject* go = ObjectAccessor::GetGameObject(*anchor, guid))
                go->Delete();
        }
        g_markers.erase(it);
    }

    uint32 CountPathPoints(uint32 pathId)
    {
        QueryResult r = WorldDatabase.Query(
            "SELECT COUNT(*) FROM custom_taxi_path_nodes WHERE path_id = {}", pathId);
        return r ? r->Fetch()[0].Get<uint32>() : 0;
    }

    std::optional<uint32> NextNodeIndex(uint32 pathId)
    {
        QueryResult r = WorldDatabase.Query(
            "SELECT MAX(node_index) FROM custom_taxi_path_nodes WHERE path_id = {}", pathId);
        if (!r)
            return 0;
        Field* f = r->Fetch();
        if (f[0].IsNull())
            return 0;
        return f[0].Get<uint32>() + 1;
    }

    struct ResolvedTaxiNode
    {
        uint32 id;
        uint32 mapId;
        float  x;
        float  y;
        float  z;
        std::string name;
        bool   isCustom;
    };

    std::optional<ResolvedTaxiNode> ResolveTaxiNode(uint32 nodeId, LocaleConstant locale)
    {
        if (QueryResult result = WorldDatabase.Query(
                "SELECT name, map_id, position_x, position_y, position_z "
                "FROM custom_taxi_nodes WHERE id = {}", nodeId))
        {
            Field* f = result->Fetch();
            return ResolvedTaxiNode{
                nodeId, f[1].Get<uint16>(),
                f[2].Get<float>(), f[3].Get<float>(), f[4].Get<float>(),
                f[0].Get<std::string>(), true
            };
        }

        if (TaxiNodesEntry const* node = sTaxiNodesStore.LookupEntry(nodeId))
        {
            std::string name = node->name[locale] ? node->name[locale] : (node->name[0] ? node->name[0] : "");
            return ResolvedTaxiNode{
                nodeId, node->map_id, node->x, node->y, node->z, std::move(name), false
            };
        }

        return std::nullopt;
    }

    // Thresholds per spec section 4.
    constexpr uint32 MIN_POINTS_HARD              = 3;     // 4.2 floor
    constexpr uint32 MIN_POINTS_RECOMMENDED       = 12;    // 4.2 short route guideline
    constexpr float  SEG_TOO_SHORT_YD             = 40.f;  // 4.3 SKIP_SPLINE_POINT_DISTANCE
    constexpr float  SEG_LONG_WARN_YD             = 120.f; // 4.3 long-segment warning
    constexpr float  SEG_LONG_ERROR_YD            = 200.f; // 4.3 hard error
    constexpr float  ENDPOINT_NEAR_YD             = 20.f;  // 4.4 first/last proximity
    constexpr float  TURN_WARN_DEG                = 75.f;  // 4.6 sharp-turn threshold

    struct PathWaypoint
    {
        uint32 nodeIndex;
        uint32 mapId;
        float  x, y, z;
    };

    struct ValidationReport
    {
        std::vector<std::string> errors;
        std::vector<std::string> warnings;
        bool ok() const { return errors.empty(); }
    };

    float DistXY(PathWaypoint const& a, PathWaypoint const& b)
    {
        float dx = b.x - a.x, dy = b.y - a.y;
        return std::sqrt(dx * dx + dy * dy);
    }

    // Returns 0..180 angle (degrees) between segments (i-1 -> i) and (i -> i+1)
    // measured in the XY plane. 0 = straight, 90 = right angle, 180 = U-turn.
    float TurnAngleDegXY(PathWaypoint const& a, PathWaypoint const& b, PathWaypoint const& c)
    {
        float v1x = b.x - a.x, v1y = b.y - a.y;
        float v2x = c.x - b.x, v2y = c.y - b.y;
        float n1 = std::sqrt(v1x*v1x + v1y*v1y);
        float n2 = std::sqrt(v2x*v2x + v2y*v2y);
        if (n1 < 0.0001f || n2 < 0.0001f)
            return 0.f;
        float cosA = (v1x*v2x + v1y*v2y) / (n1 * n2);
        cosA = std::clamp(cosA, -1.f, 1.f);
        return std::acos(cosA) * 180.f / float(M_PI);
    }

    std::vector<PathWaypoint> LoadPathWaypoints(uint32 pathId)
    {
        std::vector<PathWaypoint> out;
        QueryResult r = WorldDatabase.Query(
            "SELECT node_index, map_id, position_x, position_y, position_z "
            "FROM custom_taxi_path_nodes WHERE path_id = {} ORDER BY node_index",
            pathId);
        if (!r)
            return out;
        do
        {
            Field* f = r->Fetch();
            out.push_back({ f[0].Get<uint32>(), f[1].Get<uint16>(),
                            f[2].Get<float>(), f[3].Get<float>(), f[4].Get<float>() });
        } while (r->NextRow());
        return out;
    }

    // Apply spec sections 4.1 - 4.6. The caller is responsible for pretty-printing.
    ValidationReport ValidatePath(uint32 pathId, LocaleConstant locale)
    {
        ValidationReport rep;
        char buf[256];

        QueryResult pathRow = WorldDatabase.Query(
            "SELECT from_taxi_node_id, to_taxi_node_id FROM custom_taxi_paths WHERE id = {}",
            pathId);
        if (!pathRow)
        {
            rep.errors.emplace_back("path not found in custom_taxi_paths");
            return rep;
        }
        uint32 const fromId = pathRow->Fetch()[0].Get<uint32>();
        uint32 const toId   = pathRow->Fetch()[1].Get<uint32>();

        // 4.1: from/to nodes must resolve.
        auto fromNode = ResolveTaxiNode(fromId, locale);
        auto toNode   = ResolveTaxiNode(toId,   locale);
        if (!fromNode)
        {
            std::snprintf(buf, sizeof(buf), "from-node %u does not exist (custom or vanilla)", fromId);
            rep.errors.emplace_back(buf);
        }
        if (!toNode)
        {
            std::snprintf(buf, sizeof(buf), "to-node %u does not exist (custom or vanilla)", toId);
            rep.errors.emplace_back(buf);
        }

        auto wps = LoadPathWaypoints(pathId);

        // 4.1: must have waypoints; node_index must be 0..N-1 contiguous.
        if (wps.empty())
        {
            rep.errors.emplace_back("path has no waypoints (custom_taxi_path_nodes is empty for this path)");
            return rep;
        }
        for (size_t i = 0; i < wps.size(); ++i)
        {
            if (wps[i].nodeIndex != i)
            {
                std::snprintf(buf, sizeof(buf),
                    "node_index is not contiguous from 0: position %zu has node_index=%u",
                    i, wps[i].nodeIndex);
                rep.errors.emplace_back(buf);
                break;
            }
        }

        // 4.2: point count.
        if (wps.size() < MIN_POINTS_HARD)
        {
            std::snprintf(buf, sizeof(buf),
                "only %u waypoints; the spec requires at least %u",
                uint32(wps.size()), MIN_POINTS_HARD);
            rep.errors.emplace_back(buf);
        }
        else if (wps.size() < MIN_POINTS_RECOMMENDED)
        {
            std::snprintf(buf, sizeof(buf),
                "only %u waypoints; %u+ recommended for a short route",
                uint32(wps.size()), MIN_POINTS_RECOMMENDED);
            rep.warnings.emplace_back(buf);
        }

        // 4.3: segment XY distance bounds.
        for (size_t i = 1; i < wps.size(); ++i)
        {
            if (wps[i].mapId != wps[i - 1].mapId)
            {
                std::snprintf(buf, sizeof(buf),
                    "segment %zu->%zu crosses map (%u -> %u) — taxi paths cannot change continent mid-route",
                    i - 1, i, wps[i - 1].mapId, wps[i].mapId);
                rep.errors.emplace_back(buf);
                continue;
            }
            float d = DistXY(wps[i - 1], wps[i]);
            if (d > SEG_LONG_ERROR_YD)
            {
                std::snprintf(buf, sizeof(buf),
                    "segment %zu->%zu is %.1f yd (> %.0f yd hard limit)",
                    i - 1, i, d, SEG_LONG_ERROR_YD);
                rep.errors.emplace_back(buf);
            }
            else if (d > SEG_LONG_WARN_YD)
            {
                std::snprintf(buf, sizeof(buf),
                    "segment %zu->%zu is %.1f yd (> %.0f yd, may give a jagged spline)",
                    i - 1, i, d, SEG_LONG_WARN_YD);
                rep.warnings.emplace_back(buf);
            }
            else if (d < SEG_TOO_SHORT_YD)
            {
                std::snprintf(buf, sizeof(buf),
                    "segment %zu->%zu is %.1f yd (< %.0f yd, may be filtered by IsNodeIncludedInShortenedPath)",
                    i - 1, i, d, SEG_TOO_SHORT_YD);
                rep.warnings.emplace_back(buf);
            }
        }

        // 4.4: first/last proximity to taxi nodes.
        if (fromNode)
        {
            if (wps.front().mapId != fromNode->mapId)
            {
                std::snprintf(buf, sizeof(buf),
                    "first waypoint is on map %u but FromTaxiNode is on map %u",
                    wps.front().mapId, fromNode->mapId);
                rep.errors.emplace_back(buf);
            }
            else
            {
                PathWaypoint anchor{ 0, fromNode->mapId, fromNode->x, fromNode->y, fromNode->z };
                float d = DistXY(wps.front(), anchor);
                if (d > ENDPOINT_NEAR_YD)
                {
                    std::snprintf(buf, sizeof(buf),
                        "first waypoint is %.1f yd from FromTaxiNode (> %.0f yd)",
                        d, ENDPOINT_NEAR_YD);
                    rep.warnings.emplace_back(buf);
                }
            }
        }
        if (toNode)
        {
            if (wps.back().mapId != toNode->mapId)
            {
                std::snprintf(buf, sizeof(buf),
                    "last waypoint is on map %u but ToTaxiNode is on map %u",
                    wps.back().mapId, toNode->mapId);
                rep.errors.emplace_back(buf);
            }
            else
            {
                PathWaypoint anchor{ 0, toNode->mapId, toNode->x, toNode->y, toNode->z };
                float dXY = DistXY(wps.back(), anchor);
                if (dXY > ENDPOINT_NEAR_YD)
                {
                    std::snprintf(buf, sizeof(buf),
                        "last waypoint is %.1f yd from ToTaxiNode (> %.0f yd) — players may land off-target",
                        dXY, ENDPOINT_NEAR_YD);
                    rep.warnings.emplace_back(buf);
                }
                // 4.5: rough Z sanity. Server geometry isn't always available
                // for arbitrary maps without GRID load, so just compare against
                // the ToTaxiNode Z; large deltas almost always mean a fall.
                float dZ = std::abs(wps.back().z - toNode->z);
                if (dZ > 30.f)
                {
                    std::snprintf(buf, sizeof(buf),
                        "last waypoint Z=%.1f vs ToTaxiNode Z=%.1f (|dZ|=%.1f) — expect a drop on landing",
                        wps.back().z, toNode->z, dZ);
                    rep.warnings.emplace_back(buf);
                }
            }
        }

        // 4.6: sharp turns in XY.
        for (size_t i = 1; i + 1 < wps.size(); ++i)
        {
            if (wps[i - 1].mapId != wps[i].mapId || wps[i].mapId != wps[i + 1].mapId)
                continue;
            float ang = TurnAngleDegXY(wps[i - 1], wps[i], wps[i + 1]);
            if (ang > TURN_WARN_DEG)
            {
                std::snprintf(buf, sizeof(buf),
                    "sharp turn at NodeIndex %u: %.0f deg (> %.0f). Consider adding intermediate points.",
                    wps[i].nodeIndex, ang, TURN_WARN_DEG);
                rep.warnings.emplace_back(buf);
            }
        }

        return rep;
    }

    // Picks the most common Alliance/Horde mount creatures used by vanilla
    // taxi nodes on the same continent as the new one. Falls back to the
    // generic creature 541 (used by the spec example) when the map has no
    // vanilla nodes to learn from.
    //
    // DBC convention: MountCreatureID[0] is the Horde mount, [1] is the
    // Alliance mount (see ObjectMgr.cpp:7229 `teamId == TEAM_ALLIANCE ? 1 : 0`).
    // The taxi-builder spec text has the indices inverted; trust the runtime.
    std::pair<uint32, uint32> PickDefaultMountsForMap(uint32 mapId)
    {
        std::map<uint32, uint32> allianceCount;
        std::map<uint32, uint32> hordeCount;

        for (auto const& node : sTaxiNodesStore)
        {
            if (!node || node->map_id != mapId)
                continue;
            if (node->MountCreatureID[0])
                ++hordeCount[node->MountCreatureID[0]];
            if (node->MountCreatureID[1])
                ++allianceCount[node->MountCreatureID[1]];
        }

        auto pickMost = [](std::map<uint32, uint32> const& counts) -> uint32
        {
            uint32 best = 0;
            uint32 bestCount = 0;
            for (auto const& [creature, count] : counts)
            {
                if (count > bestCount)
                {
                    best = creature;
                    bestCount = count;
                }
            }
            return best;
        };

        uint32 alliance = pickMost(allianceCount);
        uint32 horde = pickMost(hordeCount);
        if (!alliance) alliance = 541;
        if (!horde)    horde    = 541;
        return { alliance, horde };
    }
}

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

    ChatCommandTable GetCommands() const override
    {
        static ChatCommandTable taxiNodeCommandTable =
        {
            { "create", HandleTaxiNodeCreate, SEC_ADMINISTRATOR, Console::No  },
            { "list",   HandleTaxiNodeList,   SEC_ADMINISTRATOR, Console::Yes },
            { "free",   HandleTaxiNodeFree,   SEC_ADMINISTRATOR, Console::Yes },
            { "goto",   HandleTaxiNodeGoto,   SEC_ADMINISTRATOR, Console::No  },
        };

        static ChatCommandTable taxiPathCommandTable =
        {
            { "create",   HandleTaxiPathCreate,   SEC_ADMINISTRATOR, Console::Yes },
            { "reverse",  HandleTaxiPathReverse,  SEC_ADMINISTRATOR, Console::Yes },
            { "list",     HandleTaxiPathList,     SEC_ADMINISTRATOR, Console::Yes },
            { "report",   HandleTaxiPathReport,   SEC_ADMINISTRATOR, Console::Yes },
            { "validate", HandleTaxiPathValidate, SEC_ADMINISTRATOR, Console::Yes },
            { "goto",     HandleTaxiPathGoto,     SEC_ADMINISTRATOR, Console::No  },
            { "test",     HandleTaxiPathTest,     SEC_ADMINISTRATOR, Console::No  },
        };

        static ChatCommandTable taxiRecordCommandTable =
        {
            { "start",  HandleTaxiRecordStart,  SEC_ADMINISTRATOR, Console::No },
            { "point",  HandleTaxiRecordPoint,  SEC_ADMINISTRATOR, Console::No },
            { "undo",   HandleTaxiRecordUndo,   SEC_ADMINISTRATOR, Console::No },
            { "finish", HandleTaxiRecordFinish, SEC_ADMINISTRATOR, Console::No },
            { "cancel", HandleTaxiRecordCancel, SEC_ADMINISTRATOR, Console::No },
        };

        static ChatCommandTable taxiMarkersCommandTable =
        {
            { "spawn", HandleTaxiMarkersSpawn, SEC_ADMINISTRATOR, Console::No  },
            { "clear", HandleTaxiMarkersClear, SEC_ADMINISTRATOR, Console::Yes },
        };

        static ChatCommandTable taxiExportCommandTable =
        {
            { "dbc", HandleTaxiExportDbc, SEC_ADMINISTRATOR, Console::Yes },
        };

        static ChatCommandTable taxiCommandTable =
        {
            { "node",    taxiNodeCommandTable    },
            { "path",    taxiPathCommandTable    },
            { "record",  taxiRecordCommandTable  },
            { "markers", taxiMarkersCommandTable },
            { "export",  taxiExportCommandTable  },
        };

        static ChatCommandTable commandTable =
        {
            { "taxi", taxiCommandTable },
        };

        return commandTable;
    }

    static bool HandleTaxiNodeCreate(ChatHandler* handler, QuotedString name)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player position.");
            return false;
        }

        if (name.empty())
        {
            handler->SendErrorMessage("Node name must not be empty.");
            return false;
        }

        uint32 const mapId = player->GetMapId();
        float const x = player->GetPositionX();
        float const y = player->GetPositionY();
        float const z = player->GetPositionZ();
        auto const [mountAlliance, mountHorde] = PickDefaultMountsForMap(mapId);
        uint32 const newId = PickNextCustomTaxiNodeId();
        if (!newId)
        {
            handler->SendErrorMessage(
                "All TaxiNode ids in 1..{} are occupied (vanilla + existing custom). "
                "AzerothCore TaxiMaskSize=14 caps node ids at 448. "
                "Free a slot by deleting an unused custom_taxi_nodes row "
                "(also remove its dependents from custom_taxi_paths). "
                "Run `.taxi node free` to see currently available ids.",
                CUSTOM_TAXI_NODE_ID_ABS_MAX);
            return false;
        }
        uint64 const creatorGuid = player->GetGUID().GetCounter();

        std::string escaped = name;
        WorldDatabase.EscapeString(escaped);

        WorldDatabase.Execute(
            "INSERT INTO custom_taxi_nodes "
            "(id, name, map_id, position_x, position_y, position_z, "
            " mount_alliance, mount_horde, created_by) "
            "VALUES ({}, '{}', {}, {}, {}, {}, {}, {}, {})",
            newId, escaped, mapId, x, y, z, mountAlliance, mountHorde, creatorGuid);

        handler->PSendSysMessage(
            "[taxi-builder] node created: id={} name=\"{}\" map={} xyz=({:.2f}, {:.2f}, {:.2f}) "
            "mounts=A:{}/H:{}",
            newId, name.c_str(), mapId, x, y, z, mountAlliance, mountHorde);
        return true;
    }

    static bool HandleTaxiNodeList(ChatHandler* handler, Optional<std::string> filter)
    {
        std::string needle;
        if (filter)
        {
            needle = *filter;
            std::transform(needle.begin(), needle.end(), needle.begin(),
                           [](unsigned char c) { return std::tolower(c); });
        }

        auto matches = [&needle](std::string const& name) -> bool
        {
            if (needle.empty())
                return true;
            std::string lower = name;
            std::transform(lower.begin(), lower.end(), lower.begin(),
                           [](unsigned char c) { return std::tolower(c); });
            return lower.find(needle) != std::string::npos;
        };

        constexpr uint32 MAX_LINES = 50;
        uint32 shown = 0;
        uint32 totalCustom = 0;
        uint32 totalVanilla = 0;

        // Custom nodes first.
        if (QueryResult result = WorldDatabase.Query(
                "SELECT id, name, map_id, position_x, position_y, position_z "
                "FROM custom_taxi_nodes ORDER BY id"))
        {
            do
            {
                Field* fields = result->Fetch();
                uint32 id = fields[0].Get<uint32>();
                std::string name = fields[1].Get<std::string>();
                uint32 mapId = fields[2].Get<uint16>();
                float x = fields[3].Get<float>();
                float y = fields[4].Get<float>();
                float z = fields[5].Get<float>();

                ++totalCustom;
                if (!matches(name))
                    continue;
                if (shown >= MAX_LINES)
                    continue;

                handler->PSendSysMessage(
                    "[custom]  {} {} @ map {} ({:.1f}, {:.1f}, {:.1f})",
                    id, name.c_str(), mapId, x, y, z);
                ++shown;
            } while (result->NextRow());
        }

        // Vanilla nodes from the loaded DBC.
        int const locale = handler->GetSessionDbcLocale();
        for (auto const& node : sTaxiNodesStore)
        {
            if (!node)
                continue;

            std::string name = node->name[locale] ? node->name[locale] : "";
            if (name.empty() && node->name[0])
                name = node->name[0];

            ++totalVanilla;
            if (!matches(name))
                continue;
            if (shown >= MAX_LINES)
                continue;

            handler->PSendSysMessage(
                "[vanilla] {} {} @ map {} ({:.1f}, {:.1f}, {:.1f})",
                node->ID, name.c_str(), node->map_id, node->x, node->y, node->z);
            ++shown;
        }

        handler->PSendSysMessage(
            "Shown {} (cap {}). Custom: {}, Vanilla: {}.{}",
            shown, MAX_LINES, totalCustom, totalVanilla,
            shown >= MAX_LINES ? " Use a filter to narrow." : "");
        return true;
    }

    static bool HandleTaxiNodeFree(ChatHandler* handler)
    {
        std::set<uint32> const customIds = LoadCustomTaxiNodeIds();
        std::vector<uint32> free;
        free.reserve(96);
        for (uint32 id = 1; id <= CUSTOM_TAXI_NODE_ID_ABS_MAX; ++id)
            if (!IsTaxiNodeIdInUse(id, customIds))
                free.push_back(id);

        uint32 const above = uint32(std::count_if(
            free.begin(), free.end(),
            [](uint32 id) { return id >= CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; }));
        uint32 const inGaps = uint32(free.size()) - above;

        handler->PSendSysMessage(
            "Free TaxiNode ids in 1..{}: {} total ({} above-vanilla {}..{}, {} in vanilla gaps).",
            CUSTOM_TAXI_NODE_ID_ABS_MAX, uint32(free.size()),
            above, CUSTOM_TAXI_NODE_ID_PREFERRED_MIN, CUSTOM_TAXI_NODE_ID_ABS_MAX,
            inGaps);

        if (free.empty())
        {
            handler->PSendSysMessage("No free ids — TaxiMask range exhausted.");
            return true;
        }

        // Coalesce into ranges so 84 ids don't spam the chat as 84 lines.
        size_t i = 0;
        while (i < free.size())
        {
            uint32 const start = free[i];
            while (i + 1 < free.size() && free[i + 1] == free[i] + 1)
                ++i;
            uint32 const end = free[i];
            if (start == end)
                handler->PSendSysMessage("  {}", start);
            else
                handler->PSendSysMessage("  {}..{} ({})", start, end, end - start + 1);
            ++i;
        }
        return true;
    }

    static bool HandleTaxiNodeGoto(ChatHandler* handler, uint32 nodeId)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        auto resolved = ResolveTaxiNode(nodeId, handler->GetSessionDbcLocale());
        if (!resolved)
        {
            handler->SendErrorMessage("Taxi node {} not found in custom_taxi_nodes or TaxiNodes.dbc.", nodeId);
            return false;
        }

        player->SaveRecallPosition();
        player->TeleportTo(resolved->mapId, resolved->x, resolved->y, resolved->z, player->GetOrientation());
        handler->PSendSysMessage(
            "[taxi-builder] teleported to {} node {} ({})",
            resolved->isCustom ? "custom" : "vanilla", nodeId, resolved->name.c_str());
        return true;
    }

    static bool HandleTaxiPathCreate(ChatHandler* handler, uint32 fromNodeId, uint32 toNodeId, uint32 cost)
    {
        if (fromNodeId == toNodeId)
        {
            handler->SendErrorMessage("from and to nodes must differ.");
            return false;
        }

        LocaleConstant const locale = handler->GetSessionDbcLocale();
        auto fromNode = ResolveTaxiNode(fromNodeId, locale);
        if (!fromNode)
        {
            handler->SendErrorMessage("from-node {} does not exist (custom or vanilla).", fromNodeId);
            return false;
        }
        auto toNode = ResolveTaxiNode(toNodeId, locale);
        if (!toNode)
        {
            handler->SendErrorMessage("to-node {} does not exist (custom or vanilla).", toNodeId);
            return false;
        }

        if (QueryResult dup = WorldDatabase.Query(
                "SELECT id FROM custom_taxi_paths "
                "WHERE from_taxi_node_id = {} AND to_taxi_node_id = {} AND is_enabled = 1 LIMIT 1",
                fromNodeId, toNodeId))
        {
            uint32 existing = dup->Fetch()[0].Get<uint32>();
            handler->SendErrorMessage(
                "An enabled path {} -> {} already exists with id {}. "
                "Disable it first or use a different direction.",
                fromNodeId, toNodeId, existing);
            return false;
        }

        uint32 const newId = PickNextCustomTaxiPathId();
        uint64 const creatorGuid = handler->GetSession() && handler->GetSession()->GetPlayer()
            ? handler->GetSession()->GetPlayer()->GetGUID().GetCounter() : 0;

        WorldDatabase.Execute(
            "INSERT INTO custom_taxi_paths "
            "(id, from_taxi_node_id, to_taxi_node_id, cost, is_enabled, created_by) "
            "VALUES ({}, {}, {}, {}, 1, {})",
            newId, fromNodeId, toNodeId, cost, creatorGuid);

        handler->PSendSysMessage(
            "[taxi-builder] path created: id={}  {} ({}) -> {} ({})  cost={} copper",
            newId,
            fromNodeId, fromNode->name.c_str(),
            toNodeId, toNode->name.c_str(),
            cost);
        handler->PSendSysMessage(
            "Now record waypoints with: .taxi record start {}  ->  .taxi record point ...  ->  .taxi record finish",
            newId);
        return true;
    }

    static bool HandleTaxiPathReverse(ChatHandler* handler, uint32 pathId)
    {
        QueryResult origPath = WorldDatabase.Query(
            "SELECT from_taxi_node_id, to_taxi_node_id, cost FROM custom_taxi_paths WHERE id = {}",
            pathId);
        if (!origPath)
        {
            handler->SendErrorMessage("custom_taxi_paths id={} not found.", pathId);
            return false;
        }
        Field* pf = origPath->Fetch();
        uint32 const origFrom = pf[0].Get<uint32>();
        uint32 const origTo   = pf[1].Get<uint32>();
        uint32 const cost     = pf[2].Get<uint32>();

        // Read waypoints in original order so we can reverse them.
        QueryResult points = WorldDatabase.Query(
            "SELECT node_index, map_id, position_x, position_y, position_z, "
            "flags, delay, arrival_event_id, departure_event_id, comment "
            "FROM custom_taxi_path_nodes WHERE path_id = {} ORDER BY node_index",
            pathId);
        if (!points)
        {
            handler->SendErrorMessage(
                "Path {} has no waypoints yet — record some first or there is nothing to reverse.",
                pathId);
            return false;
        }

        if (QueryResult dup = WorldDatabase.Query(
                "SELECT id FROM custom_taxi_paths "
                "WHERE from_taxi_node_id = {} AND to_taxi_node_id = {} AND is_enabled = 1 LIMIT 1",
                origTo, origFrom))
        {
            handler->SendErrorMessage(
                "An enabled path {} -> {} already exists with id {} — refusing to create a duplicate.",
                origTo, origFrom, dup->Fetch()[0].Get<uint32>());
            return false;
        }

        uint32 const newPathId = PickNextCustomTaxiPathId();
        uint64 const creatorGuid = handler->GetSession() && handler->GetSession()->GetPlayer()
            ? handler->GetSession()->GetPlayer()->GetGUID().GetCounter() : 0;

        WorldDatabase.Execute(
            "INSERT INTO custom_taxi_paths "
            "(id, from_taxi_node_id, to_taxi_node_id, cost, is_enabled, created_by) "
            "VALUES ({}, {}, {}, {}, 1, {})",
            newPathId, origTo, origFrom, cost, creatorGuid);

        struct WP {
            uint32 mapId; float x, y, z;
            uint32 flags, delay, arrival, departure;
            std::string comment;
        };
        std::vector<WP> waypoints;
        do
        {
            Field* w = points->Fetch();
            waypoints.push_back({
                w[1].Get<uint16>(),
                w[2].Get<float>(), w[3].Get<float>(), w[4].Get<float>(),
                w[5].Get<uint32>(), w[6].Get<uint32>(),
                w[7].Get<uint32>(), w[8].Get<uint32>(),
                w[9].Get<std::string>()
            });
        } while (points->NextRow());

        // Re-insert in reverse order, NodeIndex starting at 0.
        uint32 newIndex = 0;
        for (auto it = waypoints.rbegin(); it != waypoints.rend(); ++it, ++newIndex)
        {
            std::string esc = it->comment;
            WorldDatabase.EscapeString(esc);
            WorldDatabase.Execute(
                "INSERT INTO custom_taxi_path_nodes "
                "(path_id, node_index, map_id, position_x, position_y, position_z, "
                " flags, delay, arrival_event_id, departure_event_id, comment) "
                "VALUES ({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, '{}')",
                newPathId, newIndex, it->mapId, it->x, it->y, it->z,
                it->flags, it->delay, it->arrival, it->departure, esc);
        }

        handler->PSendSysMessage(
            "[taxi-builder] reversed path {} ({}->{}) into new path {} ({}->{}) with {} waypoints.",
            pathId, origFrom, origTo, newPathId, origTo, origFrom, uint32(waypoints.size()));
        handler->PSendSysMessage(
            "WARNING: reverse paths are auto-generated. Check landing waypoints by hand "
            "(`.taxi path goto {} <lastIndex>`) — a good outbound path is not always a good return.",
            newPathId);
        return true;
    }

    static bool HandleTaxiPathList(ChatHandler* handler)
    {
        QueryResult result = WorldDatabase.Query(
            "SELECT p.id, p.from_taxi_node_id, p.to_taxi_node_id, p.cost, p.is_enabled, "
            "       (SELECT COUNT(*) FROM custom_taxi_path_nodes n WHERE n.path_id = p.id) AS pts "
            "FROM custom_taxi_paths p ORDER BY p.id");
        if (!result)
        {
            handler->PSendSysMessage("[taxi-builder] no custom paths defined.");
            return true;
        }

        LocaleConstant const locale = handler->GetSessionDbcLocale();
        uint32 count = 0;
        do
        {
            Field* f = result->Fetch();
            uint32 id      = f[0].Get<uint32>();
            uint32 from    = f[1].Get<uint32>();
            uint32 to      = f[2].Get<uint32>();
            uint32 cost    = f[3].Get<uint32>();
            bool   enabled = f[4].Get<bool>();
            uint32 pts     = f[5].Get<uint32>();

            std::string fromName = "?", toName = "?";
            if (auto fr = ResolveTaxiNode(from, locale)) fromName = fr->name;
            if (auto tr = ResolveTaxiNode(to, locale))   toName   = tr->name;

            handler->PSendSysMessage(
                "{}  {} ({}) -> {} ({})  cost={}  pts={}  {}",
                id, from, fromName.c_str(), to, toName.c_str(),
                cost, pts, enabled ? "enabled" : "DISABLED");
            ++count;
        } while (result->NextRow());

        handler->PSendSysMessage("Total: {} custom paths.", count);
        return true;
    }

    static bool HandleTaxiPathReport(ChatHandler* handler, uint32 pathId)
    {
        QueryResult pathRow = WorldDatabase.Query(
            "SELECT from_taxi_node_id, to_taxi_node_id, cost, is_enabled "
            "FROM custom_taxi_paths WHERE id = {}", pathId);
        if (!pathRow)
        {
            handler->SendErrorMessage("custom_taxi_paths id={} not found.", pathId);
            return false;
        }
        Field* pf = pathRow->Fetch();
        uint32 from    = pf[0].Get<uint32>();
        uint32 to      = pf[1].Get<uint32>();
        uint32 cost    = pf[2].Get<uint32>();
        bool   enabled = pf[3].Get<bool>();

        LocaleConstant const locale = handler->GetSessionDbcLocale();
        auto fromNode = ResolveTaxiNode(from, locale);
        auto toNode   = ResolveTaxiNode(to,   locale);

        QueryResult points = WorldDatabase.Query(
            "SELECT node_index, map_id, position_x, position_y, position_z "
            "FROM custom_taxi_path_nodes WHERE path_id = {} ORDER BY node_index",
            pathId);

        struct P { uint32 idx, mapId; float x, y, z; };
        std::vector<P> wps;
        if (points)
        {
            do
            {
                Field* w = points->Fetch();
                wps.push_back({ w[0].Get<uint32>(), w[1].Get<uint16>(),
                                w[2].Get<float>(), w[3].Get<float>(), w[4].Get<float>() });
            } while (points->NextRow());
        }

        handler->PSendSysMessage("PathID: {}  {}", pathId, enabled ? "(enabled)" : "(DISABLED)");
        handler->PSendSysMessage("From: {} {}{}", from,
            fromNode ? fromNode->name.c_str() : "??",
            fromNode ? (fromNode->isCustom ? " [custom]" : " [vanilla]") : "");
        handler->PSendSysMessage("To: {} {}{}", to,
            toNode ? toNode->name.c_str() : "??",
            toNode ? (toNode->isCustom ? " [custom]" : " [vanilla]") : "");
        handler->PSendSysMessage("Cost: {} copper", cost);
        handler->PSendSysMessage("Points: {}", uint32(wps.size()));

        if (wps.size() < 2)
        {
            handler->PSendSysMessage("Not enough waypoints for distance metrics (need >= 2).");
            return true;
        }

        auto dist3 = [](P const& a, P const& b) -> float
        {
            float dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
            return std::sqrt(dx*dx + dy*dy + dz*dz);
        };

        float total = 0.f, minSeg = 0.f, maxSeg = 0.f;
        for (size_t i = 1; i < wps.size(); ++i)
        {
            float d = dist3(wps[i - 1], wps[i]);
            total += d;
            if (i == 1) { minSeg = d; maxSeg = d; }
            else { minSeg = std::min(minSeg, d); maxSeg = std::max(maxSeg, d); }
        }

        handler->PSendSysMessage("Total distance: {:.1f} yd", total);
        handler->PSendSysMessage("Min segment: {:.1f} yd  Max segment: {:.1f} yd", minSeg, maxSeg);

        if (toNode && wps.back().mapId == toNode->mapId)
        {
            P last = wps.back();
            P dest{ 0, toNode->mapId, toNode->x, toNode->y, toNode->z };
            handler->PSendSysMessage("Last point distance to ToTaxiNode: {:.2f} yd", dist3(last, dest));
        }
        else if (toNode)
        {
            handler->PSendSysMessage(
                "Last waypoint is on map {} but ToTaxiNode is on map {} — different continents.",
                wps.back().mapId, toNode->mapId);
        }
        return true;
    }

    static bool HandleTaxiPathValidate(ChatHandler* handler, uint32 pathId)
    {
        ValidationReport rep = ValidatePath(pathId, handler->GetSessionDbcLocale());

        for (auto const& e : rep.errors)
            handler->PSendSysMessage("[ERROR]   {}", e.c_str());
        for (auto const& w : rep.warnings)
            handler->PSendSysMessage("[warn]    {}", w.c_str());

        handler->PSendSysMessage(
            "Validation: {} errors, {} warnings — {}",
            uint32(rep.errors.size()), uint32(rep.warnings.size()),
            rep.ok() ? "OK" : "FAIL");
        return true;
    }

    static bool HandleTaxiPathGoto(ChatHandler* handler, uint32 pathId, uint32 nodeIndex)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        QueryResult r = WorldDatabase.Query(
            "SELECT map_id, position_x, position_y, position_z "
            "FROM custom_taxi_path_nodes WHERE path_id = {} AND node_index = {}",
            pathId, nodeIndex);
        if (!r)
        {
            handler->SendErrorMessage(
                "No waypoint found for path={} nodeIndex={}.", pathId, nodeIndex);
            return false;
        }
        Field* f = r->Fetch();
        uint32 mapId = f[0].Get<uint16>();
        float x = f[1].Get<float>(), y = f[2].Get<float>(), z = f[3].Get<float>();

        player->SaveRecallPosition();
        player->TeleportTo(mapId, x, y, z, player->GetOrientation());
        handler->PSendSysMessage(
            "[taxi-builder] teleported to path {}, NodeIndex {}  map={} ({:.2f}, {:.2f}, {:.2f})",
            pathId, nodeIndex, mapId, x, y, z);
        return true;
    }

    // MVP test: validate the route, then teleport the GM to the first
    // waypoint with orientation toward the second. A real flight via taxi
    // splines requires the new path to live in TaxiPath/TaxiPathNode DBC,
    // which is the moonwell-taxi CLI's job — until then this gives a
    // hand-on preview that the route is sane and reachable.
    static bool HandleTaxiPathTest(ChatHandler* handler, uint32 pathId)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        ValidationReport rep = ValidatePath(pathId, handler->GetSessionDbcLocale());
        if (!rep.ok())
        {
            handler->PSendSysMessage("Refusing to test: validation has {} errors.", uint32(rep.errors.size()));
            for (auto const& e : rep.errors)
                handler->PSendSysMessage("  [ERROR] {}", e.c_str());
            handler->PSendSysMessage("Run `.taxi path validate {}` for full report.", pathId);
            return false;
        }
        for (auto const& w : rep.warnings)
            handler->PSendSysMessage("  [warn] {}", w.c_str());

        auto wps = LoadPathWaypoints(pathId);
        if (wps.size() < 2)
        {
            handler->SendErrorMessage("path {} has fewer than 2 waypoints.", pathId);
            return false;
        }

        float orientation = player->GetOrientation();
        if (wps[0].mapId == wps[1].mapId)
            orientation = std::atan2(wps[1].y - wps[0].y, wps[1].x - wps[0].x);

        player->SaveRecallPosition();
        player->TeleportTo(wps[0].mapId, wps[0].x, wps[0].y, wps[0].z, orientation);

        handler->PSendSysMessage(
            "[taxi-builder] dropped at first waypoint of path {} ({} total). "
            "Step through with `.taxi path goto {} <index>`. "
            "Real taxi flight needs `moonwell-taxi export-dbc` + worldserver/client restart.",
            pathId, uint32(wps.size()), pathId);
        return true;
    }

    static bool HandleTaxiRecordStart(ChatHandler* handler, uint32 pathId)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        if (!WorldDatabase.Query("SELECT 1 FROM custom_taxi_paths WHERE id = {}", pathId))
        {
            handler->SendErrorMessage(
                "Path {} does not exist. Create it first with `.taxi path create`.", pathId);
            return false;
        }

        ObjectGuid const guid = player->GetGUID();
        if (auto it = g_recording.find(guid); it != g_recording.end())
        {
            handler->SendErrorMessage(
                "You are already recording path {}. Run `.taxi record finish` or `.taxi record cancel` first.",
                it->second.pathId);
            return false;
        }

        uint32 const baseline = CountPathPoints(pathId);
        g_recording[guid] = { pathId, baseline };

        handler->PSendSysMessage(
            "[taxi-builder] recording started for path {} (existing points: {}). "
            "Use `.taxi record point [\"comment\"]` and `.taxi record finish` when done.",
            pathId, baseline);
        return true;
    }

    static bool HandleTaxiRecordPoint(ChatHandler* handler, Optional<QuotedString> comment)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player position.");
            return false;
        }

        ObjectGuid const guid = player->GetGUID();
        auto it = g_recording.find(guid);
        if (it == g_recording.end())
        {
            handler->SendErrorMessage(
                "You are not recording. Start with `.taxi record start <pathId>` first.");
            return false;
        }

        uint32 const pathId = it->second.pathId;
        std::optional<uint32> const idx = NextNodeIndex(pathId);
        if (!idx)
        {
            handler->SendErrorMessage("Failed to compute next node_index for path {}.", pathId);
            return false;
        }

        std::string esc = comment ? std::string(*comment) : std::string();
        WorldDatabase.EscapeString(esc);

        WorldDatabase.Execute(
            "INSERT INTO custom_taxi_path_nodes "
            "(path_id, node_index, map_id, position_x, position_y, position_z, comment) "
            "VALUES ({}, {}, {}, {}, {}, {}, '{}')",
            pathId, *idx, player->GetMapId(),
            player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
            esc);

        handler->PSendSysMessage(
            "[taxi-builder] path={}  point #{}  map={}  ({:.2f}, {:.2f}, {:.2f}){}{}",
            pathId, *idx, player->GetMapId(),
            player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
            comment ? "  // " : "", comment ? comment->c_str() : "");
        return true;
    }

    static bool HandleTaxiRecordUndo(ChatHandler* handler)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        ObjectGuid const guid = player->GetGUID();
        auto it = g_recording.find(guid);
        if (it == g_recording.end())
        {
            handler->SendErrorMessage("You are not recording.");
            return false;
        }

        uint32 const pathId = it->second.pathId;
        QueryResult r = WorldDatabase.Query(
            "SELECT MAX(node_index) FROM custom_taxi_path_nodes WHERE path_id = {}", pathId);
        if (!r || r->Fetch()[0].IsNull())
        {
            handler->SendErrorMessage("Path {} has no waypoints to undo.", pathId);
            return false;
        }
        uint32 const lastIdx = r->Fetch()[0].Get<uint32>();
        if (lastIdx < it->second.baselinePointCount)
        {
            handler->SendErrorMessage(
                "All points from this recording session were already removed (baseline={}).",
                it->second.baselinePointCount);
            return false;
        }

        WorldDatabase.Execute(
            "DELETE FROM custom_taxi_path_nodes WHERE path_id = {} AND node_index = {}",
            pathId, lastIdx);

        handler->PSendSysMessage("[taxi-builder] removed point #{} from path {}.", lastIdx, pathId);
        return true;
    }

    static bool HandleTaxiRecordFinish(ChatHandler* handler)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        ObjectGuid const guid = player->GetGUID();
        auto it = g_recording.find(guid);
        if (it == g_recording.end())
        {
            handler->SendErrorMessage("You are not recording.");
            return false;
        }

        uint32 const pathId = it->second.pathId;
        uint32 const total = CountPathPoints(pathId);
        uint32 const recorded = total - it->second.baselinePointCount;
        g_recording.erase(it);

        handler->PSendSysMessage(
            "[taxi-builder] recording finished. Path {} now has {} waypoints (added {} this session).",
            pathId, total, recorded);
        handler->PSendSysMessage(
            "Run `.taxi path report {}` or `.taxi path validate {}` to review.",
            pathId, pathId);
        return true;
    }

    static bool HandleTaxiRecordCancel(ChatHandler* handler)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        ObjectGuid const guid = player->GetGUID();
        auto it = g_recording.find(guid);
        if (it == g_recording.end())
        {
            handler->SendErrorMessage("You are not recording.");
            return false;
        }

        uint32 const pathId = it->second.pathId;
        uint32 const baseline = it->second.baselinePointCount;
        g_recording.erase(it);

        // Drop only the points added during this session; pre-existing
        // waypoints are kept so a partial editing session does not destroy data.
        WorldDatabase.Execute(
            "DELETE FROM custom_taxi_path_nodes WHERE path_id = {} AND node_index >= {}",
            pathId, baseline);

        handler->PSendSysMessage(
            "[taxi-builder] recording cancelled for path {}; rolled back to {} waypoints.",
            pathId, baseline);
        return true;
    }

    static bool HandleTaxiMarkersSpawn(ChatHandler* handler, uint32 pathId)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        if (!sObjectMgr->GetGameObjectTemplate(MARKER_GO_ENTRY))
        {
            handler->SendErrorMessage(
                "Marker GO entry {} is missing from gameobject_template — "
                "edit MARKER_GO_ENTRY in mod-taxi-builder source and rebuild.",
                MARKER_GO_ENTRY);
            return false;
        }

        auto wps = LoadPathWaypoints(pathId);
        if (wps.empty())
        {
            handler->SendErrorMessage(
                "Path {} has no waypoints; record some first with `.taxi record start {}`.",
                pathId, pathId);
            return false;
        }

        // Replace any previously spawned markers for this path so consecutive
        // spawn calls don't pile up duplicates.
        DespawnMarkersForPath(player, pathId);

        uint32 const playerMap = player->GetMapId();
        uint32 spawned = 0, skipped = 0;
        for (auto const& wp : wps)
        {
            // GameObjects only become visible on grids that are actually
            // loaded around someone, so spawning markers on a different
            // continent is silently useless.
            if (wp.mapId != playerMap)
            {
                ++skipped;
                continue;
            }
            GameObject* go = player->SummonGameObject(
                MARKER_GO_ENTRY, wp.x, wp.y, wp.z, /*ang*/ 0.f,
                /*rot0*/ 0.f, /*rot1*/ 0.f, /*rot2*/ 0.f, /*rot3*/ 1.f,
                MARKER_RESPAWN_SECONDS);
            if (go)
            {
                g_markers[pathId].push_back(go->GetGUID());
                ++spawned;
            }
        }

        handler->PSendSysMessage(
            "[taxi-builder] spawned {} markers for path {} on map {} ({} skipped on other maps). "
            "Auto-despawn in {} s. Use `.taxi markers clear {}` to remove now.",
            spawned, pathId, playerMap, skipped, MARKER_RESPAWN_SECONDS, pathId);
        return spawned > 0;
    }

    static bool HandleTaxiMarkersClear(ChatHandler* handler, uint32 pathId)
    {
        Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
        if (!player)
        {
            handler->SendErrorMessage("This command requires a player.");
            return false;
        }

        auto it = g_markers.find(pathId);
        if (it == g_markers.end())
        {
            handler->PSendSysMessage("[taxi-builder] no active markers for path {}.", pathId);
            return true;
        }
        uint32 const count = uint32(it->second.size());
        DespawnMarkersForPath(player, pathId);
        handler->PSendSysMessage("[taxi-builder] cleared {} markers for path {}.", count, pathId);
        return true;
    }

    static bool HandleTaxiExportDbc(ChatHandler* handler)
    {
        handler->PSendSysMessage(
            "[taxi-builder] export dbc stub: run `moonwell-taxi export-dbc` from CLI");
        return true;
    }
};

void AddSC_taxi_builder_commandscript()
{
    new taxi_builder_commandscript();
}
