Skip to content
use std::collections::HashMap;

use actix_web::Result as AwResult;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use maud::{PreEscaped, html};
use serde::Deserialize;
use serde_json::{Value, json};

use crate::{ConnectionStore, db};

// ---------------------------------------------------------------------------
// AG Grid request / response types
// ---------------------------------------------------------------------------

#[derive(Deserialize, Debug)]
struct SortModel {
    #[serde(rename = "colId")]
    col_id: String,
    sort: String,
}

/// A column reference as sent by AG Grid in rowGroupCols / valueCols / pivotCols.
#[derive(Deserialize, Debug)]
struct ColItem {
    /// Unique column identifier (usually same as field).
    id: String,
    /// The data field name.
    field: String,
    /// Aggregation function (only present for value columns).
    #[serde(rename = "aggFunc", default)]
    agg_func: Option<String>,
}

#[derive(Deserialize, Debug)]
struct AgGridRowRequest {
    #[serde(rename = "startRow", default)]
    start_row: i64,
    #[serde(rename = "endRow", default = "default_end_row")]
    end_row: i64,
    #[serde(rename = "sortModel", default)]
    sort_model: Vec<SortModel>,
    #[serde(rename = "filterModel", default)]
    filter_model: HashMap<String, Value>,
    /// Columns used to group rows into tree nodes.
    #[serde(rename = "rowGroupCols", default)]
    row_group_cols: Vec<ColItem>,
    /// Columns being aggregated (value columns with aggFunc).
    #[serde(rename = "valueCols", default)]
    value_cols: Vec<ColItem>,
    /// Columns whose distinct values become new pivot columns.
    #[serde(rename = "pivotCols", default)]
    pivot_cols: Vec<ColItem>,
    /// Whether the grid is currently in pivot mode.
    #[serde(rename = "pivotMode", default)]
    pivot_mode: bool,
    /// Path of group key values to the currently requested node.
    #[serde(rename = "groupKeys", default)]
    group_keys: Vec<String>,
}

fn default_end_row() -> i64 {
    100
}

// ---------------------------------------------------------------------------
// Filter model → SQL helpers
// ---------------------------------------------------------------------------

fn filter_condition_to_sql(col: &str, filter: &Value) -> Option<String> {
    if let Some(operator) = filter.get("operator").and_then(|v| v.as_str()) {
        let c1 = filter
            .get("condition1")
            .and_then(|c| filter_condition_to_sql(col, c));
        let c2 = filter
            .get("condition2")
            .and_then(|c| filter_condition_to_sql(col, c));
        return match (c1, c2) {
            (Some(c1), Some(c2)) => Some(format!("({c1} {operator} {c2})")),
            (Some(c), None) | (None, Some(c)) => Some(c),
            (None, None) => None,
        };
    }

    let filter_type = filter.get("filterType").and_then(|v| v.as_str())?;
    let col_q = format!("`{}`", col.replace('`', "``"));

    // Set filter – multi-select: { filterType: "set", values: [...] }
    if filter_type == "set" {
        let values = filter.get("values").and_then(|v| v.as_array())?;
        if values.is_empty() {
            return None;
        }
        let literals: Vec<String> = values
            .iter()
            .map(|v| match v {
                Value::Null => "NULL".to_string(),
                Value::Number(n) => n.to_string(),
                Value::Bool(b) => {
                    if *b {
                        "1".to_string()
                    } else {
                        "0".to_string()
                    }
                }
                Value::String(s) => format!("'{}'", s.replace('\'', "''")),
                _ => format!("'{}'", v.to_string().replace('\'', "''")),
            })
            .collect();
        return Some(format!("{col_q} IN ({})", literals.join(", ")));
    }

    let filter_op = filter.get("type").and_then(|v| v.as_str())?;

    match filter_type {
        "text" => {
            let val = filter
                .get("filter")
                .and_then(|v| v.as_str())
                .unwrap_or_default();
            let esc = val.replace('\'', "''");
            match filter_op {
                "equals" => Some(format!("{col_q} = '{esc}'")),
                "notEqual" => Some(format!("{col_q} != '{esc}'")),
                "contains" => Some(format!("{col_q} ILIKE '%{esc}%'")),
                "notContains" => Some(format!("{col_q} NOT ILIKE '%{esc}%'")),
                "startsWith" => Some(format!("{col_q} ILIKE '{esc}%'")),
                "endsWith" => Some(format!("{col_q} ILIKE '%{esc}'")),
                "blank" => Some(format!("({col_q} = '' OR {col_q} IS NULL)")),
                "notBlank" => Some(format!("({col_q} != '' AND {col_q} IS NOT NULL)")),
                _ => None,
            }
        }
        "number" => {
            if filter_op == "inRange" {
                let from = filter.get("filter").and_then(|v| v.as_f64())?;
                let to = filter.get("filterTo").and_then(|v| v.as_f64())?;
                Some(format!("{col_q} BETWEEN {from} AND {to}"))
            } else {
                let val = filter.get("filter").and_then(|v| v.as_f64())?;
                let op = match filter_op {
                    "equals" => "=",
                    "notEqual" => "!=",
                    "lessThan" => "<",
                    "lessThanOrEqual" => "<=",
                    "greaterThan" => ">",
                    "greaterThanOrEqual" => ">=",
                    _ => return None,
                };
                Some(format!("{col_q} {op} {val}"))
            }
        }
        "date" => {
            if filter_op == "inRange" {
                let from = filter
                    .get("dateFrom")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default();
                let to = filter
                    .get("dateTo")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default();
                Some(format!(
                    "{col_q} BETWEEN '{}' AND '{}'",
                    from.replace('\'', "''"),
                    to.replace('\'', "''")
                ))
            } else {
                let val = filter
                    .get("dateFrom")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default();
                let esc = val.replace('\'', "''");
                let op = match filter_op {
                    "equals" => "=",
                    "notEqual" => "!=",
                    "lessThan" => "<",
                    "lessThanOrEqual" => "<=",
                    "greaterThan" => ">",
                    "greaterThanOrEqual" => ">=",
                    _ => return None,
                };
                Some(format!("{col_q} {op} '{esc}'"))
            }
        }
        _ => None,
    }
}

fn filter_conditions(filter_model: &HashMap<String, Value>) -> Vec<String> {
    filter_model
        .iter()
        .filter_map(|(col, filter)| filter_condition_to_sql(col, filter))
        .collect()
}

fn build_where_clause(filter_model: &HashMap<String, Value>) -> String {
    let conditions = filter_conditions(filter_model);
    if conditions.is_empty() {
        String::new()
    } else {
        format!(" WHERE {}", conditions.join(" AND "))
    }
}

/// Combine an arbitrary list of SQL condition strings into a WHERE clause.
fn join_conditions_as_where(conditions: &[String]) -> String {
    let non_empty: Vec<&str> = conditions
        .iter()
        .filter(|c| !c.is_empty())
        .map(|c| c.as_str())
        .collect();
    if non_empty.is_empty() {
        String::new()
    } else {
        format!(" WHERE {}", non_empty.join(" AND "))
    }
}

fn build_order_clause(sort_model: &[SortModel]) -> String {
    if sort_model.is_empty() {
        return String::new();
    }
    let parts: Vec<String> = sort_model
        .iter()
        // ag-Grid-AutoColumn is a virtual column used by AG Grid for row-group display;
        // it has no corresponding column in ClickHouse, so skip it.
        .filter(|s| s.col_id != "ag-Grid-AutoColumn")
        .map(|s| {
            let col_q = format!("`{}`", s.col_id.replace('`', "``"));
            let dir = if s.sort.to_lowercase() == "desc" {
                "DESC"
            } else {
                "ASC"
            };
            format!("{col_q} {dir}")
        })
        .collect();
    if parts.is_empty() {
        return String::new();
    }
    format!(" ORDER BY {}", parts.join(", "))
}

// ---------------------------------------------------------------------------
// Column-def generation from ClickHouse types
// ---------------------------------------------------------------------------

fn is_numeric_ch_type(ch_type: &str) -> bool {
    let inner = ch_type
        .strip_prefix("Nullable(")
        .and_then(|s| s.strip_suffix(')'))
        .unwrap_or(ch_type);
    inner.starts_with("Int")
        || inner.starts_with("UInt")
        || inner.starts_with("Float")
        || inner.starts_with("Decimal")
}

/// Column def for the plain server-side grid (filtering + sorting only).
fn ch_type_to_ag_col_def(name: &str, ch_type: &str) -> Value {
    let inner = ch_type
        .strip_prefix("Nullable(")
        .and_then(|s| s.strip_suffix(')'))
        .unwrap_or(ch_type);

    let (filter, extra_type) = if is_numeric_ch_type(inner) {
        ("agNumberColumnFilter", Some("numericColumn"))
    } else if inner.starts_with("DateTime") || inner.starts_with("Date") {
        ("agDateColumnFilter", None)
    } else {
        ("agSetColumnFilter", None)
    };

    let mut def = json!({
        "field": name,
        "headerName": name,
        "filter": filter,
        "sortable": true,
        "resizable": true,
        "floatingFilter": true,
    });

    if let Some(t) = extra_type {
        def["type"] = Value::String(t.to_string());
    }

    def
}

/// Column def for the pivot grid – adds enableRowGroup / enablePivot / enableValue / aggFunc.
fn ch_type_to_pivot_col_def(name: &str, ch_type: &str) -> Value {
    let mut def = ch_type_to_ag_col_def(name, ch_type);
    let numeric = is_numeric_ch_type(ch_type);

    def["enableRowGroup"] = json!(true);
    def["enablePivot"] = json!(!numeric); // non-numeric cols make good pivot axes
    def["enableValue"] = json!(numeric);
    if numeric {
        def["aggFunc"] = json!("sum");
    }
    def
}

// ---------------------------------------------------------------------------
// Pivot SQL helpers
// ---------------------------------------------------------------------------

/// Maximum distinct values collected per pivot column.  Keeps generated SQL
/// and the number of result columns manageable.
const MAX_PIVOT_VALUES: usize = 50;

/// Sanitise a pivot value so it can be used safely as a SQL alias / AG Grid field name.
fn sanitize_pivot_field_part(s: &str) -> String {
    let cleaned: String = s
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    // Collapse consecutive underscores and strip leading/trailing ones.
    let mut result = String::new();
    let mut prev_underscore = false;
    for c in cleaned.chars() {
        if c == '_' {
            if !prev_underscore && !result.is_empty() {
                result.push('_');
            }
            prev_underscore = true;
        } else {
            result.push(c);
            prev_underscore = false;
        }
    }
    let result = result.trim_end_matches('_').to_string();
    if result.is_empty() {
        "null".to_string()
    } else {
        result
    }
}

/// Quote a literal value for use inside a ClickHouse WHERE comparison.
/// Numbers are left bare; everything else is single-quoted.
fn quote_pivot_literal(val: &str) -> String {
    if val.parse::<f64>().is_ok() {
        val.to_string()
    } else {
        format!("'{}'", val.replace('\'', "''"))
    }
}

/// Map an AG Grid aggFunc name to the corresponding ClickHouse conditional
/// aggregate function name (the `If` variant).
fn ch_agg_func_if(agg_func: &str) -> &'static str {
    match agg_func.to_lowercase().as_str() {
        "sum" => "sumIf",
        "avg" => "avgIf",
        "min" => "minIf",
        "max" => "maxIf",
        "count" => "countIf",
        "first" => "anyIf",
        "last" => "anyLastIf",
        _ => "sumIf",
    }
}

/// Map an AG Grid aggFunc name to the corresponding ClickHouse plain aggregate
/// function name (no condition), for grouped-only (no pivot) queries.
fn ch_agg_func(agg_func: &str) -> &'static str {
    match agg_func.to_lowercase().as_str() {
        "sum" => "sum",
        "avg" => "avg",
        "min" => "min",
        "max" => "max",
        "count" => "count",
        "first" => "any",
        "last" => "anyLast",
        _ => "sum",
    }
}

/// Fetch up to `MAX_PIVOT_VALUES` distinct values for a single pivot column,
/// respecting the active filter conditions.
async fn get_pivot_distinct_values(
    conn: &crate::connections::Connection,
    database: &str,
    table: &str,
    pivot_col: &str,
    extra_conditions: &[String],
) -> Vec<String> {
    let col_q = format!("`{}`", pivot_col.replace('`', "``"));
    let where_clause = join_conditions_as_where(extra_conditions);
    let query = format!(
        "SELECT DISTINCT {col_q} FROM {database}.{table}{where_clause} \
         ORDER BY {col_q} \
         LIMIT {MAX_PIVOT_VALUES} \
         FORMAT TSVWithNames"
    );

    let client = reqwest::Client::new();
    let mut request = client
        .post(conn.url())
        .query(&[("user", conn.user.as_str())])
        .body(query);
    if !conn.password.is_empty() {
        request = request.query(&[("password", conn.password.as_str())]);
    }

    let Ok(response) = request.send().await else {
        return Vec::new();
    };
    if !response.status().is_success() {
        return Vec::new();
    }
    let Ok(text) = response.text().await else {
        return Vec::new();
    };

    text.lines()
        .skip(1) // skip TSV header
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.to_string())
        .collect()
}

/// Compute the Cartesian product of per-pivot-column value lists.
fn cartesian_product(sets: &[Vec<String>]) -> Vec<Vec<String>> {
    sets.iter().fold(vec![vec![]], |acc, set| {
        acc.into_iter()
            .flat_map(|combo| {
                set.iter().map(move |val| {
                    let mut new_combo = combo.clone();
                    new_combo.push(val.clone());
                    new_combo
                })
            })
            .collect()
    })
}

/// Build the SELECT list and the list of result field names for a pivot query.
///
/// Returns `(select_exprs, pivot_result_fields)`.
fn build_pivot_select_exprs(
    pivot_cols: &[ColItem],
    pivot_value_sets: &[Vec<String>],
    value_cols: &[ColItem],
) -> (Vec<String>, Vec<String>) {
    let combos = cartesian_product(pivot_value_sets);

    let mut exprs = Vec::new();
    let mut fields = Vec::new();

    for combo in &combos {
        // Build the WHERE condition for this pivot combination.
        let combo_condition: Vec<String> = pivot_cols
            .iter()
            .zip(combo.iter())
            .map(|(pc, val)| {
                let col_q = format!("`{}`", pc.field.replace('`', "``"));
                format!("{col_q} = {}", quote_pivot_literal(val))
            })
            .collect();
        let combo_cond_sql = combo_condition.join(" AND ");

        // Sanitised prefix for field names: join pivot value parts with underscore.
        let combo_prefix: String = combo
            .iter()
            .map(|v| sanitize_pivot_field_part(v))
            .collect::<Vec<_>>()
            .join("_");

        for vc in value_cols {
            let agg_fn = ch_agg_func_if(vc.agg_func.as_deref().unwrap_or("sum"));
            let val_q = format!("`{}`", vc.field.replace('`', "``"));
            // Field name convention: {pivotValues}_{valueColId}  (underscore separator,
            // matching AG Grid's default serverSidePivotResultFieldSeparator).
            let field_name = format!("{combo_prefix}~{}", sanitize_pivot_field_part(&vc.id));
            let alias = format!("`{field_name}`");
            exprs.push(format!("{agg_fn}({val_q}, {combo_cond_sql}) AS {alias}"));
            fields.push(field_name);
        }
    }

    (exprs, fields)
}

/// Build the complete pivot SQL query and return it together with the list of
/// pivot result field names that must be forwarded to AG Grid.
///
/// * `group_col`  – the column to GROUP BY at the current tree depth (None → no groups).
/// * `conditions` – all WHERE conditions already as strings (filter + groupKey).
#[allow(clippy::too_many_arguments)]
fn build_pivot_query(
    database: &str,
    table: &str,
    group_col: Option<&str>,
    conditions: &[String],
    pivot_cols: &[ColItem],
    pivot_value_sets: &[Vec<String>],
    value_cols: &[ColItem],
    sort_model: &[SortModel],
    limit: usize,
    offset: usize,
) -> (String, Vec<String>) {
    let mut select_parts: Vec<String> = Vec::new();

    // Leading GROUP BY column (if any).
    if let Some(gc) = group_col {
        select_parts.push(format!("`{}`", gc.replace('`', "``")));
    }

    let (pivot_exprs, pivot_fields) =
        build_pivot_select_exprs(pivot_cols, pivot_value_sets, value_cols);
    select_parts.extend(pivot_exprs);

    if select_parts.is_empty() {
        // Nothing to pivot on – return a sentinel.
        return (String::new(), Vec::new());
    }

    let where_clause = join_conditions_as_where(conditions);
    let group_by = group_col
        .map(|gc| format!(" GROUP BY `{}`", gc.replace('`', "``")))
        .unwrap_or_default();
    let order_by = build_order_clause(sort_model);

    let sql = format!(
        "SELECT {} FROM {database}.{table}{where_clause}{group_by}{order_by} \
         LIMIT {limit} OFFSET {offset} \
         FORMAT JSONEachRow",
        select_parts.join(", ")
    );

    (sql, pivot_fields)
}

/// Build a plain GROUP BY (no pivot) query with aggregate value columns.
#[allow(clippy::too_many_arguments)]
fn build_grouped_query(
    database: &str,
    table: &str,
    group_col: &str,
    conditions: &[String],
    value_cols: &[ColItem],
    sort_model: &[SortModel],
    limit: usize,
    offset: usize,
) -> String {
    let mut select_parts = vec![format!("`{}`", group_col.replace('`', "``"))];

    for vc in value_cols {
        let agg_fn = ch_agg_func(vc.agg_func.as_deref().unwrap_or("sum"));
        let val_q = format!("`{}`", vc.field.replace('`', "``"));
        let alias = format!("`{}`", vc.id.replace('`', "``"));
        select_parts.push(format!("{agg_fn}({val_q}) AS {alias}"));
    }

    let where_clause = join_conditions_as_where(conditions);
    let group_by = format!(" GROUP BY `{}`", group_col.replace('`', "``"));
    let order_by = build_order_clause(sort_model);

    format!(
        "SELECT {} FROM {database}.{table}{where_clause}{group_by}{order_by} \
         LIMIT {limit} OFFSET {offset} \
         FORMAT JSONEachRow",
        select_parts.join(", ")
    )
}

/// Execute a raw SQL string against ClickHouse and return JSONEachRow-parsed rows.
async fn execute_json_query(
    conn: &crate::connections::Connection,
    sql: &str,
) -> Result<Vec<Value>, Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let mut request = client
        .post(conn.url())
        .query(&[("user", conn.user.as_str())])
        .body(sql.to_string());
    if !conn.password.is_empty() {
        request = request.query(&[("password", conn.password.as_str())]);
    }

    let response = request.send().await?;
    if !response.status().is_success() {
        let err = response.text().await?;
        return Err(format!("ClickHouse error: {err}").into());
    }

    let text = response.text().await?;
    let rows = text
        .lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
        .collect();
    Ok(rows)
}

/// Count distinct values in `group_col` respecting a WHERE clause.
/// Falls back to COUNT(*) when no group column is given (single-row case).
async fn count_distinct_group(
    conn: &crate::connections::Connection,
    database: &str,
    table: &str,
    group_col: Option<&str>,
    conditions: &[String],
) -> i64 {
    let where_clause = join_conditions_as_where(conditions);
    let count_expr = match group_col {
        Some(gc) => format!("uniqExact(`{}`)", gc.replace('`', "``")),
        None => "1".to_string(), // single summary row
    };
    let sql =
        format!("SELECT {count_expr} FROM {database}.{table}{where_clause} FORMAT TSVWithNames");

    let client = reqwest::Client::new();
    let mut request = client
        .post(conn.url())
        .query(&[("user", conn.user.as_str())])
        .body(sql);
    if !conn.password.is_empty() {
        request = request.query(&[("password", conn.password.as_str())]);
    }

    let Ok(response) = request.send().await else {
        return -1;
    };
    if !response.status().is_success() {
        return -1;
    }
    let Ok(text) = response.text().await else {
        return -1;
    };

    text.lines()
        .nth(1)
        .and_then(|l| l.trim().parse::<i64>().ok())
        .unwrap_or(-1)
}

// ---------------------------------------------------------------------------
// Page – GET /ag-grid
// ---------------------------------------------------------------------------

#[get("/ag-grid")]
pub async fn ag_grid_page(
    req: HttpRequest,
    store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
    let Some(ch) = store.active_client() else {
        let content = html! {
            div class="w-100 flex flex-column items-center justify-center" style="height: 100%;" {
                div class="bg-near-black ba b--white-20 br2 pa4 mw6 w-100 tc" {
                    p class="f4 white-70 mb3" { "No active database connection" }
                    a href="/connections"
                      hx-get="/connections"
                      hx-target="#feature"
                      hx-swap="innerHTML"
                      hx-push-url="true"
                      class="dib bg-orange white br2 ph3 pv2 no-underline f6 fw6 hover-bg-dark-orange" {
                        "Configure a connection"
                    }
                }
            }
        };
        return if req.headers().get("HX-Request").is_some() {
            Ok(content)
        } else {
            Ok(super::render_layout(&content))
        };
    };

    let databases = db::all_databases(ch).await;

    let content = html! {
        link rel="stylesheet" href="/assets/ag-grid.min.css";
        link rel="stylesheet" href="/assets/ag-theme-balham.min.css";
        script src="/assets/ag-grid-enterprise.min.js" {}

        div class="w-100 flex flex-column" style="height: 100%;" {
            div class="bg-black-70 pa3" {
                div class="flex items-end" {
                    div class="mr2" {
                        select
                            id="ag-database-select"
                            name="database"
                            class="input-reset ba b--white-30 ph2 pv1 br2 f7 bg-white-10 white"
                            style="color: white;"
                            hx-get="/ag-grid/tables"
                            hx-include="[name='database']"
                            hx-target="#ag-table-select-container"
                            hx-swap="innerHTML"
                            hx-trigger="change" {
                            option value="" selected disabled style="background-color: #1a1a1a; color: #ccc;" {
                                "Database..."
                            }
                            @for database in &databases {
                                option value=(database.name) style="background-color: #1a1a1a; color: white;" {
                                    (database.name)
                                }
                            }
                        }
                    }

                    div id="ag-table-select-container" {}
                }
            }

            div class="w-100 ph3 flex-auto flex flex-column" style="overflow: hidden;" {
                h1 id="ag-heading" class="f4 fw6 white-90 mb3 lh-title" { "Select a database" }

                div id="ag-grid-content" class="flex-auto flex flex-column" style="overflow: hidden;" {
                    p class="white-70 f6 i tc pa4" {
                        "Select a database and table to load the grid"
                    }
                }
            }
        }
    };

    if req.headers().get("HX-Request").is_some() {
        Ok(content)
    } else {
        Ok(super::render_layout(&content))
    }
}

// ---------------------------------------------------------------------------
// Table selector partial – GET /ag-grid/tables
// ---------------------------------------------------------------------------

#[get("/ag-grid/tables")]
pub async fn ag_grid_get_tables(
    params: web::Query<HashMap<String, String>>,
    store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
    let db_name = params.get("database").map(|s| s.as_str()).unwrap_or("");
    if db_name.is_empty() {
        return Ok(html! {});
    }

    let Some(ch) = store.active_client() else {
        return Ok(html! { p class="white-70 f6" { "No active connection." } });
    };

    let tables = db::all_tables(ch, db_name).await;

    Ok(html! {
        h1 id="ag-heading"
            class="f4 fw6 white-90 mb3 lh-title"
            hx-swap-oob="true" {
            (db_name)
        }

        div class="mr2" {
            select
                id="ag-table-select"
                name="table"
                class="input-reset ba b--white-30 ph2 pv1 br2 f7 bg-white-10 white"
                style="color: white;"
                hx-get="/ag-grid/grid"
                hx-include="[name='database']"
                hx-target="#ag-grid-content"
                hx-swap="innerHTML"
                hx-trigger="change" {
                option value="" selected disabled style="background-color: #1a1a1a; color: #ccc;" {
                    "Table..."
                }
                @for table in &tables {
                    option value=(table.name) style="background-color: #1a1a1a; color: white;" {
                        (table.name)
                    }
                }
            }
        }
    })
}

// ---------------------------------------------------------------------------
// Grid container partial – GET /ag-grid/grid
// ---------------------------------------------------------------------------

#[get("/ag-grid/grid")]
pub async fn ag_grid_get_grid(
    params: web::Query<HashMap<String, String>>,
    store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();

    if db_name.is_empty() || table_name.is_empty() {
        return Ok(html! { p class="white-70 f6 pa3" { "Invalid parameters." } });
    }

    let Some(_conn) = store.active() else {
        return Ok(html! { p class="white-70 f6 pa3" { "No active connection." } });
    };

    let active_tab = params
        .get("tab")
        .map(|s| s.as_str())
        .unwrap_or("server-side");

    Ok(html! {
        h1 id="ag-heading"
            class="f4 fw6 white-90 mb3 lh-title"
            hx-swap-oob="true" {
            (db_name) span class="white-50" { " / " } (table_name)
        }

        // Tab bar
        div class="flex bb b--white-20 mb0" {
            button
                id="tab-btn-server-side"
                class={"f6 fw6 ph3 pv2 bn pointer mr1 br2 br--top "
                    @if active_tab == "server-side" { "bg-dark-orange white" }
                    @else { "bg-white-10 white-70 hover-white hover-bg-white-20" }}
                hx-get={"/ag-grid/grid?database=" (&db_name) "&table=" (&table_name) "&tab=server-side"}
                hx-target="#ag-grid-content"
                hx-swap="innerHTML" {
                "Server-Side Grid"
            }
            button
                id="tab-btn-pivot"
                class={"f6 fw6 ph3 pv2 bn pointer mr1 br2 br--top "
                    @if active_tab == "pivot" { "bg-dark-orange white" }
                    @else { "bg-white-10 white-70 hover-white hover-bg-white-20" }}
                hx-get={"/ag-grid/grid?database=" (&db_name) "&table=" (&table_name) "&tab=pivot"}
                hx-target="#ag-grid-content"
                hx-swap="innerHTML" {
                "Pivot"
            }
        }

        // Tab content
        div id="ag-tab-content" class="flex-auto flex flex-column" style="overflow: hidden; min-height: 0;" {
            @if active_tab == "server-side" {
                (render_server_side_tab(&db_name, &table_name))
            } @else if active_tab == "pivot" {
                (render_pivot_tab(&db_name, &table_name))
            }
        }
    })
}

// ---------------------------------------------------------------------------
// Tab renderers
// ---------------------------------------------------------------------------

fn render_server_side_tab(database: &str, table: &str) -> maud::Markup {
    let col_defs_url = format!("/ag-grid/api/column-defs?database={database}&table={table}");
    let rows_url = format!("/ag-grid/api/rows?database={database}&table={table}");
    let set_filter_values_base =
        format!("/ag-grid/api/set-filter-values?database={database}&table={table}");

    let script = format!(
        r#"
(async function() {{
    const gridDiv = document.getElementById('ag-server-side-grid');
    if (!gridDiv) return;

    let columnDefs;
    try {{
        const res = await fetch({col_defs_url_json});
        if (!res.ok) throw new Error('Failed to load column defs: ' + res.status);
        columnDefs = await res.json();
    }} catch (e) {{
        gridDiv.innerHTML = '<p style="color:#ff6b6b;padding:1rem">Error loading column definitions: ' + e.message + '</p>';
        return;
    }}

    // Inject server-side value fetching for set filters so users can pick
    // multiple values from a dropdown (e.g. to_city = NewYork OR Madrid).
    columnDefs.forEach(function(colDef) {{
        if (colDef.filter === 'agSetColumnFilter') {{
            colDef.filterParams = colDef.filterParams || {{}};
            colDef.filterParams.values = function(params) {{
                fetch({set_filter_values_base_json} + '&column=' + encodeURIComponent(colDef.field))
                    .then(function(r) {{ return r.json(); }})
                    .then(function(data) {{ params.success(data); }})
                    .catch(function() {{ params.success([]); }});
            }};
        }}
    }});

    const datasource = {{
        getRows: function(params) {{
            fetch({rows_url_json}, {{
                method: 'POST',
                headers: {{ 'Content-Type': 'application/json' }},
                body: JSON.stringify(params.request)
            }})
            .then(function(res) {{
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return res.json();
            }})
            .then(function(data) {{
                params.success({{ rowData: data.rowData, rowCount: data.rowCount }});
            }})
            .catch(function(err) {{
                console.error('AG Grid datasource error:', err);
                params.fail();
            }});
        }}
    }};

    agGrid.createGrid(gridDiv, {{
        columnDefs: columnDefs,
        rowModelType: 'serverSide',
        serverSideDatasource: datasource,
        defaultColDef: {{
            flex: 1,
            minWidth: 120,
            resizable: true,
            sortable: true,
            filter: true,
            floatingFilter: true,
        }},
        pagination: true,
        paginationPageSize: 100,
        cacheBlockSize: 100,
        animateRows: true,
        theme: 'legacy',
    }});
}})();
"#,
        col_defs_url_json = serde_json::to_string(&col_defs_url).unwrap(),
        rows_url_json = serde_json::to_string(&rows_url).unwrap(),
        set_filter_values_base_json = serde_json::to_string(&set_filter_values_base).unwrap(),
    );

    html! {
        div id="ag-server-side-grid"
            class="ag-theme-balham-dark flex-auto"
            style="width: 100%; height: 100%; min-height: 400px;" {}
        (PreEscaped(format!("<script>{script}</script>")))
    }
}

fn render_pivot_tab(database: &str, table: &str) -> maud::Markup {
    let col_defs_url = format!("/ag-grid/api/pivot-auto-config?database={database}&table={table}");
    let pivot_rows_url = format!("/ag-grid/api/pivot-rows?database={database}&table={table}");
    let set_filter_values_base =
        format!("/ag-grid/api/set-filter-values?database={database}&table={table}");

    // The pivot datasource passes the full AG Grid request (including rowGroupCols,
    // valueCols, pivotCols, groupKeys) to the server and forwards pivotResultFields
    // back to AG Grid so it can auto-generate secondary pivot columns.
    let script = format!(
        r#"
(async function() {{
    const gridDiv = document.getElementById('ag-pivot-grid');
    if (!gridDiv) return;

    let columnDefs;
    try {{
        const res = await fetch({col_defs_url_json});
        if (!res.ok) throw new Error('Failed to load column defs: ' + res.status);
        columnDefs = await res.json();
    }} catch (e) {{
        gridDiv.innerHTML = '<p style="color:#ff6b6b;padding:1rem">Error loading column definitions: ' + e.message + '</p>';
        return;
    }}

    // Inject server-side value fetching for set filters so users can pick
    // multiple values from a dropdown (e.g. to_city = NewYork OR Madrid).
    columnDefs.forEach(function(colDef) {{
        if (colDef.filter === 'agSetColumnFilter') {{
            colDef.filterParams = colDef.filterParams || {{}};
            colDef.filterParams.values = function(params) {{
                fetch({set_filter_values_base_json} + '&column=' + encodeURIComponent(colDef.field))
                    .then(function(r) {{ return r.json(); }})
                    .then(function(data) {{ params.success(data); }})
                    .catch(function() {{ params.success([]); }});
            }};
        }}
    }});

    const datasource = {{
        getRows: function(params) {{
            fetch({pivot_rows_url_json}, {{
                method: 'POST',
                headers: {{ 'Content-Type': 'application/json' }},
                body: JSON.stringify(params.request)
            }})
            .then(function(res) {{
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return res.json();
            }})
            .then(function(data) {{
                params.success({{
                    rowData: data.rowData,
                    rowCount: data.rowCount,
                    // AG Grid uses these field names to auto-build pivot result columns.
                    // The default separator is '_' which matches our naming convention.
                    pivotResultFields: data.pivotFields || [],
                }});
            }})
            .catch(function(err) {{
                console.error('AG Grid pivot datasource error:', err);
                params.fail();
            }});
        }}
    }};

    agGrid.createGrid(gridDiv, {{
        columnDefs: columnDefs,
        rowModelType: 'serverSide',
        serverSideDatasource: datasource,
        // Pivot mode must be true for the grid to send pivot-related fields.
        pivotMode: true,
        // Separator used when constructing result field names from pivot values.
        // Must match the naming convention used by the server.
        serverSidePivotResultFieldSeparator: '~',
        defaultColDef: {{
            flex: 1,
            minWidth: 100,
            resizable: true,
            sortable: true,
        }},
        // Show the column tool panel so users can drag columns into
        // Row Groups / Values / Column Labels (Pivot) buckets.
        sideBar: {{
            toolPanels: [
                {{
                    id: 'columns',
                    labelDefault: 'Columns',
                    labelKey: 'columns',
                    iconKey: 'columns',
                    toolPanel: 'agColumnsToolPanel',
                }},
                {{
                    id: 'filters',
                    labelDefault: 'Filters',
                    labelKey: 'filters',
                    iconKey: 'filter',
                    toolPanel: 'agFiltersToolPanel',
                }},
            ],
            defaultToolPanel: 'columns',
        }},
        animateRows: true,
        theme: 'legacy',
    }});
}})();
"#,
        col_defs_url_json = serde_json::to_string(&col_defs_url).unwrap(),
        pivot_rows_url_json = serde_json::to_string(&pivot_rows_url).unwrap(),
        set_filter_values_base_json = serde_json::to_string(&set_filter_values_base).unwrap(),
    );

    html! {
        p class="f7 white-60 mb2 mt2 lh-copy" {
            "Use the "
            strong { "Columns" }
            " panel on the right to drag fields into "
            strong { "Row Groups" }
            ", "
            strong { "Values" }
            " and "
            strong { "Column Labels" }
            " buckets."
        }
        div id="ag-pivot-grid"
            class="ag-theme-balham-dark flex-auto"
            style="width: 100%; height: 100%; min-height: 400px;" {}
        (PreEscaped(format!("<script>{script}</script>")))
    }
}

// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/column-defs  (plain grid)
// ---------------------------------------------------------------------------

#[get("/ag-grid/api/column-defs")]
pub async fn ag_grid_column_defs(
    params: web::Query<HashMap<String, String>>,
    store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();

    let Some(conn) = store.active() else {
        return Ok(
            HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
        );
    };

    let columns = db::describe_table(&conn, &db_name, &table_name).await;

    if columns.is_empty() {
        return Ok(
            HttpResponse::NotFound().json(json!({"error": "Table not found or has no columns"}))
        );
    }

    let col_defs: Vec<Value> = columns
        .iter()
        .map(|c| ch_type_to_ag_col_def(&c.name, &c.column_type))
        .collect();

    Ok(HttpResponse::Ok().json(col_defs))
}

// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/set-filter-values  (distinct values for set filter)
// ---------------------------------------------------------------------------

/// Maximum number of distinct values returned for a set filter column.
const MAX_SET_FILTER_VALUES: usize = 1000;

#[get("/ag-grid/api/set-filter-values")]
pub async fn ag_grid_set_filter_values(
    params: web::Query<HashMap<String, String>>,
    store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();
    let column = params.get("column").cloned().unwrap_or_default();

    if column.is_empty() {
        return Ok(HttpResponse::BadRequest().json(json!({"error": "column param required"})));
    }

    let Some(conn) = store.active() else {
        return Ok(
            HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
        );
    };

    let col_q = format!("`{}`", column.replace('`', "``"));
    let query = format!(
        "SELECT DISTINCT {col_q} FROM {db_name}.{table_name} \
         ORDER BY {col_q} \
         LIMIT {MAX_SET_FILTER_VALUES} \
         FORMAT TSVWithNames"
    );

    let client = reqwest::Client::new();
    let mut request = client
        .post(conn.url())
        .query(&[("user", conn.user.as_str())])
        .body(query);
    if !conn.password.is_empty() {
        request = request.query(&[("password", conn.password.as_str())]);
    }

    let response = match request.send().await {
        Ok(r) => r,
        Err(e) => {
            return Ok(HttpResponse::InternalServerError()
                .json(json!({"error": format!("ClickHouse request failed: {e}")})));
        }
    };

    if !response.status().is_success() {
        let body = response.text().await.unwrap_or_default();
        return Ok(HttpResponse::InternalServerError()
            .json(json!({"error": format!("ClickHouse error: {body}")})));
    }

    let text = match response.text().await {
        Ok(t) => t,
        Err(e) => {
            return Ok(HttpResponse::InternalServerError()
                .json(json!({"error": format!("Failed to read response: {e}")})));
        }
    };

    let values: Vec<String> = text
        .lines()
        .skip(1) // skip TSV header row
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.to_string())
        .collect();

    Ok(HttpResponse::Ok().json(values))
}

// ---------------------------------------------------------------------------
// Pivot auto-config helpers
// ---------------------------------------------------------------------------

/// Fetch the approximate cardinality of every listed column in one query.
/// Uses `uniqExact` for accuracy.  Returns a map of column_name → count.
async fn get_column_cardinalities(
    conn: &crate::connections::Connection,
    database: &str,
    table: &str,
    col_names: &[String],
) -> HashMap<String, u64> {
    if col_names.is_empty() {
        return HashMap::new();
    }

    let exprs: Vec<String> = col_names
        .iter()
        .map(|n| {
            let q = format!("`{}`", n.replace('`', "``"));
            format!("uniqExact({q}) AS `{}`", n.replace('`', "``"))
        })
        .collect();

    let sql = format!(
        "SELECT {} FROM {database}.{table} FORMAT JSONEachRow",
        exprs.join(", ")
    );

    let client = reqwest::Client::new();
    let mut request = client
        .post(conn.url())
        .query(&[("user", conn.user.as_str())])
        .body(sql);
    if !conn.password.is_empty() {
        request = request.query(&[("password", conn.password.as_str())]);
    }

    let Ok(resp) = request.send().await else {
        return HashMap::new();
    };
    if !resp.status().is_success() {
        return HashMap::new();
    }
    let Ok(text) = resp.text().await else {
        return HashMap::new();
    };

    // Expect exactly one JSONEachRow line.
    let Some(line) = text.lines().find(|l| !l.trim().is_empty()) else {
        return HashMap::new();
    };
    let Ok(Value::Object(map)) = serde_json::from_str::<Value>(line) else {
        return HashMap::new();
    };

    map.into_iter()
        .filter_map(|(k, v)| {
            let count = match &v {
                Value::Number(n) => n.as_u64(),
                Value::String(s) => s.parse::<u64>().ok(),
                _ => None,
            }?;
            Some((k, count))
        })
        .collect()
}

/// Roles that can be assigned to a column in pivot mode.
#[derive(Debug, Clone, PartialEq, Eq)]
enum PivotRole {
    RowGroup,
    Pivot,
    Value(String), // aggFunc
    None,
}

/// Decide the pivot role of each column based on its CH type and cardinality.
fn assign_pivot_roles(
    columns: &[db::ColumnInfo],
    cardinalities: &HashMap<String, u64>,
) -> Vec<(db::ColumnInfo, PivotRole)> {
    let mut result: Vec<(db::ColumnInfo, PivotRole)> = Vec::new();

    // Counters so we don't create too many row-groups or pivots.
    let mut pivot_count = 0usize;
    let mut row_group_count = 0usize;

    for col in columns {
        let inner = col
            .column_type
            .strip_prefix("Nullable(")
            .and_then(|s: &str| s.strip_suffix(')'))
            .unwrap_or(&col.column_type);

        let cardinality = cardinalities.get(&col.name).copied().unwrap_or(u64::MAX);

        let role = if inner.starts_with("Float") || inner.starts_with("Decimal") {
            PivotRole::Value("sum".to_string())
        } else if inner == "Bool" || inner == "Boolean" {
            // Booleans always have exactly 2 values – ideal pivot axis.
            pivot_count += 1;
            PivotRole::Pivot
        } else if inner.starts_with("Int") || inner.starts_with("UInt") {
            // Integer columns: use as value unless low-cardinality (good pivot axis).
            if cardinality <= 20 && pivot_count == 0 {
                pivot_count += 1;
                PivotRole::Pivot
            } else {
                PivotRole::Value("sum".to_string())
            }
        } else if inner.starts_with("DateTime") {
            // DateTime is too granular for grouping.
            PivotRole::None
        } else if inner.starts_with("Date") {
            if cardinality <= 366 && row_group_count < 2 {
                row_group_count += 1;
                PivotRole::RowGroup
            } else {
                PivotRole::None
            }
        } else {
            // String / Enum / other text-like types.
            if cardinality <= 20 && pivot_count == 0 {
                pivot_count += 1;
                PivotRole::Pivot
            } else if cardinality <= 500 && row_group_count < 2 {
                row_group_count += 1;
                PivotRole::RowGroup
            } else {
                PivotRole::None
            }
        };

        result.push((col.clone(), role));
    }

    result
}

/// Build fully-configured column defs for the pivot grid.
/// Columns with roles get `rowGroup`, `pivot`, or `aggFunc` pre-set so the
/// first `getRows` call already sends correct metadata to the server.
fn build_auto_config_col_defs(roles: &[(db::ColumnInfo, PivotRole)]) -> Vec<Value> {
    roles
        .iter()
        .map(|(col, role)| {
            let mut def = ch_type_to_pivot_col_def(&col.name, &col.column_type);
            match role {
                PivotRole::RowGroup => {
                    def["rowGroup"] = json!(true);
                    def["hide"] = json!(true);
                }
                PivotRole::Pivot => {
                    def["pivot"] = json!(true);
                    def["hide"] = json!(true);
                }
                PivotRole::Value(agg) => {
                    def["aggFunc"] = json!(agg);
                }
                PivotRole::None => {}
            }
            def
        })
        .collect()
}

// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/pivot-auto-config
// ---------------------------------------------------------------------------

#[get("/ag-grid/api/pivot-auto-config")]
pub async fn ag_grid_pivot_auto_config(
    params: web::Query<HashMap<String, String>>,
    store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();

    let Some(conn) = store.active() else {
        return Ok(
            HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
        );
    };

    let columns = db::describe_table(&conn, &db_name, &table_name).await;

    if columns.is_empty() {
        return Ok(
            HttpResponse::NotFound().json(json!({"error": "Table not found or has no columns"}))
        );
    }

    // Only bother computing cardinalities for non-float, non-DateTime columns
    // since floats always become values and DateTimes are never grouped.
    let candidate_names: Vec<String> = columns
        .iter()
        .filter(|c| {
            let inner = c
                .column_type
                .strip_prefix("Nullable(")
                .and_then(|s| s.strip_suffix(')'))
                .unwrap_or(&c.column_type);
            !inner.starts_with("Float")
                && !inner.starts_with("Decimal")
                && !inner.starts_with("DateTime")
        })
        .map(|c| c.name.clone())
        .collect();

    let cardinalities =
        get_column_cardinalities(&conn, &db_name, &table_name, &candidate_names).await;

    let roles = assign_pivot_roles(&columns, &cardinalities);
    let col_defs = build_auto_config_col_defs(&roles);

    Ok(HttpResponse::Ok().json(col_defs))
}

// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/pivot-column-defs  (pivot grid)
// ---------------------------------------------------------------------------

#[get("/ag-grid/api/pivot-column-defs")]
pub async fn ag_grid_pivot_column_defs(
    params: web::Query<HashMap<String, String>>,
    store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();

    let Some(conn) = store.active() else {
        return Ok(
            HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
        );
    };

    let columns = db::describe_table(&conn, &db_name, &table_name).await;

    if columns.is_empty() {
        return Ok(
            HttpResponse::NotFound().json(json!({"error": "Table not found or has no columns"}))
        );
    }

    let col_defs: Vec<Value> = columns
        .iter()
        .map(|c| ch_type_to_pivot_col_def(&c.name, &c.column_type))
        .collect();

    Ok(HttpResponse::Ok().json(col_defs))
}

// ---------------------------------------------------------------------------
// JSON API – POST /ag-grid/api/rows  (plain server-side grid)
// ---------------------------------------------------------------------------

#[post("/ag-grid/api/rows")]
pub async fn ag_grid_rows(
    params: web::Query<HashMap<String, String>>,
    body: web::Json<AgGridRowRequest>,
    store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();

    let Some(conn) = store.active() else {
        return Ok(
            HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
        );
    };

    let req = body.into_inner();
    let where_clause = build_where_clause(&req.filter_model);
    let order_clause = build_order_clause(&req.sort_model);

    let start_row = req.start_row.max(0) as usize;
    let end_row = req.end_row.max(1) as usize;
    let limit = end_row.saturating_sub(start_row).min(10_000);

    let rows_result = db::get_table_rows_json(
        &conn,
        &db_name,
        &table_name,
        limit,
        start_row,
        &where_clause,
        &order_clause,
    )
    .await;
    let count_result = db::get_table_count(&conn, &db_name, &table_name, &where_clause).await;

    match rows_result {
        Ok(rows) => {
            let row_count: i64 = count_result.unwrap_or(-1);
            Ok(HttpResponse::Ok().json(json!({
                "rowData": rows,
                "rowCount": row_count,
            })))
        }
        Err(e) => {
            log::error!("ag_grid_rows error: {e}");
            let err_msg = e.to_string();
            Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
        }
    }
}

// ---------------------------------------------------------------------------
// JSON API – POST /ag-grid/api/pivot-rows
// ---------------------------------------------------------------------------

#[post("/ag-grid/api/pivot-rows")]
pub async fn ag_grid_pivot_rows(
    params: web::Query<HashMap<String, String>>,
    body: web::Json<AgGridRowRequest>,
    store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
    let db_name = params.get("database").cloned().unwrap_or_default();
    let table_name = params.get("table").cloned().unwrap_or_default();

    let Some(conn) = store.active() else {
        return Ok(
            HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
        );
    };

    let req = body.into_inner();

    let start_row = req.start_row.max(0) as usize;
    let end_row = req.end_row.max(1) as usize;
    let limit = end_row.saturating_sub(start_row).min(10_000);

    // -----------------------------------------------------------------------
    // Build WHERE conditions
    // -----------------------------------------------------------------------

    // 1. Conditions from the AG Grid filter model.
    let filter_only_conditions = filter_conditions(&req.filter_model);
    let mut conditions = filter_only_conditions.clone();

    // 2. Conditions from groupKeys – each key constrains the corresponding
    //    rowGroupCol, narrowing the query to a single sub-group node.
    for (group_col, key_val) in req.row_group_cols.iter().zip(req.group_keys.iter()) {
        let col_q = format!("`{}`", group_col.field.replace('`', "``"));
        conditions.push(format!("{col_q} = {}", quote_pivot_literal(key_val)));
    }

    // -----------------------------------------------------------------------
    // Determine the GROUP BY column at the current tree depth.
    // depth == groupKeys.len() → the next row group column (if any).
    // -----------------------------------------------------------------------
    let depth = req.group_keys.len();
    let group_col: Option<&str> = req.row_group_cols.get(depth).map(|c| c.field.as_str());

    // -----------------------------------------------------------------------
    // Branch on whether pivot mode is actually active with pivot columns.
    // -----------------------------------------------------------------------

    if req.pivot_mode && !req.pivot_cols.is_empty() && !req.value_cols.is_empty() {
        // --- Full pivot path ---

        // Collect distinct values for each pivot column.
        let mut pivot_value_sets: Vec<Vec<String>> = Vec::new();
        for pc in &req.pivot_cols {
            // Use only filter conditions (not groupKey conditions) so that pivot
            // columns are consistent across all tree levels – a group that has no
            // data for a city will return 0 rather than omitting that column entirely.
            let vals = get_pivot_distinct_values(
                &conn,
                &db_name,
                &table_name,
                &pc.field,
                &filter_only_conditions,
            )
            .await;
            pivot_value_sets.push(vals);
        }

        // Nothing to pivot on (table may be empty or all values filtered out).
        if pivot_value_sets.iter().any(|v| v.is_empty()) {
            return Ok(HttpResponse::Ok().json(json!({
                "rowData": [],
                "rowCount": 0,
                "pivotFields": [],
            })));
        }

        let (sql, pivot_fields) = build_pivot_query(
            &db_name,
            &table_name,
            group_col,
            &conditions,
            &req.pivot_cols,
            &pivot_value_sets,
            &req.value_cols,
            &req.sort_model,
            limit,
            start_row,
        );

        if sql.is_empty() {
            return Ok(HttpResponse::Ok().json(json!({
                "rowData": [],
                "rowCount": 0,
                "pivotFields": [],
            })));
        }

        let rows_result = execute_json_query(&conn, &sql).await;
        let row_count =
            count_distinct_group(&conn, &db_name, &table_name, group_col, &conditions).await;

        match rows_result {
            Ok(rows) => Ok(HttpResponse::Ok().json(json!({
                "rowData": rows,
                "rowCount": row_count,
                "pivotFields": pivot_fields,
            }))),
            Err(e) => {
                log::error!("ag_grid_pivot_rows (pivot) error: {e}");
                let err_msg = e.to_string();
                Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
            }
        }
    } else if !req.row_group_cols.is_empty() && !req.value_cols.is_empty() {
        // --- Grouped aggregation (pivot mode on but no pivot cols yet) ---

        let Some(gc) = group_col else {
            // At leaf level with no more group columns – return raw rows.
            let where_clause = join_conditions_as_where(&conditions);
            let order_clause = build_order_clause(&req.sort_model);
            let rows_result = db::get_table_rows_json(
                &conn,
                &db_name,
                &table_name,
                limit,
                start_row,
                &where_clause,
                &order_clause,
            )
            .await;
            let count = db::get_table_count(&conn, &db_name, &table_name, &where_clause).await;
            return match rows_result {
                Ok(rows) => Ok(HttpResponse::Ok().json(json!({
                    "rowData": rows,
                    "rowCount": count.unwrap_or(-1),
                    "pivotFields": [],
                }))),
                Err(e) => {
                    let err_msg = e.to_string();
                    Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
                }
            };
        };

        let sql = build_grouped_query(
            &db_name,
            &table_name,
            gc,
            &conditions,
            &req.value_cols,
            &req.sort_model,
            limit,
            start_row,
        );

        let rows_result = execute_json_query(&conn, &sql).await;
        let row_count =
            count_distinct_group(&conn, &db_name, &table_name, Some(gc), &conditions).await;

        match rows_result {
            Ok(rows) => Ok(HttpResponse::Ok().json(json!({
                "rowData": rows,
                "rowCount": row_count,
                "pivotFields": [],
            }))),
            Err(e) => {
                log::error!("ag_grid_pivot_rows (grouped) error: {e}");
                let err_msg = e.to_string();
                Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
            }
        }
    } else {
        // --- Fallback: no groups, no pivot – return raw rows ---
        let where_clause = join_conditions_as_where(&conditions);
        let order_clause = build_order_clause(&req.sort_model);
        let rows_result = db::get_table_rows_json(
            &conn,
            &db_name,
            &table_name,
            limit,
            start_row,
            &where_clause,
            &order_clause,
        )
        .await;
        let count = db::get_table_count(&conn, &db_name, &table_name, &where_clause).await;
        match rows_result {
            Ok(rows) => Ok(HttpResponse::Ok().json(json!({
                "rowData": rows,
                "rowCount": count.unwrap_or(-1),
                "pivotFields": [],
            }))),
            Err(e) => {
                let err_msg = e.to_string();
                Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
            }
        }
    }
}