#include "app_base.h"
#include "imgui.h"

#include "core/fullscreen_window.h"
#include "core/app_about.h"
#include "core/app_settings.h"
#include "core/panel_menu.h"
#include "core/button.h"
#include "core/tokens.h"
#include "core/icons_tabler.h"
#include "core/layout_storage.h"
#include "core/layouts_menu.h"

#include "viz/graph_types.h"
#include "viz/graph_viewport.h"
#include "viz/graph_renderer.h"
#include "viz/graph_force_layout.h"
#include "viz/graph_force_layout_gpu.h"
#include "viz/graph_layouts.h"
#include "viz/graph_labels.h"
#include "viz/graph_icons.h"
#include "viz/graph_sources.h"

#include "data.h"
#include "views.h"
#include "types_registry.h"
#include "layout_store.h"
#include "entity_ops.h"
#include "project_manager.h"
#include "jobs.h"
#include "enrichers.h"
#include "agent.h"
#include "extract_panel.h"

#include "../../../../cpp/vendor/sqlite3/sqlite3.h"

#include "node_groups.h"
#include "duckdb.h"

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <cmath>
#include <string>
#include <sys/stat.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <unordered_map>
#include <unordered_set>
#include <vector>

#ifndef _WIN32
#include <unistd.h>
#else
#include <direct.h>
#define getcwd _getcwd
#endif

// ----------------------------------------------------------------------------
// Estado global de la app
// ----------------------------------------------------------------------------

static GraphData              g_graph{};
static GraphViewportState     g_viewport;
static ge::AppState           g_app;

static ge::InputArgs          g_input;
static std::string            g_input_path;       // copia para que .uri sea estable
static std::string            g_types_path;
static std::string            g_layout_initial;   // --layout flag
static uint64_t               g_graph_hash = 0;
static bool                   g_loaded = false;

// Project state — paths derivados del proyecto activo. En modo legacy
// (--input/positional explicito), `g_active_project` queda vacio y los paths
// vienen del CLI directamente.
static std::string            g_active_project;
static std::string            g_layout_db_path;   // ruta de graph_explorer.db

// Force layout GPU context (lazy init).
static ForceLayoutGPU*        g_gpu_ctx   = nullptr;
static bool                   g_gpu_dirty = true;

// Layout storage (menu Layouts) — guardado/cargado de layouts ImGui en
// graph_explorer.db tabla imgui_layouts.
static fn_ui::LayoutStorage*  g_layout_storage = nullptr;
static fn_ui::LayoutCallbacks g_layout_cb{};

// ----------------------------------------------------------------------------
// Persistencia de paneles abiertos/cerrados
//
// Los toggles `panel_chat`, `panel_jobs`, etc. viven en AppState (RAM). Sin
// persistencia, al reabrir la app vuelven a sus defaults — el usuario tiene
// que reabrir manualmente cada panel cada vez.
//
// Tabla `panel_state(name TEXT PK, open INT, updated_at INT)` en la misma
// graph_explorer.db. load al arrancar, save al cerrar.
// ----------------------------------------------------------------------------

static void panel_state_ensure_table(sqlite3* db) {
    sqlite3_exec(db,
        "CREATE TABLE IF NOT EXISTS panel_state ("
        "  name TEXT PRIMARY KEY,"
        "  open INTEGER NOT NULL,"
        "  updated_at INTEGER NOT NULL)",
        nullptr, nullptr, nullptr);
}

static void panel_state_load_db(const std::string& db_path,
                                 fn_ui::PanelToggle* panels, size_t n) {
    if (db_path.empty()) return;
    sqlite3* db = nullptr;
    if (sqlite3_open_v2(db_path.c_str(), &db,
                         SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
                         nullptr) != SQLITE_OK) {
        if (db) sqlite3_close(db);
        return;
    }
    panel_state_ensure_table(db);
    sqlite3_stmt* st = nullptr;
    if (sqlite3_prepare_v2(db,
            "SELECT open FROM panel_state WHERE name = ?",
            -1, &st, nullptr) == SQLITE_OK) {
        int restored = 0;
        for (size_t i = 0; i < n; ++i) {
            if (!panels[i].open || !panels[i].label) continue;
            sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
            if (sqlite3_step(st) == SQLITE_ROW) {
                *panels[i].open = (sqlite3_column_int(st, 0) != 0);
                ++restored;
            }
            sqlite3_reset(st);
        }
        sqlite3_finalize(st);
        std::fprintf(stdout,
            "[graph_explorer] panel_state: restored %d/%zu panels\n",
            restored, n);
    }
    sqlite3_close(db);
}

static void panel_state_save_db(const std::string& db_path,
                                 const fn_ui::PanelToggle* panels, size_t n) {
    if (db_path.empty()) return;
    sqlite3* db = nullptr;
    if (sqlite3_open_v2(db_path.c_str(), &db,
                         SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
                         nullptr) != SQLITE_OK) {
        if (db) sqlite3_close(db);
        return;
    }
    panel_state_ensure_table(db);
    sqlite3_stmt* st = nullptr;
    const char* sql =
        "INSERT INTO panel_state(name, open, updated_at) "
        "VALUES (?, ?, strftime('%s','now')) "
        "ON CONFLICT(name) DO UPDATE SET "
        "  open       = excluded.open, "
        "  updated_at = excluded.updated_at";
    if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) {
        int saved = 0;
        for (size_t i = 0; i < n; ++i) {
            if (!panels[i].open || !panels[i].label) continue;
            sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
            sqlite3_bind_int (st, 2, *panels[i].open ? 1 : 0);
            if (sqlite3_step(st) == SQLITE_DONE) ++saved;
            sqlite3_reset(st);
        }
        sqlite3_finalize(st);
        std::fprintf(stdout,
            "[graph_explorer] panel_state: saved %d/%zu panels\n", saved, n);
    }
    sqlite3_close(db);
}

// Icon atlas (de types.yaml)
static IconAtlas*             g_atlas       = nullptr;
static bool                   g_atlas_bound = false;

// Para detectar primera invocacion de viewport (necesitamos el renderer creado)
static bool                   g_first_render = true;

// FPS estimate
static auto                   g_last_frame = std::chrono::steady_clock::now();
static int                    g_frames_acc = 0;
static auto                   g_fps_timer  = std::chrono::steady_clock::now();

// Label policy
static graph::LabelPolicy     g_label_policy;

// Indice user_data -> sql id (rebuild en cada load).
static ge::EntityIndex        g_idx;

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

static int layout_name_to_index(const std::string& s) {
    if (s == "force")        return 0;
    if (s == "grid")         return 1;
    if (s == "circular")     return 2;
    if (s == "radial")       return 3;
    if (s == "hierarchical") return 4;
    if (s == "fixed")        return 5;
    return -1;
}

// ----------------------------------------------------------------------------
// Halo placement de nodos huerfanos (issue 0031)
// ----------------------------------------------------------------------------

// True si la posicion candidata (cx, cy) no colisiona con ningun nodo del
// grafo distinto de `self_idx`, considerando min_dist como umbral entre
// centros.
static bool layout_no_collision(const GraphData& g, int self_idx,
                                 float cx, float cy, float min_dist)
{
    const float md2 = min_dist * min_dist;
    for (int i = 0; i < g.node_count; ++i) {
        if (i == self_idx) continue;
        const GraphNode& o = g.nodes[i];
        // Ignora nodos que tampoco tienen posicion asignada — no son
        // un obstaculo todavia, los colocara este mismo pase.
        if (o.x == 0.0f && o.y == 0.0f) continue;
        float dx = o.x - cx, dy = o.y - cy;
        if (dx * dx + dy * dy < md2) return false;
    }
    return true;
}

// Devuelve el indice del primer vecino de `node_idx` que tenga posicion
// asignada (no (0,0)). -1 si ninguno la tiene.
static int layout_first_placed_neighbor(const GraphData& g, int node_idx) {
    for (int e = 0; e < g.edge_count; ++e) {
        int other = -1;
        if      ((int)g.edges[e].source == node_idx) other = (int)g.edges[e].target;
        else if ((int)g.edges[e].target == node_idx) other = (int)g.edges[e].source;
        if (other < 0 || other >= g.node_count) continue;
        const GraphNode& n = g.nodes[other];
        if (n.x != 0.0f || n.y != 0.0f) return other;
    }
    return -1;
}

// Encuentra una posicion sin colision para `self_idx` haciendo un barrido
// de slots angulares en radios crecientes alrededor de (cx, cy). Devuelve
// true y escribe (out_x, out_y); false si no hay hueco en los radios
// disponibles. `seed` se usa para jitter deterministico (ej: user_data).
static bool find_collision_free_slot(const GraphData& g, int self_idx,
                                       float cx, float cy, float min_dist,
                                       uint64_t seed,
                                       const float* radii, int n_radii,
                                       float* out_x, float* out_y)
{
    const int   slots    = 12;
    const float two_pi   = 6.28318530718f;
    const float slot_arc = two_pi / slots;
    float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * slot_arc;

    // Slot 0 = el centro (sin desplazamiento). Si no colisiona, perfecto.
    if (layout_no_collision(g, self_idx, cx, cy, min_dist)) {
        *out_x = cx; *out_y = cy;
        return true;
    }
    for (int ri = 0; ri < n_radii; ++ri) {
        float r = radii[ri];
        for (int s = 0; s < slots; ++s) {
            float a = jitter + s * slot_arc;
            float px = cx + r * std::cos(a);
            float py = cy + r * std::sin(a);
            if (layout_no_collision(g, self_idx, px, py, min_dist)) {
                *out_x = px; *out_y = py;
                return true;
            }
        }
    }
    return false;
}

// Coloca todos los nodos del grafo que esten en (0,0):
//   1. Si tiene un vecino con posicion → ring placement junto al vecino.
//   2. Sin vecino: si `use_camera` → ring placement alrededor de la camara
//      (cam_cx, cam_cy) con un radio inicial proporcional al zoom — asi
//      los nodos creados por el agente aparecen DENTRO de la vista actual,
//      sin solapar con lo que ya hay en pantalla.
//   3. Sin vecino y sin camera → fallback legacy: columna a la derecha del
//      bbox (usado en first-load donde el viewport todavia no se ha hecho
//      fit).
static void place_orphans_near_neighbors(GraphData& g, float min_dist,
                                          bool use_camera = false,
                                          float cam_cx = 0.0f,
                                          float cam_cy = 0.0f,
                                          float cam_radius = 120.0f) {
    if (g.node_count == 0) return;
    // Anillos para placement direccional (issue 0037 + ajuste): radios
    // mas cercanos que la version 360 original (max 240 vs 400) para
    // que un grupo de 10-15 hijos quepa cerca del source y no se
    // disperse media pantalla. La distancia entre hermanos en el arco
    // (cluster_min_dist) es menor que el min_dist de colision general
    // — los hermanos pueden estar mas apretados entre si que un nodo
    // suelto, porque conceptualmente forman un cluster.
    const float neighbor_radii[] = {60.0f, 90.0f, 120.0f, 150.0f, 180.0f, 210.0f, 240.0f};
    const int   n_neighbor_radii = (int)(sizeof(neighbor_radii) /
                                          sizeof(neighbor_radii[0]));
    const float cluster_min_dist = 35.0f;  // espaciado entre hermanos en arco

    // Anillos crecientes alrededor de la camara — empieza pequeno (cam_radius
    // base ~viewport/zoom) para mantener los nuevos cerca del foco visual.
    float cam_radii[6];
    for (int i = 0; i < 6; ++i) cam_radii[i] = cam_radius * (1.0f + i * 0.6f);

    // Bbox para fallback legacy (columna lateral) cuando use_camera=false.
    float bbox_max_x = 0.0f, bbox_min_y = 0.0f;
    bool  bbox_init  = false;
    if (!use_camera) {
        for (int i = 0; i < g.node_count; ++i) {
            const GraphNode& n = g.nodes[i];
            if (n.x == 0.0f && n.y == 0.0f) continue;
            if (!bbox_init) {
                bbox_max_x = n.x; bbox_min_y = n.y;
                bbox_init = true;
            } else {
                if (n.x > bbox_max_x) bbox_max_x = n.x;
                if (n.y < bbox_min_y) bbox_min_y = n.y;
            }
        }
    }
    float park_x = bbox_init ? bbox_max_x + 120.0f : 0.0f;
    float park_y = bbox_init ? bbox_min_y          : 0.0f;
    int   park_n = 0;

    int placed_neighbor = 0, placed_camera = 0, parked = 0;

    // ----- Pase 1: agrupar orphans por su anchor (vecino con posicion) -----
    // Cuando un enricher crea N nodos todos conectados al mismo source
    // (caso tipico: web_search → N Urls SEARCH_RESULT_OF source), queremos
    // que los N nodos clustereen MUY apretados alrededor del source en
    // un solo anillo, no que se desperdiguen por anillos concentricos
    // hasta encontrar slot libre. La busqueda anti-colision individual
    // los empuja hacia fuera cuando ya hay vecinos preexistentes; aqui
    // les damos a los hermanos del mismo anchor angulos repartidos en
    // un anillo unico cerca del padre.
    std::unordered_map<int, std::vector<int>> orphans_by_anchor;
    std::vector<int>                          orphans_no_anchor;
    for (int i = 0; i < g.node_count; ++i) {
        const GraphNode& n = g.nodes[i];
        if (n.x != 0.0f || n.y != 0.0f) continue;
        int parent = layout_first_placed_neighbor(g, i);
        if (parent >= 0) orphans_by_anchor[parent].push_back(i);
        else             orphans_no_anchor.push_back(i);
    }

    // ----- Pase 2: place clusters (orphans con anchor) -----
    // Issue 0037: los hijos del mismo anchor se reparten en un ABANICO
    // de 45 grados saliendo del anchor en la direccion outward (alejada
    // del centroide del resto del grafo). Antes era 360 grados — con
    // muchos hijos llenaba TODAS las direcciones y pisaba nodos
    // preexistentes. Ahora el grafo crece direccionalmente por cada
    // ejecucion de enricher.
    const float pi        = 3.14159265359f;
    const float arc_span  = pi / 4.0f;   // 45 grados — issue 0037
    for (auto& kv : orphans_by_anchor) {
        int parent = kv.first;
        std::vector<int>& kids = kv.second;
        if (kids.empty()) continue;
        // Orden estable por user_data para que rondas sucesivas del
        // mismo enricher (mismo set de hijos) coloquen igual.
        std::sort(kids.begin(), kids.end(),
                  [&](int a, int b) {
                      return g.nodes[a].user_data < g.nodes[b].user_data;
                  });
        float cx = g.nodes[parent].x;
        float cy = g.nodes[parent].y;

        // --- Outward direction: anchor - centroide(rest of graph) ---
        // Excluir el propio anchor y los kids (que aun estan en (0,0)).
        // Si solo hay 1 nodo placed o el centroide coincide con el
        // anchor, default a la derecha (out_angle = 0).
        float out_angle = 0.0f;
        {
            // Marcar kids para excluirlos rapido.
            std::unordered_set<int> kid_set(kids.begin(), kids.end());
            double sum_x = 0.0, sum_y = 0.0;
            int    n_other = 0;
            for (int j = 0; j < g.node_count; ++j) {
                if (j == parent) continue;
                if (kid_set.count(j)) continue;
                const GraphNode& nj = g.nodes[j];
                if (nj.x == 0.0f && nj.y == 0.0f) continue;
                sum_x += nj.x; sum_y += nj.y; ++n_other;
            }
            if (n_other > 0) {
                float cent_x = (float)(sum_x / n_other);
                float cent_y = (float)(sum_y / n_other);
                float dx = cx - cent_x;
                float dy = cy - cent_y;
                if (dx != 0.0f || dy != 0.0f) {
                    out_angle = std::atan2(dy, dx);
                }
            }
        }

        // --- Distribucion en abanico: capacidad por anillo restringida ---
        // cap_r = max(2, arc_span * r / cluster_min_dist). Con
        // arc_span=45 y cluster_min_dist=35:
        //   r=60 ->2, r=90 ->2, r=120 ->2, r=150 ->3, r=180 ->4,
        //   r=210 ->4, r=240 ->5  (total 22 hijos en max 240px).
        // Para 10-15 hijos tipicos los inner rings cubren todo,
        // los hijos quedan visiblemente cerca del source.
        size_t n = kids.size();
        size_t accum = 0;
        int    ri    = 0;
        size_t cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / cluster_min_dist);
        for (size_t k = 0; k < n; ++k) {
            // Avanzar de anillo cuando el actual se ha llenado.
            while (k >= accum + cap_r && ri < n_neighbor_radii - 1) {
                accum += cap_r;
                ++ri;
                cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
            }
            if (k >= accum + cap_r) {
                // Ultimo anillo desbordado: aceptamos solape angular.
                cap_r = std::max((size_t)2, n - accum);
            }
            size_t slot = k - accum;
            size_t slot_count = std::min(cap_r, n - accum);
            float angle;
            if (slot_count <= 1) {
                angle = out_angle;
            } else {
                angle = out_angle - arc_span * 0.5f
                      + (float)slot * (arc_span / (float)(slot_count - 1));
            }
            // Jitter pequeno por user_data — pequeno para no romper el arco.
            uint64_t seed = g.nodes[kids[k]].user_data;
            float jit_max = (arc_span / (float)cap_r) * 0.3f;
            float jitter  = (((float)((seed >> 16) & 0xFF) / 255.0f) - 0.5f) * jit_max;
            angle += jitter;

            float r_use = neighbor_radii[ri];
            float px    = cx + r_use * std::cos(angle);
            float py    = cy + r_use * std::sin(angle);

            // Si colisiona con un nodo no-orphan (ya placed), subimos
            // de radio en MISMO angulo hasta encontrar hueco. Si se
            // agotan los radios, aceptamos solape en el actual.
            GraphNode& kid = g.nodes[kids[k]];
            if (!layout_no_collision(g, kids[k], px, py, min_dist)) {
                bool found = false;
                for (int ri2 = ri + 1; ri2 < n_neighbor_radii; ++ri2) {
                    float r2  = neighbor_radii[ri2];
                    float px2 = cx + r2 * std::cos(angle);
                    float py2 = cy + r2 * std::sin(angle);
                    if (layout_no_collision(g, kids[k], px2, py2, min_dist)) {
                        px = px2; py = py2;
                        found = true;
                        break;
                    }
                }
                (void)found;  // ultimo recurso: solape coherente con la logica previa
            }
            kid.x = px; kid.y = py;
            kid.vx = kid.vy = 0.0f;
            ++placed_neighbor;
        }
    }

    // ----- Pase 3: place orphans sin anchor (camera o parking lot) -----
    for (int i : orphans_no_anchor) {
        GraphNode& n = g.nodes[i];

        if (use_camera) {
            // Sin vecino → colocar dentro de la camara con ring placement.
            float ox, oy;
            if (find_collision_free_slot(
                    g, i, cam_cx, cam_cy, min_dist, n.user_data,
                    cam_radii, 6, &ox, &oy)) {
                n.x = ox; n.y = oy;
            } else {
                // Anillo amplio aceptando solape.
                float two_pi = 6.28318530718f;
                float a = ((float)((n.user_data >> 8) & 0xFFFF) / 65535.0f) * two_pi;
                float r = cam_radii[5];
                n.x = cam_cx + std::cos(a) * r;
                n.y = cam_cy + std::sin(a) * r;
            }
            n.vx = n.vy = 0.0f;
            ++placed_camera;
            continue;
        }

        // Legacy: columna lateral (fuera de cam — usado en first_load).
        n.x  = park_x;
        n.y  = park_y + park_n * min_dist;
        n.vx = n.vy = 0.0f;
        ++park_n; ++parked;
    }

    if (placed_neighbor || placed_camera || parked) {
        std::fprintf(stdout,
            "[graph_explorer] placed %d near-neighbor, %d in-camera, %d parked\n",
            placed_neighbor, placed_camera, parked);
    }
}

static void apply_static_layout(int mode) {
    if (g_graph.node_count == 0) return;
    switch (mode) {
        case 1: graph::layout_grid(g_graph, 20.0f); break;
        case 2: graph::layout_circular(g_graph, 200.0f); break;
        case 3: graph::layout_radial(g_graph, 0, 80.0f); break;
        case 4: graph::layout_hierarchical(g_graph, 0, 120.0f, 60.0f); break;
        case 5: graph::layout_fixed(g_graph); break;
        case 0: default: break; // force: no-op (lo mueve el bucle)
    }
    g_gpu_dirty = true;
    if (mode != 0) {
        g_graph.update_bounds();
        graph_viewport_fit(g_graph, g_viewport);
    }
}

// Forward decl — definido mas abajo, lo necesita switch_to_project.
// `first_load=true` activa: layout_circular si todo (0,0), graph_viewport_fit.
// En reloads (first_load=false) ambos se omiten para preservar el estado del
// usuario (issue 0031).
static bool load_input(bool first_load = true);

// ----------------------------------------------------------------------------
// Registry path resolution (issue 0026)
// ----------------------------------------------------------------------------

#ifdef _WIN32
// Detecta la distro WSL "default" buscando que UNC `\\wsl.localhost\<name>\`
// existe y contiene `home/lucas/fn_registry/registry.db`. Devuelve "" si no
// encuentra ninguna. Probamos las distros comunes — el usuario sobrescribe
// con FN_REGISTRY_ROOT si tiene una con nombre raro.
static std::string detect_wsl_distro() {
    const char* candidates[] = {
        "Ubuntu", "Ubuntu-24.04", "Ubuntu-22.04", "Ubuntu-20.04",
        "Debian", "kali-linux", "Fedora", "openSUSE-Tumbleweed",
        nullptr
    };
    for (int i = 0; candidates[i]; ++i) {
        std::string probe = std::string("\\\\wsl.localhost\\") + candidates[i] +
                            "\\home\\lucas\\fn_registry\\registry.db";
        FILE* f = std::fopen(probe.c_str(), "rb");
        if (f) { std::fclose(f); return candidates[i]; }
    }
    return "";
}
#endif

// Devuelve el path absoluto al root de fn_registry. Estrategia:
//   1) FN_REGISTRY_ROOT env var (acepta path Linux o UNC Windows
//      `\\\\wsl.localhost\\<distro>\\home\\...`).
//   2) Sube desde getcwd() buscando un dir con `registry.db`.
//   3) En Windows, sondear UNCs de las distros comunes hasta encontrar
//      una con `registry.db`. La build se distribuye al desktop fuera del
//      arbol del registry, asi que getcwd nunca lo encuentra.
//   4) "" si no se encuentra (los enrichers quedan desactivados).
static std::string resolve_registry_root() {
    if (const char* env = std::getenv("FN_REGISTRY_ROOT")) {
        if (env && *env) return env;
    }
    char cwd[4096];
    if (getcwd(cwd, sizeof(cwd)) != nullptr) {
        std::string p = cwd;
#ifdef _WIN32
        for (char& c : p) if (c == '\\') c = '/';
#endif
        for (int i = 0; i < 8; ++i) {
            std::string probe = p + "/registry.db";
            FILE* f = std::fopen(probe.c_str(), "rb");
            if (f) { std::fclose(f); return p; }
            size_t s = p.find_last_of('/');
            if (s == std::string::npos || s == 0) break;
            p = p.substr(0, s);
        }
    }
#ifdef _WIN32
    std::string distro = detect_wsl_distro();
    if (!distro.empty()) {
        return std::string("\\\\wsl.localhost\\") + distro +
               "\\home\\lucas\\fn_registry";
    }
    std::fprintf(stderr,
        "[graph_explorer] no se detecta la distro WSL — "
        "setea FN_REGISTRY_ROOT con el UNC del registry.\n");
    return "";
#else
    return "";
#endif
}

// ----------------------------------------------------------------------------
// Project lifecycle
// ----------------------------------------------------------------------------

// Aplica los paths del proyecto `slug` a las globales (g_input_path,
// g_types_path, g_layout_db_path) y actualiza g_active_project. No abre BDs
// ni carga el grafo — eso lo hace el caller.
static void apply_project_paths(const std::string& slug) {
    ge::ProjectPaths p = ge::project_paths(slug.c_str());
    g_active_project = slug;
    g_input_path     = p.operations_db;
    g_types_path     = p.types_yaml;
    g_layout_db_path = p.layout_db;
    g_app.active_project = slug;
}

// Cambia al proyecto `slug`: cierra layout_store, libera grafo, abre BDs
// nuevas, carga grafo, persiste como last_active. Devuelve true en exito.
static bool switch_to_project(const std::string& slug) {
    if (slug.empty()) return false;
    if (!ge::project_exists(slug.c_str())) {
        std::fprintf(stderr, "[graph_explorer] project '%s' no existe\n",
                      slug.c_str());
        return false;
    }
    // Cierra estado del proyecto actual
    ge::layout_store_close();
    if (g_loaded) {
        graph::graph_free(&g_graph);
        g_loaded = false;
    }
    if (g_atlas) {
        graph_icons_destroy(g_atlas);
        g_atlas = nullptr;
    }
    g_atlas_bound = false;
    g_viewport.selection.clear();
    g_viewport.hovered_node = -1;
    g_viewport.selected_node = -1;

    // Aplica paths nuevos y abre BDs
    apply_project_paths(slug);
    ge::views_inspector_clear_draft(g_app);
    g_app.parsed_types = ge::ParsedTypes{};
    // Migracion idempotente del schema (issue 0035a y siguientes).
    {
        std::string mig_err;
        if (!ge::project_migrate_schema(g_input_path, &mig_err)) {
            std::fprintf(stderr,
                "[graph_explorer] project_migrate_schema('%s') failed: %s\n",
                g_input_path.c_str(), mig_err.c_str());
        }
    }
    if (!ge::layout_store_open(g_layout_db_path.c_str())) {
        std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
                      g_layout_db_path.c_str());
    }
    bool ok = load_input();
    if (ok) ge::project_settings_touch(slug.c_str());
    return ok;
}

static bool load_input(bool first_load) {
    g_input.kind = ge::INPUT_OPERATIONS;
    g_input.uri  = g_input_path.c_str();

    graph::GraphLoadStats stats{};
    bool ok = ge::load_graph(g_input, &g_graph, &stats);
    if (!ok) {
        std::fprintf(stderr, "[graph_explorer] load failed: %s\n", stats.error_msg);
        return false;
    }
    // Issue 0035e: iconografia heredada de hijos homogeneos (antes del filtro).
    ge::apply_group_inherited_visuals(&g_graph, g_input.uri);
    // Filtro de grupos colapsados (issue 0035b). Se aplica tras la carga
    // bruta — el loader sigue siendo agnostico al concepto de grupo.
    ge::apply_group_filter(&g_graph, g_input.uri, g_app.group_expanded);
    std::fprintf(stdout,
        "[graph_explorer] loaded %d nodes, %d edges, %d types, %d rel_types from %s\n",
        g_graph.node_count, g_graph.edge_count,
        stats.types_discovered, stats.rel_types_discovered, g_input.uri);

    // types.yaml
    if (!g_types_path.empty()) {
        ge::ParsedTypes pt;
        std::string err;
        if (!ge::types_load_yaml(g_types_path.c_str(), &pt, &err)) {
            std::fprintf(stderr, "[graph_explorer] types.yaml: %s\n", err.c_str());
        } else {
            std::vector<uint16_t> codepoints = ge::apply_types_yaml(g_graph, pt);
            // Reset atlas — la prox vez que el viewport tenga renderer, se baja
            g_atlas_bound = false;
            if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
            g_atlas = ge::build_icon_atlas(codepoints);
            int total_fields = 0;
            int with_schema  = 0;
            for (const auto& e : pt.entities) {
                total_fields += (int)e.fields.size();
                if (!e.fields.empty()) ++with_schema;
            }
            std::fprintf(stdout,
                "[graph_explorer] types.yaml: %zu entities (%d con schema, %d fields totales),"
                " %zu relations, %zu icons\n",
                pt.entities.size(), with_schema, total_fields,
                pt.relations.size(), codepoints.size());
            // Stash en AppState para que el Inspector resuelva schemas (issue 0008).
            g_app.parsed_types = std::move(pt);
        }
    }
    // Inicializar el draft del Type Editor con copia de parsed_types (0007).
    g_app.types_draft = g_app.parsed_types;
    g_app.types_dirty = false;
    g_app.types_save_error.clear();

    // Restablecer viewport state (preserva camara user-visible). Physics
    // arrancan en pausa para que las posiciones guardadas no se pierdan;
    // el usuario las activa con el boton Physics de la toolbar.
    g_viewport.selection.clear();
    g_viewport.hovered_node = -1;
    g_viewport.selected_node = -1;
    g_viewport.layout_running = false;
    g_viewport.layout_energy  = 0.0f;

    // Indice user_data -> sql id (para CRUD desde menu contextual).
    ge::entity_index_build(g_input.uri, &g_idx);
    g_app.input_db_path = g_input.uri ? g_input.uri : "";

    // issue 0026 — apunta el JobRunner a la nueva operations.db.
    if (g_input.uri) ge::jobs_set_ops_db(g_input.uri);
    // Chat agent — refrescar contexto de la nueva operations.db.
    if (g_input.uri) app_agent::chat_set_ops_db(g_input.uri);

    // Cargar posiciones guardadas para este graph_hash. Ahora ANTES del
    // bootstrap circular: si tenemos posiciones guardadas las respetamos;
    // solo aplicamos circular si NO hay nada guardado en primera carga.
    g_graph_hash = ge::compute_graph_hash(g_input.uri);
    int restored = ge::layout_store_load(g_graph_hash, g_graph);
    if (restored > 0) {
        std::fprintf(stdout,
            "[graph_explorer] restored %d node positions from layout store\n",
            restored);
    }

    // Bootstrap circular SOLO si no se restauro nada en primera carga (ej:
    // primer arranque tras crear el proyecto, o tras `Reset layout`). Si
    // restored>0 los nodos cargados ya tienen posicion; los nuevos sin
    // posicion guardada los colocara place_orphans_near_neighbors.
    if (first_load && restored == 0 && g_graph.node_count > 0) {
        graph::layout_circular(g_graph, 200.0f);
        std::fprintf(stdout,
            "[graph_explorer] bootstrap layout_circular (no saved positions)\n");
    }
    g_graph.update_bounds();

    // Huerfanos (nodos sin posicion guardada): halo placement junto a su
    // primer vecino con coordenadas conocidas (issue 0031). En primera carga
    // tambien aplica — si layout_circular ya los puso en circulo, no entran
    // (ya no estan en (0,0)). En reloads es donde mas valor da: nodos
    // creados por enrichers caen junto a su padre semantico.
    place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
    g_graph.update_bounds();

    // Vista inicial — solo en primera carga; los reloads preservan camara
    // del usuario (issue 0031).
    if (first_load) {
        graph_viewport_fit(g_graph, g_viewport);
    }
    g_gpu_dirty = true;

    // App state — visibility por tipo
    g_app.graph    = &g_graph;
    g_app.viewport = &g_viewport;
    ge::views_reset_visibility(g_app);
    ge::views_apply_visibility(g_app);

    // Cache de conteos de Table nodes (issue 0010).
    if (g_input.uri) {
        ge::node_groups_refresh_counts(g_input.uri, &g_app.node_groups_counts);
        int64_t total_rows = 0;
        for (auto& kv : g_app.node_groups_counts) total_rows += kv.second;
        std::fprintf(stdout,
            "[graph_explorer] table counts refreshed: %zu tables, %lld total rows\n",
            g_app.node_groups_counts.size(), (long long)total_rows);
        // Sync de windows expandidas (issue 0011) — reabre las que el
        // usuario tenia abiertas en la sesion previa (metadata.expanded=true).
        ge::views_node_groups_windows_sync(g_app, g_input.uri);
    }

    // Cache de la vista tabla (issue 0004) — pull bulk + neighbors desde grafo.
    {
        std::vector<ge::EntityRowSnapshot> snap;
        if (g_input.uri && ge::entity_list_rows(g_input.uri, &snap)) {
            g_app.table_rows.clear();
            g_app.table_rows.reserve(snap.size());
            for (auto& s : snap) {
                ge::AppState::TableRow tr;
                tr.id         = std::move(s.id);
                tr.name       = std::move(s.name);
                tr.type_ref   = std::move(s.type_ref);
                tr.status     = std::move(s.status);
                tr.updated_at = std::move(s.updated_at);
                tr.group_id   = std::move(s.group_id);
                g_app.table_rows.push_back(std::move(tr));
            }
            ge::views_table_refresh_indices(g_app);
            g_app.table_cache_dirty = false;
        }
    }

    // Inspector: refresca caches (tags distintas, lista de tipos) y limpia
    // cualquier draft anterior. El draft se cargara cuando el usuario
    // seleccione un nodo en el render loop.
    ge::views_inspector_clear_draft(g_app);
    ge::views_inspector_refresh_caches(g_app);

    // --layout inicial (si llego del CLI)
    int idx = layout_name_to_index(g_layout_initial);
    if (idx >= 0) {
        g_app.layout_mode = idx;
        apply_static_layout(idx);
    }

    g_loaded = true;
    return true;
}

static void run_force_step() {
    if (!g_viewport.layout_running) return;
    if (g_app.layout_mode != 0) return; // force solo en mode 0

    ForceLayoutConfig cfg;
    cfg.repulsion    = g_app.repulsion;
    cfg.attraction   = g_app.attraction;
    cfg.gravity      = g_app.gravity;
    cfg.iterations   = 1;
    // Tapa de energia: damping mas agresivo + max_velocity bajo evita que el
    // grafo "explote" al cargar (nodos que arrancan cerca del origen y se
    // dispersan con repulsion alta). Valores tuneados para sentir movimiento
    // suave sin saltos visibles entre frames.
    cfg.damping      = 0.7f;
    cfg.max_velocity = 8.0f;

    if (g_app.use_gpu) {
        if (!g_gpu_ctx) {
            g_gpu_ctx = graph_force_layout_gpu_create(g_graph.node_count + 1024,
                                                       g_graph.edge_count + 1024);
            g_gpu_dirty = true;
        }
        if (g_gpu_ctx) {
            if (g_gpu_dirty) {
                graph_force_layout_gpu_upload(g_gpu_ctx, g_graph);
                g_gpu_dirty = false;
            }
            g_viewport.layout_energy = graph_force_layout_gpu_step(g_gpu_ctx, cfg);
            graph_force_layout_gpu_readback(g_gpu_ctx, g_graph, /*include_velocities=*/true);
        } else {
            g_app.use_gpu = false;
            g_viewport.layout_energy = graph_force_layout_step(g_graph, cfg);
        }
    } else {
        g_viewport.layout_energy = graph_force_layout_step(g_graph, cfg);
    }

    // Auto-pause heuristica: si energia/nodo es muy baja durante muchos
    // frames, apagar simulacion. El usuario puede reanudarla con el toggle.
    static int low = 0;
    const float k_pause_per_node = 0.001f;
    const int   k_pause_after    = 60;
    float per = g_graph.node_count > 0
                    ? g_viewport.layout_energy / (float)g_graph.node_count
                    : 0.0f;
    if (per < k_pause_per_node) ++low;
    else                         low = 0;
    if (graph_force_layout_should_pause(low, k_pause_after)) {
        g_viewport.layout_running = false;
        low = 0;
    }
}

// FPS estimate sintetico (por segundo).
static void update_fps() {
    using namespace std::chrono;
    auto now = steady_clock::now();
    ++g_frames_acc;
    if (duration_cast<milliseconds>(now - g_fps_timer).count() >= 1000) {
        g_app.fps_estimate = g_frames_acc;
        g_frames_acc = 0;
        g_fps_timer = now;
    }
    g_last_frame = now;
}

// ----------------------------------------------------------------------------
// Context menu callback (right-click sobre nodo)
// ----------------------------------------------------------------------------

// Doble click sobre nodo: solicita abrir el panel Note. main.cpp procesa
// despues (necesita acceso al EntityIndex para resolver el sql id).
static void on_double_click_cb(int node_idx, void* /*user*/) {
    g_app.want_open_note  = true;
    g_app.open_note_target = node_idx;
}

static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) {
    g_app.ctx_node = node_idx;
    g_app.ctx_open_request = true;
    if (node_idx >= 0 && node_idx < g_graph.node_count) {
        const GraphNode& n = g_graph.nodes[node_idx];
        if (n.type_id < (uint16_t)g_graph.type_count && g_graph.types[n.type_id].name) {
            std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
                          g_graph.types[n.type_id].name);
        } else {
            g_app.ctx_new_type[0] = 0;
        }
    }
}

// Lista de tipos disponibles para "Change type" — se construye desde el grafo
// activo. Si esta vacia, se usa una lista por defecto.
static const char* k_default_types[] = {
    "text", "person", "organization", "email", "ip_address", "domain",
    "url", "phone", "crypto_wallet", "malware", "vulnerability",
};
constexpr int k_default_types_n = (int)(sizeof(k_default_types) / sizeof(k_default_types[0]));

static void render_context_menu() {
    if (g_app.ctx_open_request) {
        ImGui::OpenPopup("##node_ctx");
        g_app.ctx_open_request = false;
    }
    if (!ImGui::BeginPopup("##node_ctx")) return;

    int idx = g_app.ctx_node;
    if (idx < 0 || idx >= g_graph.node_count) {
        ImGui::TextDisabled("(no node)");
        ImGui::EndPopup();
        return;
    }
    const GraphNode& n = g_graph.nodes[idx];
    const char* lbl = graph::graph_label(&g_graph, n.label_idx);
    ImGui::TextDisabled("%s", lbl && *lbl ? lbl : "(unnamed)");
    ImGui::Separator();

    // Detectar si el nodo es Table y resolver entity_id para opciones tabla.
    bool is_table = false;
    if (n.type_id < (uint16_t)g_graph.type_count) {
        const EntityType& t = g_graph.types[n.type_id];
        if (t.name && std::strcmp(t.name, "Table") == 0) is_table = true;
    }
    const char* sql_id = ge::entity_index_lookup(g_idx, n.user_data);

    if (is_table && sql_id) {
        // Determinar estado actual sin ir a BD: mira node_groups_windows.
        bool currently_open =
            g_app.node_groups_windows.find(sql_id) != g_app.node_groups_windows.end();
        const char* lbl_exp = currently_open
                                ? TI_X " Close NodeGroups"
                                : TI_TABLE " Open NodeGroups";
        if (ImGui::MenuItem(lbl_exp)) {
            g_app.want_toggle_nodegroups  = true;
            g_app.toggle_nodegroups_id    = sql_id;
        }
        ImGui::Separator();
    }

    if (ImGui::BeginMenu("Change type")) {
        // Construye un set ordenado y deduplicado: tipos del grafo + defaults.
        // Asi evitamos colisiones de ID en ImGui ("person" en grafo y default).
        std::vector<const char*> all;
        all.reserve(g_graph.type_count + k_default_types_n);
        for (int i = 0; i < g_graph.type_count; ++i) {
            if (g_graph.types[i].name && *g_graph.types[i].name) {
                all.push_back(g_graph.types[i].name);
            }
        }
        for (int i = 0; i < k_default_types_n; ++i) {
            const char* d = k_default_types[i];
            bool dup = false;
            for (const char* x : all) { if (std::strcmp(x, d) == 0) { dup = true; break; } }
            if (!dup) all.push_back(d);
        }
        for (size_t i = 0; i < all.size(); ++i) {
            ImGui::PushID((int)i);
            if (ImGui::MenuItem(all[i])) {
                std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", all[i]);
                g_app.want_change_type = true;
            }
            ImGui::PopID();
        }
        ImGui::EndMenu();
    }

    if (ImGui::MenuItem("Duplicate")) {
        g_app.want_duplicate_node = true;
    }
    if (ImGui::MenuItem("Delete")) {
        g_app.want_delete_node = true;
    }

    ImGui::Separator();
    if (ImGui::BeginMenu("Run enricher")) {
        // issue 0026 — listamos enrichers cuyo applies_to incluye este type.
        const char* type_name = (n.type_id < (uint16_t)g_graph.type_count)
                                ? g_graph.types[n.type_id].name : "";
        const auto& all   = ge::enrichers_all();
        auto        specs = ge::enrichers_for_type(type_name);
        if (!sql_id) {
            ImGui::TextDisabled("(node has no entity id)");
        } else if (all.empty()) {
            ImGui::TextDisabled("(no enrichers cargados)");
            ImGui::TextDisabled("revisa FN_REGISTRY_ROOT");
        } else if (specs.empty()) {
            ImGui::TextDisabled("(0/%d enrichers para tipo '%s')",
                                (int)all.size(), type_name);
        } else {
            for (const auto& s : specs) {
                if (ImGui::MenuItem(s.name.c_str())) {
                    if (s.params.empty()) {
                        // Sin params editables: submit directo, comportamiento
                        // historico — un click y a correr.
                        char job_id[64];
                        bool ok = ge::jobs_submit(s.id.c_str(), sql_id, lbl,
                                                  "{}", job_id, sizeof(job_id));
                        if (ok) g_app.panel_jobs = true;
                    } else {
                        // Abrir ventana de configuracion. Inicializar
                        // buffers con los defaults del manifest.
                        g_app.enr_modal_id          = s.id;
                        g_app.enr_modal_node_id     = sql_id;
                        g_app.enr_modal_node_label  = lbl ? lbl : "";
                        g_app.enr_modal_param_bufs.clear();
                        g_app.enr_modal_param_bufs.resize(s.params.size());
                        for (size_t i = 0; i < s.params.size(); ++i) {
                            const std::string& dv = s.params[i].default_value;
                            auto& buf = g_app.enr_modal_param_bufs[i];
                            buf.assign(256, '\0');
                            std::snprintf(buf.data(), buf.size(), "%s", dv.c_str());
                        }
                        g_app.enr_window_open = true;
                    }
                }
                if (!s.description.empty() && ImGui::IsItemHovered()) {
                    ImGui::SetTooltip("%s", s.description.c_str());
                }
            }
        }
        ImGui::EndMenu();
    }

    ImGui::EndPopup();
}

// ----------------------------------------------------------------------------
// Modal: configurar parametros de enricher antes de lanzar el job
// ----------------------------------------------------------------------------
// Se invoca desde el context menu (Run enricher → click). Si el enricher
// declara `params` en su manifest, en lugar de submitear directamente,
// llenamos el AppState (ver bloque `enr_modal_*`) y aqui renderizamos el
// dialogo. El usuario ajusta valores y al pulsar Run construimos el
// JSON `{ "param": value, ... }` y lo pasamos a `jobs_submit`.

static std::string json_escape_str(const std::string& s) {
    std::string out;
    out.reserve(s.size() + 8);
    for (char c : s) {
        switch (c) {
            case '"':  out += "\\\""; break;
            case '\\': out += "\\\\"; break;
            case '\n': out += "\\n";  break;
            case '\r': out += "\\r";  break;
            case '\t': out += "\\t";  break;
            default:
                if ((unsigned char)c < 0x20) {
                    char b[8];
                    std::snprintf(b, sizeof(b), "\\u%04x", (unsigned char)c);
                    out += b;
                } else {
                    out.push_back(c);
                }
        }
    }
    return out;
}

// Renderiza una fila label/input dentro de una BeginTable de 2 columnas.
// El label va a la izquierda alineado al frame del input; el input usa
// todo el ancho disponible de la columna derecha.
static void labeled_row_begin(const char* label) {
    ImGui::TableNextRow();
    ImGui::TableNextColumn();
    ImGui::AlignTextToFramePadding();
    ImGui::TextUnformatted(label);
    ImGui::TableNextColumn();
    ImGui::SetNextItemWidth(-FLT_MIN);
}

static void render_enricher_config_window() {
    if (!g_app.enr_window_open) return;

    ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver);
    if (!ImGui::Begin("Run enricher", &g_app.enr_window_open,
                       ImGuiWindowFlags_NoCollapse)) {
        ImGui::End();
        return;
    }

    const ge::EnricherSpec* spec = ge::enricher_by_id(g_app.enr_modal_id.c_str());
    if (!spec) {
        ImGui::TextDisabled("(enricher no encontrado)");
        ImGui::End();
        return;
    }

    ImGui::Text("%s", spec->name.c_str());
    if (!spec->description.empty()) {
        ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.7f, 0.7f, 0.7f, 1.0f));
        ImGui::TextWrapped("%s", spec->description.c_str());
        ImGui::PopStyleColor();
    }
    ImGui::Separator();
    ImGui::TextDisabled("Node: %s", g_app.enr_modal_node_label.c_str());
    ImGui::Spacing();

    // Asegurar tamaño de buffers — un manifest puede haberse recargado
    // con mas params de los que llenamos al abrir la ventana.
    if (g_app.enr_modal_param_bufs.size() < spec->params.size()) {
        g_app.enr_modal_param_bufs.resize(spec->params.size());
    }

    if (ImGui::BeginTable("##enr_params", 2,
                           ImGuiTableFlags_SizingStretchProp |
                           ImGuiTableFlags_NoBordersInBody)) {
        ImGui::TableSetupColumn("name",  ImGuiTableColumnFlags_WidthFixed, 110.0f);
        ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch);

        for (size_t i = 0; i < spec->params.size(); ++i) {
            const auto& p = spec->params[i];
            auto& buf = g_app.enr_modal_param_bufs[i];
            if (buf.size() < 256) buf.resize(256, '\0');

            ImGui::PushID((int)i);
            labeled_row_begin(p.name.c_str());
            const std::string& t = p.type;
            if (t == "int") {
                int v = std::atoi(buf.data());
                if (ImGui::InputInt("##v", &v, 1, 10)) {
                    std::snprintf(buf.data(), buf.size(), "%d", v);
                }
            } else if (t == "float" || t == "double" || t == "number") {
                float v = (float)std::atof(buf.data());
                if (ImGui::InputFloat("##v", &v)) {
                    std::snprintf(buf.data(), buf.size(), "%g", v);
                }
            } else if (t == "bool") {
                bool v = (std::strcmp(buf.data(), "true") == 0 ||
                           std::strcmp(buf.data(), "1") == 0);
                if (ImGui::Checkbox("##v", &v)) {
                    std::snprintf(buf.data(), buf.size(), "%s", v ? "true" : "false");
                }
            } else {
                ImGui::InputText("##v", buf.data(), buf.size());
            }
            if (!p.description.empty() && ImGui::IsItemHovered()) {
                ImGui::SetTooltip("%s", p.description.c_str());
            }
            ImGui::PopID();
        }
        ImGui::EndTable();
    }

    ImGui::Separator();
    if (ImGui::Button("Run", ImVec2(100, 0))) {
        // Construir JSON `{ "name": value, ... }` segun los tipos.
        std::string j = "{";
        for (size_t i = 0; i < spec->params.size(); ++i) {
            const auto& p = spec->params[i];
            const auto& buf = g_app.enr_modal_param_bufs[i];
            if (i) j += ",";
            j += "\"";
            j += json_escape_str(p.name);
            j += "\":";
            if (p.type == "int") {
                int v = std::atoi(buf.data());
                char b[32]; std::snprintf(b, sizeof(b), "%d", v);
                j += b;
            } else if (p.type == "float" || p.type == "double" || p.type == "number") {
                double v = std::atof(buf.data());
                char b[64]; std::snprintf(b, sizeof(b), "%g", v);
                j += b;
            } else if (p.type == "bool") {
                bool v = (std::strcmp(buf.data(), "true") == 0 ||
                           std::strcmp(buf.data(), "1") == 0);
                j += v ? "true" : "false";
            } else {
                j += "\"";
                j += json_escape_str(buf.data());
                j += "\"";
            }
        }
        j += "}";

        char job_id[64];
        bool ok = ge::jobs_submit(spec->id.c_str(),
                                   g_app.enr_modal_node_id.c_str(),
                                   g_app.enr_modal_node_label.c_str(),
                                   j.c_str(), job_id, sizeof(job_id));
        if (ok) g_app.panel_jobs = true;
        g_app.enr_window_open = false;
    }
    ImGui::SameLine();
    if (ImGui::Button("Cancel", ImVec2(100, 0))) {
        g_app.enr_window_open = false;
    }

    ImGui::End();
}

// ----------------------------------------------------------------------------
// Label callback
// ----------------------------------------------------------------------------

static const char* get_label_cb(int node_idx, void* /*user*/) {
    if (node_idx < 0 || node_idx >= g_graph.node_count) return "";
    const GraphNode& n = g_graph.nodes[node_idx];
    return graph::graph_label(&g_graph, n.label_idx);
}

// ----------------------------------------------------------------------------
// Render
// ----------------------------------------------------------------------------

static fn_ui::PanelToggle g_panels[] = {
    {"Viewport",  nullptr, &g_app.panel_viewport},
    {"Legend",    nullptr, &g_app.panel_legend},
    {"Inspector", nullptr, &g_app.panel_inspector},
    {"Stats",     nullptr, &g_app.panel_stats},
    {"Note",      nullptr, &g_app.panel_note},
    {"Types",     nullptr, &g_app.panel_type_editor},
    {"Table",     nullptr, &g_app.panel_table},
    {"Jobs",      nullptr, &g_app.panel_jobs},
    {"Echo",      nullptr, &g_app.panel_chat},
    {"Extract",   nullptr, &g_app.panel_extract},
};

static void render() {
    update_fps();

    // Aplicar layout pendiente (si el usuario seleccionó uno del menu Layouts).
    // Debe ir antes de crear ventanas — LoadIniSettingsFromMemory afecta a las
    // posiciones que se calculan a continuación.
    if (g_layout_storage) {
        std::string applied = fn_ui::layout_storage_apply_pending(g_layout_storage);
        if (!applied.empty()) {
            std::fprintf(stdout, "[graph_explorer] layout aplicado: %s\n",
                          applied.c_str());
        }
    }

    // No tenemos menu propio — fn::run_app llamara al app_menubar via panels[].

    if (!g_loaded) {
        fullscreen_window_begin("##empty");
        ImGui::TextColored(ImVec4(1, 0.7f, 0.3f, 1),
                           "graph_explorer — no input loaded");
        ImGui::Spacing();
        ImGui::TextWrapped(
            "Usage: graph_explorer [<operations.db>] [--input operations <path>] "
            "[--types <yaml>] [--layout <name>]");
        ImGui::Spacing();
        ge::views_open_modal(g_app);
        if (g_app.want_open_file) {
            g_input_path = g_app.open_buf;
            g_app.want_open_file = false;
            load_input();
        }
        if (fn_ui::button("Open file...", fn_ui::ButtonVariant::Primary)) {
            g_app.show_open_modal = true;
        }
        fullscreen_window_end();
        return;
    }

    // Dockspace host: ocupa el area BAJO la toolbar (44 px) para que las
    // ventanas dockeadas no queden detras de la barra superior.
    ImGuiViewport* vp = ImGui::GetMainViewport();
    const float k_toolbar_h = 44.0f;
    {
        ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, vp->WorkPos.y + k_toolbar_h));
        ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - k_toolbar_h));
        ImGui::SetNextWindowViewport(vp->ID);
        ImGuiWindowFlags hostFlags =
            ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
            ImGuiWindowFlags_NoMove     | ImGuiWindowFlags_NoCollapse |
            ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
            ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDocking |
            ImGuiWindowFlags_NoSavedSettings;
        ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
        ImGui::Begin("##dock_host", nullptr, hostFlags);
        ImGui::PopStyleVar();
        ImGui::DockSpace(ImGui::GetID("##dockspace"), ImVec2(0, 0),
                         ImGuiDockNodeFlags_PassthruCentralNode);
        ImGui::End();
    }

    // Toolbar superior — usa una ventana sin scroll y sin titulo
    ImGui::SetNextWindowPos(vp->WorkPos);
    ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, 44.0f));
    ImGui::Begin("##toolbar", nullptr,
                 ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
                 ImGuiWindowFlags_NoMove     | ImGuiWindowFlags_NoCollapse |
                 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse |
                 ImGuiWindowFlags_NoSavedSettings);
    ge::views_toolbar(g_app);
    ImGui::End();

    // Modals
    ge::views_open_modal(g_app);
    ge::views_filters_modal(g_app);
    ge::views_new_project_modal(g_app);

    // Project switch (desde menu Project, o tras crear proyecto nuevo)
    if (g_app.want_switch_project && !g_app.switch_project_target.empty()) {
        std::string target = g_app.switch_project_target;
        g_app.want_switch_project = false;
        g_app.switch_project_target.clear();
        if (!switch_to_project(target)) {
            std::fprintf(stderr,
                "[graph_explorer] switch_to_project('%s') failed\n", target.c_str());
        }
    }

    // Si el usuario aplico nuevo layout en la toolbar
    if (g_app.apply_layout_tick > 0) {
        apply_static_layout(g_app.layout_mode);
        g_app.apply_layout_tick = 0;
    }

    // issue 0026 — si un job termino con cambios, dispara reload del grafo.
    {
        static int s_last_dirty = 0;
        int d = ge::jobs_dirty_counter();
        if (d != s_last_dirty) {
            s_last_dirty = d;
            g_app.want_reload = true;
        }
    }

    // Chat agent — gx-cli toca .mutations.marker tras cada mutacion.
    // Polleamos su mtime cada N frames; si cambia, recargamos el grafo.
    // (Antes usaba un contador en agent_mutations.SQLite, pero WAL falla
    // cross-NTFS<->9p cuando el .exe Windows tiene la BD abierta.)
    {
        static int  s_last_mut = -1;     // -1 = primera lectura no hecha
        static int  s_throttle = 0;
        if (++s_throttle >= 8) {
            s_throttle = 0;
            int m = app_agent::chat_mutations_counter();
            if (s_last_mut == -1) {
                // Primera lectura: solo memorizar, sin disparar reload.
                s_last_mut = m;
            } else if (m != s_last_mut) {
                app_agent::chat_log("mut",
                    "marker mtime cambio %d -> %d, disparando reload",
                    s_last_mut, m);
                s_last_mut = m;
                g_app.want_reload = true;
            }
        }
    }

    // Chat agent — drena cola de jobs encolados por gx-cli (Echo agent).
    //
    // Antes esta cola era una tabla `agent_jobs` en graph_explorer.db,
    // pero gx-cli corre dentro de WSL y graph_explorer.exe la tiene
    // abierta con WAL desde Windows. SQLite WAL falla cross-9p (mmap del
    // .shm) -> "disk I/O error" al hacer INSERT desde gx-cli. Igual que
    // el contador de mutaciones, lo movimos a ficheros JSON sueltos en
    // <project_dir>/agent_jobs_queue/. Cada fichero = 1 job. Aqui
    // escaneamos el dir, cargamos cada JSON, llamamos jobs_submit, y
    // borramos el fichero (atomico via rename desde gx-cli).
    if (!g_layout_db_path.empty()) {
        std::filesystem::path queue_dir =
            std::filesystem::path(g_layout_db_path).parent_path() /
            "agent_jobs_queue";
        std::error_code ec;
        // Log el path una sola vez por sesion para detectar mismatches
        // entre lo que escribe gx-cli y lo que escaneamos aqui.
        static bool s_logged_queue_dir = false;
        if (!s_logged_queue_dir) {
            std::fprintf(stdout,
                "[chat] agent queue scan dir: %s (exists=%d)\n",
                queue_dir.string().c_str(),
                std::filesystem::is_directory(queue_dir, ec) ? 1 : 0);
            s_logged_queue_dir = true;
        }
        if (std::filesystem::is_directory(queue_dir, ec)) {
            // Reusamos el sqlite ya en memoria solo para parsear JSON via
            // json_extract (json1 esta enabled en el build). Sin WAL.
            sqlite3* json_db = nullptr;
            sqlite3_open(":memory:", &json_db);
            sqlite3_stmt* parse = nullptr;
            sqlite3_prepare_v2(json_db,
                "SELECT json_extract(?,'$.id'), "
                "       json_extract(?,'$.enricher_id'), "
                "       json_extract(?,'$.node_id'), "
                "       json_extract(?,'$.node_name'), "
                "       json_extract(?,'$.params_json')",
                -1, &parse, nullptr);

            int n_processed = 0;
            for (auto& ent : std::filesystem::directory_iterator(queue_dir, ec)) {
                if (n_processed >= 8) break;  // throttle por frame
                if (!ent.is_regular_file()) continue;
                auto path = ent.path();
                if (path.extension() != ".json") continue;

                // Leer contenido.
                std::ifstream f(path, std::ios::binary);
                if (!f) continue;
                std::string body((std::istreambuf_iterator<char>(f)),
                                  std::istreambuf_iterator<char>());
                f.close();

                // Parsear via json_extract (5 binds del mismo body).
                sqlite3_reset(parse);
                for (int i = 1; i <= 5; ++i) {
                    sqlite3_bind_text(parse, i, body.c_str(), -1,
                                       SQLITE_TRANSIENT);
                }
                if (sqlite3_step(parse) != SQLITE_ROW) {
                    std::fprintf(stderr,
                        "[chat] queue file %s: json_extract failed\n",
                        path.string().c_str());
                    std::filesystem::remove(path, ec);
                    continue;
                }
                auto col_str = [&](int i) -> std::string {
                    const unsigned char* t = sqlite3_column_text(parse, i);
                    return t ? (const char*)t : "";
                };
                std::string req_id = col_str(0);
                std::string enr_id = col_str(1);
                std::string node   = col_str(2);
                std::string nname  = col_str(3);
                std::string params = col_str(4);
                if (params.empty()) params = "{}";

                char job_id[64];
                if (ge::jobs_submit(enr_id.c_str(), node.c_str(),
                                     nname.c_str(), params.c_str(),
                                     job_id, sizeof(job_id))) {
                    std::fprintf(stdout,
                        "[chat] queued enricher=%s node=%s as %s (req=%s)\n",
                        enr_id.c_str(), node.c_str(), job_id, req_id.c_str());
                    g_app.panel_jobs = true;
                } else {
                    std::fprintf(stderr,
                        "[chat] jobs_submit failed (req=%s enricher=%s)\n",
                        req_id.c_str(), enr_id.c_str());
                }
                // Borrar el fichero independientemente de exito de submit:
                // si jobs_submit fallo, reintenrar produciria duplicados.
                std::filesystem::remove(path, ec);
                ++n_processed;
            }
            if (parse) sqlite3_finalize(parse);
            if (json_db) sqlite3_close(json_db);
        }
    }

    // Triggers desde la toolbar
    if (g_app.want_fit) {
        graph_viewport_fit(g_graph, g_viewport);
        g_app.want_fit = false;
    }
    if (g_app.want_reload) {
        g_app.want_reload = false;
        // (A) Auto-save antes de liberar el grafo: preserva las posiciones
        // que tenia el usuario en pantalla sin que tenga que pulsar
        // "Save layout" jamas (issue 0031).
        if (g_loaded && g_graph_hash != 0) {
            ge::layout_store_save(g_graph_hash, g_graph);
        }

        graph::GraphLoadStats stats{};
        if (ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) {
            ge::views_reset_visibility(g_app);
            ge::views_apply_visibility(g_app);

            // Reaplica types.yaml + atlas. Sin esto, los tipos pierden
            // color/shape/icon tras reload (todo nodo vuelve a circulo
            // gris). Mismo flujo que reload_after_mutation.
            if (!g_app.parsed_types.entities.empty() ||
                !g_app.parsed_types.relations.empty()) {
                std::vector<uint16_t> cps =
                    ge::apply_types_yaml(g_graph, g_app.parsed_types);
                if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
                g_atlas = ge::build_icon_atlas(cps);
            }

            // Restaura posiciones guardadas para nodos preexistentes.
            int restored = ge::layout_store_load(g_graph_hash, g_graph);
            (void)restored;

            // Halo placement: junto a vecino con posicion conocida; si no
            // tiene vecino (caso tipico cuando el agente crea un nodo
            // aislado via MCP node_create), DENTRO de la camara visible
            // con anti-colision. Convencion: world_pos == cam_pos cuando
            // el nodo cae en el centro de la pantalla (graph_viewport.cpp
            // L23: gx = (vx - center) / zoom + cam_x).
            float cam_cx = g_viewport.cam_x;
            float cam_cy = g_viewport.cam_y;
            float cam_r  = 80.0f / (g_viewport.zoom > 0.01f
                                     ? g_viewport.zoom : 0.01f);
            place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
                                          /*use_camera=*/true,
                                          cam_cx, cam_cy, cam_r);
            g_graph.update_bounds();

            // Physics pausadas tras reload (issue 0031).
            g_viewport.layout_running = false;

            // Refresca el indice user_data -> sql id (puede haber nuevos
            // nodos cuyo user_data no estaba en el indice anterior).
            ge::entity_index_build(g_input.uri, &g_idx);

            g_atlas_bound = false; // re-bind atlas tras reload
            g_gpu_dirty   = true;
        }
    }
    if (g_app.want_save_layout) {
        int n = ge::layout_store_save(g_graph_hash, g_graph);
        std::fprintf(stdout, "[graph_explorer] saved %d node positions\n", n);
        g_app.want_save_layout = false;
    }

    // Filtro FTS5/tags (issue 0009) — reaplica si el toolbar marco dirty.
    if (g_app.filter_dirty) {
        ge::views_filter_apply(g_app);
    }
    // Centrado del nodo seleccionado desde el dropdown.
    if (g_app.filter_focus_target >= 0
        && g_app.filter_focus_target < g_graph.node_count) {
        const GraphNode& n = g_graph.nodes[g_app.filter_focus_target];
        g_viewport.cam_x = -n.x;
        g_viewport.cam_y = -n.y;
        g_app.filter_focus_target = -1;
    }
    if (g_app.want_open_file) {
        g_input_path = g_app.open_buf;
        g_app.want_open_file = false;
        // Cleanup viejo grafo
        graph::graph_free(&g_graph);
        load_input();
    }


    // ---- Type Editor (issue 0007) ----
    if (g_app.want_types_save) {
        g_app.want_types_save = false;
        g_app.types_save_error.clear();
        if (g_types_path.empty()) {
            g_app.types_save_error =
                "No hay types.yaml asignado (abre un proyecto o usa --types).";
        } else {
            std::string err;
            if (!ge::types_save_yaml(g_types_path.c_str(),
                                       g_app.types_draft, &err)) {
                g_app.types_save_error = "Save failed: " + err;
            } else {
                std::fprintf(stdout,
                    "[graph_explorer] types.yaml saved -> %s\n",
                    g_types_path.c_str());
                g_app.parsed_types = g_app.types_draft;
                std::vector<uint16_t> cps =
                    ge::apply_types_yaml(g_graph, g_app.parsed_types);
                if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
                g_atlas = ge::build_icon_atlas(cps);
                g_atlas_bound = false;
                g_gpu_dirty   = true;
                g_app.types_dirty = false;
                ge::views_inspector_refresh_caches(g_app);
            }
        }
    }
    if (g_app.want_types_reload) {
        g_app.want_types_reload = false;
        g_app.types_save_error.clear();
        if (g_types_path.empty()) {
            // Sin types.yaml en disco: descarta el draft a parsed_types actual.
            g_app.types_draft = g_app.parsed_types;
            g_app.types_dirty = false;
        } else {
            ge::ParsedTypes pt;
            std::string err;
            if (!ge::types_load_yaml(g_types_path.c_str(), &pt, &err)) {
                g_app.types_save_error = "Reload failed: " + err;
            } else {
                std::vector<uint16_t> cps = ge::apply_types_yaml(g_graph, pt);
                if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
                g_atlas = ge::build_icon_atlas(cps);
                g_atlas_bound = false;
                g_gpu_dirty   = true;
                g_app.parsed_types = pt;
                g_app.types_draft  = std::move(pt);
                g_app.types_dirty  = false;
                ge::views_inspector_refresh_caches(g_app);
            }
        }
    }
    // Conteo de uso para el modal de borrado (entidades activas en BD).
    if (g_app.show_te_delete_modal && g_app.te_delete_use_count == 0
        && !g_app.input_db_path.empty()) {
        const char* tname = nullptr;
        if (g_app.te_pending_delete_e >= 0
            && g_app.te_pending_delete_e < (int)g_app.types_draft.entities.size()) {
            tname = g_app.types_draft.entities[g_app.te_pending_delete_e].name.c_str();
        }
        if (tname && *tname) {
            sqlite3* db = nullptr;
            if (sqlite3_open_v2(g_app.input_db_path.c_str(), &db,
                                 SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK) {
                sqlite3_stmt* st = nullptr;
                if (sqlite3_prepare_v2(db,
                        "SELECT COUNT(*) FROM entities WHERE type_ref = ?",
                        -1, &st, nullptr) == SQLITE_OK) {
                    sqlite3_bind_text(st, 1, tname, -1, SQLITE_TRANSIENT);
                    if (sqlite3_step(st) == SQLITE_ROW) {
                        g_app.te_delete_use_count = sqlite3_column_int(st, 0);
                    }
                    sqlite3_finalize(st);
                }
                sqlite3_close(db);
            }
        }
    }

    // ---- Mutaciones (add/delete/duplicate/change_type) ----
    auto reload_after_mutation = [&]() {
        // Auto-save antes de liberar el grafo (issue 0031).
        if (g_loaded && g_graph_hash != 0) {
            ge::layout_store_save(g_graph_hash, g_graph);
        }
        graph::GraphLoadStats stats{};
        if (!ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) return;
        ge::entity_index_build(g_input.uri, &g_idx);
        ge::views_reset_visibility(g_app);
        ge::views_apply_visibility(g_app);

        // Reaplica types.yaml + atlas. Sin esto, despues de cualquier
        // mutacion los tipos pierden color/shape/icon (todo nodo vuelve a
        // circulo gris). Issue: al promover desde node_groups el Table
        // dejaba de ser cuadrado.
        if (!g_app.parsed_types.entities.empty() ||
            !g_app.parsed_types.relations.empty()) {
            std::vector<uint16_t> cps = ge::apply_types_yaml(g_graph, g_app.parsed_types);
            if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
            g_atlas = ge::build_icon_atlas(cps);
            g_atlas_bound = false;
            g_gpu_dirty   = true;
        }

        // Refresh Table node counts (issue 0010).
        ge::node_groups_refresh_counts(g_input.uri, &g_app.node_groups_counts);

        // Sincroniza windows (issue 0011) por si una Table aparecio o desaparecio.
        ge::views_node_groups_windows_sync(g_app, g_input.uri);

        // Refresh table cache (issue 0004).
        std::vector<ge::EntityRowSnapshot> snap;
        if (ge::entity_list_rows(g_input.uri, &snap)) {
            g_app.table_rows.clear();
            g_app.table_rows.reserve(snap.size());
            for (auto& s : snap) {
                ge::AppState::TableRow tr;
                tr.id         = std::move(s.id);
                tr.name       = std::move(s.name);
                tr.type_ref   = std::move(s.type_ref);
                tr.status     = std::move(s.status);
                tr.updated_at = std::move(s.updated_at);
                tr.group_id   = std::move(s.group_id);
                g_app.table_rows.push_back(std::move(tr));
            }
            ge::views_table_refresh_indices(g_app);
        }

        // Restablece posiciones guardadas. Los nodos nuevos no tienen
        // posicion en el layout_store y caen en (0,0).
        int restored = ge::layout_store_load(g_graph_hash, g_graph);
        (void)restored;

        // Halo placement: prefiere vecino, fallback a la camara con anti-
        // colision. Los nodos nuevos aparecen DENTRO de la camara y NO
        // encima de otros — el usuario los ve sin pan/zoom.
        // (cam_x, cam_y) es el world point en el centro de la pantalla.
        float cam_cx = g_viewport.cam_x;
        float cam_cy = g_viewport.cam_y;
        float cam_r  = 80.0f / (g_viewport.zoom > 0.01f
                                 ? g_viewport.zoom : 0.01f);
        place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
                                      /*use_camera=*/true,
                                      cam_cx, cam_cy, cam_r);
        g_graph.update_bounds();
        g_atlas_bound = false;
        g_gpu_dirty = true;
    };

    if (g_app.want_add_node && g_app.add_buf[0]) {
        char new_id[80];
        if (ge::entity_insert(g_app.input_db_path.c_str(), g_app.add_buf,
                              /*type_ref=*/nullptr, new_id, sizeof(new_id))) {
            std::fprintf(stdout, "[graph_explorer] added entity %s\n", new_id);
            g_app.add_buf[0] = 0;
            reload_after_mutation();
        } else {
            std::fprintf(stderr, "[graph_explorer] add_entity failed\n");
        }
        g_app.want_add_node = false;
    }

    auto ctx_id = [&]() -> const char* {
        if (g_app.ctx_node < 0 || g_app.ctx_node >= g_graph.node_count) return nullptr;
        return ge::entity_index_lookup(g_idx, g_graph.nodes[g_app.ctx_node].user_data);
    };

    if (g_app.want_delete_node) {
        if (const char* id = ctx_id()) {
            if (ge::entity_delete(g_app.input_db_path.c_str(), id)) {
                std::fprintf(stdout, "[graph_explorer] deleted entity %s\n", id);
                reload_after_mutation();
            }
        }
        g_app.want_delete_node = false;
        g_app.ctx_node = -1;
    }

    if (g_app.want_duplicate_node) {
        if (const char* id = ctx_id()) {
            char new_id[80];
            if (ge::entity_duplicate(g_app.input_db_path.c_str(), id,
                                      new_id, sizeof(new_id))) {
                std::fprintf(stdout, "[graph_explorer] duplicated %s -> %s\n", id, new_id);
                reload_after_mutation();
            }
        }
        g_app.want_duplicate_node = false;
    }

    if (g_app.want_change_type && g_app.ctx_new_type[0]) {
        if (const char* id = ctx_id()) {
            if (ge::entity_update_type(g_app.input_db_path.c_str(), id, g_app.ctx_new_type)) {
                std::fprintf(stdout, "[graph_explorer] %s -> type %s\n", id, g_app.ctx_new_type);
                reload_after_mutation();
            }
        }
        g_app.want_change_type = false;
    }

    // ---- Table node UI fase 2 (issue 0011) ----
    if (g_app.want_toggle_nodegroups && !g_app.toggle_nodegroups_id.empty()
        && !g_input_path.empty()) {
        std::string id = g_app.toggle_nodegroups_id;
        bool currently = g_app.node_groups_windows.find(id) != g_app.node_groups_windows.end();
        ge::node_groups_set_expanded(g_input_path.c_str(), id.c_str(), !currently);
        ge::views_node_groups_windows_sync(g_app, g_input_path.c_str());
        g_app.want_toggle_nodegroups = false;
        g_app.toggle_nodegroups_id.clear();
    }
    // Cierre via X de la ventana -> bajar expanded en BD (solo kind=Table).
    // En kind=Group no hay metadata `expanded`; basta con borrar la entry.
    for (auto it = g_app.node_groups_windows.begin(); it != g_app.node_groups_windows.end(); ) {
        if (!it->second.open) {
            if (it->second.kind == ge::NodeGroupsKind::Table
                && !g_input_path.empty()) {
                ge::node_groups_set_expanded(g_input_path.c_str(),
                                             it->first.c_str(), false);
            }
            it = g_app.node_groups_windows.erase(it);
        } else ++it;
    }
    // Refrescar la pagina si alguna window esta dirty.
    for (auto& kv : g_app.node_groups_windows) {
        auto& w = kv.second;
        if (!w.page_dirty) continue;
        const auto& m = w.meta;
        w.last_error.clear();

        if (w.kind == ge::NodeGroupsKind::Group) {
            // kind=Group: contar y paginar entidades hijas via group_id.
            bool ok_count = ge::node_groups_count_for_group(
                                g_input_path.c_str(),
                                m.entity_id.c_str(), &w.total_rows);
            if (!ok_count) {
                char buf[256];
                std::snprintf(buf, sizeof(buf),
                    "group count failed | container=%s", m.entity_id.c_str());
                w.last_error = buf;
                std::fprintf(stderr, "[graph_explorer] %s\n", buf);
            }
            bool ok_page = ge::node_groups_page_for_group(
                                g_input_path.c_str(),
                                m.entity_id.c_str(),
                                w.offset, 200, &w.page);
            if (!ok_page && w.last_error.empty()) {
                char buf[256];
                std::snprintf(buf, sizeof(buf),
                    "group page query failed | offset=%lld limit=200",
                    (long long)w.offset);
                w.last_error = buf;
                std::fprintf(stderr, "[graph_explorer] %s\n", buf);
            }
            w.page_dirty = false;
            continue;
        }

        // kind=Table: comportamiento original (DuckDB).
        bool ok_count = ge::node_groups_count(m.duckdb_path_abs.c_str(),
                                              m.table_name.c_str(),
                                              m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
                                              &w.total_rows);
        if (!ok_count) {
            char buf[512];
            std::snprintf(buf, sizeof(buf),
                "count failed | duckdb=%s table=%s",
                m.duckdb_path_abs.c_str(), m.table_name.c_str());
            w.last_error = buf;
            std::fprintf(stderr, "[graph_explorer] %s\n", buf);
        }
        if (m.columns.empty()) {
            std::vector<std::string> cols;
            if (ge::node_groups_list_columns(m.duckdb_path_abs.c_str(),
                                            m.table_name.c_str(), &cols)) {
                ge::node_groups_set_columns(g_input_path.c_str(),
                                            m.entity_id.c_str(), cols);
                w.meta.columns = cols;
            }
        }
        bool ok_page = ge::node_groups_page(m.duckdb_path_abs.c_str(),
                             m.table_name.c_str(), m.id_column.c_str(),
                             w.meta.columns,
                             m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
                             g_input_path.c_str(), m.row_type.c_str(),
                             w.offset, 200, &w.page);
        if (!ok_page && w.last_error.empty()) {
            char buf[256];
            std::snprintf(buf, sizeof(buf),
                "page query failed | offset=%lld limit=200", (long long)w.offset);
            w.last_error = buf;
            std::fprintf(stderr, "[graph_explorer] %s\n", buf);
        }
        w.page_dirty = false;
    }
    if (g_app.want_promote_row && !g_app.promote_table_id.empty()
        && !g_input_path.empty()) {
        ge::NodeGroupsMeta m;
        if (ge::node_groups_get_metadata(g_input_path.c_str(),
                                          g_app.promote_table_id.c_str(), &m)) {
            char new_id[128] = {};
            if (ge::node_groups_promote_row(g_input_path.c_str(),
                                             g_app.promote_table_id.c_str(),
                                             m.duckdb_path_abs.c_str(),
                                             m.table_name.c_str(),
                                             g_app.promote_row_id.c_str(),
                                             m.row_type.c_str(),
                                             m.label_column.c_str(),
                                             new_id, sizeof(new_id))) {
                std::fprintf(stdout, "[promote] %s -> %s\n",
                              g_app.promote_row_id.c_str(), new_id);
                auto it = g_app.node_groups_windows.find(g_app.promote_table_id);
                if (it != g_app.node_groups_windows.end()) it->second.page_dirty = true;
                reload_after_mutation();
                g_app.want_focus_entity = true;
                g_app.focus_entity_id   = new_id;
            }
        }
        g_app.want_promote_row = false;
        g_app.promote_table_id.clear();
        g_app.promote_row_id.clear();
    }
    if (g_app.want_demote_entity && !g_app.demote_entity_id.empty()
        && !g_input_path.empty()) {
        if (ge::node_groups_demote_row(g_input_path.c_str(),
                                        g_app.demote_entity_id.c_str())) {
            std::fprintf(stdout, "[demote] %s\n", g_app.demote_entity_id.c_str());
            for (auto& kv : g_app.node_groups_windows) kv.second.page_dirty = true;
            reload_after_mutation();
        }
        g_app.want_demote_entity = false;
        g_app.demote_entity_id.clear();
    }
    // Issue 0036d: promote out-of-group (kind=Group de NodeGroups window).
    if (g_app.want_clear_group_id_entity
        && !g_app.clear_group_id_entity_id.empty()
        && !g_input_path.empty()) {
        if (ge::entity_clear_group_id(g_input_path.c_str(),
                                       g_app.clear_group_id_entity_id.c_str())) {
            std::fprintf(stdout, "[node_groups] promoted %s out of group\n",
                          g_app.clear_group_id_entity_id.c_str());
            for (auto& kv : g_app.node_groups_windows) kv.second.page_dirty = true;
            reload_after_mutation();
        }
        g_app.want_clear_group_id_entity = false;
        g_app.clear_group_id_entity_id.clear();
    }
    if (g_app.want_focus_entity && !g_app.focus_entity_id.empty()) {
        for (int i = 0; i < g_graph.node_count; ++i) {
            const char* sid = ge::entity_index_lookup(
                                 g_idx, g_graph.nodes[i].user_data);
            if (sid && g_app.focus_entity_id == sid) {
                g_app.filter_focus_target = i;
                graph_viewport_clear_selection(g_graph, g_viewport);
                graph_viewport_add_to_selection(g_graph, g_viewport, i);
                g_app.panel_inspector = true;
                ge::views_inspector_load_draft(g_app, i, sid);
                g_app.insp_node_idx   = i;
                g_app.insp_entity_id  = sid;
                break;
            }
        }
        g_app.want_focus_entity = false;
        g_app.focus_entity_id.clear();
    }
    if (g_app.want_import) {
        g_app.want_import = false;
        g_app.import_error.clear();
        std::string duck_abs = ge::node_groups_resolve_path(
                                  g_input_path.c_str(), g_app.import_duckdb_buf);
        std::string err;
        if (!ge::node_groups_ingest_file(duck_abs.c_str(),
                                          g_app.import_path_buf,
                                          g_app.import_table_buf,
                                          ge::INGEST_AUTO, &err)) {
            g_app.import_error = "Ingest failed: " + err;
        } else {
            char new_id[80] = {};
            if (ge::node_groups_create(g_input_path.c_str(),
                                        g_app.import_table_buf,
                                        g_app.import_duckdb_buf,
                                        g_app.import_table_buf,
                                        g_app.import_row_type_buf,
                                        new_id, sizeof(new_id))) {
                std::fprintf(stdout, "[import] %s -> %s\n",
                              g_app.import_path_buf, new_id);
                g_app.show_import_modal = false;
                reload_after_mutation();
            } else {
                g_app.import_error = "Tabla DuckDB creada pero no se pudo registrar el nodo.";
            }
        }
    }

    // ---- Inspector (issue 0008): sync draft con seleccion + save/discard ----
    {
        const auto& sel = g_viewport.selection;
        if (sel.size() == 1) {
            int sidx = sel.front();
            if (sidx >= 0 && sidx < g_graph.node_count
                && sidx != g_app.insp_node_idx
                && !g_app.insp_dirty) {
                const char* sql_id = ge::entity_index_lookup(
                    g_idx, g_graph.nodes[sidx].user_data);
                ge::views_inspector_load_draft(g_app, sidx, sql_id);
            }
        }
    }

    if (g_app.want_inspector_save && !g_app.insp_entity_id.empty()) {
        ge::EntityRecord rec = ge::views_inspector_build_record(g_app);
        if (ge::entity_update(g_app.input_db_path.c_str(), rec)) {
            std::fprintf(stdout, "[graph_explorer] saved entity %s\n",
                          rec.id.c_str());
            // Reload del grafo para que cambios de name/type/etc. se reflejen
            // en el viewport (label, color del tipo, etc.).
            graph::GraphLoadStats stats{};
            if (ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) {
                ge::entity_index_build(g_input.uri, &g_idx);
                ge::views_reset_visibility(g_app);
                ge::views_apply_visibility(g_app);
                int restored = ge::layout_store_load(g_graph_hash, g_graph);
                (void)restored;
                g_atlas_bound = false;
                g_gpu_dirty = true;
            }
            ge::views_inspector_refresh_caches(g_app);
            // Re-cargar draft tras el reload (los node_idx pueden haber cambiado
            // por reordenamiento de la BD). Buscamos el nuevo idx por sql_id.
            int new_idx = -1;
            for (int i = 0; i < g_graph.node_count; ++i) {
                const char* sid = ge::entity_index_lookup(
                    g_idx, g_graph.nodes[i].user_data);
                if (sid && rec.id == sid) { new_idx = i; break; }
            }
            if (new_idx >= 0) {
                ge::views_inspector_load_draft(g_app, new_idx, rec.id.c_str());
                graph_viewport_clear_selection(g_graph, g_viewport);
                graph_viewport_add_to_selection(g_graph, g_viewport, new_idx);
            } else {
                ge::views_inspector_clear_draft(g_app);
            }
        } else {
            std::fprintf(stderr, "[graph_explorer] entity_update failed for %s\n",
                          rec.id.c_str());
        }
        g_app.want_inspector_save = false;
    }

    if (g_app.want_inspector_discard && !g_app.insp_entity_id.empty()) {
        int idx = g_app.insp_node_idx;
        std::string id = g_app.insp_entity_id;
        ge::views_inspector_load_draft(g_app, idx, id.c_str());
        g_app.want_inspector_discard = false;
    }

    // Reset layout: limpia NF_PINNED en todos los nodos. El layout activo se
    // reaplica via apply_layout_tick (la toolbar ya lo incrementa).
    if (g_app.want_unpin_all) {
        for (int i = 0; i < g_graph.node_count; ++i) {
            g_graph.nodes[i].flags &= ~NF_PINNED;
            g_graph.nodes[i].vx = 0.0f;
            g_graph.nodes[i].vy = 0.0f;
        }
        g_viewport.layout_running = true;
        g_app.want_unpin_all = false;
    }

    // Note editor — abrir / guardar.
    // Excepcion (issue 0036c): si el nodo es un Group o un Table, abrir/enfocar
    // la NodeGroups window correspondiente en lugar del panel Note.
    if (g_app.want_open_note && g_app.open_note_target >= 0
        && g_app.open_note_target < g_graph.node_count) {
        int n = g_app.open_note_target;
        const char* sql_id = ge::entity_index_lookup(g_idx, g_graph.nodes[n].user_data);

        // Detectar si el nodo es de tipo Group o Table.
        bool is_group = false;
        bool is_table = false;
        uint16_t tid = g_graph.nodes[n].type_id;
        const char* type_name = (tid < (uint16_t)g_graph.type_count
                                    && g_graph.types[tid].name)
                                    ? g_graph.types[tid].name : "";
        if (type_name && std::strcmp(type_name, "Group") == 0) is_group = true;
        else if (type_name && std::strcmp(type_name, "Table") == 0) is_table = true;

        if ((is_group || is_table) && sql_id) {
            // Drill-in: abre/enfoca la NodeGroups window. views_node_groups_open
            // crea la entry si no existe, marca focus_request=true en cualquier
            // caso (el render lo consume con SetWindowFocus).
            ge::NodeGroupsKind kind = is_group
                ? ge::NodeGroupsKind::Group
                : ge::NodeGroupsKind::Table;
            ge::views_node_groups_open(g_app, sql_id, kind,
                                        g_input_path.c_str());
        } else if (sql_id) {
            std::string md;
            ge::entity_get_notes(g_app.input_db_path.c_str(), sql_id, &md);
            g_app.note_node = n;
            g_app.note_entity_id = sql_id;
            const char* lbl = graph::graph_label(&g_graph, g_graph.nodes[n].label_idx);
            g_app.note_entity_label = lbl ? lbl : "";
            g_app.note_entity_type = type_name;
            // Asegura buffer >= max(64KB, contenido + holgura).
            size_t need = md.size() + 4096;
            if (need < 65536) need = 65536;
            g_app.note_buf.assign(need, 0);
            std::memcpy(g_app.note_buf.data(), md.data(), md.size());
            g_app.note_dirty = false;
            g_app.panel_note = true;
            ImGui::SetWindowFocus(TI_FILE_TEXT " Note");
        }
        g_app.want_open_note = false;
        g_app.open_note_target = -1;
    }

    if (g_app.want_save_note && !g_app.note_entity_id.empty()) {
        if (ge::entity_set_notes(g_app.input_db_path.c_str(),
                                  g_app.note_entity_id.c_str(),
                                  g_app.note_buf.data())) {
            g_app.note_dirty = false;
            std::fprintf(stdout, "[graph_explorer] saved note for %s (%zu bytes)\n",
                          g_app.note_entity_id.c_str(),
                          std::strlen(g_app.note_buf.data()));
        } else {
            std::fprintf(stderr, "[graph_explorer] save note failed\n");
        }
        g_app.want_save_note = false;
    }

    // Posiciones iniciales razonables; el usuario puede moverlas y se
    // persiste via imgui.ini.
    const float top   = vp->WorkPos.y + 44.0f;
    const float W     = vp->WorkSize.x;
    const float H     = vp->WorkSize.y - 44.0f;
    const float lw    = 240.0f;   // Legend
    const float rw    = 320.0f;   // Inspector / Stats
    const float sh    = H * 0.55f; // Inspector altura

    // Viewport — ventana central
    if (g_app.panel_viewport) {
        ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + lw, top), ImGuiCond_FirstUseEver);
        ImGui::SetNextWindowSize(ImVec2(W - lw - rw, H),          ImGuiCond_FirstUseEver);
        if (ImGui::Begin("Viewport", &g_app.panel_viewport)) {
            run_force_step();

            GraphViewportCallbacks vp_cb{};
            vp_cb.on_context_menu = &on_context_menu_cb;
            vp_cb.on_double_click = &on_double_click_cb;
            graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb);
            render_context_menu();

            // La primera vez que el viewport se dibuja, el renderer existe —
            // bind del atlas (si tenemos uno).
            if (!g_atlas_bound && g_viewport.renderer) {
                if (g_atlas) {
                    graph_renderer_set_icon_atlas(g_viewport.renderer,
                                                   graph_icons_texture(g_atlas),
                                                   graph_icons_uv_table(g_atlas),
                                                   graph_icons_count(g_atlas));
                }
                g_atlas_bound = true;
            }

            if (g_app.labels_enabled) {
                graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
                                          &get_label_cb, nullptr);
            }
            // Table node overlay (issue 0010) — encima de las labels.
            ge::views_node_groups_overlay(g_app);
        }
        ImGui::End();
    } else {
        // Sin ventana visible, igual avanzamos la simulacion para que al
        // reabrirla el grafo este actualizado.
        run_force_step();
    }

    // Legend — izquierda
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, top), ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(lw, H),              ImGuiCond_FirstUseEver);
    ge::views_legend(g_app);

    // Inspector / Stats — derecha (apilados)
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top), ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(rw, sh),                      ImGuiCond_FirstUseEver);
    ge::views_inspector(g_app);

    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top + sh), ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(rw, H - sh),                       ImGuiCond_FirstUseEver);
    ge::views_stats(g_app);

    // Note editor — al abrirse por primera vez se posiciona como ventana
    // centrada. El usuario la puede dockear donde prefiera.
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.25f, top + 40.0f),
                              ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(700.0f, 480.0f), ImGuiCond_FirstUseEver);
    ge::views_note(g_app);

    // Type Editor (issue 0007) — flotante, dockeable.
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.20f, top + 40.0f),
                              ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(720.0f, 500.0f), ImGuiCond_FirstUseEver);
    ge::views_type_editor(g_app);
    ge::views_type_editor_delete_modal(g_app);

    // Table view (issue 0004) — flotante, dockeable.
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.15f, top + 60.0f),
                              ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(820.0f, 520.0f), ImGuiCond_FirstUseEver);
    ge::views_table(g_app);

    // Table node windows (issue 0011) — una por Table expandida.
    ge::views_node_groups_window(g_app);
    ge::views_import_dataset_modal(g_app);

    // Jobs panel (issue 0026) — flotante, dockeable.
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.20f, top + 40.0f),
                              ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
    ge::views_jobs(g_app);

    // Chat panel (claude -p) — flotante, dockeable.
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.55f, top + 40.0f),
                              ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver);
    app_agent::chat_render(&g_app.panel_chat);

    // Extract panel (issue 0013) — flotante, dockeable.
    ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.30f, top + 50.0f),
                              ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSize(ImVec2(720.0f, 640.0f), ImGuiCond_FirstUseEver);
    ge::extract_panel_render(g_app);

    // Enricher config window (abierto desde context menu Run enricher).
    render_enricher_config_window();

    g_first_render = false;
}

// ----------------------------------------------------------------------------
// CLI parsing
// ----------------------------------------------------------------------------

static void usage() {
    std::fprintf(stderr,
        "Usage: graph_explorer [<operations.db>]\n"
        "       graph_explorer --input operations <path>\n"
        "       graph_explorer --types <types.yaml>\n"
        "       graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n"
        "       graph_explorer --project <slug>\n"
        "       graph_explorer --test-types-yaml <path>   (load+save+reload smoke test)\n"
        "       graph_explorer --test-duckdb <path>        (open + SELECT 42 smoke test)\n"
        "       graph_explorer --test-tableview <path>     (1M rows count + page test)\n");
}

// Smoke test del parser+writer (issue 0005 round-trip): carga `path`,
// serializa a un temporal y vuelve a cargar. Compara campos clave de
// ParsedTypes. Devuelve exit code 0 si OK, 1 si discrepancia, 2 si error.
static int test_types_yaml_roundtrip(const char* path) {
    ge::ParsedTypes pt1;
    std::string err;
    if (!ge::types_load_yaml(path, &pt1, &err)) {
        std::fprintf(stderr, "[test] load1 fail: %s\n", err.c_str());
        return 2;
    }
    std::string tmp = std::string(path) + ".roundtrip.yaml";
    if (!ge::types_save_yaml(tmp.c_str(), pt1, &err)) {
        std::fprintf(stderr, "[test] save fail: %s\n", err.c_str());
        return 2;
    }
    ge::ParsedTypes pt2;
    if (!ge::types_load_yaml(tmp.c_str(), &pt2, &err)) {
        std::fprintf(stderr, "[test] load2 fail: %s\n", err.c_str());
        return 2;
    }

    auto cmp = [&]() -> bool {
        if (pt1.entities.size() != pt2.entities.size()) return false;
        if (pt1.relations.size() != pt2.relations.size()) return false;
        for (size_t i = 0; i < pt1.entities.size(); ++i) {
            const auto& a = pt1.entities[i];
            const auto& b = pt2.entities[i];
            if (a.name != b.name)                       return false;
            if (a.color != b.color)                     return false;
            if (a.icon_name != b.icon_name)             return false;
            if (a.principal_field != b.principal_field) return false;
            if (a.fields.size() != b.fields.size())     return false;
            for (size_t j = 0; j < a.fields.size(); ++j) {
                const auto& fa = a.fields[j];
                const auto& fb = b.fields[j];
                if (fa.name != fb.name)             return false;
                if (fa.kind != fb.kind)             return false;
                if (fa.required != fb.required)     return false;
                if (fa.enum_values != fb.enum_values) return false;
            }
        }
        for (size_t i = 0; i < pt1.relations.size(); ++i) {
            const auto& a = pt1.relations[i];
            const auto& b = pt2.relations[i];
            if (a.name != b.name)   return false;
            if (a.color != b.color) return false;
            if (a.style != b.style) return false;
        }
        return true;
    };

    int total_fields = 0;
    for (const auto& e : pt1.entities) total_fields += (int)e.fields.size();

    if (cmp()) {
        std::fprintf(stdout,
            "[test] PASS — %zu entities, %d fields, %zu relations (round-trip estable)\n",
            pt1.entities.size(), total_fields, pt1.relations.size());
        std::remove(tmp.c_str());
        return 0;
    }
    std::fprintf(stderr,
        "[test] FAIL — discrepancia tras round-trip. dump preservado en %s\n",
        tmp.c_str());
    return 1;
}

int main(int argc, char** argv) {
    bool       legacy_mode      = false;     // --input / positional dado: NO usar proyecto
    std::string project_arg;                 // --project <slug> (puede estar vacio)

    for (int i = 1; i < argc; ++i) {
        const char* a = argv[i];
        if (std::strcmp(a, "--input") == 0 && i + 2 < argc) {
            const char* kind = argv[++i];
            const char* path = argv[++i];
            if (std::strcmp(kind, "operations") == 0) {
                g_input_path = path;
                legacy_mode  = true;
            } else {
                std::fprintf(stderr, "[graph_explorer] unsupported input kind: %s\n", kind);
                return 1;
            }
        } else if (std::strcmp(a, "--types") == 0 && i + 1 < argc) {
            g_types_path = argv[++i];
        } else if (std::strcmp(a, "--layout") == 0 && i + 1 < argc) {
            g_layout_initial = argv[++i];
        } else if (std::strcmp(a, "--project") == 0 && i + 1 < argc) {
            project_arg = argv[++i];
        } else if (std::strcmp(a, "--test-types-yaml") == 0 && i + 1 < argc) {
            return test_types_yaml_roundtrip(argv[++i]);
        } else if (std::strcmp(a, "--test-duckdb") == 0 && i + 1 < argc) {
            const char* p = argv[++i];
            if (!ge::node_groups_smoke_test(p)) {
                std::fprintf(stderr, "[duckdb] smoke test FAILED for %s\n", p);
                return 2;
            }
            std::fprintf(stdout, "[duckdb] smoke test OK (SELECT 42 -> 42) on %s\n", p);
            return 0;
        } else if (std::strcmp(a, "--test-tableview") == 0 && i + 1 < argc) {
            // Crea 1M filas en duckdb_path/people, cuenta y pagina.
            const char* p = argv[++i];
            std::remove(p);  // empezar desde cero
            duckdb_database db = nullptr;
            duckdb_connection cn = nullptr;
            if (duckdb_open(p, &db) == DuckDBError) { std::fprintf(stderr, "open fail\n"); return 2; }
            duckdb_connect(db, &cn);
            duckdb_result r;
            if (duckdb_query(cn,
                "CREATE TABLE people AS "
                "SELECT range AS id, 'name_' || CAST(range AS VARCHAR) AS name, "
                "       (range * 7) % 100 AS age FROM range(1000000)", &r) == DuckDBError) {
                std::fprintf(stderr, "create fail: %s\n",
                              duckdb_result_error(&r) ? duckdb_result_error(&r) : "?");
                duckdb_destroy_result(&r);
                duckdb_disconnect(&cn); duckdb_close(&db); return 2;
            }
            duckdb_destroy_result(&r);
            duckdb_disconnect(&cn); duckdb_close(&db);

            int64_t total = 0;
            if (!ge::node_groups_count(p, "people", nullptr, &total) || total != 1000000) {
                std::fprintf(stderr, "[node_groups_count] expected 1000000, got %lld\n",
                              (long long)total);
                return 2;
            }
            std::vector<std::string> cols = { "name", "age" };
            std::vector<ge::NodeGroupsRow> page;
            if (!ge::node_groups_page(p, "people", "id", cols, nullptr,
                                     nullptr, nullptr, 500000, 10, &page)) {
                std::fprintf(stderr, "[node_groups_page] failed\n");
                return 2;
            }
            if (page.size() != 10) {
                std::fprintf(stderr, "[node_groups_page] expected 10 rows, got %zu\n",
                              page.size());
                return 2;
            }
            std::fprintf(stdout,
                "[node_groups] OK — count=%lld, page[0]={id=%s, name=%s, age=%s}\n",
                (long long)total, page[0].id.c_str(),
                page[0].values.size() > 0 ? page[0].values[0].c_str() : "",
                page[0].values.size() > 1 ? page[0].values[1].c_str() : "");
            return 0;
        } else if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
            usage();
            return 0;
        } else if (a[0] == '-') {
            std::fprintf(stderr, "[graph_explorer] unknown flag: %s\n", a);
            usage();
            return 1;
        } else {
            // Positional: tratado como operations.db (legacy)
            if (g_input_path.empty()) {
                g_input_path = a;
                legacy_mode  = true;
            }
        }
    }

    if (legacy_mode) {
        // Modo legacy: paths sueltos en local_files/ (graph_explorer.db
        // como fallback cuando no se ha cargado un proyecto).
        std::string legacy_db = fn::local_path("graph_explorer.db");
        ge::layout_store_open(legacy_db.c_str());
        g_layout_db_path = legacy_db;
        if (!g_input_path.empty()) {
            load_input();
        }
        panel_state_load_db(g_layout_db_path, g_panels,
                              sizeof(g_panels) / sizeof(g_panels[0]));
    } else {
        // Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
        // crea default si no existe ninguno.
        ge::projects_migrate_legacy_layout();

        std::string target = project_arg;
        if (target.empty()) {
            ge::ProjectSettings ps;
            ge::project_settings_load(&ps);
            target = ps.last_active;
        }
        if (target.empty()) target = ge::k_default_project;

        if (!ge::project_exists(target.c_str())) {
            std::string err;
            if (!ge::project_create(target.c_str(), &err)) {
                std::fprintf(stderr,
                    "[graph_explorer] no se pudo crear el proyecto '%s': %s\n",
                    target.c_str(), err.c_str());
                return 1;
            }
            std::fprintf(stdout,
                "[graph_explorer] proyecto creado: projects/%s/\n", target.c_str());
        }

        apply_project_paths(target);
        // Migracion idempotente del schema (issue 0035a y siguientes).
        {
            std::string mig_err;
            if (!ge::project_migrate_schema(g_input_path, &mig_err)) {
                std::fprintf(stderr,
                    "[graph_explorer] project_migrate_schema('%s') failed: %s\n",
                    g_input_path.c_str(), mig_err.c_str());
            }
        }
        ge::layout_store_open(g_layout_db_path.c_str());
        ge::project_settings_touch(target.c_str());
        load_input();
        panel_state_load_db(g_layout_db_path, g_panels,
                              sizeof(g_panels) / sizeof(g_panels[0]));
    }

    fn_ui::about_window_set_info(
        "graph_explorer",
        "0.1.0",
        "Visor de grafos GPU-accelerated agnostico del backend. Lee operations.db de "
        "cualquier app del registry y permite explorar entidades/relaciones con "
        "shapes/iconos/layouts/filtros.");

    // issue 0026 — sistema de jobs + enrichers.
    {
        std::string registry_root = resolve_registry_root();
        std::string app_dir = registry_root.empty()
            ? "."
            : registry_root + "/projects/osint_graph/apps/graph_explorer";

        // Convencion assets/: enrichers vienen empaquetados en
        // <exe_dir>/assets/enrichers/. Fallback al app_dir del repo
        // para modo dev local cuando se ejecuta desde build/.
        std::string enrichers_dir;
        {
            std::string assets_enrichers = fn::asset_path("enrichers");
            struct stat st{};
            if (::stat(assets_enrichers.c_str(), &st) == 0 &&
                S_ISDIR(st.st_mode)) {
                enrichers_dir = assets_enrichers;
            } else {
                enrichers_dir = app_dir + "/enrichers";
            }
        }

        // graph_explorer.db es el mismo SQLite usado por layout_store.
        // Default a <local_files>/graph_explorer.db si no hay proyecto.
        std::string fallback_db = fn::local_path("graph_explorer.db");
        const char* app_db = g_layout_db_path.empty()
            ? fallback_db.c_str() : g_layout_db_path.c_str();

        // Layout storage — guardado/cargado de layouts ImGui en
        // graph_explorer.db. El menu Layouts del menubar consume estos cb.
        if (g_layout_db_path.empty()) {
            std::fprintf(stderr,
                "[graph_explorer] layout storage skipped (no db_path)\n");
        } else {
            g_layout_storage = fn_ui::layout_storage_open(
                g_layout_db_path.c_str());
            if (g_layout_storage) {
                fn_ui::layout_storage_make_callbacks(
                    g_layout_storage, g_layout_cb);
                std::fprintf(stdout,
                    "[graph_explorer] layout storage abierto en %s\n",
                    g_layout_db_path.c_str());
            } else {
                std::fprintf(stderr,
                    "[graph_explorer] layout_storage_open fallo: %s\n",
                    g_layout_db_path.c_str());
            }
        }

        ge::enrichers_load(enrichers_dir.c_str());
        if (!ge::jobs_init(app_db,
                            g_input.uri ? g_input.uri : "",
                            enrichers_dir.c_str(),
                            app_dir.c_str(),
                            registry_root.c_str(),
                            /*n_workers=*/2)) {
            std::fprintf(stderr, "[graph_explorer] jobs_init failed (panel disabled)\n");
        } else {
            std::fprintf(stdout,
                "[graph_explorer] jobs_init OK — enrichers_dir=%s, registry_root=%s, %d enrichers\n",
                enrichers_dir.c_str(), registry_root.c_str(),
                (int)ge::enrichers_all().size());
        }

        // Extract panel (issue 0013) — invoca enrichers/paste_extract/run.py
        // directamente en su propio hilo, sin pasar por el sistema de jobs.
        ge::extract_panel_init(enrichers_dir.c_str(),
                                app_dir.c_str(),
                                registry_root.c_str());

        // Chat panel (claude -p) — el agente invoca gx-cli para mutar
        // operations.db. agent_mutations counter en graph_explorer.db dispara
        // reload del viewport en cada cambio.
        if (!app_agent::chat_init(g_input.uri ? g_input.uri : "",
                            app_db, app_dir.c_str())) {
            std::fprintf(stderr,
                "[graph_explorer] chat_init: claude no detectado "
                "(panel Chat deshabilitado)\n");
        }
    }

    // 0036f — accion en el menu View que abre la NodeGroups window del
    // nodo seleccionado. Disabled si la seleccion no es Table ni Group.
    auto view_extras_cb = []() -> bool {
        // Resolver seleccion actual: priorizamos el nodo del viewport;
        // si no hay nada seleccionado en el canvas pero el inspector
        // tiene una entidad cargada, usamos esa.
        int sel_idx = -1;
        if (!g_viewport.selection.empty()) {
            int s = g_viewport.selection.front();
            if (s >= 0 && s < g_graph.node_count) sel_idx = s;
        }
        if (sel_idx < 0
            && g_app.insp_node_idx >= 0
            && g_app.insp_node_idx < g_graph.node_count) {
            sel_idx = g_app.insp_node_idx;
        }

        bool is_table = false;
        bool is_group = false;
        const char* sql_id = nullptr;
        if (sel_idx >= 0) {
            uint16_t tid = g_graph.nodes[sel_idx].type_id;
            const char* tn = (tid < (uint16_t)g_graph.type_count
                              && g_graph.types[tid].name)
                                 ? g_graph.types[tid].name : "";
            if (tn && std::strcmp(tn, "Table") == 0) is_table = true;
            else if (tn && std::strcmp(tn, "Group") == 0) is_group = true;
            sql_id = ge::entity_index_lookup(
                        g_idx, g_graph.nodes[sel_idx].user_data);
        }
        const bool enabled = (is_table || is_group) && sql_id != nullptr;

        ImGui::Separator();
        bool acted = false;
        if (!enabled) ImGui::BeginDisabled();
        if (ImGui::MenuItem(TI_FOLDER_OPEN " Open NodeGroups for selected")) {
            if (enabled) {
                ge::NodeGroupsKind kind = is_group
                    ? ge::NodeGroupsKind::Group
                    : ge::NodeGroupsKind::Table;
                ge::views_node_groups_open(g_app, sql_id, kind,
                                            g_input_path.c_str());
                acted = true;
            }
        }
        if (!enabled) ImGui::EndDisabled();
        if (!enabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
            ImGui::SetTooltip("Select a Table or Group node first");
        }
        return acted;
    };

    int rc = fn::run_app(
        {.title          = "graph_explorer",
         .width          = 1600,
         .height         = 1000,
         .viewports      = true,
         .panels         = g_panels,
         .panel_count    = sizeof(g_panels) / sizeof(g_panels[0]),
         .layouts_cb     = g_layout_storage ? &g_layout_cb : nullptr,
         .view_extras    = view_extras_cb,
         .init_gl_loader = true,
         .auto_dockspace = false, // host window propio con DockSpace custom
         .log            = {"graph_explorer.log", 1}},
        render);

    // Auto-save de posiciones de nodos al salir — sin esto las posiciones se
    // pierden si el usuario nunca presiona "Save layout" (issue 0031 + nudge).
    if (g_loaded && g_graph_hash != 0) {
        int n = ge::layout_store_save(g_graph_hash, g_graph);
        std::fprintf(stdout,
            "[graph_explorer] auto-saved %d node positions on exit\n", n);
    }

    // Auto-save de paneles abiertos/cerrados al salir.
    panel_state_save_db(g_layout_db_path, g_panels,
                         sizeof(g_panels) / sizeof(g_panels[0]));

    // Cleanup
    app_agent::chat_shutdown();
    ge::extract_panel_shutdown();
    ge::jobs_shutdown();
    if (g_layout_storage) {
        fn_ui::layout_storage_close(g_layout_storage);
        g_layout_storage = nullptr;
    }
    if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
    if (g_atlas)   graph_icons_destroy(g_atlas);
    graph_viewport_destroy(g_viewport);
    graph::graph_free(&g_graph);
    ge::layout_store_close();

    return rc;
}
