#include "GameOverlay.h"

#include <algorithm>
#include <chrono>
#include <cmath>
#include <format>
#include <string>
#include <unordered_set>
#include <vector>

#include "Readers/EntityTypeCache.h"

#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iphlpapi.h>
#pragma comment(lib, "iphlpapi.lib")

#include <imgui.h>
#include <imgui_internal.h>

#include "MenuBridge.h"

// Per-frame bridge pointer consumed by the vendored Lumin menu (gui.cpp).
MenuBridge* g_menu = nullptr;

// -------------------------------------------------------------------------
// Bone interpolation / extrapolation helpers
// -------------------------------------------------------------------------

int64_t GameOverlay::NowMs() {
    using namespace std::chrono;
    return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}

// Linearly interpolate (or extrapolate when t > 1) between two bone sets.
static SkeletonBones LerpBones(const SkeletonBones& a, const SkeletonBones& b, float t) {
    auto lv = [t](const Vector3& p, const Vector3& q) -> Vector3 {
        return { p.x + t*(q.x-p.x), p.y + t*(q.y-p.y), p.z + t*(q.z-p.z) };
    };
    SkeletonBones out;
    out.valid         = b.valid;
    out.neck          = lv(a.neck,          b.neck);
    out.head          = lv(a.head,          b.head);
    out.spine         = lv(a.spine,         b.spine);
    out.pelvis        = lv(a.pelvis,        b.pelvis);
    out.rightShoulder = lv(a.rightShoulder, b.rightShoulder);
    out.rightElbow    = lv(a.rightElbow,    b.rightElbow);
    out.rightHand     = lv(a.rightHand,     b.rightHand);
    out.leftShoulder  = lv(a.leftShoulder,  b.leftShoulder);
    out.leftElbow     = lv(a.leftElbow,     b.leftElbow);
    out.leftHand      = lv(a.leftHand,      b.leftHand);
    out.rightHip      = lv(a.rightHip,      b.rightHip);
    out.rightKnee     = lv(a.rightKnee,     b.rightKnee);
    out.rightAnkle    = lv(a.rightAnkle,    b.rightAnkle);
    out.leftHip       = lv(a.leftHip,       b.leftHip);
    out.leftKnee      = lv(a.leftKnee,      b.leftKnee);
    out.leftAnkle     = lv(a.leftAnkle,     b.leftAnkle);
    return out;
}

// Update the history when a new DMA bone read arrives.
// lastReadMs is always updated (drives the staleness gate — prevents blink for
// stationary players).  currMs / prevMs are only updated on positional change
// so the extrapolation velocity is derived from actual player movement, not
// every-frame DMA polling.
static void UpdateBoneHistory(GameOverlay::BoneHistory& h,
                               const SkeletonBones& bones, int64_t nowMs)
{
    h.lastReadMs = nowMs;  // always stamp: skeleton visible while reads keep arriving
    if (!h.initialized) {
        h.prev = h.curr = bones;
        h.prevMs = h.currMs = nowMs;
        h.initialized = true;
        return;
    }
    const Vector3& o = h.curr.head;
    const Vector3& n = bones.head;
    float dx = o.x-n.x, dy = o.y-n.y, dz = o.z-n.z;
    float dist2 = dx*dx + dy*dy + dz*dz;
    // Reject implausible jumps: > 3 m in one update cycle can't happen at
    // any in-game speed — it means the scatter read returned garbage.
    if (dist2 > 9.0f) return;
    if (dist2 > 1e-8f) {
        h.prev   = h.curr;
        h.prevMs = h.currMs;
        h.curr   = bones;
        h.currMs = nowMs;
    }
}

// Return bones extrapolated to nowMs using the recorded velocity.
// Alpha is clamped to 1.5 update intervals to limit overshoot on direction changes.
static SkeletonBones GetSmoothedBones(const GameOverlay::BoneHistory& h, int64_t nowMs) {
    if (!h.initialized) return h.curr;
    int64_t dt = h.currMs - h.prevMs;
    if (dt <= 0) return h.curr;
    float alpha = static_cast<float>(nowMs - h.prevMs) / static_cast<float>(dt);
    alpha = std::min(alpha, 1.5f);
    return LerpBones(h.prev, h.curr, alpha);
}

// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------

// -------------------------------------------------------------------------
// SetWebRadarPort — resolve LAN IPs and build display URLs
// -------------------------------------------------------------------------

void GameOverlay::SetWebRadarPort(int port) {
    m_webPort = port;
    m_webUrls.clear();
    m_webUrls.push_back(std::format("http://localhost:{}", port));

    // Enumerate all unicast IPv4 addresses.
    ULONG bufLen = 15000;
    std::vector<BYTE> buf(bufLen);
    auto* table = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
    DWORD ret = GetAdaptersAddresses(AF_INET,
        GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
        nullptr, table, &bufLen);
    if (ret == ERROR_BUFFER_OVERFLOW) {
        buf.resize(bufLen);
        table = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
        ret = GetAdaptersAddresses(AF_INET,
            GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
            nullptr, table, &bufLen);
    }

    if (ret == NO_ERROR) {
        for (auto* a = table; a; a = a->Next) {
            if (a->OperStatus != IfOperStatusUp) continue;
            for (auto* ua = a->FirstUnicastAddress; ua; ua = ua->Next) {
                auto* sin = reinterpret_cast<sockaddr_in*>(ua->Address.lpSockaddr);
                char ipStr[INET_ADDRSTRLEN] = {};
                inet_ntop(AF_INET, &sin->sin_addr, ipStr, sizeof(ipStr));
                std::string ip(ipStr);
                if (ip == "127.0.0.1") continue;
                m_webUrls.push_back(std::format("http://{}:{}", ip, port));
            }
        }
    }
}

float GameOverlay::Dist(const Vector3& a, const Vector3& b) {
    float dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
    return std::sqrtf(dx*dx + dy*dy + dz*dz);
}

// Returns true if the world position is in front of the camera (depth >= 0.65).
// Uses the same depth calculation as WorldToScreen so the threshold is consistent.
// Call this before WorldToScreen to skip the full projection for behind-camera entities.
static bool IsFacingCamera(const CameraData& cam, const Vector3& pos) {
    float tx = pos.x - cam.translation.x;
    float ty = pos.y - cam.translation.y;
    float tz = pos.z - cam.translation.z;
    float depth = tx*cam.invertedViewForward.x
                + ty*cam.invertedViewForward.y
                + tz*cam.invertedViewForward.z;
    return depth >= 0.65f;
}

bool GameOverlay::WorldToScreen(const CameraData& cam,
                                 const Vector3&    world,
                                 float& sx, float& sy,
                                 float w, float h)
{
    float tx = world.x - cam.translation.x;
    float ty = world.y - cam.translation.y;
    float tz = world.z - cam.translation.z;

    float x = tx*cam.invertedViewRight.x   + ty*cam.invertedViewRight.y   + tz*cam.invertedViewRight.z;
    float y = tx*cam.invertedViewUp.x       + ty*cam.invertedViewUp.y       + tz*cam.invertedViewUp.z;
    float z = tx*cam.invertedViewForward.x  + ty*cam.invertedViewForward.y  + tz*cam.invertedViewForward.z;

    if (z < 0.65f) return false;
    if (cam.projectionD1x == 0.0f || cam.projectionD2y == 0.0f) return false;

    float nx = (x / cam.projectionD1x) / z;
    float ny = (y / cam.projectionD2y) / z;

    sx = roundf((w * 0.5f) + nx * (w * 0.5f));
    sy = roundf((h * 0.5f) - ny * (h * 0.5f));

    return sx >= 0.0f && sx <= w && sy >= 0.0f && sy <= h;
}

// Viewport-aware projection.
// For stretch-to-fill (or when no render res is configured) the viewport
// equals the full overlay — identical to calling WorldToScreen directly.
// For maintain-aspect-ratio with render != display, computes the inset
// viewport (letterbox top/bottom or pillarbox left/right) and offsets the
// result so entities land on the correct monitor pixels.
bool GameOverlay::Proj(const CameraData& cam, const Vector3& worldPos,
                        float& sx, float& sy,
                        float overlayW, float overlayH) const
{
    float vx = 0.0f, vy = 0.0f, vw = overlayW, vh = overlayH;

    if (m_renderW > 0 && m_renderH > 0 && !m_stretchToFill) {
        float renderAr  = static_cast<float>(m_renderW) / static_cast<float>(m_renderH);
        float displayAr = overlayW / overlayH;

        if (renderAr > displayAr + 0.01f) {
            // Render is wider than display → letterbox (black bars top/bottom)
            vw = overlayW;
            vh = overlayW / renderAr;
            vy = (overlayH - vh) * 0.5f;
        } else if (displayAr > renderAr + 0.01f) {
            // Display is wider than render → pillarbox (black bars left/right)
            vh = overlayH;
            vw = overlayH * renderAr;
            vx = (overlayW - vw) * 0.5f;
        }
    }
    // stretch-to-fill: vx=0, vy=0, vw=overlayW, vh=overlayH — no change

    if (!WorldToScreen(cam, worldPos, sx, sy, vw, vh)) return false;
    sx += vx;
    sy += vy;
    return sx >= 0.0f && sx <= overlayW && sy >= 0.0f && sy <= overlayH;
}

// -------------------------------------------------------------------------
// Draw — entry point called each ImGui frame
// -------------------------------------------------------------------------

void GameOverlay::SyncConfig() {
    m_cfg.showPlayers      = m_showPlayers;
    m_cfg.showAnimals      = m_showAnimals;
    m_cfg.showZombies      = m_showZombies;
    m_cfg.showItems        = m_showItems;
    m_cfg.showBox          = m_showBox;
    m_cfg.showSkeleton     = m_showSkeleton;
    m_cfg.showHeadDot      = m_showHeadDot;
    m_cfg.showCorpses      = m_showCorpses;
    m_cfg.showWeapon         = m_showWeapon;
    m_cfg.showHealthBar      = m_showHealthBar;
    m_cfg.showHealthNumber   = m_showHealthNumber;
    m_cfg.showBallisticDot   = m_showBallisticDot;
    m_cfg.showBulletTrails   = m_showBulletTrails;
    m_cfg.itemCategories   = m_itemCategories;
    m_cfg.playerMaxDist    = m_playerMaxDist;
    m_cfg.animalMaxDist    = m_animalMaxDist;
    m_cfg.zombieMaxDist    = m_zombieMaxDist;
    m_cfg.itemMaxDist      = m_itemMaxDist;
    m_cfg.renderWidth      = m_renderW;
    m_cfg.renderHeight     = m_renderH;
    m_cfg.stretchToFill    = m_stretchToFill;
}

void GameOverlay::Draw(float w, float h) {
    // Refresh snapshot once per frame — shared_ptr copy is 8 bytes, no vector alloc
    m_snapshot = m_service.GetLatestUpdate();
    if (!m_snapshot) return;
    const RuntimeUpdate& u = *m_snapshot;

    // Record frame time once; used by all bone-smoothing calls this frame.
    m_frameTimeMs = NowMs();

    // Feed live bullet positions into the overlay-owned trail tracker.
    m_bulletTracks.Update(u.bullets, m_frameTimeMs);

    // Feed new DMA bone reads into the per-entity history so extrapolation
    // always has the two most recent samples to work from.
    for (const auto& p : u.players)
        if (p.skeleton.valid)
            UpdateBoneHistory(m_playerBoneHistory[p.address], p.skeleton, m_frameTimeMs);
    for (const auto& z : u.zombies)
        if (z.skeleton.valid)
            UpdateBoneHistory(m_zombieBoneHistory[z.address], z.skeleton, m_frameTimeMs);

    // Prune bone histories for entities no longer present in the snapshot.
    // Prevents ghost extrapolation when a player logs out or leaves the scan zone:
    // their history is cleared immediately so stale bones can't persist.
    {
        std::unordered_set<uint64_t> livePlayers, liveZombies;
        livePlayers.reserve(u.players.size());
        liveZombies.reserve(u.zombies.size());
        for (const auto& p : u.players) livePlayers.insert(p.address);
        for (const auto& z : u.zombies) liveZombies.insert(z.address);

        for (auto it = m_playerBoneHistory.begin(); it != m_playerBoneHistory.end(); )
            it = livePlayers.count(it->first) ? std::next(it) : m_playerBoneHistory.erase(it);
        for (auto it = m_zombieBoneHistory.begin(); it != m_zombieBoneHistory.end(); )
            it = liveZombies.count(it->first) ? std::next(it) : m_zombieBoneHistory.erase(it);
    }

    // INSERT key toggles the menu
    if (GetAsyncKeyState(VK_INSERT) & 1) {
        m_menuOpen  = !m_menuOpen;
        m_menuAlpha = m_menuOpen ? 1.0f : 0.0f;
        if (!m_menuOpen) {
            // Persist settings back to config on close.
            SyncConfig();
            m_cfg.Save(m_cfgPath);
        }
    }

    // ---- Lumin menu (vendored under external/lumin; driven via MenuBridge) ----
    if (m_menuOpen) {
        static MenuBridge bridge;

        bridge.showPlayers      = &m_showPlayers;
        bridge.showAnimals      = &m_showAnimals;
        bridge.showZombies      = &m_showZombies;
        bridge.showItems        = &m_showItems;
        bridge.showBox          = &m_showBox;
        bridge.showSkeleton     = &m_showSkeleton;
        bridge.showHeadDot      = &m_showHeadDot;
        bridge.showWeapon         = &m_showWeapon;
        bridge.showHealthBar      = &m_showHealthBar;
        bridge.showHealthNumber   = &m_showHealthNumber;
        bridge.showCorpses        = &m_showCorpses;
        bridge.debugSkeleton      = &m_debugSkeleton;
        bridge.showBallisticDot   = &m_showBallisticDot;
        bridge.showBulletTrails   = &m_showBulletTrails;

        bridge.playerMaxDist = &m_playerMaxDist;
        bridge.animalMaxDist = &m_animalMaxDist;
        bridge.zombieMaxDist = &m_zombieMaxDist;
        bridge.itemMaxDist   = &m_itemMaxDist;

        bridge.itemCategories = &m_itemCategories;

        bridge.pendingW      = &m_pendingW;
        bridge.pendingH      = &m_pendingH;
        bridge.pendingRW     = &m_pendingRW;
        bridge.pendingRH     = &m_pendingRH;
        bridge.stretchToFill = &m_stretchToFill;

        // Read-only info panels.
        bridge.connected  = u.areBaseObjectsReady;
        bridge.serverName = u.serverName.value_or("");
        bridge.mapName    = u.serverMapName.value_or("");
        bridge.status     = u.status;
        if (u.localPlayerPosition.has_value()) {
            bridge.hasPos = true;
            bridge.px = u.localPlayerPosition->x;
            bridge.py = u.localPlayerPosition->y;
            bridge.pz = u.localPlayerPosition->z;
        } else {
            bridge.hasPos = false;
        }
        bridge.nPlayers  = u.players.size();
        bridge.nAnimals  = u.animals.size();
        bridge.nZombies  = u.zombies.size();
        bridge.nVehicles = u.carsAndBoats.size();
        bridge.nItems    = u.items.size();
        bridge.nBullets  = u.bullets.size();

        bridge.webPort = m_webPort;
        bridge.webUrls = m_webUrls;

        {
            auto ds = m_service.GetDmaStats();
            bridge.dmaAttached   = ds.attached;
            bridge.dmaReadMBps   = ds.readMBps;
            bridge.dmaScatterOps = ds.scatterOpsPs;
            bridge.dmaTotalGB    = ds.totalGB;
            bridge.dmaTotalOps   = ds.totalOps;
        }

        bridge.onSaveConfig = [this]() {
            SyncConfig();
            m_cfg.Save(m_cfgPath);
        };
        bridge.onExit = [this]() {
            SyncConfig();
            m_cfg.Save(m_cfgPath);
            if (m_exitCallback) m_exitCallback();
        };
        bridge.onApplyDisplayRes = [this]() {
            m_cfg.overlayWidth  = m_pendingW;
            m_cfg.overlayHeight = m_pendingH;
            m_cfg.Save(m_cfgPath);
            if (m_resizeCallback) m_resizeCallback(m_pendingW, m_pendingH);
        };
        bridge.onApplyRenderRes = [this]() {
            m_renderW           = m_pendingRW;
            m_renderH           = m_pendingRH;
            m_cfg.renderWidth   = m_pendingRW;
            m_cfg.renderHeight  = m_pendingRH;
            m_cfg.stretchToFill = m_stretchToFill;
            m_cfg.Save(m_cfgPath);
        };

        g_menu = &bridge;
        RenderLuminMenu();
    }

    // ---- ESP draw lists — camera read directly from service every frame ----
    m_service.GetLatestCamera(m_liveCamera);
    DrawESP(w, h, u, m_liveCamera);
}

void GameOverlay::DrawESP(float w, float h, const RuntimeUpdate& u, const CameraData& cam) {
    if (!u.areBaseObjectsReady || !cam.valid) return;

    ImDrawList* dl = ImGui::GetBackgroundDrawList();
    if (m_showBulletTrails) DrawBulletTrails(dl, cam, w, h);
    if (m_showPlayers) DrawPlayers(dl, u, cam, w, h);
    if (m_showAnimals) DrawAnimals(dl, u, cam, w, h);
    if (m_showZombies) DrawZombies(dl, u, cam, w, h);
    if (m_showItems)   DrawItems  (dl, u, cam, w, h);
    if (m_debugSkeleton) DrawSkeletonDebug(dl, u, cam, w, h);
}

// -------------------------------------------------------------------------
// DrawSkeleton — render 11 bone segments for one entity
// -------------------------------------------------------------------------

void GameOverlay::DrawSkeleton(ImDrawList* dl, const SkeletonBones& bones,
                                const CameraData& cam,
                                float w, float h, unsigned int color,
                                bool isZombie) const
{
    // Project each unique bone ONCE, then draw segments from cached screen coords.
    // Old approach called WorldToScreen twice per segment; shared bones (neck, spine)
    // were projected 3-4 times each.  16 projections replaces 28-30.
    enum : int {
        B_Neck=0, B_Head, B_Spine, B_Pelvis,
        B_RShoulder, B_RElbow, B_RHand,
        B_LShoulder, B_LElbow, B_LHand,
        B_RHip, B_RKnee, B_RAnkle,
        B_LHip, B_LKnee, B_LAnkle,
        B_COUNT
    };

    const Vector3* const kWorld[B_COUNT] = {
        &bones.neck,          &bones.head,       &bones.spine,      &bones.pelvis,
        &bones.rightShoulder, &bones.rightElbow, &bones.rightHand,
        &bones.leftShoulder,  &bones.leftElbow,  &bones.leftHand,
        &bones.rightHip,      &bones.rightKnee,  &bones.rightAnkle,
        &bones.leftHip,       &bones.leftKnee,   &bones.leftAnkle,
    };

    struct SP { float x, y; bool ok; } scr[B_COUNT];
    for (int i = 0; i < B_COUNT; ++i)
        scr[i].ok = Proj(cam, *kWorld[i], scr[i].x, scr[i].y, w, h);

    struct Seg { int8_t a, b; };
    static const Seg kPlayerSegs[14] = {
        {B_Neck,B_Head},
        {B_Neck,B_RShoulder},{B_RShoulder,B_RElbow},{B_RElbow,B_RHand},
        {B_Neck,B_LShoulder},{B_LShoulder,B_LElbow},{B_LElbow,B_LHand},
        {B_Neck,B_Spine},
        {B_Spine,B_RHip},{B_RHip,B_RKnee},{B_RKnee,B_RAnkle},
        {B_Spine,B_LHip},{B_LHip,B_LKnee},{B_LKnee,B_LAnkle},
    };
    static const Seg kZombieSegs[15] = {
        {B_Spine,B_Neck},{B_Neck,B_Head},
        {B_Spine,B_LShoulder},{B_LShoulder,B_LElbow},{B_LElbow,B_LHand},
        {B_Spine,B_RShoulder},{B_RShoulder,B_RElbow},{B_RElbow,B_RHand},
        {B_Spine,B_Pelvis},
        {B_Pelvis,B_RHip},{B_RHip,B_RKnee},{B_RKnee,B_RAnkle},
        {B_Pelvis,B_LHip},{B_LHip,B_LKnee},{B_LKnee,B_LAnkle},
    };

    const Seg* segs  = isZombie ? kZombieSegs : kPlayerSegs;
    const int  count = isZombie ? 15           : 14;

    for (int i = 0; i < count; ++i) {
        const SP& a = scr[segs[i].a];
        const SP& b = scr[segs[i].b];
        if (!a.ok || !b.ok) continue;
        dl->AddLine(ImVec2(a.x, a.y), ImVec2(b.x, b.y), color, 1.5f);
    }
}

// -------------------------------------------------------------------------
// DrawSkeletonDebug — named bone dots for the closest player
// -------------------------------------------------------------------------

void GameOverlay::DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u,
                                     const CameraData& cam, float w, float h) const
{
    // Find the closest player that has skeleton data (valid or not).
    const DayZPlayerEntry* target = nullptr;
    float bestDist = 1e9f;
    for (const auto& p : u.players) {
        if (!p.position.has_value()) continue;
        float d = Dist(cam.translation, *p.position);
        if (d < 2.0f || d > m_playerMaxDist) continue;
        if (d < bestDist) { bestDist = d; target = &p; }
    }

    if (!target) return;

    // Top-left status block.
    const float pad  = 10.0f;
    const float lineH = 16.0f;
    float tx = pad, ty = pad;

    auto txt = [&](ImU32 col, std::string s) {
        dl->AddText(ImVec2(tx, ty), col, s.c_str());
        ty += lineH;
    };

    const char* name = target->nickname.empty() ? "Player" : target->nickname.c_str();
    txt(IM_COL32(255,255,255,220), std::format("SkelDebug: {} | dist={:.1f}m", name, bestDist));
    txt(IM_COL32(200,200,200,180), std::format("skeleton.valid = {}", target->skeleton.valid ? "true" : "false"));

    if (!target->skeleton.valid) {
        txt(IM_COL32(255,100,100,200), "No valid skeleton data -- check offsets");
        return;
    }

    // Named bones with distinct colors — mirrors Spectre stable player bone layout.
    struct BoneDef { const char* name; const Vector3& pos; ImU32 color; };
    const SkeletonBones& sk = target->skeleton;
    const BoneDef bones[] = {
        { "neck",      sk.neck,          IM_COL32(255,100,  0,255) },
        { "head",      sk.head,          IM_COL32(255, 60, 60,255) },
        { "spine",     sk.spine,         IM_COL32(255,165,  0,255) },
        { "rShoulder", sk.rightShoulder, IM_COL32( 80,255,180,255) },
        { "rElbow",    sk.rightElbow,    IM_COL32( 50,220,140,255) },
        { "rHand",     sk.rightHand,     IM_COL32( 30,200,120,255) },
        { "lShoulder", sk.leftShoulder,  IM_COL32( 80,200,255,255) },
        { "lElbow",    sk.leftElbow,     IM_COL32( 50,160,230,255) },
        { "lHand",     sk.leftHand,      IM_COL32( 30,120,255,255) },
        { "rHip",      sk.rightHip,      IM_COL32(255,255,  0,255) },
        { "rKnee",     sk.rightKnee,     IM_COL32(220,220,  0,255) },
        { "rAnkle",    sk.rightAnkle,    IM_COL32(180,180,  0,255) },
        { "lHip",      sk.leftHip,       IM_COL32(200,100,255,255) },
        { "lKnee",     sk.leftKnee,      IM_COL32(170, 80,230,255) },
        { "lAnkle",    sk.leftAnkle,     IM_COL32(140, 60,200,255) },
    };

    int projected = 0;
    for (const auto& b : bones) {
        float sx{}, sy{};
        bool onScreen = Proj(cam, b.pos, sx, sy, w, h);

        txt(b.color, std::format("  {} ({:.1f},{:.1f},{:.1f}) {}",
            b.name, b.pos.x, b.pos.y, b.pos.z,
            onScreen ? "" : "[off]"));

        if (!onScreen) continue;
        ++projected;

        dl->AddCircleFilled(ImVec2(sx, sy), 4.0f, b.color);
        dl->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(0,0,0,200), 0, 1.5f);
        dl->AddText(ImVec2(sx + 7.0f, sy - 6.0f), b.color, b.name);
    }

    txt(IM_COL32(180,255,180,200), std::format("  {}/17 bones on screen", projected));
}

// -------------------------------------------------------------------------
// Players — red bounding box + name + held item + distance
// -------------------------------------------------------------------------

void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {

    for (const auto& p : u.players) {
        if (p.isDead && !m_showCorpses) continue;
        if (!p.position.has_value()) continue;

        // Skip the local player by address — more reliable than proximity check.
        if (u.localPlayerEntityAddress.has_value()
            && p.address == *u.localPlayerEntityAddress) continue;

        const Vector3& pos  = *p.position;
        float          dist = Dist(cam.translation, pos);
        if (dist > m_playerMaxDist) continue;

        if (!IsFacingCamera(cam, pos)) continue;

        const ImU32 kColor = p.isAdmin  ? IM_COL32(255, 200,   0, 255)   // gold
                           : p.isDead  ? IM_COL32(120, 120, 120, 160)   // grey
                                       : IM_COL32(255,  60,  60, 220);  // red

        // ---- Smoothed skeleton (extrapolated between DMA reads) ----
        SkeletonBones smoothBones{};
        bool hasSmoothBones = false;
        if (!p.isDead) {
            auto hit = m_playerBoneHistory.find(p.address);
            if (hit != m_playerBoneHistory.end() && hit->second.initialized
                && (m_frameTimeMs - hit->second.lastReadMs) < 3000LL) {
                smoothBones    = GetSmoothedBones(hit->second, m_frameTimeMs);
                hasSmoothBones = true;
            }
        }

        // ---- Bounding box ----
        // When the skeleton is valid, derive the box from the projected extents of
        // all 16 bones.  This keeps the box perfectly in sync with the skeleton
        // (both come from the bone-scatter data, updated at ~60 Hz) instead of
        // lagging one update behind because it uses the entity position (50 Hz).
        float bx0 = 0.0f, by0 = 0.0f, bx1 = 0.0f, by1 = 0.0f;
        bool  boxReady = false;

        if (hasSmoothBones) {
            const Vector3* const kBones[] = {
                &smoothBones.neck,          &smoothBones.head,
                &smoothBones.spine,         &smoothBones.pelvis,
                &smoothBones.rightShoulder, &smoothBones.rightElbow, &smoothBones.rightHand,
                &smoothBones.leftShoulder,  &smoothBones.leftElbow,  &smoothBones.leftHand,
                &smoothBones.rightHip,      &smoothBones.rightKnee,  &smoothBones.rightAnkle,
                &smoothBones.leftHip,       &smoothBones.leftKnee,   &smoothBones.leftAnkle,
            };

            float minX =  1e9f, maxX = -1e9f;
            float minY =  1e9f, maxY = -1e9f;
            int   hits = 0;
            for (const auto* bp : kBones) {
                // Reject bones whose world position is > 3 m from the entity root.
                // Garbage reads (zero-initialised bones, failed scatter reads) land
                // at or near world origin and would drag the box hundreds of pixels
                // off to the side of the actual player.
                float dbx = bp->x - pos.x, dby = bp->y - pos.y, dbz = bp->z - pos.z;
                if (dbx*dbx + dby*dby + dbz*dbz > 9.0f) continue;

                float bsx{}, bsy{};
                if (!Proj(cam, *bp, bsx, bsy, w, h)) continue;
                if (bsx < minX) minX = bsx;
                if (bsx > maxX) maxX = bsx;
                if (bsy < minY) minY = bsy;
                if (bsy > maxY) maxY = bsy;
                ++hits;
            }

            if (hits >= 4) {
                constexpr float kPad = 8.0f;
                bx0 = minX - kPad;
                by0 = minY - kPad;
                bx1 = maxX + kPad;
                by1 = maxY + kPad;
                boxReady = true;
            }
        }

        if (!boxReady) {
            // Fallback: entity ground position + hardcoded 1.8 m head.
            // Used for corpses (no skeleton) and when bones haven't been read yet.
            float fx{}, fy{}, hx{}, hy{};
            if (!Proj(cam, pos, fx, fy, w, h)) continue;
            Vector3 headPos{ pos.x, pos.y + 1.8f, pos.z };
            if (!Proj(cam, headPos, hx, hy, w, h)) continue;
            float bH = fy - hy;
            if (bH < 2.0f) continue;
            float bW = bH * 0.4f;
            bx0 = hx - bW * 0.5f;
            by0 = hy;
            bx1 = hx + bW * 0.5f;
            by1 = fy;
        }

        // ---- Head circle (computed before the box so by0 can be adjusted) ----
        float hbx{}, hby{};
        float headRadius = 0.0f;
        bool  hasHeadCircle = false;

        if (m_showHeadDot && !p.isDead && hasSmoothBones) {
            if (Proj(cam, smoothBones.head, hbx, hby, w, h)) {
                // Radius ≈ 12.75% of box height (15% × 0.85 = −15% of original).
                headRadius = std::clamp((by1 - by0) * 0.128f, 2.0f, 15.0f);
                hasHeadCircle = true;
                by0 = std::min(by0, hby - headRadius - 2.0f);
            }
        }

        if (by1 - by0 < 2.0f) continue;

        if (m_showBox)
            dl->AddRect(ImVec2(bx0, by0), ImVec2(bx1, by1), kColor, 0.0f, 0, 1.5f);

        // ---- Skeleton + head circle (use extrapolated bones) ----
        if (!p.isDead && hasSmoothBones) {
            if (m_showSkeleton)
                DrawSkeleton(dl, smoothBones, cam, w, h, kColor, false);

            if (hasHeadCircle) {
                dl->AddCircleFilled(ImVec2(hbx, hby), headRadius, IM_COL32(0, 0, 0, 80));
                dl->AddCircle(ImVec2(hbx, hby), headRadius, kColor, 0, 1.5f);
            }
        }

        // ---- Name + distance label (top-right of box) ----
        char lblBuf[256];
        const char* nick = p.nickname.empty() ? "Player" : p.nickname.c_str();
        if (p.isAdmin && p.isDead)
            snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [DEAD] [%.0fm]", nick, dist);
        else if (p.isAdmin)
            snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [%.0fm]", nick, dist);
        else if (p.isDead)
            snprintf(lblBuf, sizeof(lblBuf), "%s [DEAD] [%.0fm]", nick, dist);
        else
            snprintf(lblBuf, sizeof(lblBuf), "%s [%.0fm]", nick, dist);
        dl->AddText(ImVec2(bx1 + 3.0f, by0), kColor, lblBuf);

        // ---- Right-side extras (weapon, health bar, health number) ----
        const float kBarW   = 4.0f;
        const float kBarGap = 5.0f;
        const float barX    = bx1 + kBarGap;
        const float boxH    = by1 - by0;

        if (m_showWeapon && !p.isDead && !p.itemInHands.empty()) {
            constexpr ImU32 kWeaponColor = IM_COL32(220, 220, 100, 200);
            dl->AddText(ImVec2(barX, by0 + 13.0f), kWeaponColor, p.itemInHands.c_str());
        }

        const bool hasHealth = (p.health >= 0.0f);
        if ((m_showHealthBar || m_showHealthNumber) && !p.isDead && hasHealth) {
            const float hpFrac = std::min(p.health / 100.0f, 1.0f);

            if (m_showHealthBar) {
                dl->AddRectFilled(
                    ImVec2(barX,          by0),
                    ImVec2(barX + kBarW,  by1),
                    IM_COL32(0, 0, 0, 140));

                float r = hpFrac < 0.5f ? 1.0f : 2.0f * (1.0f - hpFrac);
                float g = hpFrac > 0.5f ? 1.0f : 2.0f * hpFrac;
                ImU32 hpColor = IM_COL32(
                    static_cast<int>(r * 220),
                    static_cast<int>(g * 220),
                    0, 220);

                float filledTop = by0 + boxH * (1.0f - hpFrac);
                dl->AddRectFilled(
                    ImVec2(barX,          filledTop),
                    ImVec2(barX + kBarW,  by1),
                    hpColor);
            }

            if (m_showHealthNumber) {
                char hpBuf[32];
                snprintf(hpBuf, sizeof(hpBuf), "%.0f/100", p.health);
                float numX = m_showHealthBar ? barX + kBarW + 2.0f : barX;
                dl->AddText(ImVec2(numX, by1 - 12.0f),
                            IM_COL32(200, 200, 200, 200), hpBuf);
            }
        }

        // ---- Ballistic prediction dot ----
        if (m_showBallisticDot && !p.isDead && u.localWeaponInitSpeed > 1.0f
            && u.localPlayerPosition.has_value())
        {
            // --- Velocity estimate (EMA of position delta) ---
            constexpr float kVelAlpha = 0.35f;
            Vector3 vel{};
            auto& ve = m_playerVel[p.address];
            if (ve.ms > 0 && (m_frameTimeMs - ve.ms) < 2000) {
                float dt = static_cast<float>(m_frameTimeMs - ve.ms) * 0.001f;
                if (dt > 0.016f) {
                    float vx = (pos.x - ve.pos.x) / dt;
                    float vy = (pos.y - ve.pos.y) / dt;
                    float vz = (pos.z - ve.pos.z) / dt;
                    vel.x = kVelAlpha * vx + (1.0f - kVelAlpha) * ve.vel.x;
                    vel.y = kVelAlpha * vy + (1.0f - kVelAlpha) * ve.vel.y;
                    vel.z = kVelAlpha * vz + (1.0f - kVelAlpha) * ve.vel.z;
                } else {
                    vel = ve.vel;
                }
            }
            ve.pos = pos;
            ve.ms  = m_frameTimeMs;
            ve.vel = vel;

            // --- Time of flight with drag (continuous model) ---
            const Vector3& lp = *u.localPlayerPosition;
            float dx = pos.x - lp.x;
            float dz = pos.z - lp.z;
            float horizDist = std::sqrt(dx*dx + dz*dz);

            float V0 = u.localWeaponInitSpeed;
            float k  = u.localWeaponAirFriction;
            float T;
            if (k > 0.0001f) {
                float ratio = horizDist * k / V0;
                T = (ratio < 0.99f) ? (-std::log(1.0f - ratio) / k)
                                    : (horizDist / (V0 * 0.5f));
            } else {
                T = horizDist / V0;
            }
            T = std::min(T, 8.0f);

            // --- Aim point: center mass + lead + drop compensation ---
            constexpr float kGravity   = 9.81f;
            constexpr float kCenterMassY = 1.0f;  // m above entity root
            Vector3 aimPt;
            aimPt.x = pos.x + vel.x * T;
            aimPt.y = pos.y + kCenterMassY + 0.5f * kGravity * T * T;
            aimPt.z = pos.z + vel.z * T;

            float adx{}, ady{};
            if (Proj(cam, aimPt, adx, ady, w, h)) {
                dl->AddCircleFilled(ImVec2(adx, ady), 5.0f, IM_COL32(0, 0, 0, 160));
                dl->AddCircleFilled(ImVec2(adx, ady), 3.5f, IM_COL32(0, 255, 220, 240));
                dl->AddCircle      (ImVec2(adx, ady), 3.5f, IM_COL32(0, 0, 0,  180), 0, 1.0f);
            }
        }
    }
}

// Prune velocity entries for players that left the snapshot.
// Called implicitly each DrawPlayers pass via the map auto-insert; no explicit prune
// needed since the map entries are tiny and the set stays bounded by server pop.

// -------------------------------------------------------------------------
// DrawBulletTrails — draws a line strip from each bullet's origin to its
// current or final position, with alpha fade for completed/phantom tracks.
// -------------------------------------------------------------------------

void GameOverlay::DrawBulletTrails(ImDrawList* dl, const CameraData& cam, float w, float h) {
    auto tracks = m_bulletTracks.GetSnapshot();

    for (const auto& t : tracks) {
        // Pick color + alpha based on track state.
        ImU32 lineColor, originColor;
        if (t.isCompleted) {
            int64_t age = m_frameTimeMs - t.finalizedMs;
            int alpha = static_cast<int>(210.0f * std::max(0.0f,
                1.0f - static_cast<float>(age) / 1500.0f));
            if (alpha <= 0) continue;
            lineColor   = IM_COL32(255, 140,  30, alpha);
            originColor = IM_COL32(255,  60,  60, alpha);
        } else if (t.isPhantom) {
            int64_t age = m_frameTimeMs - t.lastSeenMs;
            int alpha = static_cast<int>(160.0f * std::max(0.0f,
                1.0f - static_cast<float>(age) / 4000.0f));
            if (alpha <= 0) continue;
            lineColor   = IM_COL32(200, 100,  40, alpha);
            originColor = IM_COL32(200,  60,  60, alpha);
        } else {
            // Active bullet — bright yellow-orange.
            lineColor   = IM_COL32(255, 210,  50, 230);
            originColor = IM_COL32(255,  80,  80, 230);
        }

        // Collect screen-space points: origin → history → endpoint.
        std::vector<ImVec2> pts;
        pts.reserve(t.points.size() + 2);

        auto pushPt = [&](const Vector3& wp) {
            float sx{}, sy{};
            if (Proj(cam, wp, sx, sy, w, h))
                pts.push_back(ImVec2(sx, sy));
        };

        pushPt(t.initialPos);
        for (const auto& [ts, pos] : t.points)
            pushPt(pos);
        pushPt(t.isCompleted ? t.finalPos : t.currentPos);

        if (pts.size() < 2) continue;

        // Draw line strip.
        for (size_t i = 1; i < pts.size(); ++i)
            dl->AddLine(pts[i - 1], pts[i], lineColor, 1.5f);

        // Origin dot.
        dl->AddCircleFilled(pts[0], 3.0f, originColor);

        // Endpoint dot for completed tracks.
        if (t.isCompleted && pts.size() >= 2) {
            int alpha = static_cast<int>((lineColor >> IM_COL32_A_SHIFT) & 0xFF);
            dl->AddCircleFilled(pts.back(), 3.0f, IM_COL32(255, 255, 100, alpha));
        }
    }
}

// -------------------------------------------------------------------------
// Animals — green label
// -------------------------------------------------------------------------

void GameOverlay::DrawAnimals(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
    constexpr ImU32 kGreen = IM_COL32(50, 230, 50, 200);

    for (const auto& a : u.animals) {
        if (!a.position.has_value()) continue;
        float dist = Dist(cam.translation, *a.position);
        if (dist > m_animalMaxDist) continue;
        if (!IsFacingCamera(cam, *a.position)) continue;

        float sx{}, sy{};
        if (!Proj(cam, *a.position, sx, sy, w, h)) continue;

        std::string raw = a.entityName.empty() ? a.typeName : a.entityName;
        std::string fmtName = raw.empty() ? "Animal" : FormatEntityName(raw);
        char abuf[256];
        snprintf(abuf, sizeof(abuf), "%s [%.0fm]", fmtName.c_str(), dist);
        dl->AddText(ImVec2(sx, sy), kGreen, abuf);
    }
}

// -------------------------------------------------------------------------
// Zombies — yellow label
// -------------------------------------------------------------------------

void GameOverlay::DrawZombies(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
    constexpr ImU32 kYellow = IM_COL32(230, 230, 50, 180);

    for (const auto& z : u.zombies) {
        // Resolve position: use fresh read if available, fall back to cache.
        Vector3 pos;
        if (z.position.has_value()) {
            pos = *z.position;
            m_zombieLastPos[z.address] = pos;
        } else {
            auto it = m_zombieLastPos.find(z.address);
            if (it == m_zombieLastPos.end()) continue;
            pos = it->second;
        }

        float dist = Dist(cam.translation, pos);
        if (dist > m_zombieMaxDist) continue;
        if (!IsFacingCamera(cam, pos)) continue;

        float sx{}, sy{};
        if (!Proj(cam, pos, sx, sy, w, h)) continue;

        auto zhit = m_zombieBoneHistory.find(z.address);
        if (zhit != m_zombieBoneHistory.end() && zhit->second.initialized
            && (m_frameTimeMs - zhit->second.lastReadMs) < 3000LL) {
            SkeletonBones zsm = GetSmoothedBones(zhit->second, m_frameTimeMs);
            if (m_showSkeleton)
                DrawSkeleton(dl, zsm, cam, w, h, kYellow, true);
            if (m_showHeadDot) {
                float hbx{}, hby{};
                if (Proj(cam, zsm.head, hbx, hby, w, h)) {
                    // Angular size of a ~25 cm head at the zombie's actual distance.
                    // Scales from ~18px at 5m down to ~3px at 50m; clamp keeps it
                    // always visible and never massive.
                    const float projD2y = std::max(cam.projectionD2y, 0.1f);
                    float zr = std::clamp((0.25f * h * 0.5f) / (dist * projD2y), 2.0f, 18.0f);
                    dl->AddCircleFilled(ImVec2(hbx, hby), zr, IM_COL32(0, 0, 0, 80));
                    dl->AddCircle(ImVec2(hbx, hby), zr, kYellow, 0, 1.5f);
                }
            }
        }

        char zbuf[32];
        snprintf(zbuf, sizeof(zbuf), "Z [%.0fm]", dist);
        dl->AddText(ImVec2(sx, sy), kYellow, zbuf);
    }
}

// -------------------------------------------------------------------------
// Items — white label
// -------------------------------------------------------------------------

void GameOverlay::DrawItems(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
    constexpr ImU32 kWhite = IM_COL32(255, 255, 255, 200);
    const float maxDistSq = m_itemMaxDist * m_itemMaxDist;

    for (const auto& item : u.items) {
        // Per-category filter (missing key = enabled)
        if (!item.filterKey.empty()) {
            auto it = m_itemCategories.find(item.filterKey);
            if (it != m_itemCategories.end() && !it->second) continue;
        }

        if (!item.position.has_value()) continue;

        // Squared-distance cull avoids sqrtf for every item.
        float dx = item.position->x - cam.translation.x;
        float dy = item.position->y - cam.translation.y;
        float dz = item.position->z - cam.translation.z;
        float distSq = dx*dx + dy*dy + dz*dz;
        if (distSq > maxDistSq) continue;

        if (!IsFacingCamera(cam, *item.position)) continue;

        float sx{}, sy{};
        if (!Proj(cam, *item.position, sx, sy, w, h)) continue;

        const std::string& name = item.cleanName.empty()
            ? item.entityName
            : item.cleanName;
        if (name.empty()) continue;

        float dist = std::sqrtf(distSq);  // sqrt only for items that will actually be drawn

        // Two-tier font: larger close up, smaller at range.
        char ibuf[256];
        snprintf(ibuf, sizeof(ibuf), "%s [%.0fm]", name.c_str(), dist);
        ImFont* font = (dist < 50.0f) ? m_fontLootClose : m_fontLootFar;
        if (font) ImGui::PushFont(font);
        dl->AddText(ImVec2(sx, sy), kWhite, ibuf);
        if (font) ImGui::PopFont();
    }
}
