// tql_apply.cpp — TQL parser using Lua 5.4 C API directly (no lua_engine).
// See tql_apply.h for documentation.
// Promoted from primitives_gallery/playground/tables/tql.cpp (apply()).

#include "core/tql_apply.h"
#include "core/tql_helpers.h"

extern "C" {
#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
}

#include <algorithm>
#include <cstdio>
#include <cstring>

namespace tql {

using namespace data_table;

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
namespace {

// Find col index in a header list; returns -1 if not found.
int find_col(const std::vector<std::string>& headers, const std::string& name) {
    for (size_t i = 0; i < headers.size(); ++i)
        if (headers[i] == name) return (int)i;
    return -1;
}

// Safe lua_tostring wrapper that never returns null.
std::string lua_to_str(lua_State* L, int idx) {
    if (lua_isnil(L, idx))     return {};
    if (lua_isboolean(L, idx)) return lua_toboolean(L, idx) ? "true" : "false";
    size_t n = 0;
    const char* s = luaL_tolstring(L, idx, &n);
    std::string out(s, n);
    lua_pop(L, 1);
    return out;
}

} // anon

// ---------------------------------------------------------------------------
// apply
// ---------------------------------------------------------------------------
ApplyResult apply(const std::string& lua_text,
                  const std::vector<std::string>& available_headers)
{
    ApplyResult res;
    State& state = res.state;

    auto warn = [&](std::string msg) {
        res.warnings.push_back(std::move(msg));
    };

    // Create a fresh Lua state for parsing only (no engine libs needed).
    lua_State* L = luaL_newstate();
    if (!L) {
        res.error = "luaL_newstate failed";
        return res;
    }
    luaL_openlibs(L);  // needed for string coercions (luaL_tolstring)

    // Load and execute the TQL chunk.
    if (luaL_loadbufferx(L, lua_text.data(), lua_text.size(), "tql", "t") != LUA_OK) {
        res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "load error";
        lua_close(L);
        return res;
    }
    if (lua_pcall(L, 0, 1, 0) != LUA_OK) {
        res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "exec error";
        lua_close(L);
        return res;
    }
    if (!lua_istable(L, -1)) {
        res.error = "TQL root must be a table";
        lua_close(L);
        return res;
    }

    // version check
    lua_getfield(L, -1, "version");
    if (lua_isnil(L, -1)) {
        warn("version missing (assuming 1)");
    } else if (!lua_isnumber(L, -1)) {
        res.error = "version must be a number";
        lua_close(L);
        return res;
    } else {
        int v = (int)lua_tointeger(L, -1);
        if (v != 1) {
            char buf[64];
            std::snprintf(buf, sizeof(buf),
                          "unsupported TQL version %d (expected 1)", v);
            res.error = buf;
            lua_close(L);
            return res;
        }
    }
    lua_pop(L, 1);

    // main_source
    lua_getfield(L, -1, "main_source");
    if (lua_isstring(L, -1)) state.main_source = lua_tostring(L, -1);
    else                     state.main_source.clear();
    lua_pop(L, 1);

    // display
    lua_getfield(L, -1, "display");
    if (lua_isstring(L, -1)) {
        std::string d = lua_tostring(L, -1);
        ViewMode m = view_mode_from_token(d.c_str());
        state.display = m;
        if (view_mode_token(m) != d) {
            warn("unknown display \"" + d + "\" (defaulting to table)");
        }
    }
    lua_pop(L, 1);

    // Reset mutable state.
    state.stages.clear();
    state.active_stage = 0;
    state.color_rules.clear();
    state.joins.clear();

    // ---- joins ----
    lua_getfield(L, -1, "joins");
    if (lua_istable(L, -1)) {
        int nj = (int)lua_rawlen(L, -1);
        for (int i = 1; i <= nj; ++i) {
            lua_rawgeti(L, -1, i);
            if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
            Join jn;

            lua_getfield(L, -1, "alias");
            if (lua_isstring(L, -1)) jn.alias = lua_tostring(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "source");
            if (lua_isstring(L, -1)) jn.source = lua_tostring(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "strategy");
            if (lua_isstring(L, -1))
                jn.strategy = join_strategy_from_token(lua_tostring(L, -1));
            lua_pop(L, 1);

            lua_getfield(L, -1, "on");
            if (lua_istable(L, -1)) {
                int on_n = (int)lua_rawlen(L, -1);
                for (int k = 1; k <= on_n; ++k) {
                    lua_rawgeti(L, -1, k);
                    if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) {
                        lua_rawgeti(L, -1, 1); std::string a = lua_to_str(L, -1); lua_pop(L, 1);
                        lua_rawgeti(L, -1, 2); std::string b = lua_to_str(L, -1); lua_pop(L, 1);
                        jn.on.push_back({a, b});
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1);

            lua_getfield(L, -1, "fields");
            if (lua_istable(L, -1)) {
                int fn_n = (int)lua_rawlen(L, -1);
                for (int k = 1; k <= fn_n; ++k) {
                    lua_rawgeti(L, -1, k);
                    if (lua_isstring(L, -1)) jn.fields.emplace_back(lua_tostring(L, -1));
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1);

            state.joins.push_back(std::move(jn));
            lua_pop(L, 1); // pop join entry
        }
    }
    lua_pop(L, 1); // joins

    // ---- stages ----
    lua_getfield(L, -1, "stages");
    if (lua_istable(L, -1)) {
        int n_stages = (int)lua_rawlen(L, -1);
        // cur_headers: tracks effective headers stage by stage for filter col lookup.
        std::vector<std::string> cur_headers = available_headers;

        for (int si = 1; si <= n_stages; ++si) {
            lua_rawgeti(L, -1, si);
            if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }

            Stage stg;

            // expressions (stage 0 only in practice; accepted in any stage for symmetry)
            lua_getfield(L, -1, "expressions");
            if (lua_istable(L, -1)) {
                lua_pushnil(L);
                while (lua_next(L, -2) != 0) {
                    if (lua_isstring(L, -2) && lua_isstring(L, -1)) {
                        DerivedColumn d;
                        d.source_col = -1;
                        d.name       = lua_tostring(L, -2);
                        d.formula    = lua_tostring(L, -1);
                        d.lua_id     = -1;  // caller must compile
                        d.type       = ColumnType::String;
                        stg.derived.push_back(std::move(d));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // expressions

            // filter
            lua_getfield(L, -1, "filter");
            if (lua_istable(L, -1)) {
                int n = (int)lua_rawlen(L, -1);
                for (int i = 1; i <= n; ++i) {
                    lua_rawgeti(L, -1, i);
                    if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 3) {
                        lua_rawgeti(L, -1, 1); std::string op  = lua_to_str(L, -1); lua_pop(L, 1);
                        lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1);
                        lua_rawgeti(L, -1, 3); std::string val = lua_to_str(L, -1); lua_pop(L, 1);
                        int ci = find_col(cur_headers, col);
                        if (ci >= 0) {
                            stg.filters.push_back({ci, op_from_label(op.c_str()), val});
                        } else if (!cur_headers.empty()) {
                            // Only warn when headers were provided (validation mode).
                            warn("stage " + std::to_string(si - 1) +
                                 ": filter col \"" + col + "\" not found");
                        } else {
                            // No headers: store filter with col=-1 as placeholder.
                            stg.filters.push_back({-1, op_from_label(op.c_str()), val});
                        }
                        // Validate op token.
                        if (op != "=" && op != "!=" && op != ">" && op != ">=" &&
                            op != "<" && op != "<=" && op != "contains" &&
                            op != "!contains" && op != "starts" && op != "ends") {
                            warn("stage " + std::to_string(si - 1) +
                                 ": unknown filter op \"" + op + "\" (defaulting to =)");
                        }
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // filter

            // breakout
            lua_getfield(L, -1, "breakout");
            if (lua_istable(L, -1)) {
                int n = (int)lua_rawlen(L, -1);
                for (int i = 1; i <= n; ++i) {
                    lua_rawgeti(L, -1, i);
                    if (lua_isstring(L, -1)) {
                        std::string bn = lua_tostring(L, -1);
                        if (!cur_headers.empty()) {
                            std::string clean;
                            parse_breakout_granularity(bn, clean);
                            if (find_col(cur_headers, clean) < 0) {
                                warn("stage " + std::to_string(si - 1) +
                                     ": breakout col \"" + clean +
                                     "\" not in input headers");
                            }
                        }
                        stg.breakouts.emplace_back(std::move(bn));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // breakout

            // aggregation
            lua_getfield(L, -1, "aggregation");
            if (lua_istable(L, -1)) {
                int n = (int)lua_rawlen(L, -1);
                for (int i = 1; i <= n; ++i) {
                    lua_rawgeti(L, -1, i);
                    if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 1) {
                        Aggregation a;
                        lua_rawgeti(L, -1, 1);
                        std::string fn_name = lua_to_str(L, -1);
                        lua_pop(L, 1);
                        const bool known = (fn_name == "count"  || fn_name == "sum"  ||
                                            fn_name == "avg"    || fn_name == "min"  ||
                                            fn_name == "max"    || fn_name == "distinct" ||
                                            fn_name == "stddev" || fn_name == "median" ||
                                            fn_name == "p25"    || fn_name == "p75"  ||
                                            fn_name == "p90"    || fn_name == "p99"  ||
                                            fn_name == "percentile");
                        if (!known) {
                            warn("stage " + std::to_string(si - 1) +
                                 ": unknown aggregation fn \"" + fn_name +
                                 "\" (defaulting to count)");
                        }
                        a.fn = agg_fn_from_string(fn_name);
                        if ((int)lua_rawlen(L, -1) >= 2) {
                            lua_rawgeti(L, -1, 2);
                            a.col = lua_to_str(L, -1);
                            lua_pop(L, 1);
                            if (a.fn != AggFn::Count && !cur_headers.empty() &&
                                find_col(cur_headers, a.col) < 0) {
                                warn("stage " + std::to_string(si - 1) +
                                     ": aggregation col \"" + a.col +
                                     "\" not in input headers");
                            }
                        } else if (a.fn != AggFn::Count) {
                            warn("stage " + std::to_string(si - 1) +
                                 ": aggregation \"" + fn_name + "\" requires a column");
                        }
                        if ((int)lua_rawlen(L, -1) >= 3) {
                            lua_rawgeti(L, -1, 3);
                            if (lua_isnumber(L, -1)) a.arg = lua_tonumber(L, -1);
                            lua_pop(L, 1);
                        }
                        stg.aggregations.push_back(std::move(a));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // aggregation

            // sort
            lua_getfield(L, -1, "sort");
            if (lua_istable(L, -1)) {
                int n = (int)lua_rawlen(L, -1);
                for (int i = 1; i <= n; ++i) {
                    lua_rawgeti(L, -1, i);
                    if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) {
                        lua_rawgeti(L, -1, 1); std::string dir = lua_to_str(L, -1); lua_pop(L, 1);
                        lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1);
                        if (dir != "asc" && dir != "desc") {
                            warn("stage " + std::to_string(si - 1) +
                                 ": unknown sort dir \"" + dir + "\" (defaulting to asc)");
                        }
                        stg.sorts.push_back({col, dir == "desc"});
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // sort

            state.stages.push_back(std::move(stg));

            // Advance cur_headers for next stage col resolution.
            const Stage& last = state.stages.back();
            if (si == 1) {
                // Stage 0: cur_headers = orig + derived names.
                for (const auto& d : last.derived) cur_headers.push_back(d.name);
            } else {
                if (!last.breakouts.empty() || !last.aggregations.empty()) {
                    std::vector<std::string> next;
                    for (const auto& b : last.breakouts) next.push_back(b);
                    for (const auto& a : last.aggregations) next.push_back(aggregation_alias(a));
                    cur_headers = std::move(next);
                }
            }

            lua_pop(L, 1); // pop stage entry
        }
    }
    lua_pop(L, 1); // stages

    // ensure_stage0 equivalent: at least one stage.
    if (state.stages.empty()) state.stages.push_back(Stage{});
    if (state.active_stage < 0) state.active_stage = 0;
    if (state.active_stage >= (int)state.stages.size())
        state.active_stage = (int)state.stages.size() - 1;

    // ---- columns (per-col render config) ----
    int orig_cols = (int)available_headers.size();
    int eff_cols  = orig_cols + (int)state.stages[0].derived.size();

    lua_getfield(L, -1, "columns");
    if (lua_istable(L, -1) && eff_cols > 0) {
        state.col_visible.assign(eff_cols, true);
        std::vector<std::pair<int,int>> order_pairs;
        std::vector<bool> seen(eff_cols, false);

        int n = (int)lua_rawlen(L, -1);
        for (int i = 1; i <= n; ++i) {
            lua_rawgeti(L, -1, i);
            if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }

            lua_getfield(L, -1, "name");
            std::string nm = lua_to_str(L, -1);
            lua_pop(L, 1);

            int col_idx = find_col(available_headers, nm);
            if (col_idx < 0) {
                // Check derived.
                const auto& der = state.stages[0].derived;
                for (int di = 0; di < (int)der.size(); ++di) {
                    if (der[di].name == nm) { col_idx = orig_cols + di; break; }
                }
            }
            if (col_idx < 0 || col_idx >= eff_cols) { lua_pop(L, 1); continue; }
            seen[col_idx] = true;

            lua_getfield(L, -1, "visible");
            if (lua_isboolean(L, -1))
                state.col_visible[col_idx] = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "order");
            int order_val = lua_isnumber(L, -1)
                            ? (int)lua_tointeger(L, -1) : (col_idx + 1);
            lua_pop(L, 1);
            order_pairs.emplace_back(order_val, col_idx);

            // type: only mutable for derived cols.
            lua_getfield(L, -1, "type");
            if (lua_isstring(L, -1)) {
                ColumnType t = column_type_from_string(lua_tostring(L, -1));
                if (col_idx >= orig_cols && col_idx - orig_cols < (int)state.stages[0].derived.size())
                    state.stages[0].derived[col_idx - orig_cols].type = t;
            }
            lua_pop(L, 1);

            // color_rules
            lua_getfield(L, -1, "color_rules");
            if (lua_istable(L, -1)) {
                int rn = (int)lua_rawlen(L, -1);
                for (int j = 1; j <= rn; ++j) {
                    lua_rawgeti(L, -1, j);
                    if (lua_istable(L, -1)) {
                        lua_getfield(L, -1, "equals");
                        std::string eq = lua_to_str(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "color");
                        std::string hx = lua_to_str(L, -1);
                        lua_pop(L, 1);
                        state.color_rules.push_back({col_idx, eq, hex_to_color(hx)});
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // color_rules

            lua_pop(L, 1); // col entry
        }

        std::sort(order_pairs.begin(), order_pairs.end());
        state.col_order.clear();
        for (auto& p : order_pairs) state.col_order.push_back(p.second);
        for (int c = 0; c < eff_cols; ++c)
            if (!seen[c]) state.col_order.push_back(c);
    }
    lua_pop(L, 1); // columns

    // ---- views (viz panels) ----
    state.extra_panels.clear();
    lua_getfield(L, -1, "views");
    if (lua_istable(L, -1)) {
        int n = (int)lua_rawlen(L, -1);
        for (int i = 1; i <= n; ++i) {
            lua_rawgeti(L, -1, i);
            if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }

            VizPanel p;
            lua_getfield(L, -1, "display");
            if (lua_isstring(L, -1))
                p.display = view_mode_from_token(lua_tostring(L, -1));
            lua_pop(L, 1);

            auto read_str = [&](const char* key, std::string& out_s) {
                lua_getfield(L, -1, key);
                if (lua_isstring(L, -1)) out_s = lua_tostring(L, -1);
                lua_pop(L, 1);
            };
            read_str("x_col",    p.config.x_col);
            read_str("cat_col",  p.config.cat_col);
            read_str("size_col", p.config.size_col);

            lua_getfield(L, -1, "y_cols");
            if (lua_istable(L, -1)) {
                int yn = (int)lua_rawlen(L, -1);
                for (int j = 1; j <= yn; ++j) {
                    lua_rawgeti(L, -1, j);
                    if (lua_isstring(L, -1)) p.config.y_cols.emplace_back(lua_tostring(L, -1));
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1);

            lua_getfield(L, -1, "color");
            if (lua_isstring(L, -1)) p.config.primary_color = hex_to_color(lua_tostring(L, -1));
            lua_pop(L, 1);

            lua_getfield(L, -1, "hist_bins");
            if (lua_isnumber(L, -1)) p.config.hist_bins = (int)lua_tointeger(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "pie_radius");
            if (lua_isnumber(L, -1)) p.config.pie_radius = (float)lua_tonumber(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "show_legend");
            if (lua_isboolean(L, -1)) p.config.show_legend = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "show_markers");
            if (lua_isboolean(L, -1)) p.config.show_markers = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "locked");
            if (lua_isboolean(L, -1)) p.config.locked = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);

            // Panel 0 = main viz (state.display + state.viz_config).
            if (i == 1) {
                state.display    = p.display;
                state.viz_config = p.config;
            } else {
                state.extra_panels.push_back(std::move(p));
            }
            lua_pop(L, 1); // panel entry
        }
    }
    lua_pop(L, 1); // views

    // ---- column_specs (Phase 2, v1.2.0) ----
    // Populate state.aux_column_specs[0] from optional "column_specs" block.
    state.aux_column_specs.clear();
    lua_getfield(L, -1, "column_specs");
    if (lua_istable(L, -1)) {
        std::vector<data_table::ColumnSpec> specs;
        int n_cs = (int)lua_rawlen(L, -1);
        for (int i = 1; i <= n_cs; ++i) {
            lua_rawgeti(L, -1, i);
            if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }

            data_table::ColumnSpec cs;

            lua_getfield(L, -1, "id");
            if (lua_isstring(L, -1)) cs.id = lua_tostring(L, -1);
            lua_pop(L, 1);

            lua_getfield(L, -1, "renderer");
            if (lua_isstring(L, -1)) {
                std::string rn = lua_tostring(L, -1);
                if (rn == "badge")                  cs.renderer = data_table::CellRenderer::Badge;
                else if (rn == "progress")          cs.renderer = data_table::CellRenderer::Progress;
                else if (rn == "duration")          cs.renderer = data_table::CellRenderer::Duration;
                else if (rn == "icon")              cs.renderer = data_table::CellRenderer::Icon;
                else if (rn == "button")            cs.renderer = data_table::CellRenderer::Button;
                else if (rn == "dots")              cs.renderer = data_table::CellRenderer::Dots;
                else if (rn == "categorical_chip")  cs.renderer = data_table::CellRenderer::CategoricalChip;
                else if (rn == "color_scale")       cs.renderer = data_table::CellRenderer::ColorScale;
                else cs.renderer = data_table::CellRenderer::Text;
            }
            lua_pop(L, 1);

            // Badge rules
            lua_getfield(L, -1, "badges");
            if (lua_istable(L, -1)) {
                int nb = (int)lua_rawlen(L, -1);
                for (int j = 1; j <= nb; ++j) {
                    lua_rawgeti(L, -1, j);
                    if (lua_istable(L, -1)) {
                        data_table::BadgeRule br;
                        lua_getfield(L, -1, "value");
                        if (lua_isstring(L, -1)) br.value = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "color");
                        if (lua_isstring(L, -1)) br.color_hex = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "label");
                        if (lua_isstring(L, -1)) br.label = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        cs.badges.push_back(std::move(br));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // badges

            // Progress
            lua_getfield(L, -1, "progress_scale_100");
            if (lua_isboolean(L, -1)) cs.progress_scale_100 = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "progress_color");
            if (lua_isstring(L, -1)) cs.progress_color_hex = lua_tostring(L, -1);
            lua_pop(L, 1);

            // Duration
            lua_getfield(L, -1, "warn_ms");
            if (lua_isnumber(L, -1)) cs.duration_warn_ms = (float)lua_tonumber(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "error_ms");
            if (lua_isnumber(L, -1)) cs.duration_error_ms = (float)lua_tonumber(L, -1);
            lua_pop(L, 1);

            // Icon map
            lua_getfield(L, -1, "icon_map");
            if (lua_istable(L, -1)) {
                int ni = (int)lua_rawlen(L, -1);
                for (int j = 1; j <= ni; ++j) {
                    lua_rawgeti(L, -1, j);
                    if (lua_istable(L, -1)) {
                        data_table::IconMapEntry ie;
                        lua_getfield(L, -1, "value");
                        if (lua_isstring(L, -1)) ie.value = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "icon");
                        if (lua_isstring(L, -1)) ie.icon_name = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "color");
                        if (lua_isstring(L, -1)) ie.color_hex = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        cs.icon_map.push_back(std::move(ie));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // icon_map

            // Button
            lua_getfield(L, -1, "button_action");
            if (lua_isstring(L, -1)) cs.button_action = lua_tostring(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "button_label");
            if (lua_isstring(L, -1)) cs.button_label = lua_tostring(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "button_color");
            if (lua_isstring(L, -1)) cs.button_color_hex = lua_tostring(L, -1);
            lua_pop(L, 1);

            // Dots (Phase 2.5, v1.3.0)
            lua_getfield(L, -1, "dots_separator");
            if (lua_isstring(L, -1)) {
                const char* sep_s = lua_tostring(L, -1);
                if (sep_s && sep_s[0]) cs.dots_separator = sep_s[0];
            }
            lua_pop(L, 1);
            lua_getfield(L, -1, "dots_max");
            if (lua_isnumber(L, -1)) cs.dots_max = (int)lua_tointeger(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "dots_show_count");
            if (lua_isboolean(L, -1)) cs.dots_show_count = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "dots_glyph_size");
            if (lua_isnumber(L, -1)) cs.dots_glyph_size = (float)lua_tonumber(L, -1);
            lua_pop(L, 1);

            // CategoricalChip (v1.4.0)
            lua_getfield(L, -1, "chips");
            if (lua_istable(L, -1)) {
                int nc = (int)lua_rawlen(L, -1);
                for (int j = 1; j <= nc; ++j) {
                    lua_rawgeti(L, -1, j);
                    if (lua_istable(L, -1)) {
                        data_table::ChipRule cr;
                        lua_getfield(L, -1, "match");
                        if (lua_isstring(L, -1)) cr.match = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "color");
                        if (lua_isstring(L, -1)) cr.color = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        cs.chips.push_back(std::move(cr));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // chips

            // ColorScale (v1.4.0)
            lua_getfield(L, -1, "range_min");
            if (lua_isnumber(L, -1)) cs.range_min = (double)lua_tonumber(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "range_max");
            if (lua_isnumber(L, -1)) cs.range_max = (double)lua_tonumber(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "range_alpha");
            if (lua_isnumber(L, -1)) cs.range_alpha = (float)lua_tonumber(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "range_stops");
            if (lua_istable(L, -1)) {
                int ns = (int)lua_rawlen(L, -1);
                for (int j = 1; j <= ns; ++j) {
                    lua_rawgeti(L, -1, j);
                    if (lua_istable(L, -1)) {
                        data_table::ColorStop stop;
                        lua_getfield(L, -1, "position");
                        if (lua_isnumber(L, -1)) stop.position = (float)lua_tonumber(L, -1);
                        lua_pop(L, 1);
                        lua_getfield(L, -1, "color");
                        if (lua_isstring(L, -1)) stop.color = lua_tostring(L, -1);
                        lua_pop(L, 1);
                        cs.range_stops.push_back(std::move(stop));
                    }
                    lua_pop(L, 1);
                }
            }
            lua_pop(L, 1); // range_stops

            // Tooltip
            lua_getfield(L, -1, "tooltip");
            if (lua_isstring(L, -1)) cs.tooltip = lua_tostring(L, -1);
            lua_pop(L, 1);
            lua_getfield(L, -1, "tooltip_on_hover");
            if (lua_isboolean(L, -1)) cs.tooltip_on_hover = (bool)lua_toboolean(L, -1);
            lua_pop(L, 1);

            specs.push_back(std::move(cs));
            lua_pop(L, 1); // spec entry
        }
        if (!specs.empty()) {
            state.aux_column_specs.push_back(std::move(specs));
        }
    }
    lua_pop(L, 1); // column_specs

    lua_pop(L, 1); // root
    lua_close(L);

    res.ok = true;
    return res;
}

// ---------------------------------------------------------------------------
// apply (extended overload) — playground-compatible bool-returning wrapper.
// ---------------------------------------------------------------------------
bool apply(const std::string& lua_text,
           data_table::State& st,
           const std::vector<std::string>& orig_headers,
           const std::vector<data_table::ColumnType>& /*orig_types*/,
           const char* const* /*cells*/,
           int /*rows*/,
           int /*orig_cols*/,
           std::string* err)
{
    ApplyResult res = apply(lua_text, orig_headers);
    if (res.ok) {
        st = std::move(res.state);
    } else {
        if (err) *err = res.error;
    }
    return res.ok;
}

} // namespace tql
