#include "ThreadPool.h"
#include <algorithm>
#include "Engine.h"
#include "Input.h"
#include <iostream>
#include <cmath>
#include <deque>
#include <sstream>
#include <filesystem>
#include <fstream>
#include "OBJModelPrimitive.h"
#include "TextureCache.h"
// Utility functions
namespace {
    GLuint CompileShader(GLenum type, const char* source) {
        GLuint shader = glCreateShader(type);
        glShaderSource(shader, 1, &source, nullptr);
        glCompileShader(shader);
        
        GLint success;
        glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
        if (!success) {
            char log[512];
            glGetShaderInfoLog(shader, 512, nullptr, log);
            std::cerr << "Shader compilation error: " << log << std::endl;
        }
        return shader;
    }
    
    GLuint CreateProgram(const char* vertex_source, const char* fragment_source) {
        GLuint vertex_shader = CompileShader(GL_VERTEX_SHADER, vertex_source);
        GLuint fragment_shader = CompileShader(GL_FRAGMENT_SHADER, fragment_source);
        
        GLuint program = glCreateProgram();
        glAttachShader(program, vertex_shader);
        glAttachShader(program, fragment_shader);
        glLinkProgram(program);
        
        GLint success;
        glGetProgramiv(program, GL_LINK_STATUS, &success);
        if (!success) {
            char log[512];
            glGetProgramInfoLog(program, 512, nullptr, log);
            std::cerr << "Program linking error: " << log << std::endl;
        }
        
	
        glDeleteShader(vertex_shader);
        glDeleteShader(fragment_shader);
        return program;
    }
}


Engine::~Engine() {
   
}




void Engine::InitializeShader() {
    const char* vertex_shader = R"(
    #version 410 core
    layout(location = 0) in vec3 aPos;
    layout(location = 1) in vec3 aColor;
    layout(location = 2) in vec3 aNormal;
    layout(location = 3) in vec2 aUV;
    layout(location = 6) in mat4 aInstanceModel;

    uniform mat4 uView;
    uniform mat4 uProjection;
    uniform mat4 uLightVP;
    uniform vec2 uUVScale;

    out vec3 FragPos;
    out vec3 Normal;
    out vec3 Color;
    out vec2 vUV;
    out vec4 LightSpacePos;

    void main() {
        vec4 worldPos = aInstanceModel * vec4(aPos, 1.0);
        FragPos = worldPos.xyz;
        Normal = mat3(transpose(inverse(aInstanceModel))) * aNormal;
        Color = aColor;
        vUV = aUV * uUVScale;
        LightSpacePos = uLightVP * worldPos;
        gl_Position = uProjection * uView * worldPos;
    }
    )";

    
    const char* fragment_shader = R"(
    #version 410 core
    in vec3 FragPos;
    in vec3 Normal;
    in vec3 Color;
    in vec2 vUV;
    in vec4 LightSpacePos;

    out vec4 FragColor;

    uniform sampler2D uDiffuse;
    uniform sampler2D uNormalMap;
    uniform vec3 uSunDir;
    uniform vec3 uSunColor;
    uniform vec3 uSkyColor;
    uniform vec3 uGroundColor;
    uniform vec3 uViewPos;
    uniform float uStorminess;
    uniform int   uWaterEnabled;
    uniform float uWaterLevel;
    uniform vec3  uWaterDeep;
    uniform sampler2DShadow uShadowMap;
    uniform int   uShadowsEnabled;
    uniform vec3  uHorizonColor;
    uniform float uFogDensity;

    uniform float uMetallic;
    uniform float uRoughness;
    uniform vec3  uEmissive;

    const float PI = 3.14159265359;

    float SampleShadow(vec3 proj, float bias) {
        if (proj.x < 0.0 || proj.x > 1.0 ||
            proj.y < 0.0 || proj.y > 1.0 ||
            proj.z > 1.0) return 1.0;
        proj.z -= bias;
        float result = 0.0;
        vec2 texel = 1.0 / vec2(textureSize(uShadowMap, 0));
        for (int y = -1; y <= 1; ++y)
            for (int x = -1; x <= 1; ++x)
                result += texture(uShadowMap, vec3(proj.xy + vec2(x, y) * texel, proj.z));
        return result / 9.0;
    }

    // Build TBN from screen-space derivatives (no per-vertex tangents needed).
    mat3 CalcTBN(vec3 N) {
        vec3 dp1 = dFdx(FragPos);
        vec3 dp2 = dFdy(FragPos);
        vec2 duv1 = dFdx(vUV);
        vec2 duv2 = dFdy(vUV);
        vec3 dp2perp = cross(dp2, N);
        vec3 dp1perp = cross(N, dp1);
        vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
        vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
        float inv = inversesqrt(max(dot(T, T), dot(B, B)));
        return mat3(T * inv, B * inv, N);
    }

    // GGX normal distribution
    float NDF_GGX(float NdotH, float r) {
        float a  = r * r;
        float a2 = a * a;
        float d  = NdotH * NdotH * (a2 - 1.0) + 1.0;
        return a2 / (PI * d * d);
    }
    // Schlick-GGX geometry + Smith's method for combined shadow/masking
    float G_SchlickGGX(float NdotX, float r) {
        float k = (r + 1.0);
        k = (k * k) / 8.0;
        return NdotX / (NdotX * (1.0 - k) + k);
    }
    float G_Smith(float NdotV, float NdotL, float r) {
        return G_SchlickGGX(NdotV, r) * G_SchlickGGX(NdotL, r);
    }
    vec3 F_Schlick(float cosTheta, vec3 F0) {
        return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
    }

    void main() {
        vec3 N_geom = normalize(Normal);

        // Safe-guard TBN + normal map
        vec3 nSample = texture(uNormalMap, vUV).xyz * 2.0 - 1.0;
        vec3 N = N_geom;
        if (abs(nSample.x) > 0.02 || abs(nSample.y) > 0.02) {
            mat3 TBN = CalcTBN(N_geom);
            vec3 Nm = TBN * nSample;
            float L2 = dot(Nm, Nm);
            if (L2 > 1e-6) N = Nm * inversesqrt(L2);
        }

        // Storminess-tinted light colors
        vec3 sunC    = mix(uSunColor,    uSunColor    * 0.35, uStorminess);
        vec3 skyC    = mix(uSkyColor,    uSkyColor    * 0.45, uStorminess);
        vec3 groundC = mix(uGroundColor, uGroundColor * 0.70, uStorminess);

        vec3 V = normalize(uViewPos - FragPos);
        vec3 L = uSunDir;
        vec3 H = normalize(V + L);

        float NdotL = max(dot(N, L), 0.0);
        float NdotV = max(dot(N, V), 0.0);
        float NdotH = max(dot(N, H), 0.0);
        float VdotH = max(dot(V, H), 0.0);

        // Albedo from texture × vertex color
        vec3 baseColor = texture(uDiffuse, vUV).rgb * Color;

        // Shadow attenuation for direct light
        float lit = 1.0;
        if (uShadowsEnabled == 1 && NdotL > 0.0) {
            vec3 proj = LightSpacePos.xyz / LightSpacePos.w;
            proj = proj * 0.5 + 0.5;
            float bias = max(0.005 * (1.0 - NdotL), 0.0015);
            lit = SampleShadow(proj, bias);
        }

        // Cook-Torrance BRDF — defensive clamps
        vec3  F0  = mix(vec3(0.04), baseColor, uMetallic);
        vec3  F   = F_Schlick(VdotH, F0);
        float NDF = NDF_GGX(NdotH, uRoughness);
        float G   = G_Smith(NdotV, NdotL, uRoughness);
        float denom = max(4.0 * NdotV * NdotL, 0.001);
        vec3  specular = min((NDF * G * F) / denom, vec3(20.0));

        vec3 kD = (vec3(1.0) - F) * (1.0 - uMetallic);
        vec3 diffuse = kD * baseColor / PI;

        vec3 direct = (diffuse + specular) * sunC * NdotL * lit;

        // Hemispheric indirect (approximate IBL: sky on top, ground below).
        float up = 0.5 + 0.5 * N.y;
        vec3 amb_color = mix(groundC, skyC, up);
        // Indirect contribution: metal reflects ambient via F, dielectric picks albedo
        vec3 indirect = amb_color * mix(baseColor, baseColor * F, uMetallic);

        vec3 result = direct + indirect + uEmissive;

        // Atmospheric / underwater fog
        if (!(uWaterEnabled == 1 && uViewPos.y < uWaterLevel)) {
            float dist = length(FragPos - uViewPos);
            float fog  = 1.0 - exp(-uFogDensity * dist);
            result = mix(result, uHorizonColor, clamp(fog, 0.0, 0.85));
        } else {
            float dist = length(FragPos - uViewPos);
            float fog  = 1.0 - exp(-0.08 * dist);
            result = mix(result, uWaterDeep, clamp(fog, 0.0, 0.95));
        }

        // NaN / inf guard + non-negative
        if (any(isnan(result)) || any(isinf(result))) result = baseColor;
        result = max(result, vec3(0.0));
        FragColor = vec4(result, 1.0);
    }
    )";

    shader_program_ = CreateProgram(vertex_shader, fragment_shader);
    
    // Keep overlay shader creation
    const char* vs_overlay = R"(
    #version 410 core
    layout(location=0) in vec2 aPos;
    layout(location=1) in vec3 aColor;
    uniform mat4 uOrtho;
    out vec3 vColor;
    void main() {
        vec4 pos = uOrtho * vec4(aPos, 0.0, 1.0);
        gl_Position = pos;
        vColor = aColor;
    }
    )";

    const char* fs_overlay = R"(
    #version 410 core
    in vec3 vColor;
    out vec4 FragColor;
    void main() {
        FragColor = vec4(vColor, 1.0);
    }
    )";
    overlay_program = CreateProgram(vs_overlay, fs_overlay);
}



void Engine::FramebufferSizeCallback(GLFWwindow* window, int width, int height) {
    if (width == 0 || height == 0) return; // minimized
    glViewport(0, 0, width, height);
    Engine* engine = static_cast<Engine*>(glfwGetWindowUserPointer(window));
    if (engine) {
        engine->width_  = width;
        engine->height_ = height;
        engine->ortho2D = glm::ortho(0.0f, float(width), 0.0f, float(height));
        engine->settings_.updateWindowSize(width, height);
    }
}


class CubePrimitive : public Primitive {
public:
    CubePrimitive(const glm::vec3& color) {
        color_ = color;
        const float r = color.r, g = color.g, b = color.b;

        // Per-vertex: Pos(3) + Color(3) + Normal(3) + UV(2) = 11 floats.
        // UVs: each face maps [0,0]..[1,1] in standard (bottom-left origin)
        // so a tiling texture wraps cleanly per face.
        float vertices[] = {
            // Front face (z = +1)
            -1,-1, 1,  r,g,b,  0,0, 1,  0,0,
             1,-1, 1,  r,g,b,  0,0, 1,  1,0,
             1, 1, 1,  r,g,b,  0,0, 1,  1,1,
            -1, 1, 1,  r,g,b,  0,0, 1,  0,1,
            // Back face (z = -1)
            -1,-1,-1,  r,g,b,  0,0,-1,  1,0,
            -1, 1,-1,  r,g,b,  0,0,-1,  1,1,
             1, 1,-1,  r,g,b,  0,0,-1,  0,1,
             1,-1,-1,  r,g,b,  0,0,-1,  0,0,
            // Top face (y = +1)
            -1, 1,-1,  r,g,b,  0, 1,0,  0,1,
            -1, 1, 1,  r,g,b,  0, 1,0,  0,0,
             1, 1, 1,  r,g,b,  0, 1,0,  1,0,
             1, 1,-1,  r,g,b,  0, 1,0,  1,1,
            // Bottom face (y = -1)
            -1,-1,-1,  r,g,b,  0,-1,0,  0,0,
             1,-1,-1,  r,g,b,  0,-1,0,  1,0,
             1,-1, 1,  r,g,b,  0,-1,0,  1,1,
            -1,-1, 1,  r,g,b,  0,-1,0,  0,1,
            // Right face (x = +1)
             1,-1,-1,  r,g,b,  1, 0,0,  1,0,
             1, 1,-1,  r,g,b,  1, 0,0,  1,1,
             1, 1, 1,  r,g,b,  1, 0,0,  0,1,
             1,-1, 1,  r,g,b,  1, 0,0,  0,0,
            // Left face (x = -1)
            -1,-1,-1,  r,g,b, -1, 0,0,  0,0,
            -1,-1, 1,  r,g,b, -1, 0,0,  1,0,
            -1, 1, 1,  r,g,b, -1, 0,0,  1,1,
            -1, 1,-1,  r,g,b, -1, 0,0,  0,1
        };
        
        // Indices for 12 triangles (2 per face)
        unsigned int indices[] = {
            0,  1,  2,    0,  2,  3,    // front
            4,  5,  6,    4,  6,  7,    // back
            8,  9,  10,   8,  10, 11,   // top
            12, 13, 14,   12, 14, 15,   // bottom
            16, 17, 18,   16, 18, 19,   // right
            20, 21, 22,   20, 22, 23    // left
        };
        
        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        glGenBuffers(1, &ebo_);
        
        glBindVertexArray(vao_);
        
        // Upload vertex data
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
        
        // Upload index data
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo_);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
        
        const GLsizei stride = 11 * sizeof(float);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float)));
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, (void*)(9 * sizeof(float)));
        glEnableVertexAttribArray(3);

        glBindVertexArray(0);
        index_count_ = 36;
        intrinsic_radius_ = 1.732f;  // unit cube diagonal
    }
    
    void Render() override {
        glBindVertexArray(vao_);
        glDrawElements(GL_TRIANGLES, index_count_, GL_UNSIGNED_INT, 0);
        glBindVertexArray(0);
    }
    
    void Bind() override {
        glBindVertexArray(vao_);
    }
    
    void Cleanup() override {
        if (vao_) glDeleteVertexArrays(1, &vao_);
        if (vbo_) glDeleteBuffers(1, &vbo_);
        if (ebo_) glDeleteBuffers(1, &ebo_);
    }
};



class SpherePrimitive : public Primitive {
public:
    SpherePrimitive(int segments, const glm::vec3& color) {
        color_ = color;
        std::vector<float> vertices;
        std::vector<unsigned int> indices;

        // pos(3) + color(3) + normal(3) + uv(2) = 11 floats.
        // Normals on a unit sphere = the position itself.
        // UV: u = longitude / 2π, v = latitude / π.
        for (int i = 0; i <= segments; ++i) {
            float v = float(i) / segments;
            float phi = M_PI * v;
            float sp = std::sin(phi), cp = std::cos(phi);
            for (int j = 0; j <= segments; ++j) {
                float u = float(j) / segments;
                float th = 2.0f * M_PI * u;
                float st = std::sin(th), ct = std::cos(th);
                float x = ct * sp, y = cp, z = st * sp;
                vertices.push_back(x); vertices.push_back(y); vertices.push_back(z);
                vertices.push_back(color.r); vertices.push_back(color.g); vertices.push_back(color.b);
                vertices.push_back(x); vertices.push_back(y); vertices.push_back(z);  // normal
                vertices.push_back(u); vertices.push_back(1.0f - v);
            }
        }

        for (int i = 0; i < segments; ++i) {
            for (int j = 0; j < segments; ++j) {
                int first = i * (segments + 1) + j;
                int second = first + segments + 1;
                indices.push_back(first);  indices.push_back(second); indices.push_back(first + 1);
                indices.push_back(second); indices.push_back(second + 1); indices.push_back(first + 1);
            }
        }

        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        glGenBuffers(1, &ebo_);

        glBindVertexArray(vao_);
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo_);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), indices.data(), GL_STATIC_DRAW);

        const GLsizei stride = 11 * sizeof(float);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float)));
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, (void*)(9 * sizeof(float)));
        glEnableVertexAttribArray(3);

        glBindVertexArray(0);
        index_count_ = (GLsizei)indices.size();
        intrinsic_radius_ = 1.0f;  // unit sphere
    }

    void Render() override {
        glBindVertexArray(vao_);
        glDrawElements(GL_TRIANGLES, index_count_, GL_UNSIGNED_INT, 0);
        glBindVertexArray(0);
    }

    void Bind() override {
        glBindVertexArray(vao_);
    }

    void Cleanup() override {
        if (vao_) glDeleteVertexArrays(1, &vao_);
        if (vbo_) glDeleteBuffers(1, &vbo_);
        if (ebo_) glDeleteBuffers(1, &ebo_);
    }
};

class PlanePrimitive : public Primitive {
public:
    PlanePrimitive(float width, float height, const glm::vec3& color) {
        color_ = color;
        float w = width / 2.0f;
        float h = height / 2.0f;

        // pos(3) + color(3) + normal(3) + uv(2) = 11 floats.
        // UV tiling based on plane size so textures repeat at ~1 per unit.
        const float uTileU = width;
        const float uTileV = height;
        float vertices[] = {
            -w, 0, -h,  color.r, color.g, color.b,  0, 1, 0,  0,        0,
             w, 0, -h,  color.r, color.g, color.b,  0, 1, 0,  uTileU,   0,
             w, 0,  h,  color.r, color.g, color.b,  0, 1, 0,  uTileU,   uTileV,
            -w, 0,  h,  color.r, color.g, color.b,  0, 1, 0,  0,        uTileV,
        };

        unsigned int indices[] = { 0, 1, 2, 2, 3, 0 };

        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        glGenBuffers(1, &ebo_);

        glBindVertexArray(vao_);
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo_);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

        const GLsizei stride = 11 * sizeof(float);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float)));
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, (void*)(9 * sizeof(float)));
        glEnableVertexAttribArray(3);

        glBindVertexArray(0);
        index_count_ = 6;
        intrinsic_radius_ = std::sqrt(w * w + h * h);
    }
    
    void Render() override {
        glBindVertexArray(vao_);
        glDrawElements(GL_TRIANGLES, index_count_, GL_UNSIGNED_INT, 0);
        glBindVertexArray(0);
    }

    void Bind() override {
        glBindVertexArray(vao_);
    }
    
    void Cleanup() override {
        if (vao_) glDeleteVertexArrays(1, &vao_);
        if (vbo_) glDeleteBuffers(1, &vbo_);
        if (ebo_) glDeleteBuffers(1, &ebo_);
    }
};

class GridPrimitive : public Primitive {
public:
    GridPrimitive(int size, float spacing, const glm::vec3& color) {
        color_ = color;
        std::vector<float> vertices;
        
        float extent = size * spacing;
        
        // Lines parallel to X
        for (int i = -size; i <= size; ++i) {
            float z = i * spacing;
            vertices.insert(vertices.end(), {-extent, 0, z, color.r, color.g, color.b});
            vertices.insert(vertices.end(), { extent, 0, z, color.r, color.g, color.b});
        }
        
        // Lines parallel to Z
        for (int i = -size; i <= size; ++i) {
            float x = i * spacing;
            vertices.insert(vertices.end(), {x, 0, -extent, color.r, color.g, color.b});
            vertices.insert(vertices.end(), {x, 0,  extent, color.r, color.g, color.b});
        }
        
        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        
        glBindVertexArray(vao_);
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW);
        
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        
        glBindVertexArray(0);
        vertex_count_ = (GLsizei)(vertices.size() / 6);
        draw_mode_ = GL_LINES;

       
    }

    GLenum GetDrawMode() const override { return GL_LINES; }
    
    void Render() override {
        glBindVertexArray(vao_);
        glDrawArrays(draw_mode_, 0, vertex_count_);
        glBindVertexArray(0);
    }

    void Bind() override {
        glBindVertexArray(vao_);
    }
    
    void Cleanup() override {
        if (vao_) glDeleteVertexArrays(1, &vao_);
        if (vbo_) glDeleteBuffers(1, &vbo_);
    }
};

// CylinderPrimitive - trees, pillars, towers
class CylinderPrimitive : public Primitive {
public:
    CylinderPrimitive(float radius, float height, int segments, const glm::vec3& color) {
        color_ = color;
        std::vector<float> vertices;
        std::vector<unsigned int> indices;
        float halfH = height / 2.0f;
        const float r0 = color.r, g0 = color.g, b0 = color.b;

        // Side: u wraps longitude, v = 0 (bottom) or 1 (top). Height scaled
        // so taller cylinders tile vertically.
        float vHi = height;
        for (int i = 0; i <= segments; ++i) {
            float angle = 2.0f * M_PI * i / segments;
            float x = std::cos(angle) * radius;
            float z = std::sin(angle) * radius;
            float nx = std::cos(angle), nz = std::sin(angle);
            float u = (float)i / segments;
            vertices.insert(vertices.end(), {x, -halfH, z, r0,g0,b0, nx, 0.0f, nz, u, 0.0f});
            vertices.insert(vertices.end(), {x,  halfH, z, r0,g0,b0, nx, 0.0f, nz, u, vHi});
        }
        for (int i = 0; i < segments; ++i) {
            unsigned int b0v = i * 2, t0 = b0v + 1, b1 = b0v + 2, t1 = b0v + 3;
            indices.insert(indices.end(), {b0v, b1, t0, t0, b1, t1});
        }
        // Top cap — UV is disk-parameterized (cos/2+0.5, sin/2+0.5)
        unsigned int topCenter = (unsigned int)(vertices.size() / 11);
        vertices.insert(vertices.end(), {0.0f, halfH, 0.0f, r0,g0,b0, 0,1,0, 0.5f, 0.5f});
        for (int i = 0; i <= segments; ++i) {
            float angle = 2.0f * M_PI * i / segments;
            float x = std::cos(angle) * radius, z = std::sin(angle) * radius;
            float u = std::cos(angle) * 0.5f + 0.5f, v = std::sin(angle) * 0.5f + 0.5f;
            vertices.insert(vertices.end(), {x, halfH, z, r0,g0,b0, 0,1,0, u, v});
        }
        for (int i = 0; i < segments; ++i)
            indices.insert(indices.end(), {topCenter, topCenter + 1 + (unsigned int)i, topCenter + 2 + (unsigned int)i});
        // Bottom cap
        unsigned int botCenter = (unsigned int)(vertices.size() / 11);
        vertices.insert(vertices.end(), {0.0f, -halfH, 0.0f, r0,g0,b0, 0,-1,0, 0.5f, 0.5f});
        for (int i = 0; i <= segments; ++i) {
            float angle = 2.0f * M_PI * i / segments;
            float x = std::cos(angle) * radius, z = std::sin(angle) * radius;
            float u = std::cos(angle) * 0.5f + 0.5f, v = std::sin(angle) * 0.5f + 0.5f;
            vertices.insert(vertices.end(), {x, -halfH, z, r0,g0,b0, 0,-1,0, u, v});
        }
        for (int i = 0; i < segments; ++i)
            indices.insert(indices.end(), {botCenter, botCenter + 2 + (unsigned int)i, botCenter + 1 + (unsigned int)i});

        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        glGenBuffers(1, &ebo_);
        glBindVertexArray(vao_);
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo_);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), indices.data(), GL_STATIC_DRAW);
        const GLsizei stride = 11 * sizeof(float);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float)));
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, (void*)(9 * sizeof(float)));
        glEnableVertexAttribArray(3);
        glBindVertexArray(0);
        index_count_ = (GLsizei)indices.size();
        intrinsic_radius_ = std::sqrt(radius * radius + halfH * halfH);
    }
    void Render() override { glBindVertexArray(vao_); glDrawElements(GL_TRIANGLES, index_count_, GL_UNSIGNED_INT, 0); glBindVertexArray(0); }
    void Bind() override { glBindVertexArray(vao_); }
    void Cleanup() override { if (vao_) glDeleteVertexArrays(1, &vao_); if (vbo_) glDeleteBuffers(1, &vbo_); if (ebo_) glDeleteBuffers(1, &ebo_); }
};

// ConePrimitive - treetops, mountains, rooftops
class ConePrimitive : public Primitive {
public:
    ConePrimitive(float radius, float height, int segments, const glm::vec3& color) {
        color_ = color;
        std::vector<float> vertices;
        std::vector<unsigned int> indices;
        float halfH = height / 2.0f;
        float slopeLen = std::sqrt(radius * radius + height * height);
        float ny = radius / slopeLen;
        float nr = height / slopeLen;
        const float r0 = color.r, g0 = color.g, b0 = color.b;

        // Apex
        vertices.insert(vertices.end(), {0.0f, halfH, 0.0f, r0,g0,b0, 0,1,0, 0.5f, 1.0f});
        // Base ring — side, u wraps, v=0
        for (int i = 0; i <= segments; ++i) {
            float angle = 2.0f * M_PI * i / segments;
            float x = std::cos(angle) * radius, z = std::sin(angle) * radius;
            float nx = std::cos(angle) * nr, nz = std::sin(angle) * nr;
            float u = (float)i / segments;
            vertices.insert(vertices.end(), {x, -halfH, z, r0,g0,b0, nx, ny, nz, u, 0.0f});
        }
        for (int i = 0; i < segments; ++i)
            indices.insert(indices.end(), {0, (unsigned int)(1 + i), (unsigned int)(2 + i)});
        // Bottom cap
        unsigned int botCenter = (unsigned int)(vertices.size() / 11);
        vertices.insert(vertices.end(), {0.0f, -halfH, 0.0f, r0,g0,b0, 0,-1,0, 0.5f, 0.5f});
        for (int i = 0; i <= segments; ++i) {
            float angle = 2.0f * M_PI * i / segments;
            float x = std::cos(angle) * radius, z = std::sin(angle) * radius;
            float u = std::cos(angle) * 0.5f + 0.5f, v = std::sin(angle) * 0.5f + 0.5f;
            vertices.insert(vertices.end(), {x, -halfH, z, r0,g0,b0, 0,-1,0, u, v});
        }
        for (int i = 0; i < segments; ++i)
            indices.insert(indices.end(), {botCenter, botCenter + 2 + (unsigned int)i, botCenter + 1 + (unsigned int)i});

        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        glGenBuffers(1, &ebo_);
        glBindVertexArray(vao_);
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo_);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), indices.data(), GL_STATIC_DRAW);
        const GLsizei stride = 11 * sizeof(float);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float)));
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, (void*)(9 * sizeof(float)));
        glEnableVertexAttribArray(3);
        glBindVertexArray(0);
        index_count_ = (GLsizei)indices.size();
        intrinsic_radius_ = std::sqrt(radius * radius + halfH * halfH);
    }
    void Render() override { glBindVertexArray(vao_); glDrawElements(GL_TRIANGLES, index_count_, GL_UNSIGNED_INT, 0); glBindVertexArray(0); }
    void Bind() override { glBindVertexArray(vao_); }
    void Cleanup() override { if (vao_) glDeleteVertexArrays(1, &vao_); if (vbo_) glDeleteBuffers(1, &vbo_); if (ebo_) glDeleteBuffers(1, &ebo_); }
};

// TerrainPrimitive - heightmap terrain with procedural noise for landscapes
class TerrainPrimitive : public Primitive {
public:
    TerrainPrimitive(int resolution, float size, float maxHeight, const glm::vec3& baseColor) {
        color_ = baseColor;
        std::vector<float> vertices;
        std::vector<unsigned int> indices;
        float step = size / resolution;
        float halfSize = size / 2.0f;

        // Generate heightmap vertices using layered sine waves
        for (int z = 0; z <= resolution; ++z) {
            for (int x = 0; x <= resolution; ++x) {
                float px = -halfSize + x * step;
                float pz = -halfSize + z * step;
                // Procedural height: layered sine for rolling hills
                float h = 0.0f;
                h += std::sin(px * 0.1f) * std::cos(pz * 0.1f) * maxHeight * 0.5f;
                h += std::sin(px * 0.25f + 1.3f) * std::cos(pz * 0.3f + 0.7f) * maxHeight * 0.25f;
                h += std::sin(px * 0.6f + 2.1f) * std::cos(pz * 0.5f + 1.5f) * maxHeight * 0.125f;
                // Color by height: green low, brown mid, grey/white high
                float t = (h + maxHeight) / (2.0f * maxHeight);
                glm::vec3 col;
                if (t < 0.3f)      col = glm::mix(glm::vec3(0.2f, 0.35f, 0.1f), glm::vec3(0.3f, 0.5f, 0.15f), t / 0.3f);
                else if (t < 0.6f) col = glm::mix(glm::vec3(0.3f, 0.5f, 0.15f), glm::vec3(0.45f, 0.35f, 0.2f), (t - 0.3f) / 0.3f);
                else if (t < 0.85f)col = glm::mix(glm::vec3(0.45f, 0.35f, 0.2f), glm::vec3(0.5f, 0.5f, 0.5f), (t - 0.6f) / 0.25f);
                else               col = glm::mix(glm::vec3(0.5f, 0.5f, 0.5f), glm::vec3(0.95f, 0.95f, 0.98f), (t - 0.85f) / 0.15f);
                // pos(3) + color(3) + normal placeholder(3) + uv(2) = 11 floats
                float u = (float)x / resolution * (size * 0.1f);
                float v = (float)z / resolution * (size * 0.1f);
                vertices.insert(vertices.end(), {px, h, pz, col.r, col.g, col.b, 0, 1, 0, u, v});
            }
        }
        // Compute proper normals from adjacent heights — stride is now 11
        int w = resolution + 1;
        for (int z = 0; z <= resolution; ++z) {
            for (int x = 0; x <= resolution; ++x) {
                int idx = z * w + x;
                float hL = (x > 0)          ? vertices[((z)*w + (x-1)) * 11 + 1] : vertices[idx * 11 + 1];
                float hR = (x < resolution) ? vertices[((z)*w + (x+1)) * 11 + 1] : vertices[idx * 11 + 1];
                float hD = (z > 0)          ? vertices[((z-1)*w + x)   * 11 + 1] : vertices[idx * 11 + 1];
                float hU = (z < resolution) ? vertices[((z+1)*w + x)   * 11 + 1] : vertices[idx * 11 + 1];
                glm::vec3 n = glm::normalize(glm::vec3(hL - hR, 2.0f * step, hD - hU));
                vertices[idx * 11 + 6] = n.x;
                vertices[idx * 11 + 7] = n.y;
                vertices[idx * 11 + 8] = n.z;
            }
        }
        for (int z = 0; z < resolution; ++z) {
            for (int x = 0; x < resolution; ++x) {
                unsigned int tl = z * w + x, tr = tl + 1;
                unsigned int bl = (z + 1) * w + x, br = bl + 1;
                indices.insert(indices.end(), {tl, bl, tr, tr, bl, br});
            }
        }

        glGenVertexArrays(1, &vao_);
        glGenBuffers(1, &vbo_);
        glGenBuffers(1, &ebo_);
        glBindVertexArray(vao_);
        glBindBuffer(GL_ARRAY_BUFFER, vbo_);
        glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo_);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), indices.data(), GL_STATIC_DRAW);
        const GLsizei stride = 11 * sizeof(float);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float)));
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float)));
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, stride, (void*)(9 * sizeof(float)));
        glEnableVertexAttribArray(3);
        glBindVertexArray(0);
        index_count_ = (GLsizei)indices.size();
        intrinsic_radius_ = std::sqrt(halfSize * halfSize * 2.0f + maxHeight * maxHeight);
    }
    void Render() override { glBindVertexArray(vao_); glDrawElements(GL_TRIANGLES, index_count_, GL_UNSIGNED_INT, 0); glBindVertexArray(0); }
    void Bind() override { glBindVertexArray(vao_); }
    void Cleanup() override { if (vao_) glDeleteVertexArrays(1, &vao_); if (vbo_) glDeleteBuffers(1, &vbo_); if (ebo_) glDeleteBuffers(1, &ebo_); }
};

// GameObject implementation
GameObject::GameObject(std::shared_ptr<Primitive> primitive, const glm::vec3& position) 
    : primitive_(primitive), position_(position) {}

glm::mat4 GameObject::GetModelMatrix() const {
    glm::mat4 model = glm::mat4(1.0f);
    model = glm::translate(model, position_);
    model = glm::rotate(model, glm::radians(rotation_.x), glm::vec3(1, 0, 0));
    model = glm::rotate(model, glm::radians(rotation_.y), glm::vec3(0, 1, 0));
    model = glm::rotate(model, glm::radians(rotation_.z), glm::vec3(0, 0, 1));
    model = glm::scale(model, scale_);
    return model;
}

void GameObject::Cleanup() {
    if (primitive_) {
        primitive_->Cleanup();
    }
}

float GameObject::GetBoundingRadius() const {
    float maxScale = std::max({ scale_.x, scale_.y, scale_.z });
    float base = primitive_ ? primitive_->GetIntrinsicRadius() : 1.732f;
    return maxScale * base;
}



void GameObject::Render() {
    if (primitive_) {
        primitive_->Render();
    }
}


// Engine constructor – must be defined before the other methods
Engine::Engine(int w, int h, const char* title)
    : width_(w)
    , height_(h)
    , window_(nullptr)
    , threadPool_(std::thread::hardware_concurrency())
{
    // Load persistent settings
    settings_.load();
    if (width_ <= 0)  width_  = settings_.windowWidth;
    if (height_ <= 0) height_ = settings_.windowHeight;
    show_overlay_ = settings_.showFps;

    if (!glfwInit()) {
        throw std::runtime_error("Failed to initialize GLFW");
    }

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

    // Windowed mode with decorations (title bar, resize, close/minimize/maximize)
    glfwWindowHint(GLFW_DECORATED, GLFW_TRUE);
    glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);

    // Clamp to monitor if needed
    GLFWmonitor* primary = glfwGetPrimaryMonitor();
    const GLFWvidmode* mode = glfwGetVideoMode(primary);
    if (width_ <= 0 || height_ <= 0) {
        width_  = (int)(mode->width  * 0.8f);
        height_ = (int)(mode->height * 0.8f);
    }

    if (settings_.fullscreen) {
        window_ = glfwCreateWindow(mode->width, mode->height, title, primary, nullptr);
        width_  = mode->width;
        height_ = mode->height;
    } else {
        window_ = glfwCreateWindow(width_, height_, title, nullptr, nullptr);
    }

    if (!window_) {
        glfwTerminate();
        throw std::runtime_error("Failed to create GLFW window");
    }

    glfwMakeContextCurrent(window_);
    glfwSwapInterval(settings_.vsync ? 1 : 0);

    // Link this Engine instance so resize callback can update width_/height_
    glfwSetWindowUserPointer(window_, this);
    glfwSetFramebufferSizeCallback(window_, FramebufferSizeCallback);

    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
        throw std::runtime_error("Failed to initialize GLAD");
    }

    glEnable(GL_DEPTH_TEST);
    glClearColor(0.1f, 0.1f, 0.2f, 1.0f);
    glViewport(0, 0, width_, height_);

    ortho2D = glm::ortho(0.0f, float(width_), 0.0f, float(height_));

    glDisable(GL_DEPTH_TEST);

    InitializeShader();
    InitOverlay();

    // Initialize font with settings
    font_.init(overlay_vao, overlay_vbo, overlay_program);
    font_.setSpacing(settings_.fontSpacing);
    font_.setWeight(settings_.fontWeight);
    font_.setSmoothing(true);

    Input::Initialize(window_);

    // Initialize audio
    audio_.init();
    audio_.setMasterVolume(settings_.masterVolume);
    audio_.setSfxVolume(settings_.sfxVolume);
    audio_.setMusicVolume(settings_.musicVolume);
}

// -------------------------------------------------------------
// CullSphere — Gribb/Hartmann frustum plane extraction
//
// Extracts 6 frustum planes directly from the view-projection
// matrix by combining its rows. [web:72][web:76]
// Returns TRUE if the sphere is fully OUTSIDE any plane
// (i.e. should be culled — skip rendering).
// -------------------------------------------------------------
bool Engine::CullSphere(const glm::mat4& vp,
                        const glm::vec3& center,
                        float radius)
{
    // GLM stores matrices column-major, so row(vp, n) extracts row n.
    // Each frustum plane = combination of row 3 ± row n. [web:75][web:76]
    const glm::vec4 row0 = glm::row(vp, 0);
    const glm::vec4 row1 = glm::row(vp, 1);
    const glm::vec4 row2 = glm::row(vp, 2);
    const glm::vec4 row3 = glm::row(vp, 3);

    const glm::vec4 planes[6] = {
        row3 + row0,   // Left
        row3 - row0,   // Right
        row3 + row1,   // Bottom
        row3 - row1,   // Top
        row3 + row2,   // Near
        row3 - row2,   // Far
    };

    const glm::vec4 pt(center, 1.0f);

    for (const auto& plane : planes) {
        // Signed distance from sphere centre to plane.
        // If distance < -radius, sphere is fully on the outside. [web:73]
        if (glm::dot(plane, pt) < -radius) {
            return true;   // Cull — outside this plane
        }
    }
    return false;   // Inside or intersecting all 6 planes — keep
}

// Culling subset method
Engine::CullingResult Engine::CullObjectsSubset(
    const std::vector<GameObject*>& objects,
    size_t startIdx,
    size_t endIdx,
    const glm::mat4& viewProjection)
{
    CullingResult result;
    std::unordered_map<BatchKey, size_t, BatchKeyHash> keyToIndex;

    for (size_t i = startIdx; i < endIdx && i < objects.size(); ++i) {
        GameObject* obj = objects[i];
        glm::vec3 pos = obj->GetPosition();
        float radius = obj->GetBoundingRadius();
        if (CullSphere(viewProjection, pos, radius)) continue;

        BatchKey key{
            obj->GetPrimitive(),
            obj->GetTexture(),
            obj->GetNormalMap(),
            obj->GetUVScale(),
            obj->GetMetallic(),
            obj->GetRoughness(),
            obj->GetEmissive()
        };
        auto it = keyToIndex.find(key);
        if (it == keyToIndex.end()) {
            result.emplace_back();
            result.back().key = key;
            result.back().modelMatrices.push_back(obj->GetModelMatrix());
            keyToIndex[key] = result.size() - 1;
        } else {
            result[it->second].modelMatrices.push_back(obj->GetModelMatrix());
        }
    }
    return result;
}

// Merge results from all threads
void Engine::MergeCullingResults(
    const std::vector<CullingResult>& results,
    std::unordered_map<BatchKey, std::vector<glm::mat4>, BatchKeyHash>& outBatches)
{
    for (const auto& result : results) {
        for (const auto& batch : result) {
            auto& targetBatch = outBatches[batch.key];
            targetBatch.insert(targetBatch.end(),
                             batch.modelMatrices.begin(),
                             batch.modelMatrices.end());
        }
    }
}


void Engine::InitOverlay() {
    // 1) Generate VAO/VBO for overlay
    glGenVertexArrays(1, &overlay_vao);
    glGenBuffers(1, &overlay_vbo);

    // 2) Overlay shaders
    const char* vs_overlay = R"(
    #version 410 core
    layout(location = 0) in vec2 aPos;      // Position in screen pixels
    layout(location = 1) in vec3 aColor;    // Vertex color
    uniform mat4 uOrtho;                    // Orthographic projection
    out vec3 vColor;
    void main() {
        gl_Position = uOrtho * vec4(aPos, 0.0, 1.0);
        vColor = aColor;
    }
    )";

    const char* fs_overlay = R"(
    #version 410 core
    in vec3 vColor;
    out vec4 FragColor;
    void main() {
        FragColor = vec4(vColor, 1.0);
    }
    )";

    // 3) Compile and link overlay program
    overlay_program = CreateProgram(vs_overlay, fs_overlay);

    // 4) Configure VAO/VBO vertex attributes (two floats + three floats)
    glBindVertexArray(overlay_vao);
    glBindBuffer(GL_ARRAY_BUFFER, overlay_vbo);

    // Attribute 0: aPos (vec2)
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(
        0,                   // index
        2,                   // size (x, y)
        GL_FLOAT,            // type
        GL_FALSE,            // normalized
        sizeof(float) * 5,   // stride (2 pos + 3 color)
        (void*)0             // offset
    );

    // Attribute 1: aColor (vec3)
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(
        1,                               // index
        3,                               // size (r, g, b)
        GL_FLOAT,                        // type
        GL_FALSE,                        // normalized
        sizeof(float) * 5,               // stride
        (void*)(sizeof(float) * 2)       // offset after 2 floats
    );

    // Unbind VAO to avoid accidental modification
    glBindVertexArray(0);
}





bool Engine::OnInitialize() { return true; }

void Engine::OnShutdown() {
    // Save settings on exit
    settings_.save();

    // Shutdown audio
    audio_.shutdown();

    for (auto& obj : game_objects_) {
        if (obj) obj->Cleanup();
    }
    game_objects_.clear();
}

void Engine::OnUpdate(float delta_time) {}

void Engine::RenderOverlayTestPoint() {
    glDisable(GL_DEPTH_TEST);
    glUseProgram(overlay_program);
    GLint locOrtho = glGetUniformLocation(overlay_program, "uOrtho");
    glm::mat4 ortho = glm::ortho(0.0f, (float)width_, 0.0f, (float)height_);
    glUniformMatrix4fv(locOrtho, 1, GL_FALSE, &ortho[0][0]);

    // Single white point at (100,100)
    struct P { float x,y; float r,g,b; };
    P pt = {100, 100, 1,1,1};
    glBindVertexArray(overlay_vao);
    glBindBuffer(GL_ARRAY_BUFFER, overlay_vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(pt), &pt, GL_DYNAMIC_DRAW);

    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(P), (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(P), (void*)(2*sizeof(float)));
    glEnableVertexAttribArray(1);

    glDrawArrays(GL_POINTS, 0, 1);

    glBindVertexArray(0);
    glUseProgram(0);
    glEnable(GL_DEPTH_TEST);
}



void Engine::RenderOverlay() {
    // 1) Update stats every 1.0s
    static float timer = 0.0f;
    static float last_fps = 0.0f, last_ms = 0.0f;
    static float min_fps = FLT_MAX, max_fps = 0.0f, sum_fps = 0.0f;
    static int count = 0;
    timer += perf_times[(perf_pos + PERF_HISTORY - 1) % PERF_HISTORY] / 1000.0f;
    if (timer >= 1.0f) {
        float ms = perf_times[(perf_pos + PERF_HISTORY - 1) % PERF_HISTORY];
        last_ms  = ms;
        last_fps = 1000.0f / (ms > 0 ? ms : 1);
        min_fps = std::min(min_fps, last_fps);
        max_fps = std::max(max_fps, last_fps);
        sum_fps += last_fps;
        count++;
        timer = 0.0f;
    }
    float avg_fps = count ? sum_fps / count : last_fps;

    // 2) GL state
    font_.begin(width_, height_);

    struct V { float x,y; float r,g,b; };

    // 3) Dynamic panel background
    int samples = std::min(perf_count, PERF_HISTORY);
    float scrollSpeed = 1.0f;
    float graphHeight = 60.0f, padding = 14.0f, sz = 18.0f;
    float totalWidth = samples * scrollSpeed;
    float x0 = 10.0f, y0 = height_ - (3 * (graphHeight + padding) + 80.0f);
    float bgW = totalWidth + 80.0f, bgH = 3 * (graphHeight + padding) + 80.0f;

    V bgPanel[6] = {
        {x0,      y0,      0.1f,0.1f,0.15f},
        {x0+bgW,  y0,      0.1f,0.1f,0.15f},
        {x0+bgW,  y0+bgH,  0.15f,0.15f,0.2f},
        {x0,      y0,      0.1f,0.1f,0.15f},
        {x0+bgW,  y0+bgH,  0.15f,0.15f,0.2f},
        {x0,      y0+bgH,  0.15f,0.15f,0.2f},
    };
    glBufferData(GL_ARRAY_BUFFER, sizeof(bgPanel), bgPanel, GL_DYNAMIC_DRAW);
    glVertexAttribPointer(0,2,GL_FLOAT,GL_FALSE,sizeof(V),(void*)0);
    glVertexAttribPointer(1,3,GL_FLOAT,GL_FALSE,sizeof(V),(void*)(2*sizeof(float)));
    glDrawArrays(GL_TRIANGLES, 0, 6);

    // 4) Stats arrays
    static float avg_times[PERF_HISTORY];
    for(int i=0;i<samples;++i)
        avg_times[i] = 1000.0f / perf_times[(perf_pos+PERF_HISTORY-samples+i)%PERF_HISTORY];
    struct Stat { const float* data; glm::vec3 color; const char* label; };
    Stat stats[3] = {
        { perf_times, {0.0f,1.0f,0.0f}, "MS" },
        { avg_times,  {0.0f,1.0f,1.0f}, "AVG" },
        { nullptr,    {1.0f,1.0f,0.0f}, "FPS" }
    };
    static std::deque<float> fpsHistory;
    if(fpsHistory.size()>= (size_t)samples) fpsHistory.pop_front();
    fpsHistory.push_back(last_fps);

    // 5) Draw each graph row with labels
    for(int g=0; g<3; ++g) {
        float gx = x0 + 70.0f, gy = y0 + 14.0f + g*(graphHeight+padding);

        // Label via font system
        font_.drawText(stats[g].label, x0 + 8, gy + graphHeight*0.5f - sz*0.5f, sz,
                       stats[g].color, 2.0f);

        // Build strip
        std::vector<V> strip;
        for(int i=0;i<samples;++i) {
            float val = (g<2 ? stats[g].data[i] : fpsHistory[i]);
            float norm = glm::clamp(val / 100.0f, 0.0f, 1.0f);
            float fx = gx + i * scrollSpeed;
            float fy = gy + norm * graphHeight;
            strip.push_back({fx, fy, stats[g].color.r, stats[g].color.g, stats[g].color.b});
        }

        glBufferData(GL_ARRAY_BUFFER, strip.size()*sizeof(V), strip.data(), GL_DYNAMIC_DRAW);
        glVertexAttribPointer(0,2,GL_FLOAT,GL_FALSE,sizeof(V),(void*)0);
        glVertexAttribPointer(1,3,GL_FLOAT,GL_FALSE,sizeof(V),(void*)(2*sizeof(float)));
        glEnable(GL_LINE_SMOOTH);
        glHint(GL_LINE_SMOOTH_HINT, GL_NICEST);
        glLineWidth(2.0f);
        glDrawArrays(GL_LINE_STRIP, 0, (GLsizei)strip.size());
        glLineWidth(1.0f);
        glDisable(GL_LINE_SMOOTH);
    }

    // 6) Stats text line
    char txtbuf[256];
    snprintf(txtbuf, sizeof(txtbuf),
        "FPS:%.1f  MS:%.1f  MIN:%.1f  MAX:%.1f  AVG:%.1f  OBJ:%zu",
        last_fps, last_ms, min_fps, max_fps, avg_fps, game_objects_.size());
    font_.drawText(txtbuf, x0 + 10, y0 + bgH - 28, sz, {1,1,1}, 2.0f);

    // 7) Agent log message
    if (agentLogTimer_ > 0 && !agentLog_.empty()) {
        float alpha = std::min(agentLogTimer_, 1.0f);
        glm::vec3 logColor = glm::vec3(0.3f, 1.0f, 0.5f) * alpha;
        font_.drawText("[Agent] " + agentLog_, 10.0f, 40.0f, 16.0f, logColor, 2.0f);
    }

    // 8) API status indicator
    if (agentApi_.isRunning()) {
        font_.drawTextRight("API :9090", (float)width_ - 10.0f, (float)height_ - 24.0f, 12.0f,
                           {0.3f, 0.8f, 0.3f}, 1.5f);
    }

    // 9) Restore GL state
    font_.end();
}


void Engine::RenderSceneInstanced() {
    // --- Shadow pass (fills shadow_map_ depth texture) ---
    light_vp_ = ComputeLightVP();
    RenderShadowPass();

    // --- HDR main pass ---
    if (hdr_fbo_ == 0 || post_w_ != width_ || post_h_ != height_) {
        if (composite_program_ == 0) InitPostFBOs();
        else EnsurePostFBOs();
    }
    glBindFramebuffer(GL_FRAMEBUFFER, hdr_fbo_);
    glViewport(0, 0, width_, height_);
    glEnable(GL_DEPTH_TEST);
    glDepthFunc(GL_LESS);
    glClearColor(0, 0, 0, 1);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // Sky draws first with depth off; scene paints over where objects are.
    {
        glm::mat4 v = glm::lookAt(camera_position_, camera_target_, camera_up_);
        glm::mat4 p = glm::perspective(glm::radians(settings_.fov),
                                       float(width_) / height_, 0.1f, settings_.drawDistance);
        RenderSky(v, p);
    }

    glUseProgram(shader_program_);

    // Bind shadow map on texture unit 1; leave unit 0 for diffuse.
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, shadow_map_);
    glUniform1i(glGetUniformLocation(shader_program_, "uShadowMap"), 1);
    glUniform1i(glGetUniformLocation(shader_program_, "uShadowsEnabled"), shadow_map_ ? 1 : 0);
    glUniformMatrix4fv(glGetUniformLocation(shader_program_, "uLightVP"),
                       1, GL_FALSE, &light_vp_[0][0]);
    glActiveTexture(GL_TEXTURE0);

    // Directional sun + hemispheric ambient
    glUniform1i(glGetUniformLocation(shader_program_, "texture_diffuse1"), 0);

    glUniform3fv(glGetUniformLocation(shader_program_, "uSunDir"),     1, &sun_dir_[0]);
    glUniform3fv(glGetUniformLocation(shader_program_, "uSunColor"),   1, &sun_color_[0]);
    glUniform3fv(glGetUniformLocation(shader_program_, "uSkyColor"),   1, &sky_color_[0]);
    glUniform3fv(glGetUniformLocation(shader_program_, "uGroundColor"),1, &ground_color_[0]);
    glUniform3fv(glGetUniformLocation(shader_program_, "uViewPos"),    1, &camera_position_[0]);
    glUniform1f(glGetUniformLocation(shader_program_, "uStorminess"), storminess_);
    glUniform1i(glGetUniformLocation(shader_program_, "uWaterEnabled"), water_enabled_ ? 1 : 0);
    glUniform1f(glGetUniformLocation(shader_program_, "uWaterLevel"), water_level_);
    glUniform3fv(glGetUniformLocation(shader_program_, "uWaterDeep"), 1, &water_color_deep_[0]);
    glUniform3fv(glGetUniformLocation(shader_program_, "uHorizonColor"), 1, &horizon_color_[0]);
    glUniform1f(glGetUniformLocation(shader_program_, "uFogDensity"), fog_density_);

    // Set view and projection matrices
    glm::mat4 view = glm::lookAt(camera_position_, camera_target_, camera_up_);
    glm::mat4 proj = glm::perspective(glm::radians(settings_.fov), float(width_) / height_, 0.1f, settings_.drawDistance);
    
    GLint viewLoc = glGetUniformLocation(shader_program_, "uView");
    GLint projLoc = glGetUniformLocation(shader_program_, "uProjection");
    glUniformMatrix4fv(viewLoc, 1, GL_FALSE, &view[0][0]);
    glUniformMatrix4fv(projLoc, 1, GL_FALSE, &proj[0][0]);

    // MULTITHREADED CULLING SECTION
    glm::mat4 vp = proj * view;
   
    std::vector<GameObject*> raw_objects;
    raw_objects.reserve(game_objects_.size());
    for (const auto& sp : game_objects_)
        raw_objects.push_back(sp.get());

    if (raw_objects.empty()) return;

    // Determine number of threads and chunk size
    // Minimum 64 objects per thread to avoid thread overhead dominating
    static constexpr size_t MIN_OBJECTS_PER_THREAD = 64;
    size_t totalObjects = raw_objects.size();
    size_t numThreads = std::min(threadPool_.size(), totalObjects);
    numThreads = std::max(numThreads, static_cast<size_t>(1));
    // Reduce thread count if chunks would be too small
    while (numThreads > 1 && (totalObjects / numThreads) < MIN_OBJECTS_PER_THREAD)
        numThreads--;
    size_t objectsPerThread = (totalObjects + numThreads - 1) / numThreads;

    std::unordered_map<BatchKey, std::vector<glm::mat4>, BatchKeyHash> batches;

    if (numThreads <= 1) {
        // Single-threaded path — no thread overhead
        CullingResult result = CullObjectsSubset(raw_objects, 0, totalObjects, vp);
        for (auto& batch : result) {
            auto& target = batches[batch.key];
            target.insert(target.end(), batch.modelMatrices.begin(), batch.modelMatrices.end());
        }
    } else {
        // Multi-threaded culling across thread pool
        std::vector<std::future<CullingResult>> futures;
        futures.reserve(numThreads);

        for (size_t i = 0; i < numThreads; ++i) {
            size_t startIdx = i * objectsPerThread;
            size_t endIdx = std::min(startIdx + objectsPerThread, totalObjects);
            if (startIdx >= totalObjects) break;

            futures.push_back(threadPool_.enqueue(
                [this, &raw_objects, startIdx, endIdx, vp]() -> CullingResult {
                    return CullObjectsSubset(raw_objects, startIdx, endIdx, vp);
                }
            ));
        }

        // Collect and merge results
        std::vector<CullingResult> cullingResults;
        cullingResults.reserve(futures.size());
        for (auto& future : futures)
            cullingResults.push_back(future.get());

        MergeCullingResults(cullingResults, batches);
    }

    // Instance buffer setup (same as before)
    static GLuint instVBO = 0;
    if (!instVBO) glGenBuffers(1, &instVBO);

    // Render each batch (same as before)
    for (auto& kv : batches) {
        auto& models = kv.second;
        if (models.empty()) continue;

        // Bind per-batch diffuse texture on unit 0, normal map on unit 2
        // (shadow map stays on unit 1).
        glActiveTexture(GL_TEXTURE0);
        GLuint tex = kv.first.texture ? kv.first.texture : TextureCache::DefaultWhite();
        glBindTexture(GL_TEXTURE_2D, tex);
        glUniform1i(glGetUniformLocation(shader_program_, "uDiffuse"), 0);
        glUniform2fv(glGetUniformLocation(shader_program_, "uUVScale"), 1,
                     &kv.first.uv_scale[0]);

        glActiveTexture(GL_TEXTURE2);
        GLuint nmap = kv.first.normal_map ? kv.first.normal_map : TextureCache::DefaultNormal();
        glBindTexture(GL_TEXTURE_2D, nmap);
        glUniform1i(glGetUniformLocation(shader_program_, "uNormalMap"), 2);
        glActiveTexture(GL_TEXTURE0);

        glUniform1f(glGetUniformLocation(shader_program_, "uMetallic"),  kv.first.metallic);
        glUniform1f(glGetUniformLocation(shader_program_, "uRoughness"), kv.first.roughness);
        glUniform3fv(glGetUniformLocation(shader_program_, "uEmissive"), 1, &kv.first.emissive[0]);

        // Upload model matrices to instance buffer
        glBindBuffer(GL_ARRAY_BUFFER, instVBO);
        glBufferData(GL_ARRAY_BUFFER,
                     models.size() * sizeof(glm::mat4),
                     models.data(),
                     GL_DYNAMIC_DRAW);

        // Bind primitive VAO
        Primitive* prim = kv.first.primitive;
        prim->Bind();
        
        // Set up instanced attributes for model matrix (locations 6-9)
        glBindBuffer(GL_ARRAY_BUFFER, instVBO);
        for (int i = 0; i < 4; ++i) {
            int loc = 6 + i;
            glEnableVertexAttribArray(loc);
            glVertexAttribPointer(loc, 4, GL_FLOAT, GL_FALSE,
                                  sizeof(glm::mat4), (void*)(sizeof(float) * 4 * i));
            glVertexAttribDivisor(loc, 1);
        }

        // Draw instanced geometry
        if (prim->GetIndexCount() > 0) {
            glDrawElementsInstanced(
                prim->GetDrawMode(),
                prim->GetIndexCount(),
                GL_UNSIGNED_INT,
                0,
                (GLsizei)models.size()
            );
        } else {
            glDrawArraysInstanced(
                prim->GetDrawMode(),
                0,
                prim->GetVertexCount(),
                (GLsizei)models.size()
            );
        }

        // Reset divisors to 0 for next batch
        for (int i = 0; i < 4; ++i)
            glVertexAttribDivisor(6 + i, 0);
    }

    // Water is drawn after opaque scene so Fresnel reflection is layered on top.
    if (water_enabled_ || sec_water_.enabled) {
        glm::mat4 v = glm::lookAt(camera_position_, camera_target_, camera_up_);
        glm::mat4 p = glm::perspective(glm::radians(settings_.fov),
                                       float(width_) / height_, 0.1f, settings_.drawDistance);
        RenderWater(v, p);
        RenderSecWater(v, p);
    }

    glUseProgram(0);

    // --- Post: bloom + ACES + gamma, then composite to default framebuffer ---
    RenderBloom();
    CompositeToScreen();
}




void Engine::OnRender() {
    RenderSceneInstanced();
    if (show_overlay_) {
        glDisable(GL_DEPTH_TEST);
        RenderOverlay();
    }
}


// ---- Agent API command processing (runs on main thread each frame) ----

static glm::vec3 readVec3(const json::Value& v, const std::string& key, glm::vec3 def = glm::vec3(0)) {
    if (v.has(key) && v[key].is_array() && v[key].size() >= 3) {
        auto& a = v[key].as_array();
        return {a[0].as_float(), a[1].as_float(), a[2].as_float()};
    }
    return def;
}

void Engine::processAgentCommands() {
    auto cmds = agentApi_.pollCommands();
    for (auto& cmd : cmds)
        processAgentCommand(cmd);

    // Update query response with current state
    {
        std::ostringstream ss;
        ss << "{\"objects\":" << game_objects_.size()
           << ",\"named\":" << namedObjects_.size()
           << ",\"camera\":{\"position\":["
           << camera_position_.x << "," << camera_position_.y << "," << camera_position_.z
           << "],\"target\":["
           << camera_target_.x << "," << camera_target_.y << "," << camera_target_.z
           << "]},\"fps\":" << (perf_count > 0 ? 1000.0f / perf_times[(perf_pos + PERF_HISTORY - 1) % PERF_HISTORY] : 0)
           << ",\"api_port\":" << agentApi_.getPort()
           << "}";
        agentApi_.setQueryResponse(ss.str());
    }

    // Agent log display
    std::string newLog = agentApi_.popLastLog();
    if (!newLog.empty()) {
        agentLog_ = newLog;
        agentLogTimer_ = 5.0f;
    }
    if (agentLogTimer_ > 0) {
        agentLogTimer_ -= perf_times[(perf_pos + PERF_HISTORY - 1) % PERF_HISTORY] / 1000.0f;
    }
}

std::string Engine::processAgentCommand(const json::Value& cmd) {
    std::string action = cmd.get_string("action", "");

    if (action == "create") {
        std::string type = cmd.get_string("type", "cube");
        std::string id   = cmd.get_string("id", "");
        glm::vec3 pos    = readVec3(cmd, "position");
        glm::vec3 scl    = readVec3(cmd, "scale", glm::vec3(1));
        glm::vec3 rot    = readVec3(cmd, "rotation");
        glm::vec3 color  = readVec3(cmd, "color", glm::vec3(1));

        std::shared_ptr<Primitive> prim;
        if (type == "cube")
            prim = CreateCube(color);
        else if (type == "sphere")
            prim = CreateSphere(cmd.get_int("segments", 16), color);
        else if (type == "cylinder")
            prim = CreateCylinder(cmd.get_float("radius", 0.5f), cmd.get_float("height", 2.0f),
                                  cmd.get_int("segments", 16), color);
        else if (type == "cone")
            prim = CreateCone(cmd.get_float("radius", 1.0f), cmd.get_float("height", 2.0f),
                              cmd.get_int("segments", 16), color);
        else if (type == "plane")
            prim = CreatePlane(cmd.get_float("width", 2.0f), cmd.get_float("height", 2.0f), color);
        else if (type == "grid")
            prim = CreateGrid(cmd.get_int("size", 20), cmd.get_float("spacing", 1.0f), color);
        else if (type == "terrain")
            prim = CreateTerrain(cmd.get_int("resolution", 64), cmd.get_float("size", 100.0f),
                                 cmd.get_float("max_height", 8.0f), color);
        else
            prim = CreateCube(color);

        auto obj = CreateGameObject(prim, pos);
        obj->SetScale(scl);
        obj->SetRotation(rot);
        AddGameObject(obj);

        if (!id.empty()) {
            namedObjects_[id] = obj;
            namedObjectCreationParams_[id] = cmd;
        }

        // Optional texture / material at creation
        if (cmd.has("texture"))
            obj->SetTexture(TextureCache::Get(cmd.get_string("texture", "")));
        if (cmd.has("normal_map"))
            obj->SetNormalMap(TextureCache::Get(cmd.get_string("normal_map", "")));
        if (cmd.has("uv_scale"))
            obj->SetUVScale(glm::vec2(readVec3(cmd, "uv_scale", glm::vec3(1))));
        if (cmd.has("metallic"))  obj->SetMetallic(cmd.get_float("metallic", 0.0f));
        if (cmd.has("roughness")) obj->SetRoughness(cmd.get_float("roughness", 0.7f));
        if (cmd.has("emissive"))  obj->SetEmissive(readVec3(cmd, "emissive", glm::vec3(0)));

        return R"({"ok":true})";
    }
    else if (action == "texture") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it == namedObjects_.end()) return R"({"ok":false,"error":"no such object"})";
        if (cmd.has("name"))
            it->second->SetTexture(TextureCache::Get(cmd.get_string("name", "")));
        if (cmd.has("normal_map"))
            it->second->SetNormalMap(TextureCache::Get(cmd.get_string("normal_map", "")));
        if (cmd.has("uv_scale"))
            it->second->SetUVScale(glm::vec2(readVec3(cmd, "uv_scale", glm::vec3(1))));
        return R"({"ok":true})";
    }
    else if (action == "material") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it == namedObjects_.end()) return R"({"ok":false,"error":"no such object"})";
        if (cmd.has("metallic"))  it->second->SetMetallic(cmd.get_float("metallic", 0.0f));
        if (cmd.has("roughness")) it->second->SetRoughness(cmd.get_float("roughness", 0.7f));
        if (cmd.has("emissive"))  it->second->SetEmissive(readVec3(cmd, "emissive", glm::vec3(0)));
        if (cmd.has("normal_map"))
            it->second->SetNormalMap(TextureCache::Get(cmd.get_string("normal_map", "")));
        return R"({"ok":true})";
    }
    else if (action == "move") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it != namedObjects_.end())
            it->second->SetPosition(readVec3(cmd, "position"));
        return R"({"ok":true})";
    }
    else if (action == "rotate") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it != namedObjects_.end())
            it->second->SetRotation(readVec3(cmd, "rotation"));
        return R"({"ok":true})";
    }
    else if (action == "scale") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it != namedObjects_.end())
            it->second->SetScale(readVec3(cmd, "scale", glm::vec3(1)));
        return R"({"ok":true})";
    }
    else if (action == "delete") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it != namedObjects_.end()) {
            RemoveGameObject(it->second);
            namedObjects_.erase(it);
        }
        namedObjectCreationParams_.erase(id);
        auto bh = bodyHandleByName_.find(id);
        if (bh != bodyHandleByName_.end()) {
            physics_.RemoveBody(bh->second);
            bodyHandleByName_.erase(bh);
        }
        return R"({"ok":true})";
    }
    else if (action == "clear") {
        game_objects_.clear();
        namedObjects_.clear();
        namedObjectCreationParams_.clear();
        bodyHandleByName_.clear();
        physics_ = phys::World{};  // fresh world, retains disabled state
        return R"({"ok":true})";
    }
    else if (action == "camera") {
        if (cmd.has("position"))
            SetCameraPosition(readVec3(cmd, "position"));
        if (cmd.has("target"))
            SetCameraTarget(readVec3(cmd, "target"));
        return R"({"ok":true})";
    }
    else if (action == "orbit") {
        SetCameraOrbit(cmd.get_float("distance", 20.0f),
                       cmd.get_float("angle", 0.0f),
                       cmd.get_float("height", 10.0f));
        return R"({"ok":true})";
    }
    else if (action == "sound") {
        audio_.playSound(cmd.get_string("name", ""), cmd.get_float("volume", 1.0f));
        return R"({"ok":true})";
    }
    else if (action == "music") {
        audio_.playMusic(cmd.get_string("name", ""), cmd.get_float("volume", 0.7f));
        return R"({"ok":true})";
    }
    else if (action == "stop_music") {
        audio_.stopMusic();
        return R"({"ok":true})";
    }
    else if (action == "set") {
        std::string prop = cmd.get_string("property", "");
        if (prop == "fov")
            settings_.fov = cmd.get_float("value", 60.0f);
        else if (prop == "draw_distance")
            settings_.drawDistance = cmd.get_float("value", 1000.0f);
        else if (prop == "clear_color") {
            glm::vec3 c = readVec3(cmd, "value", glm::vec3(0.1f));
            glClearColor(c.r, c.g, c.b, 1.0f);
            clear_color_ = c;
        }
        return R"({"ok":true})";
    }
    else if (action == "save_world") {
        std::string name = cmd.get_string("name", "world");
        std::string path = cmd.get_string("path", "worlds/" + name + ".json");
        return saveWorldToFile(path);
    }
    else if (action == "load_world") {
        std::string name = cmd.get_string("name", "");
        std::string path = cmd.get_string("path",
            name.empty() ? std::string("worlds/world.json") : "worlds/" + name + ".json");
        return loadWorldFromFile(path);
    }
    else if (action == "list_worlds") {
        namespace fs = std::filesystem;
        std::ostringstream ss;
        ss << "{\"ok\":true,\"worlds\":[";
        bool first = true;
        std::error_code ec;
        if (fs::exists("worlds", ec)) {
            for (auto& e : fs::directory_iterator("worlds", ec)) {
                if (e.path().extension() == ".json") {
                    if (!first) ss << ",";
                    first = false;
                    ss << "\"" << e.path().stem().string() << "\"";
                }
            }
        }
        ss << "]}";
        return ss.str();
    }
    else if (action == "batch") {
        if (cmd.has("commands") && cmd["commands"].is_array()) {
            for (auto& c : cmd["commands"].as_array())
                processAgentCommand(c);
        }
        return R"({"ok":true})";
    }
    else if (action == "water") {
        water_enabled_    = cmd.get_bool("enabled", true);
        water_level_      = cmd.get_float("level",      water_level_);
        water_amplitude_  = cmd.get_float("amplitude",  water_amplitude_);
        water_wavelength_ = std::max(0.5f, cmd.get_float("wavelength", water_wavelength_));
        water_speed_      = cmd.get_float("speed",      water_speed_);
        water_size_       = std::max(5.0f, cmd.get_float("size", water_size_));
        if (cmd.has("shallow")) water_color_shallow_ = readVec3(cmd, "shallow", water_color_shallow_);
        if (cmd.has("deep"))    water_color_deep_    = readVec3(cmd, "deep",    water_color_deep_);

        // Physics buoyancy now samples the actual wave surface — mirror params.
        physics_.water_enabled   = water_enabled_;
        physics_.water_level     = water_level_;
        physics_.water_amplitude = water_amplitude_;
        physics_.water_wavelength = water_wavelength_;
        physics_.water_speed     = water_speed_;
        if (cmd.has("density")) physics_.water_density = cmd.get_float("density", 1.0f);

        // Directional water flow (independent of wind)
        if (cmd.has("flow_dir")) {
            glm::vec3 d = readVec3(cmd, "flow_dir", glm::vec3(0, 0, 0));
            glm::vec2 xz(d.x, d.z);
            float l = glm::length(xz);
            if (l > 1e-5f) physics_.water_flow_dir = xz / l;
        }
        if (cmd.has("flow_speed"))
            physics_.water_flow_speed = std::max(0.0f, cmd.get_float("flow_speed", 0.0f));

        RebuildWaterMesh(cmd.get_int("resolution", 128));
        return R"({"ok":true})";
    }
    else if (action == "sun") {
        if (cmd.has("direction")) {
            glm::vec3 d = readVec3(cmd, "direction", sun_dir_);
            float l = glm::length(d);
            if (l > 1e-5f) sun_dir_ = d / l;
        }
        if (cmd.has("color"))   sun_color_      = readVec3(cmd, "color",   sun_color_);
        if (cmd.has("sky"))     sky_color_      = readVec3(cmd, "sky",     sky_color_);
        if (cmd.has("zenith"))  zenith_color_   = readVec3(cmd, "zenith",  zenith_color_);
        if (cmd.has("horizon")) horizon_color_  = readVec3(cmd, "horizon", horizon_color_);
        if (cmd.has("ground"))  ground_color_   = readVec3(cmd, "ground",  ground_color_);
        if (cmd.has("shadow_extent"))
            shadow_extent_ = std::max(5.0f, cmd.get_float("shadow_extent", shadow_extent_));
        if (cmd.has("exposure"))        exposure_        = cmd.get_float("exposure", 1.0f);
        if (cmd.has("bloom_threshold")) bloom_threshold_ = cmd.get_float("bloom_threshold", 1.2f);
        if (cmd.has("bloom_intensity")) bloom_intensity_ = cmd.get_float("bloom_intensity", 0.8f);
        if (cmd.has("fog_density"))     fog_density_     = cmd.get_float("fog_density", 0.0025f);
        return R"({"ok":true})";
    }
    else if (action == "water2") {
        sec_water_.enabled = cmd.get_bool("enabled", true);
        if (cmd.has("center"))    sec_water_.center     = readVec3(cmd, "center", sec_water_.center);
        if (cmd.has("size"))      sec_water_.size       = std::max(2.0f, cmd.get_float("size", sec_water_.size));
        if (cmd.has("level"))     sec_water_.level      = cmd.get_float("level", sec_water_.level);
        if (cmd.has("amplitude")) sec_water_.amplitude  = cmd.get_float("amplitude", sec_water_.amplitude);
        if (cmd.has("wavelength"))sec_water_.wavelength = std::max(0.5f, cmd.get_float("wavelength", sec_water_.wavelength));
        if (cmd.has("speed"))     sec_water_.speed      = cmd.get_float("speed", sec_water_.speed);
        if (cmd.has("shallow"))   sec_water_.shallow    = readVec3(cmd, "shallow", sec_water_.shallow);
        if (cmd.has("deep"))      sec_water_.deep       = readVec3(cmd, "deep", sec_water_.deep);
        RebuildSecWaterMesh(cmd.get_int("resolution", 96));
        return R"({"ok":true})";
    }
    else if (action == "weather") {
        if (cmd.has("wind_dir")) {
            glm::vec3 d = readVec3(cmd, "wind_dir", glm::vec3(1, 0, 0));
            glm::vec2 xz(d.x, d.z);
            float l = glm::length(xz);
            if (l > 1e-5f) wind_dir_xz_ = xz / l;
        }
        wind_speed_   = cmd.get_float("wind_speed", wind_speed_);
        storminess_   = std::clamp(cmd.get_float("storminess", storminess_), 0.0f, 1.0f);
        if (cmd.has("current_coef"))
            physics_.current_coef = cmd.get_float("current_coef", 0.6f);
        return R"({"ok":true})";
    }
    else if (action == "physics") {
        physics_.enabled = cmd.get_bool("enabled", true);
        if (cmd.has("gravity") && cmd["gravity"].is_array() && cmd["gravity"].size() == 3) {
            physics_.gravity = readVec3(cmd, "gravity");
        }
        if (cmd.has("iterations"))
            physics_.solver_iterations = std::max(1, cmd.get_int("iterations", 8));
        return R"({"ok":true})";
    }
    else if (action == "body") {
        std::string id = cmd.get_string("id", "");
        auto it = namedObjects_.find(id);
        if (it == namedObjects_.end())
            return R"({"ok":false,"error":"no such object"})";

        // Replace if a body already exists for this id.
        auto bh = bodyHandleByName_.find(id);
        if (bh != bodyHandleByName_.end()) physics_.RemoveBody(bh->second);

        phys::Body body;
        std::string shape = cmd.get_string("shape", "");
        glm::vec3 scl = it->second->GetScale();
        if (shape == "sphere") {
            body.shape = phys::Shape::Sphere;
            body.half_extents = glm::vec3(std::max({scl.x, scl.y, scl.z}));
        } else if (shape == "box" || shape == "cube") {
            body.shape = phys::Shape::Box;
            body.half_extents = 0.5f * glm::abs(scl);
        } else if (shape == "plane") {
            body.shape = phys::Shape::Plane;
            body.plane_normal = glm::vec3(0, 1, 0);
            if (cmd.has("normal")) body.plane_normal = glm::normalize(readVec3(cmd, "normal"));
            body.plane_offset = cmd.get_float("offset", it->second->GetPosition().y);
            body.is_static = true;
        } else {
            return R"({"ok":false,"error":"shape must be sphere|box|plane"})";
        }

        body.position = it->second->GetPosition();
        // GameObject uses Euler degrees; convert to quat for physics.
        glm::vec3 r = it->second->GetRotation();
        glm::quat qx = glm::angleAxis(glm::radians(r.x), glm::vec3(1, 0, 0));
        glm::quat qy = glm::angleAxis(glm::radians(r.y), glm::vec3(0, 1, 0));
        glm::quat qz = glm::angleAxis(glm::radians(r.z), glm::vec3(0, 0, 1));
        body.orientation = qx * qy * qz;

        float mass = cmd.get_float("mass", 1.0f);
        body.is_static = cmd.get_bool("static", body.is_static);
        body.inv_mass = (body.is_static || mass <= 0.0f) ? 0.0f : 1.0f / mass;
        body.restitution     = cmd.get_float("restitution", 0.2f);
        body.friction        = cmd.get_float("friction",    0.5f);
        body.linear_damping  = cmd.get_float("linear_damping",  0.02f);
        body.angular_damping = cmd.get_float("angular_damping", 0.05f);

        int handle = physics_.AddBody(body);
        bodyHandleByName_[id] = handle;

        if (!physics_.enabled) physics_.enabled = true;
        std::ostringstream ok;
        ok << R"({"ok":true,"handle":)" << handle << "}";
        return ok.str();
    }
    else if (action == "impulse") {
        std::string id = cmd.get_string("id", "");
        auto it = bodyHandleByName_.find(id);
        if (it == bodyHandleByName_.end()) return R"({"ok":false,"error":"no body"})";
        phys::Body* b = physics_.Get(it->second);
        if (!b || b->inv_mass == 0.0f) return R"({"ok":false,"error":"static/dead"})";
        glm::vec3 J = readVec3(cmd, "impulse");
        b->linear_velocity += J * b->inv_mass;
        if (cmd.has("point")) {
            glm::vec3 p = readVec3(cmd, "point");
            glm::vec3 r = p - b->position;
            b->angular_velocity += b->inv_inertia_world * glm::cross(r, J);
        }
        b->sleeping = false;
        b->sleep_timer = 0.0f;
        return R"({"ok":true})";
    }
    else if (action == "velocity") {
        std::string id = cmd.get_string("id", "");
        auto it = bodyHandleByName_.find(id);
        if (it == bodyHandleByName_.end()) return R"({"ok":false,"error":"no body"})";
        phys::Body* b = physics_.Get(it->second);
        if (!b) return R"({"ok":false,"error":"dead"})";
        if (cmd.has("linear"))  b->linear_velocity  = readVec3(cmd, "linear");
        if (cmd.has("angular")) b->angular_velocity = readVec3(cmd, "angular");
        b->sleeping = false;
        b->sleep_timer = 0.0f;
        return R"({"ok":true})";
    }
    else if (action == "remove_body") {
        std::string id = cmd.get_string("id", "");
        auto it = bodyHandleByName_.find(id);
        if (it == bodyHandleByName_.end()) return R"({"ok":false,"error":"no body"})";
        physics_.RemoveBody(it->second);
        bodyHandleByName_.erase(it);
        return R"({"ok":true})";
    }

    return R"({"ok":false,"error":"unknown action"})";
}

void Engine::SyncPhysicsWriteback() {
    // For every body whose name we track, write updated transform to the object.
    for (auto& [name, handle] : bodyHandleByName_) {
        const phys::Body* b = physics_.Get(handle);
        if (!b || b->is_static) continue;
        auto it = namedObjects_.find(name);
        if (it == namedObjects_.end()) continue;
        it->second->SetPosition(b->position);

        // quat → Euler(deg) in X-then-Y-then-Z intrinsic, matching GetModelMatrix.
        glm::vec3 e = glm::eulerAngles(b->orientation);
        it->second->SetRotation(glm::degrees(e));
    }
}

void Engine::Run() {
    // Start agent API server
    agentApi_.start(9090);

    if (!OnInitialize()) return;

    last_time_ = (float)glfwGetTime();

    
    while (!glfwWindowShouldClose(window_)) {
        float current_time = (float)glfwGetTime();
        float delta_time = current_time - last_time_;
        last_time_ = current_time;

        // <-- Record performance here -->
        perf_times[perf_pos] = delta_time * 1000.0f;
        perf_pos = (perf_pos + 1) % PERF_HISTORY;
        if (perf_count < PERF_HISTORY) ++perf_count;

        Input::Update();       // Clear previous frame's input state
        glfwPollEvents();      // Callbacks fill in this frame's state

        // Tilde (~) toggles perf overlay
        if (Input::IsKeyPressed(GLFW_KEY_GRAVE_ACCENT))
            show_overlay_ = !show_overlay_;

        // Process AI agent commands
        processAgentCommands();

        OnUpdate(delta_time);

        if (physics_.enabled) {
            // Sync time-dependent water + weather state into physics so
            // buoyancy samples the same wave surface the shader draws.
            physics_.water_time      = (float)glfwGetTime();
            physics_.water_amp_scale = AmpScale();
            physics_.wind_dir_xz     = wind_dir_xz_;
            physics_.wind_speed      = wind_speed_;
            physics_.Step(delta_time);
            SyncPhysicsWriteback();
        }

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

       

        OnRender();  // Calls RenderScene() and now RenderOverlay()
        glfwSwapBuffers(window_);
    }
    agentApi_.stop();
    OnShutdown();
}



std::shared_ptr<Primitive> Engine::CreateOBJModel(const std::string& objPath, const std::string& texturePath) {
    return std::make_shared<OBJModelPrimitive>(objPath, texturePath);
}


void Engine::RenderScene() {
    glUseProgram(shader_program_);
    
    // Set up matrices
    glm::mat4 view = glm::lookAt(camera_position_, camera_target_, camera_up_);
    glm::mat4 projection = glm::perspective(glm::radians(60.0f), (float)width_ / height_, 0.1f, 100.0f);
    
    GLint viewLoc = glGetUniformLocation(shader_program_, "uView");
    GLint projLoc = glGetUniformLocation(shader_program_, "uProjection");
    GLint modelLoc = glGetUniformLocation(shader_program_, "uModel");
    
    glUniformMatrix4fv(viewLoc, 1, GL_FALSE, &view[0][0]);
    glUniformMatrix4fv(projLoc, 1, GL_FALSE, &projection[0][0]);
    
    // Render all game objects
    for (const auto& obj : game_objects_) {
        if (obj) {
            glm::mat4 model = obj->GetModelMatrix();
            glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &model[0][0]);
            obj->Render();
        }
    }
    
    glUseProgram(0);
}

// Primitive factory methods
std::shared_ptr<Primitive> Engine::CreateCube(const glm::vec3& color) {
    return std::make_shared<CubePrimitive>(color);
}

std::shared_ptr<Primitive> Engine::CreateSphere(int segments, const glm::vec3& color) {
    return std::make_shared<SpherePrimitive>(segments, color);
}

std::shared_ptr<Primitive> Engine::CreatePlane(float width, float height, const glm::vec3& color) {
    return std::make_shared<PlanePrimitive>(width, height, color);
}

std::shared_ptr<Primitive> Engine::CreateGrid(int size, float spacing, const glm::vec3& color) {
    return std::make_shared<GridPrimitive>(size, spacing, color);
}

std::shared_ptr<Primitive> Engine::CreateCylinder(float radius, float height, int segments, const glm::vec3& color) {
    return std::make_shared<CylinderPrimitive>(radius, height, segments, color);
}

std::shared_ptr<Primitive> Engine::CreateCone(float radius, float height, int segments, const glm::vec3& color) {
    return std::make_shared<ConePrimitive>(radius, height, segments, color);
}

std::shared_ptr<Primitive> Engine::CreateTerrain(int resolution, float size, float maxHeight, const glm::vec3& baseColor) {
    return std::make_shared<TerrainPrimitive>(resolution, size, maxHeight, baseColor);
}

// GameObject management
std::shared_ptr<GameObject> Engine::CreateGameObject(std::shared_ptr<Primitive> primitive, const glm::vec3& position) {
    return std::make_shared<GameObject>(primitive, position);
}

void Engine::AddGameObject(std::shared_ptr<GameObject> obj) {
    game_objects_.push_back(obj);
}

void Engine::RemoveGameObject(std::shared_ptr<GameObject> obj) {
    game_objects_.erase(std::remove(game_objects_.begin(), game_objects_.end(), obj), game_objects_.end());
}

// Camera control
void Engine::SetCameraPosition(const glm::vec3& position) {
    camera_position_ = position;
}

void Engine::SetCameraTarget(const glm::vec3& target) {
    camera_target_ = target;
}

void Engine::SetCameraOrbit(float distance, float angle, float height) {
    camera_position_ = glm::vec3(
        std::cos(glm::radians(angle)) * distance,
        height,
        std::sin(glm::radians(angle)) * distance
    );
}

// ---- Sky ----
void Engine::InitSky() {
    const char* vs = R"(#version 410 core
        out vec2 vNDC;
        void main() {
            // Fullscreen triangle trick
            vec2 p = vec2((gl_VertexID == 1) ? 3.0 : -1.0,
                          (gl_VertexID == 2) ? 3.0 : -1.0);
            vNDC = p;
            gl_Position = vec4(p, 1.0, 1.0);
        }
    )";
    const char* fs = R"(#version 410 core
        in vec2 vNDC;
        out vec4 FragColor;
        uniform mat4 uInvViewProj;
        uniform vec3 uCamPos;
        uniform vec3 uSunDir;
        uniform vec3 uSunColor;
        uniform vec3 uZenith;
        uniform vec3 uHorizon;
        uniform vec3 uGround;
        uniform float uStorminess;
        void main() {
            vec4 near4 = uInvViewProj * vec4(vNDC, -1.0, 1.0);
            vec4 far4  = uInvViewProj * vec4(vNDC,  1.0, 1.0);
            vec3 dir = normalize((far4.xyz / far4.w) - (near4.xyz / near4.w));

            vec3 zen = mix(uZenith,  uZenith  * 0.25, uStorminess);
            vec3 hor = mix(uHorizon, uHorizon * 0.50, uStorminess);
            vec3 gnd = mix(uGround,  uGround  * 0.80, uStorminess);

            float h = dir.y;
            float t = smoothstep(0.0, 0.45, h);
            vec3 sky = mix(hor, zen, t);
            if (h < 0.0) {
                float g = smoothstep(0.0, -0.2, h);
                sky = mix(hor, gnd, g);
            }

            // Sun: disk + halo, both attenuated by storminess
            float cosA = dot(dir, uSunDir);
            float disk = smoothstep(0.9985, 0.9995, cosA);
            float halo = pow(max(cosA, 0.0), 180.0) * 0.7;
            float atten = 1.0 - uStorminess * 0.85;
            sky += uSunColor * atten * (disk * 6.0 + halo);

            FragColor = vec4(sky, 1.0);
        }
    )";
    auto compile = [](GLenum type, const char* src) {
        GLuint s = glCreateShader(type);
        glShaderSource(s, 1, &src, nullptr);
        glCompileShader(s);
        GLint ok = 0; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
        if (!ok) {
            char log[512]; glGetShaderInfoLog(s, 512, nullptr, log);
            std::cerr << "sky shader: " << log << std::endl;
        }
        return s;
    };
    GLuint v = compile(GL_VERTEX_SHADER, vs);
    GLuint f = compile(GL_FRAGMENT_SHADER, fs);
    sky_program_ = glCreateProgram();
    glAttachShader(sky_program_, v);
    glAttachShader(sky_program_, f);
    glLinkProgram(sky_program_);
    glDeleteShader(v); glDeleteShader(f);

    glGenVertexArrays(1, &sky_vao_);  // empty VAO — we use gl_VertexID
}

void Engine::RenderSky(const glm::mat4& view, const glm::mat4& proj) {
    if (sky_program_ == 0) InitSky();
    glDepthMask(GL_FALSE);
    glDisable(GL_DEPTH_TEST);
    glUseProgram(sky_program_);

    // Strip camera translation from the view matrix so the sky stays
    // anchored to infinity regardless of where the camera walks.
    glm::mat4 sky_view = glm::mat4(glm::mat3(view));
    glm::mat4 inv = glm::inverse(proj * sky_view);
    glUniformMatrix4fv(glGetUniformLocation(sky_program_, "uInvViewProj"), 1, GL_FALSE, &inv[0][0]);
    glUniform3fv(glGetUniformLocation(sky_program_, "uCamPos"),  1, &camera_position_[0]);
    glUniform3fv(glGetUniformLocation(sky_program_, "uSunDir"),  1, &sun_dir_[0]);
    glUniform3fv(glGetUniformLocation(sky_program_, "uSunColor"),1, &sun_color_[0]);
    glUniform3fv(glGetUniformLocation(sky_program_, "uZenith"),  1, &zenith_color_[0]);
    glUniform3fv(glGetUniformLocation(sky_program_, "uHorizon"), 1, &horizon_color_[0]);
    glUniform3fv(glGetUniformLocation(sky_program_, "uGround"),  1, &ground_color_[0]);
    glUniform1f(glGetUniformLocation(sky_program_, "uStorminess"), storminess_);

    glBindVertexArray(sky_vao_);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glBindVertexArray(0);

    glEnable(GL_DEPTH_TEST);
    glDepthMask(GL_TRUE);
}

// ---- HDR + Bloom + Tone map ----
void Engine::InitPostShaders() {
    auto compile = [](GLenum type, const char* src) {
        GLuint s = glCreateShader(type);
        glShaderSource(s, 1, &src, nullptr);
        glCompileShader(s);
        GLint ok = 0; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
        if (!ok) { char log[1024]; glGetShaderInfoLog(s, 1024, nullptr, log);
                   std::cerr << "post shader: " << log << std::endl; }
        return s;
    };
    auto link = [&](const char* vs, const char* fs) {
        GLuint v = compile(GL_VERTEX_SHADER, vs);
        GLuint f = compile(GL_FRAGMENT_SHADER, fs);
        GLuint p = glCreateProgram();
        glAttachShader(p, v); glAttachShader(p, f);
        glLinkProgram(p);
        glDeleteShader(v); glDeleteShader(f);
        return p;
    };

    // Shared fullscreen-triangle vertex shader
    const char* fs_vs = R"(#version 410 core
        out vec2 vUV;
        void main() {
            vec2 p = vec2((gl_VertexID == 1) ? 3.0 : -1.0,
                          (gl_VertexID == 2) ? 3.0 : -1.0);
            vUV = p * 0.5 + 0.5;
            gl_Position = vec4(p, 0.0, 1.0);
        }
    )";

    // Bright-pass extract
    const char* extract_fs = R"(#version 410 core
        in vec2 vUV;
        out vec4 FragColor;
        uniform sampler2D uScene;
        uniform float uThreshold;
        void main() {
            vec3 c = texture(uScene, vUV).rgb;
            float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
            float k = max(luma - uThreshold, 0.0) / max(luma, 1e-4);
            FragColor = vec4(c * k, 1.0);
        }
    )";

    // Separable 9-tap Gaussian blur
    const char* blur_fs = R"(#version 410 core
        in vec2 vUV;
        out vec4 FragColor;
        uniform sampler2D uTex;
        uniform vec2 uTexel;      // 1 / size
        uniform int  uHorizontal; // 1 = horizontal, 0 = vertical
        const float w[5] = float[5](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);
        void main() {
            vec2 step = (uHorizontal == 1) ? vec2(uTexel.x, 0.0) : vec2(0.0, uTexel.y);
            vec3 sum = texture(uTex, vUV).rgb * w[0];
            for (int i = 1; i < 5; ++i) {
                sum += texture(uTex, vUV + step * float(i)).rgb * w[i];
                sum += texture(uTex, vUV - step * float(i)).rgb * w[i];
            }
            FragColor = vec4(sum, 1.0);
        }
    )";

    // Composite: HDR + bloom -> ACES -> gamma -> backbuffer
    const char* composite_fs = R"(#version 410 core
        in vec2 vUV;
        out vec4 FragColor;
        uniform sampler2D uHDR;
        uniform sampler2D uBloom;
        uniform float uExposure;
        uniform float uBloomIntensity;

        // Narkowicz ACES approximation
        vec3 aces(vec3 x) {
            const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
            return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
        }

        void main() {
            vec3 hdr   = texture(uHDR,   vUV).rgb;
            vec3 bloom = texture(uBloom, vUV).rgb;
            vec3 c = hdr + bloom * uBloomIntensity;
            c *= uExposure;
            c = aces(c);
            c = pow(c, vec3(1.0 / 2.2));
            FragColor = vec4(c, 1.0);
        }
    )";

    bloom_extract_program_ = link(fs_vs, extract_fs);
    bloom_blur_program_    = link(fs_vs, blur_fs);
    composite_program_     = link(fs_vs, composite_fs);

    if (fullscreen_vao_ == 0) glGenVertexArrays(1, &fullscreen_vao_);
}

void Engine::EnsurePostFBOs() {
    if (post_w_ == width_ && post_h_ == height_ && hdr_fbo_ != 0) return;
    // Delete old
    if (hdr_color_)    glDeleteTextures(1, &hdr_color_);
    if (hdr_depth_rb_) glDeleteRenderbuffers(1, &hdr_depth_rb_);
    if (hdr_fbo_)      glDeleteFramebuffers(1, &hdr_fbo_);
    for (int i = 0; i < 2; ++i) {
        if (bloom_tex_[i]) glDeleteTextures(1, &bloom_tex_[i]);
        if (bloom_fbo_[i]) glDeleteFramebuffers(1, &bloom_fbo_[i]);
    }
    hdr_color_ = hdr_depth_rb_ = hdr_fbo_ = 0;
    hdr_fbo_ms_ = hdr_color_ms_ = 0;  // unused in single-sample mode
    for (int i = 0; i < 2; ++i) bloom_tex_[i] = bloom_fbo_[i] = 0;

    post_w_ = width_; post_h_ = height_;
    bloom_w_ = std::max(1, width_ / 2);
    bloom_h_ = std::max(1, height_ / 2);

    // Single-sample HDR render target.
    glGenTextures(1, &hdr_color_);
    glBindTexture(GL_TEXTURE_2D, hdr_color_);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, post_w_, post_h_, 0,
                 GL_RGBA, GL_FLOAT, nullptr);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

    glGenRenderbuffers(1, &hdr_depth_rb_);
    glBindRenderbuffer(GL_RENDERBUFFER, hdr_depth_rb_);
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, post_w_, post_h_);

    glGenFramebuffers(1, &hdr_fbo_);
    glBindFramebuffer(GL_FRAMEBUFFER, hdr_fbo_);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                           GL_TEXTURE_2D, hdr_color_, 0);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                              GL_RENDERBUFFER, hdr_depth_rb_);
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
        std::cerr << "HDR FBO incomplete\n";

    // Bloom ping-pong at half resolution
    for (int i = 0; i < 2; ++i) {
        glGenTextures(1, &bloom_tex_[i]);
        glBindTexture(GL_TEXTURE_2D, bloom_tex_[i]);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, bloom_w_, bloom_h_, 0,
                     GL_RGBA, GL_FLOAT, nullptr);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        glGenFramebuffers(1, &bloom_fbo_[i]);
        glBindFramebuffer(GL_FRAMEBUFFER, bloom_fbo_[i]);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                               GL_TEXTURE_2D, bloom_tex_[i], 0);
        if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
            std::cerr << "Bloom FBO incomplete\n";
    }

    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

void Engine::InitPostFBOs() {
    InitPostShaders();
    EnsurePostFBOs();
}

void Engine::RenderBloom() {
    if (hdr_fbo_ == 0) return;
    glDisable(GL_DEPTH_TEST);
    glBindVertexArray(fullscreen_vao_);

    // 1. Bright-pass extract: hdr -> bloom[0]
    glBindFramebuffer(GL_FRAMEBUFFER, bloom_fbo_[0]);
    glViewport(0, 0, bloom_w_, bloom_h_);
    glUseProgram(bloom_extract_program_);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, hdr_color_);
    glUniform1i(glGetUniformLocation(bloom_extract_program_, "uScene"), 0);
    glUniform1f(glGetUniformLocation(bloom_extract_program_, "uThreshold"), bloom_threshold_);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // 2. Blur horizontal: bloom[0] -> bloom[1]
    glBindFramebuffer(GL_FRAMEBUFFER, bloom_fbo_[1]);
    glUseProgram(bloom_blur_program_);
    glBindTexture(GL_TEXTURE_2D, bloom_tex_[0]);
    glUniform1i(glGetUniformLocation(bloom_blur_program_, "uTex"), 0);
    glUniform2f(glGetUniformLocation(bloom_blur_program_, "uTexel"),
                1.0f / bloom_w_, 1.0f / bloom_h_);
    glUniform1i(glGetUniformLocation(bloom_blur_program_, "uHorizontal"), 1);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // 3. Blur vertical: bloom[1] -> bloom[0]
    glBindFramebuffer(GL_FRAMEBUFFER, bloom_fbo_[0]);
    glBindTexture(GL_TEXTURE_2D, bloom_tex_[1]);
    glUniform1i(glGetUniformLocation(bloom_blur_program_, "uHorizontal"), 0);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    glEnable(GL_DEPTH_TEST);
}

void Engine::CompositeToScreen() {
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    glViewport(0, 0, width_, height_);
    glDisable(GL_DEPTH_TEST);
    glUseProgram(composite_program_);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, hdr_color_);
    glUniform1i(glGetUniformLocation(composite_program_, "uHDR"), 0);
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, bloom_tex_[0]);
    glUniform1i(glGetUniformLocation(composite_program_, "uBloom"), 1);
    glUniform1f(glGetUniformLocation(composite_program_, "uExposure"), exposure_);
    glUniform1f(glGetUniformLocation(composite_program_, "uBloomIntensity"), bloom_intensity_);
    glActiveTexture(GL_TEXTURE0);
    glBindVertexArray(fullscreen_vao_);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glEnable(GL_DEPTH_TEST);
}

// ---- Shadow map ----
void Engine::InitShadowMap() {
    glGenFramebuffers(1, &shadow_fbo_);
    glGenTextures(1, &shadow_map_);
    glBindTexture(GL_TEXTURE_2D, shadow_map_);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24,
                 SHADOW_SIZE, SHADOW_SIZE, 0,
                 GL_DEPTH_COMPONENT, GL_FLOAT, nullptr);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
    float border[] = {1.0f, 1.0f, 1.0f, 1.0f};
    glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border);
    // Enable hardware PCF: texture lookup returns comparison result vs ref.
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

    glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo_);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                           GL_TEXTURE_2D, shadow_map_, 0);
    glDrawBuffer(GL_NONE);
    glReadBuffer(GL_NONE);
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
        std::cerr << "Shadow FBO incomplete\n";
    glBindFramebuffer(GL_FRAMEBUFFER, 0);

    const char* vs = R"(#version 410 core
        layout(location = 0) in vec3 aPos;
        layout(location = 6) in mat4 aInstanceModel;
        uniform mat4 uLightVP;
        void main() { gl_Position = uLightVP * aInstanceModel * vec4(aPos, 1.0); }
    )";
    const char* fs = R"(#version 410 core
        void main() {}
    )";
    auto compile = [](GLenum type, const char* src) {
        GLuint s = glCreateShader(type);
        glShaderSource(s, 1, &src, nullptr);
        glCompileShader(s);
        GLint ok = 0; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
        if (!ok) { char log[512]; glGetShaderInfoLog(s, 512, nullptr, log);
                   std::cerr << "shadow shader: " << log << std::endl; }
        return s;
    };
    GLuint v = compile(GL_VERTEX_SHADER, vs);
    GLuint f = compile(GL_FRAGMENT_SHADER, fs);
    shadow_program_ = glCreateProgram();
    glAttachShader(shadow_program_, v);
    glAttachShader(shadow_program_, f);
    glLinkProgram(shadow_program_);
    glDeleteShader(v); glDeleteShader(f);
}

glm::mat4 Engine::ComputeLightVP() const {
    // Frustum centered on camera target along the ground; tracks the player.
    glm::vec3 target(camera_target_.x, 0.0f, camera_target_.z);
    glm::vec3 eye = target + sun_dir_ * 60.0f;
    glm::vec3 up  = std::abs(sun_dir_.y) > 0.95f
                    ? glm::vec3(0, 0, 1) : glm::vec3(0, 1, 0);
    glm::mat4 view = glm::lookAt(eye, target, up);
    float h = shadow_extent_;
    glm::mat4 proj = glm::ortho(-h, h, -h, h, 1.0f, 200.0f);
    return proj * view;
}

void Engine::RenderShadowPass() {
    if (shadow_fbo_ == 0) InitShadowMap();

    glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo_);
    glViewport(0, 0, SHADOW_SIZE, SHADOW_SIZE);
    glClear(GL_DEPTH_BUFFER_BIT);
    glEnable(GL_DEPTH_TEST);
    glEnable(GL_POLYGON_OFFSET_FILL);
    glPolygonOffset(2.0f, 4.0f);

    glUseProgram(shadow_program_);
    glUniformMatrix4fv(glGetUniformLocation(shadow_program_, "uLightVP"),
                       1, GL_FALSE, &light_vp_[0][0]);

    // Group all game objects by primitive (no culling — sun frustum).
    std::unordered_map<Primitive*, std::vector<glm::mat4>> batches;
    for (auto& sp : game_objects_)
        batches[sp->GetPrimitive()].push_back(sp->GetModelMatrix());

    static GLuint shadowInstVBO = 0;
    if (!shadowInstVBO) glGenBuffers(1, &shadowInstVBO);

    for (auto& [prim, models] : batches) {
        if (models.empty()) continue;
        glBindBuffer(GL_ARRAY_BUFFER, shadowInstVBO);
        glBufferData(GL_ARRAY_BUFFER, models.size() * sizeof(glm::mat4),
                     models.data(), GL_DYNAMIC_DRAW);
        prim->Bind();
        glBindBuffer(GL_ARRAY_BUFFER, shadowInstVBO);
        for (int i = 0; i < 4; ++i) {
            int loc = 6 + i;
            glEnableVertexAttribArray(loc);
            glVertexAttribPointer(loc, 4, GL_FLOAT, GL_FALSE,
                                  sizeof(glm::mat4), (void*)(sizeof(float) * 4 * i));
            glVertexAttribDivisor(loc, 1);
        }
        if (prim->GetIndexCount() > 0)
            glDrawElementsInstanced(prim->GetDrawMode(), prim->GetIndexCount(),
                                    GL_UNSIGNED_INT, 0, (GLsizei)models.size());
        else
            glDrawArraysInstanced(prim->GetDrawMode(), 0, prim->GetVertexCount(),
                                  (GLsizei)models.size());
        for (int i = 0; i < 4; ++i) glVertexAttribDivisor(6 + i, 0);
    }

    glDisable(GL_POLYGON_OFFSET_FILL);
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

// ---- Water ----
void Engine::InitWater() {
    const char* vs = R"(#version 410 core
        layout(location = 0) in vec2 aXZ;
        uniform mat4 uView;
        uniform mat4 uProjection;
        uniform float uTime;
        uniform float uLevel;
        uniform float uAmplitude;
        uniform float uWavelength;
        uniform float uSpeed;
        uniform float uAmpScale;
        uniform float uChopScale;

        out vec3 vWorldPos;
        out vec3 vNormal;
        out float vFoam;

        // 10 Gerstner wave trains — broad wavelength spread + golden-angle
        // directions so the sum doesn't form a visible tile. KEEP IN SYNC
        // with phys::World::SampleWaterY in Physics.cpp.
        const vec2 DIRS[10] = vec2[10](
            vec2( 1.00,  0.00),
            vec2( 0.71, -0.71),
            vec2(-0.41,  0.91),
            vec2(-0.85, -0.52),
            vec2( 0.30,  0.95),
            vec2( 0.88,  0.47),
            vec2(-0.94,  0.33),
            vec2( 0.60, -0.80),
            vec2(-0.15, -0.99),
            vec2( 0.43,  0.90)
        );
        const float AMP_SCALE[10]  = float[10](
            0.70, 0.46, 0.32, 0.25, 0.18, 0.14, 0.11, 0.08, 0.06, 0.05);
        const float LEN_SCALE[10]  = float[10](
            1.00, 0.77, 0.59, 0.46, 0.36, 0.28, 0.22, 0.17, 0.13, 0.10);
        const float SPEED_SCALE[10]= float[10](
            1.00, 1.23, 0.82, 1.37, 0.91, 1.19, 0.75, 1.48, 1.07, 0.88);
        const float Q[10] = float[10](
            0.50, 0.45, 0.40, 0.36, 0.32, 0.28, 0.24, 0.20, 0.17, 0.14);

        void main() {
            vec3 p = vec3(aXZ.x, uLevel, aXZ.y);
            vec3 tangent = vec3(1, 0, 0);
            vec3 binorm  = vec3(0, 0, 1);
            float height_accum = 0.0;
            for (int i = 0; i < 10; ++i) {
                vec2 d = normalize(DIRS[i]);
                float L = uWavelength * LEN_SCALE[i];
                float w = 6.28318530718 / L;
                float A = uAmplitude * AMP_SCALE[i] * uAmpScale;
                float phi = uTime * uSpeed * SPEED_SCALE[i] * sqrt(9.81 * w);
                float theta = dot(d, vec2(p.x, p.z)) * w + phi;
                float s = sin(theta);
                float c = cos(theta);

                // Gerstner: horizontal drag in wave direction, vertical rise
                float QA = Q[i] * uChopScale * A;
                p.x += QA * d.x * c;
                p.z += QA * d.y * c;
                p.y += A * s;
                height_accum += A * s;

                // Partial derivatives for normal
                float wa = w * A;
                tangent += vec3(-Q[i] * d.x * d.x * wa * s,
                                 d.x * wa * c,
                                -Q[i] * d.x * d.y * wa * s);
                binorm  += vec3(-Q[i] * d.x * d.y * wa * s,
                                 d.y * wa * c,
                                -Q[i] * d.y * d.y * wa * s);
            }
            vec3 N = normalize(cross(binorm, tangent));
            if (N.y < 0.0) N = -N;  // face up

            vWorldPos = p;
            vNormal = N;
            vFoam = height_accum;
            gl_Position = uProjection * uView * vec4(p, 1.0);
        }
    )";
    const char* fs = R"(#version 410 core
        in vec3 vWorldPos;
        in vec3 vNormal;
        in float vFoam;
        out vec4 FragColor;
        uniform vec3 uViewPos;
        uniform vec3 uSunDir;
        uniform vec3 uSunColor;
        uniform vec3 uZenith;
        uniform vec3 uHorizon;
        uniform vec3 uShallow;
        uniform vec3 uDeep;
        uniform float uAmplitude;
        uniform float uAmpScale;

        void main() {
            vec3 N = normalize(vNormal);
            vec3 V = normalize(uViewPos - vWorldPos);

            // Fresnel (Schlick)
            float ndv = clamp(dot(N, V), 0.0, 1.0);
            float F0 = 0.02;
            float fres = F0 + (1.0 - F0) * pow(1.0 - ndv, 5.0);

            // Water body color: shallow where facing viewer, deep at grazing.
            vec3 body = mix(uDeep, uShallow, ndv);

            // Reflected sky direction
            vec3 R = reflect(-V, N);
            float up = clamp(R.y, 0.0, 1.0);
            vec3 sky = mix(uHorizon, uZenith, smoothstep(0.0, 0.6, up));

            // Sun specular
            vec3 H = normalize(uSunDir + V);
            float spec = pow(max(dot(N, H), 0.0), 180.0);
            vec3 sun_hi = uSunColor * spec * 3.0;

            vec3 color = mix(body, sky, fres) + sun_hi;

            // Foam on wave crests: fade in near max wave height.
            float peakH = uAmplitude * uAmpScale * 1.2;
            float foam  = smoothstep(peakH * 0.55, peakH * 1.05, vFoam);
            color = mix(color, vec3(0.95, 0.97, 1.0), foam * 0.8);

            FragColor = vec4(color, 0.92);
        }
    )";
    auto compile = [](GLenum type, const char* src) {
        GLuint s = glCreateShader(type);
        glShaderSource(s, 1, &src, nullptr);
        glCompileShader(s);
        GLint ok = 0; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
        if (!ok) { char log[512]; glGetShaderInfoLog(s, 512, nullptr, log);
                   std::cerr << "water shader: " << log << std::endl; }
        return s;
    };
    GLuint v = compile(GL_VERTEX_SHADER, vs);
    GLuint f = compile(GL_FRAGMENT_SHADER, fs);
    water_program_ = glCreateProgram();
    glAttachShader(water_program_, v);
    glAttachShader(water_program_, f);
    glLinkProgram(water_program_);
    glDeleteShader(v); glDeleteShader(f);
}

void Engine::RebuildWaterMesh(int resolution) {
    if (water_vao_ == 0) {
        glGenVertexArrays(1, &water_vao_);
        glGenBuffers(1, &water_vbo_);
        glGenBuffers(1, &water_ebo_);
    }

    int N = std::max(8, resolution);
    float half = water_size_ * 0.5f;
    float step = water_size_ / (float)N;

    std::vector<float> verts;
    verts.reserve((N + 1) * (N + 1) * 2);
    for (int j = 0; j <= N; ++j) {
        for (int i = 0; i <= N; ++i) {
            verts.push_back(-half + i * step);
            verts.push_back(-half + j * step);
        }
    }
    std::vector<unsigned int> idx;
    idx.reserve(N * N * 6);
    for (int j = 0; j < N; ++j) {
        for (int i = 0; i < N; ++i) {
            unsigned int a = j * (N + 1) + i;
            unsigned int b = a + 1;
            unsigned int c = a + (N + 1);
            unsigned int d = c + 1;
            idx.push_back(a); idx.push_back(c); idx.push_back(b);
            idx.push_back(b); idx.push_back(c); idx.push_back(d);
        }
    }
    water_index_count_ = (int)idx.size();

    glBindVertexArray(water_vao_);
    glBindBuffer(GL_ARRAY_BUFFER, water_vbo_);
    glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, water_ebo_);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, idx.size() * sizeof(unsigned int), idx.data(), GL_STATIC_DRAW);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
    glBindVertexArray(0);
}

void Engine::RebuildSecWaterMesh(int resolution) {
    if (sec_water_.vao == 0) {
        glGenVertexArrays(1, &sec_water_.vao);
        glGenBuffers(1, &sec_water_.vbo);
        glGenBuffers(1, &sec_water_.ebo);
    }
    int N = std::max(8, resolution);
    float half = sec_water_.size * 0.5f;
    float step = sec_water_.size / (float)N;
    std::vector<float> verts;
    verts.reserve((N + 1) * (N + 1) * 2);
    for (int j = 0; j <= N; ++j)
        for (int i = 0; i <= N; ++i) {
            verts.push_back(sec_water_.center.x - half + i * step);
            verts.push_back(sec_water_.center.z - half + j * step);
        }
    std::vector<unsigned int> idx;
    idx.reserve(N * N * 6);
    for (int j = 0; j < N; ++j)
        for (int i = 0; i < N; ++i) {
            unsigned int a = j * (N + 1) + i;
            unsigned int b = a + 1;
            unsigned int c = a + (N + 1);
            unsigned int d = c + 1;
            idx.push_back(a); idx.push_back(c); idx.push_back(b);
            idx.push_back(b); idx.push_back(c); idx.push_back(d);
        }
    sec_water_.idx_count = (int)idx.size();

    glBindVertexArray(sec_water_.vao);
    glBindBuffer(GL_ARRAY_BUFFER, sec_water_.vbo);
    glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sec_water_.ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, idx.size() * sizeof(unsigned int), idx.data(), GL_STATIC_DRAW);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
    glBindVertexArray(0);
}

void Engine::RenderSecWater(const glm::mat4& view, const glm::mat4& proj) {
    if (!sec_water_.enabled) return;
    if (water_program_ == 0) InitWater();
    if (sec_water_.idx_count == 0) RebuildSecWaterMesh(96);

    glUseProgram(water_program_);
    glUniformMatrix4fv(glGetUniformLocation(water_program_, "uView"),       1, GL_FALSE, &view[0][0]);
    glUniformMatrix4fv(glGetUniformLocation(water_program_, "uProjection"), 1, GL_FALSE, &proj[0][0]);
    glUniform1f(glGetUniformLocation(water_program_, "uTime"),       (float)glfwGetTime());
    glUniform1f(glGetUniformLocation(water_program_, "uLevel"),      sec_water_.level);
    glUniform1f(glGetUniformLocation(water_program_, "uAmplitude"),  sec_water_.amplitude);
    glUniform1f(glGetUniformLocation(water_program_, "uWavelength"), sec_water_.wavelength);
    glUniform1f(glGetUniformLocation(water_program_, "uSpeed"),      sec_water_.speed);
    glUniform1f(glGetUniformLocation(water_program_, "uAmpScale"),   1.0f);
    glUniform1f(glGetUniformLocation(water_program_, "uChopScale"),  1.0f);
    glUniform3fv(glGetUniformLocation(water_program_, "uViewPos"),  1, &camera_position_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uSunDir"),   1, &sun_dir_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uSunColor"), 1, &sun_color_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uZenith"),   1, &zenith_color_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uHorizon"),  1, &horizon_color_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uShallow"),  1, &sec_water_.shallow[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uDeep"),     1, &sec_water_.deep[0]);

    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glBindVertexArray(sec_water_.vao);
    glDrawElements(GL_TRIANGLES, sec_water_.idx_count, GL_UNSIGNED_INT, nullptr);
    glBindVertexArray(0);
    glDisable(GL_BLEND);
}

void Engine::RenderWater(const glm::mat4& view, const glm::mat4& proj) {
    if (!water_enabled_) return;
    if (water_program_ == 0) InitWater();
    if (water_index_count_ == 0) RebuildWaterMesh(128);

    glUseProgram(water_program_);
    glUniformMatrix4fv(glGetUniformLocation(water_program_, "uView"),       1, GL_FALSE, &view[0][0]);
    glUniformMatrix4fv(glGetUniformLocation(water_program_, "uProjection"), 1, GL_FALSE, &proj[0][0]);
    glUniform1f(glGetUniformLocation(water_program_, "uTime"),       (float)glfwGetTime());
    glUniform1f(glGetUniformLocation(water_program_, "uLevel"),      water_level_);
    glUniform1f(glGetUniformLocation(water_program_, "uAmplitude"),  water_amplitude_);
    glUniform1f(glGetUniformLocation(water_program_, "uWavelength"), water_wavelength_);
    glUniform1f(glGetUniformLocation(water_program_, "uSpeed"),      water_speed_);
    glUniform1f(glGetUniformLocation(water_program_, "uAmpScale"),   AmpScale());
    glUniform1f(glGetUniformLocation(water_program_, "uChopScale"),  ChopScale());
    glUniform3fv(glGetUniformLocation(water_program_, "uViewPos"),  1, &camera_position_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uSunDir"),   1, &sun_dir_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uSunColor"), 1, &sun_color_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uZenith"),   1, &zenith_color_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uHorizon"),  1, &horizon_color_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uShallow"),  1, &water_color_shallow_[0]);
    glUniform3fv(glGetUniformLocation(water_program_, "uDeep"),     1, &water_color_deep_[0]);

    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glBindVertexArray(water_vao_);
    glDrawElements(GL_TRIANGLES, water_index_count_, GL_UNSIGNED_INT, nullptr);
    glBindVertexArray(0);
    glDisable(GL_BLEND);
}

// ---- World serialization ----
namespace {
    void jsonEscape(std::ostringstream& o, const std::string& s) {
        o << '"';
        for (char c : s) {
            switch (c) {
                case '"':  o << "\\\""; break;
                case '\\': o << "\\\\"; break;
                case '\n': o << "\\n"; break;
                case '\r': o << "\\r"; break;
                case '\t': o << "\\t"; break;
                default:
                    if ((unsigned char)c < 0x20) {
                        char buf[8];
                        std::snprintf(buf, sizeof(buf), "\\u%04x", c);
                        o << buf;
                    } else o << c;
            }
        }
        o << '"';
    }

    void jsonWrite(std::ostringstream& o, const json::Value& v);

    void jsonWriteNumber(std::ostringstream& o, double n) {
        if (n == (double)(long long)n) o << (long long)n;
        else o << n;
    }

    void jsonWrite(std::ostringstream& o, const json::Value& v) {
        switch (v.type()) {
            case json::Value::Null:    o << "null"; break;
            case json::Value::Bool:    o << (v.as_bool() ? "true" : "false"); break;
            case json::Value::Number:  jsonWriteNumber(o, v.as_number()); break;
            case json::Value::String:  jsonEscape(o, v.as_string()); break;
            case json::Value::ArrayT: {
                o << '[';
                bool first = true;
                for (auto& e : v.as_array()) {
                    if (!first) o << ','; first = false;
                    jsonWrite(o, e);
                }
                o << ']';
                break;
            }
            case json::Value::ObjectT: {
                o << '{';
                bool first = true;
                for (auto& [k, val] : v.as_object()) {
                    if (!first) o << ','; first = false;
                    jsonEscape(o, k); o << ':';
                    jsonWrite(o, val);
                }
                o << '}';
                break;
            }
        }
    }

    void jsonVec3(std::ostringstream& o, const glm::vec3& v) {
        o << '[' << v.x << ',' << v.y << ',' << v.z << ']';
    }
}

std::string Engine::saveWorldToFile(const std::string& path) {
    namespace fs = std::filesystem;
    std::error_code ec;
    fs::create_directories(fs::path(path).parent_path(), ec);

    std::ostringstream o;
    o.precision(6);
    o << "{\n  \"version\":1,\n  \"clear_color\":";
    jsonVec3(o, clear_color_);
    o << ",\n  \"camera\":{\"position\":";
    jsonVec3(o, camera_position_);
    o << ",\"target\":";
    jsonVec3(o, camera_target_);
    o << "},\n  \"objects\":[";

    bool first = true;
    for (auto& [id, obj] : namedObjects_) {
        auto it = namedObjectCreationParams_.find(id);
        if (it == namedObjectCreationParams_.end()) continue;
        if (!first) o << ',';
        first = false;
        o << "\n    ";

        // Start with the original creation command, then overlay live
        // transform so saved file reflects current position/rotation/scale.
        json::Object merged = it->second.as_object();
        json::Array pos{{(double)obj->GetPosition().x},
                        {(double)obj->GetPosition().y},
                        {(double)obj->GetPosition().z}};
        json::Array rot{{(double)obj->GetRotation().x},
                        {(double)obj->GetRotation().y},
                        {(double)obj->GetRotation().z}};
        json::Array scl{{(double)obj->GetScale().x},
                        {(double)obj->GetScale().y},
                        {(double)obj->GetScale().z}};
        merged["position"] = json::Value(pos);
        merged["rotation"] = json::Value(rot);
        merged["scale"]    = json::Value(scl);
        jsonWrite(o, json::Value(merged));
    }
    o << "\n  ]\n}\n";

    std::ofstream f(path);
    if (!f) {
        std::ostringstream err;
        err << R"({"ok":false,"error":"cannot open )" << path << R"("})";
        return err.str();
    }
    f << o.str();
    f.close();

    std::ostringstream ok;
    ok << R"({"ok":true,"path":")" << path << R"(","objects":)"
       << namedObjects_.size() << "}";
    return ok.str();
}

std::string Engine::loadWorldFromFile(const std::string& path) {
    json::Value world;
    try {
        world = json::parseFile(path);
    } catch (const std::exception& e) {
        std::ostringstream err;
        err << R"({"ok":false,"error":"parse: )" << e.what() << R"("})";
        return err.str();
    }

    // Wipe current state before loading
    game_objects_.clear();
    namedObjects_.clear();
    namedObjectCreationParams_.clear();

    if (world.has("clear_color") && world["clear_color"].is_array()) {
        auto& a = world["clear_color"].as_array();
        if (a.size() == 3) {
            glm::vec3 c(a[0].as_float(), a[1].as_float(), a[2].as_float());
            clear_color_ = c;
            glClearColor(c.r, c.g, c.b, 1.0f);
        }
    }
    if (world.has("camera") && world["camera"].is_object()) {
        auto& cam = world["camera"];
        if (cam.has("position") && cam["position"].is_array() && cam["position"].size() == 3) {
            auto& a = cam["position"].as_array();
            SetCameraPosition(glm::vec3(a[0].as_float(), a[1].as_float(), a[2].as_float()));
        }
        if (cam.has("target") && cam["target"].is_array() && cam["target"].size() == 3) {
            auto& a = cam["target"].as_array();
            SetCameraTarget(glm::vec3(a[0].as_float(), a[1].as_float(), a[2].as_float()));
        }
    }
    if (world.has("objects") && world["objects"].is_array()) {
        for (auto& obj : world["objects"].as_array()) {
            // Each object is a fully-formed create command; just replay it.
            json::Object cmd = obj.as_object();
            cmd["action"] = json::Value(std::string("create"));
            processAgentCommand(json::Value(cmd));
        }
    }

    std::ostringstream ok;
    ok << R"({"ok":true,"path":")" << path << R"(","objects":)"
       << namedObjects_.size() << "}";
    return ok.str();
}
