use crate::{
    schema::{Column, Index, Schema},
    stats::TableStat,
    translate::{
        collate::get_collseq_from_expr,
        expr::{as_binary_components, comparison_affinity},
        expression_index::normalize_expr_for_index_matching,
        plan::{JoinOrderMember, JoinedTable, NonFromClauseSubquery, TableReferences, WhereTerm},
        planner::{table_mask_from_expr, TableMask},
    },
    util::exprs_are_equivalent,
    vdbe::affinity::Affinity,
    Result,
};
use crate::{turso_assert, turso_debug_assert};
use rustc_hash::FxHashMap as HashMap;
use std::{cmp::Ordering, collections::VecDeque, sync::Arc};
use turso_ext::{ConstraintInfo, ConstraintOp};
use turso_parser::ast::{self, SortOrder, TableInternalId};

use super::cost_params::CostModelParams;

/// Represents a single condition derived from a `WHERE` clause term
/// that constrains a specific column of a table.
///
/// Constraints are precomputed for each table involved in a query. They are used
/// during query optimization to estimate the cost of different access paths (e.g., using an index)
/// and to determine the optimal join order. A constraint can only be applied if all tables
/// referenced in its expression (other than the constrained table itself) are already
/// available in the current join context, i.e. on the left side in the join order
/// relative to the table. Expression indexes are represented by leaving `table_col_pos` empty
/// and storing the indexed expression in `expr`.
#[derive(Debug, Clone)]
pub struct Constraint {
    /// The position of the original `WHERE` clause term this constraint derives from,
    /// and which side of the [ast::Expr::Binary] comparison contains the expression
    /// that constrains the column.
    /// E.g. in SELECT * FROM t WHERE t.x = 10, the constraint is (0, BinaryExprSide::Rhs)
    /// because the RHS '10' is the constraining expression.
    ///
    /// This is tracked so we can:
    ///
    /// 1. Extract the constraining expression for use in an index seek key, and
    /// 2. Remove the relevant binary expression from the WHERE clause, if used as an index seek key.
    pub where_clause_pos: (usize, BinaryExprSide),
    /// The comparison operator (e.g., `=`, `>`, `<`) used in the constraint.
    pub operator: ConstraintOperator,
    /// The zero-based index of the constrained column within the table's schema.
    /// None for expression-index constraints.
    pub table_col_pos: Option<usize>,
    /// The expression constrained by this constraint, if it is not a simple column reference.
    pub expr: Option<ast::Expr>,
    /// For multi-index scan branches: the constraining expression and its affinity.
    /// When set, `get_constraining_expr` uses this instead of looking up in where_clause.
    /// This is needed because multi-index branches come from sub-expressions of an OR/AND,
    /// not directly from a top-level WHERE term.
    pub constraining_expr: Option<(ast::Operator, ast::Expr, Affinity)>,
    /// A bitmask representing the set of tables that appear on the *constraining* side
    /// of the comparison expression. For example, in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x,
    /// the lhs_mask contains t2 and t3. Thus, this constraint can only be used if t2 and t3
    /// have already been joined (i.e. are on the left side of the join order relative to t1).
    pub lhs_mask: TableMask,
    /// An estimated selectivity factor (0.0 to 1.0) indicating the fraction of rows
    /// expected to satisfy this constraint. Used for cost and cardinality estimation.
    pub selectivity: f64,
    /// Whether the constraint is usable for an index seek.
    /// This is explicitly set to false if the constraint has a different collation than the constrained column.
    pub usable: bool,
    /// Whether this constraint references the implicit rowid (tables without an INTEGER PRIMARY KEY alias).
    /// When true and `table_col_pos` is None, this constraint targets the rowid pseudo-column.
    pub is_rowid: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConstraintOperator {
    AstNativeOperator(ast::Operator),
    Like { not: bool },
    In { not: bool, estimated_values: f64 },
}

impl ConstraintOperator {
    pub fn as_ast_operator(&self) -> Option<ast::Operator> {
        let ConstraintOperator::AstNativeOperator(op) = self else {
            return None;
        };
        Some(*op)
    }
}

impl From<ast::Operator> for ConstraintOperator {
    fn from(op: ast::Operator) -> Self {
        ConstraintOperator::AstNativeOperator(op)
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BinaryExprSide {
    Lhs,
    Rhs,
}

impl Constraint {
    /// Get the constraining expression and operator, e.g. ('>=', '2+3') from 't.x >= 2+3'
    pub fn get_constraining_expr(
        &self,
        where_clause: &[WhereTerm],
        referenced_tables: Option<&TableReferences>,
    ) -> (ast::Operator, ast::Expr, Affinity) {
        // For multi-index branches, use the pre-computed constraining expression
        if let Some(constraining) = &self.constraining_expr {
            return constraining.clone();
        }

        let (idx, side) = self.where_clause_pos;
        let where_term = &where_clause[idx];
        let Ok(Some((lhs, op, rhs))) = as_binary_components(&where_term.expr) else {
            panic!("Expected a valid binary expression");
        };
        let mut affinity = Affinity::Blob;
        if op.as_ast_operator().is_some_and(|op| op.is_comparison()) && self.table_col_pos.is_some()
        {
            affinity = comparison_affinity(lhs, rhs, referenced_tables, None);
        }

        if side == BinaryExprSide::Lhs {
            if affinity.expr_needs_no_affinity_change(lhs) {
                affinity = Affinity::Blob;
            }
            (
                self.operator
                    .as_ast_operator()
                    .expect("expected an ast operator because as_binary_components returned Some"),
                lhs.clone(),
                affinity,
            )
        } else {
            if affinity.expr_needs_no_affinity_change(rhs) {
                affinity = Affinity::Blob;
            }
            (
                self.operator
                    .as_ast_operator()
                    .expect("expected an ast operator because as_binary_components returned Some"),
                rhs.clone(),
                affinity,
            )
        }
    }

    pub fn get_constraining_expr_ref<'a>(&self, where_clause: &'a [WhereTerm]) -> &'a ast::Expr {
        let (idx, side) = self.where_clause_pos;
        let where_term = &where_clause[idx];
        let Ok(Some((lhs, _, rhs))) = as_binary_components(&where_term.expr) else {
            panic!("Expected a valid binary expression");
        };
        if side == BinaryExprSide::Lhs {
            lhs
        } else {
            rhs
        }
    }
}

#[derive(Debug, Clone)]
/// A reference to a [Constraint] in a [TableConstraints].
///
/// This is used to track which constraints may be used as an index seek key.
pub struct ConstraintRef {
    /// The position of the constraint in the [TableConstraints::constraints] vector.
    pub constraint_vec_pos: usize,
    /// The position of the constrained column in the index. Always 0 for rowid indices.
    pub index_col_pos: usize,
    /// The sort order of the constrained column in the index. Always ascending for rowid indices.
    pub sort_order: SortOrder,
}

/// A collection of [ConstraintRef]s for a given index, or if index is None, for the table's rowid index.
/// For example, given a table `T (x,y,z)` with an index `T_I (y desc,z)`, take the following query:
/// ```sql
/// SELECT * FROM T WHERE y = 10 AND z = 20;
/// ```
///
/// This will produce the following [ConstraintUseCandidate]:
///
/// ConstraintUseCandidate {
///     index: Some(T_I)
///     refs: [
///         ConstraintRef {
///             constraint_vec_pos: 0, // y = 10
///             index_col_pos: 0, // y
///             sort_order: SortOrder::Desc,
///         },
///         ConstraintRef {
///             constraint_vec_pos: 1, // z = 20
///             index_col_pos: 1, // z
///             sort_order: SortOrder::Asc,
///         },
///     ],
/// }
///
#[derive(Debug)]
pub struct ConstraintUseCandidate {
    /// The index that may be used to satisfy the constraints. If none, the table's rowid index is used.
    pub index: Option<Arc<Index>>,
    /// References to the constraints that may be used as an access path for the index.
    /// Refs are sorted by [ConstraintRef::index_col_pos]
    pub refs: Vec<ConstraintRef>,
}

#[derive(Debug)]
/// A collection of [Constraint]s and their potential [ConstraintUseCandidate]s for a given table.
pub struct TableConstraints {
    /// The internal ID of the [TableReference] that these constraints are for.
    pub table_id: TableInternalId,
    /// The constraints for the table, i.e. any [WhereTerm]s that reference columns from this table.
    pub constraints: Vec<Constraint>,
    /// Candidates for indexes that may use the constraints to perform a lookup.
    pub candidates: Vec<ConstraintUseCandidate>,
}

/// Estimate selectivity for IN expressions given the number of values and table row count.
fn estimate_in_selectivity(in_list_len: f64, row_count: f64, not: bool) -> f64 {
    if not {
        // NOT IN: each value in the list excludes roughly 1/ndv of the rows.
        // Without ANALYZE stats we don't know ndv, so we use the equality
        // selectivity heuristic (sel_eq_unindexed = 0.1) per excluded value.
        // This gives NOT IN (v1,v2,v3) ≈ (1 - 0.1)^3 ≈ 0.729, which is a
        // reasonable estimate that the filter does meaningful work.
        let per_value_sel = 0.1_f64; // matches sel_eq_unindexed default
        (1.0 - per_value_sel).powf(in_list_len).max(0.01)
    } else {
        (in_list_len / row_count).min(1.0)
    }
}

/// Estimate the selectivity of a constraint based on the operator, column type, and ANALYZE stats.
///
/// When ANALYZE stats are available, we use:
/// - For unique/PK columns: 1 / row_count (one row expected per lookup)
/// - For non-unique indexed columns: uses index stats to find avg rows per distinct value
///
/// The sqlite_stat1 format stores: total_rows, avg_rows_per_key_col1, avg_rows_per_key_col1_col2, ...
/// So selectivity = avg_rows_per_key / total_rows
///
/// Falls back to hardcoded estimates when stats are unavailable.
#[allow(clippy::too_many_arguments)]
fn estimate_selectivity(
    schema: &Schema,
    table_name: &str,
    column: Option<&Column>,
    column_pos: Option<usize>,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    op: ConstraintOperator,
    params: &CostModelParams,
    is_rowid: bool,
) -> f64 {
    // Get ANALYZE stats for this table if available
    let table_stats = schema.analyze_stats.table_stats(table_name);
    let row_count = table_stats.and_then(|s| s.row_count).unwrap_or(0);

    match op {
        ConstraintOperator::AstNativeOperator(ast::Operator::Equals) => {
            let is_pk_or_rowid_alias =
                is_rowid || column.is_some_and(|c| c.is_rowid_alias() || c.primary_key());

            let selectivity_when_unique = if row_count > 0 {
                1.0 / row_count as f64
            } else {
                // Fallback: use hardcoded estimate based on expected table size
                1.0 / params.rows_per_table_fallback
            };

            if is_pk_or_rowid_alias {
                selectivity_when_unique
            } else if let Some(col_pos) = column_pos {
                // For non-unique columns, find an index containing this column and use its stats
                if let Some(indexes) = available_indexes.get(table_name) {
                    for index in indexes {
                        // Check if this index has our column as its first column
                        // (selectivity is most accurate when column is leftmost in index)
                        if let Some(idx_col_pos) = index.column_table_pos_to_index_pos(col_pos) {
                            // Only use stats if column is first in index (idx_col_pos == 0)
                            // because that's when the distinct count is most useful
                            if idx_col_pos == 0 {
                                // Only use unique selectivity for single-column unique indexes.
                                // For composite unique indexes like tpc-h (l_orderkey, l_linenumber),
                                // the first column alone is NOT unique.
                                if index.unique && index.columns.len() == 1 {
                                    return selectivity_when_unique;
                                }
                                if let Some(stats) = table_stats {
                                    if let Some(idx_stat) = stats.index_stats.get(&index.name) {
                                        if let (Some(total), Some(&avg_rows)) = (
                                            idx_stat.total_rows,
                                            idx_stat.avg_rows_per_distinct_prefix.first(),
                                        ) {
                                            if total > 0 && avg_rows > 0 {
                                                // selectivity = avg_rows_per_key / total_rows
                                                return avg_rows as f64 / total as f64;
                                            }
                                        }
                                    }
                                } else {
                                    return params.sel_eq_indexed;
                                }
                            }
                        }
                    }
                }
                // Fallback: use hardcoded selectivity for non-indexed columns
                // Don't scale by row_count - keep it distinct from PK selectivity
                params.sel_eq_unindexed
            } else {
                params.sel_eq_unindexed
            }
        }
        ConstraintOperator::AstNativeOperator(ast::Operator::Greater)
        | ConstraintOperator::AstNativeOperator(ast::Operator::GreaterEquals)
        | ConstraintOperator::AstNativeOperator(ast::Operator::Less)
        | ConstraintOperator::AstNativeOperator(ast::Operator::LessEquals) => params.sel_range,
        ConstraintOperator::AstNativeOperator(ast::Operator::Is) => params.sel_is_null,
        ConstraintOperator::AstNativeOperator(ast::Operator::IsNot) => params.sel_is_not_null,
        ConstraintOperator::Like { not: false } => params.sel_like,
        ConstraintOperator::Like { not: true } => params.sel_not_like,
        ConstraintOperator::In {
            not,
            estimated_values,
        } => estimate_in_selectivity(estimated_values, row_count as f64, not),
        _ => params.sel_other,
    }
}

#[derive(Clone, Copy, Debug)]
/// A simplified reference to a single column-like value in an expression.
/// Used for estimating join selectivity, cheaper than ast::Expr
enum SimpleColumnRef {
    Column {
        table_id: TableInternalId,
        column_pos: usize,
    },
    RowId {
        table_id: TableInternalId,
    },
}

/// Extract a `SimpleColumnRef` from an expression, if it is a direct column-like reference.
fn simple_column_ref(expr: &ast::Expr) -> Option<SimpleColumnRef> {
    match expr {
        ast::Expr::Column { table, column, .. } => Some(SimpleColumnRef::Column {
            table_id: *table,
            column_pos: *column,
        }),
        ast::Expr::RowId { table, .. } => Some(SimpleColumnRef::RowId { table_id: *table }),
        _ => None,
    }
}

/// Estimate the "number of distinct values" (NDV) for a column-like value.
/// This is used as a building block for selectivity estimation, especially for
/// equality predicates and equi-joins.
///
/// Important limitations / assumptions:
/// - This assumes the leading column of an index provides the most reliable stats.
///   Non-leading columns are ignored for stats-derived NDV because prefix
///   cardinalities do not directly represent NDV of later columns.
/// - When stats are missing or unusable, heuristics are used; these constants
///   are intentionally conservative.
fn estimate_column_ndv(
    schema: &Schema,
    table_reference: &JoinedTable,
    column_pos: Option<usize>,
    is_rowid_expr: bool,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    params: &CostModelParams,
) -> Option<f64> {
    let table_name = table_reference.table.get_name();
    let table_stats = schema.analyze_stats.table_stats(table_name);
    let row_count = table_stats
        .and_then(|s| s.row_count)
        .unwrap_or(params.rows_per_table_fallback as u64) as f64;

    if is_rowid_expr {
        return Some(row_count.max(1.0));
    }

    let column_pos = column_pos?;
    let column = table_reference.table.columns().get(column_pos)?;
    if column.is_rowid_alias() || column.primary_key() {
        return Some(row_count.max(1.0));
    }

    if let Some(indexes) = available_indexes.get(table_name) {
        for index in indexes {
            if let Some(idx_pos) = index.column_table_pos_to_index_pos(column_pos) {
                if idx_pos != 0 {
                    continue;
                }
                if index.unique {
                    return Some(row_count.max(1.0));
                }
                if let Some(stats) = table_stats {
                    if let Some(idx_stat) = stats.index_stats.get(&index.name) {
                        if let (Some(total), Some(&avg_rows)) = (
                            idx_stat.total_rows,
                            idx_stat.avg_rows_per_distinct_prefix.first(),
                        ) {
                            if total > 0 && avg_rows > 0 {
                                let ndv = total as f64 / avg_rows as f64;
                                if ndv > 0.0 {
                                    return Some(ndv);
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    // If we have *any* index involvement for this column, assume better selectivity than unindexed.
    let has_index = available_indexes.get(table_name).is_some_and(|indexes| {
        indexes
            .iter()
            .any(|index| index.column_table_pos_to_index_pos(column_pos).is_some())
    });
    let fallback_selectivity = if has_index {
        params.sel_eq_indexed
    } else {
        params.sel_eq_unindexed
    };
    let mut ndv = (1.0 / fallback_selectivity).max(1.0);
    if row_count > 0.0 {
        ndv = ndv.min(row_count);
    }
    Some(ndv)
}

fn estimate_join_eq_selectivity(
    schema: &Schema,
    table_reference: &JoinedTable,
    column_pos: Option<usize>,
    other_ref: SimpleColumnRef,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    table_references: &TableReferences,
    params: &CostModelParams,
) -> Option<f64> {
    column_pos?;
    let other_table = table_references.joined_tables().iter().find(|t| {
        t.internal_id
            == match other_ref {
                SimpleColumnRef::Column { table_id, .. } => table_id,
                SimpleColumnRef::RowId { table_id } => table_id,
            }
    })?;
    let (other_col_pos, other_is_rowid) = match other_ref {
        SimpleColumnRef::Column { column_pos, .. } => (Some(column_pos), false),
        SimpleColumnRef::RowId { .. } => (None, true),
    };

    let left_ndv = estimate_column_ndv(
        schema,
        table_reference,
        column_pos,
        false,
        available_indexes,
        params,
    );
    let right_ndv = estimate_column_ndv(
        schema,
        other_table,
        other_col_pos,
        other_is_rowid,
        available_indexes,
        params,
    );

    let left_stats = schema
        .analyze_stats
        .table_stats(table_reference.table.get_name());
    let right_stats = schema
        .analyze_stats
        .table_stats(other_table.table.get_name());
    let left_ndv = join_ndv_with_fallback(left_stats, left_ndv, params);
    let right_ndv = join_ndv_with_fallback(right_stats, right_ndv, params);

    let max_ndv = match (left_ndv, right_ndv) {
        (Some(left), Some(right)) => left.max(right),
        (Some(left), None) | (None, Some(left)) => left,
        (None, None) => return None,
    };
    if max_ndv <= 0.0 {
        return None;
    }
    Some((1.0 / max_ndv).clamp(0.0, 1.0))
}

/// Estimate selectivity for a cross-table equality when we only know the tables,
/// not the specific columns.
///
/// This is a fallback used for expression-based equi-joins (e.g. CAST/expressions)
/// when we lack per-column stats. We approximate NDV using row counts, and fall
/// back to sqrt(rows) when ANALYZE data is missing.
fn estimate_generic_eq_join_selectivity(
    schema: &Schema,
    left_table: &JoinedTable,
    right_table: &JoinedTable,
    params: &CostModelParams,
) -> Option<f64> {
    let left_stats = schema
        .analyze_stats
        .table_stats(left_table.table.get_name());
    let right_stats = schema
        .analyze_stats
        .table_stats(right_table.table.get_name());
    let left_ndv = join_ndv_with_fallback(left_stats, None, params).unwrap_or(0.0);
    let right_ndv = join_ndv_with_fallback(right_stats, None, params).unwrap_or(0.0);

    let max_ndv = left_ndv.max(right_ndv);
    if max_ndv <= 0.0 {
        return None;
    }
    Some((1.0 / max_ndv).clamp(0.0, 1.0))
}

/// Normalize NDV estimates for join selectivity.
///
/// If ANALYZE stats are missing, we fall back to sqrt(row_count). When we already
/// have a column NDV, we clamp it to at least the fallback to avoid underestimates.
fn join_ndv_with_fallback(
    stats: Option<&TableStat>,
    column_ndv: Option<f64>,
    params: &CostModelParams,
) -> Option<f64> {
    let row_count = stats
        .and_then(|s| s.row_count)
        .unwrap_or(params.rows_per_table_fallback as u64) as f64;
    let has_stats = stats.is_some() && stats.and_then(|s| s.row_count).is_some();
    let fallback_ndv = row_count.sqrt().max(1.0);
    if has_stats {
        Some(column_ndv.unwrap_or_else(|| row_count.max(1.0)))
    } else {
        Some(column_ndv.unwrap_or(fallback_ndv).max(fallback_ndv))
    }
}

#[allow(clippy::too_many_arguments)]
/// Estimate selectivity for a single WHERE/ON constraint applied to `table_reference`.
///
/// This function attempts to recognize join-equality constraints and use a
/// join-aware selectivity estimate; otherwise it falls back to the generic
/// per-column selectivity estimator.
///
/// Specifically:
/// - If `operator == Equals` and `constraining_expr` is a simple column reference
///   from a *different* table, treat this as a join predicate and estimate:
///   selectivity ~= 1 / max(NDV(left), NDV(right)).
/// - Otherwise defer to `estimate_selectivity(...)`, which handles constants,
///   non-equality operators, and general column constraints (possibly using index stats).
fn estimate_constraint_selectivity(
    schema: &Schema,
    table_reference: &JoinedTable,
    column: Option<&Column>,
    column_pos: Option<usize>,
    operator: ConstraintOperator,
    constraining_expr: &ast::Expr,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    table_references: &TableReferences,
    subqueries: &[NonFromClauseSubquery],
    params: &CostModelParams,
    is_rowid: bool,
) -> f64 {
    // Special-case: equality to another table's column is likely an equi-join.
    if operator.as_ast_operator() == Some(ast::Operator::Equals) {
        if let Some(other_ref) = simple_column_ref(constraining_expr) {
            let other_table_id = match other_ref {
                SimpleColumnRef::Column { table_id, .. } => table_id,
                SimpleColumnRef::RowId { table_id } => table_id,
            };
            if other_table_id != table_reference.internal_id {
                // Only treat as a join if it references a different table than the constrained one.
                if let Some(selectivity) = estimate_join_eq_selectivity(
                    schema,
                    table_reference,
                    column_pos,
                    other_ref,
                    available_indexes,
                    table_references,
                    params,
                ) {
                    return selectivity;
                }
            }
        }
        if let Ok(mask) = table_mask_from_expr(constraining_expr, table_references, subqueries) {
            if mask.table_count() == 1 {
                if let Some(other_idx) = mask.tables_iter().next() {
                    let other_table = &table_references.joined_tables()[other_idx];
                    if other_table.internal_id != table_reference.internal_id {
                        if let Some(selectivity) = estimate_generic_eq_join_selectivity(
                            schema,
                            table_reference,
                            other_table,
                            params,
                        ) {
                            return selectivity;
                        }
                    }
                }
            }
        }
    }

    // Generic constraint selectivity (constants, non-equality, same-table refs, etc).
    estimate_selectivity(
        schema,
        table_reference.table.get_name(),
        column,
        column_pos,
        available_indexes,
        operator,
        params,
        is_rowid,
    )
}

fn expression_matches_table(
    expr: &ast::Expr,
    table_reference: &JoinedTable,
    table_references: &TableReferences,
    subqueries: &[NonFromClauseSubquery],
) -> bool {
    match table_mask_from_expr(expr, table_references, subqueries) {
        Ok(mask) => table_references
            .joined_tables()
            .iter()
            .position(|t| t.internal_id == table_reference.internal_id)
            .is_some_and(|idx| mask.contains_table(idx) && mask.table_count() == 1),
        Err(_) => false,
    }
}

/// Precompute all potentially usable [Constraints] from a WHERE clause.
/// The resulting list of [TableConstraints] is then used to evaluate the best access methods for various join orders.
///
/// This method do not perform much filtering of constraints and delegate this tasks to the consumers of the method
/// Consumers must inspect [TableConstraints] and its candidates and pick best constraints for optimized access
pub fn constraints_from_where_clause(
    where_clause: &[WhereTerm],
    table_references: &TableReferences,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    subqueries: &[NonFromClauseSubquery],
    schema: &Schema,
    params: &CostModelParams,
) -> Result<Vec<TableConstraints>> {
    let mut constraints = Vec::new();

    // For each table, collect all the Constraints and all potential index candidates that may use them.
    for table_reference in table_references.joined_tables() {
        let rowid_alias_column = table_reference
            .columns()
            .iter()
            .position(|c| c.is_rowid_alias());

        let mut cs = TableConstraints {
            table_id: table_reference.internal_id,
            constraints: Vec::new(),
            candidates: available_indexes
                .get(table_reference.table.get_name())
                .map_or(Vec::new(), |indexes| {
                    indexes
                        .iter()
                        // Skip IndexMethod-based indexes (FTS, vector, etc.) - they use
                        // pattern matching rather than btree index scans
                        .filter(|index| index.index_method.is_none())
                        .map(|index| ConstraintUseCandidate {
                            index: Some(index.clone()),
                            refs: Vec::new(),
                        })
                        .collect()
                }),
        };
        // Add a candidate for the rowid index, which is always available when the table has a rowid alias.
        cs.candidates.push(ConstraintUseCandidate {
            index: None,
            refs: Vec::new(),
        });

        for (i, term) in where_clause.iter().enumerate() {
            // Constraints originating from a LEFT JOIN must always be evaluated in that join's RHS table's loop,
            // regardless of which tables the constraint references.
            if let Some(outer_join_tbl) = term.from_outer_join {
                if outer_join_tbl != table_reference.internal_id {
                    continue;
                }
            }

            // Try to extract as binary expression first
            if let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? {
                // If either the LHS or RHS of the constraint is a column from the table, add the constraint.
                match lhs {
                    ast::Expr::Column { table, column, .. } => {
                        if *table == table_reference.internal_id {
                            let table_column = &table_reference.table.columns()[*column];
                            cs.constraints.push(Constraint {
                                where_clause_pos: (i, BinaryExprSide::Rhs),
                                operator,
                                table_col_pos: Some(*column),
                                expr: None,
                                constraining_expr: None,
                                lhs_mask: table_mask_from_expr(rhs, table_references, subqueries)?,
                                selectivity: estimate_constraint_selectivity(
                                    schema,
                                    table_reference,
                                    Some(table_column),
                                    Some(*column),
                                    operator,
                                    rhs,
                                    available_indexes,
                                    table_references,
                                    subqueries,
                                    params,
                                    false,
                                ),
                                usable: true,
                                is_rowid: false,
                            });
                        }
                    }
                    ast::Expr::RowId { table, .. } => {
                        if *table == table_reference.internal_id {
                            let (col, col_pos) = if let Some(alias) = rowid_alias_column {
                                (Some(&table_reference.table.columns()[alias]), Some(alias))
                            } else {
                                (None, None)
                            };
                            cs.constraints.push(Constraint {
                                where_clause_pos: (i, BinaryExprSide::Rhs),
                                operator,
                                table_col_pos: col_pos,
                                expr: None,
                                constraining_expr: None,
                                lhs_mask: table_mask_from_expr(rhs, table_references, subqueries)?,
                                selectivity: estimate_constraint_selectivity(
                                    schema,
                                    table_reference,
                                    col,
                                    col_pos,
                                    operator,
                                    rhs,
                                    available_indexes,
                                    table_references,
                                    subqueries,
                                    params,
                                    true,
                                ),
                                usable: true,
                                is_rowid: true,
                            });
                        }
                    }
                    _ if expression_matches_table(
                        lhs,
                        table_reference,
                        table_references,
                        subqueries,
                    ) =>
                    {
                        let selectivity = estimate_constraint_selectivity(
                            schema,
                            table_reference,
                            None,
                            None,
                            operator,
                            rhs,
                            available_indexes,
                            table_references,
                            subqueries,
                            params,
                            false,
                        );
                        tracing::debug!(
                            table = table_reference.table.get_name(),
                            where_clause_pos = i,
                            operator = ?operator,
                            lhs_mask = ?table_mask_from_expr(rhs, table_references, subqueries)?,
                            selectivity,
                            "expr constraint (lhs matches table)"
                        );
                        cs.constraints.push(Constraint {
                            where_clause_pos: (i, BinaryExprSide::Rhs),
                            operator,
                            table_col_pos: None,
                            expr: Some(lhs.clone()),
                            constraining_expr: None,
                            lhs_mask: table_mask_from_expr(rhs, table_references, subqueries)?,
                            selectivity,
                            usable: true,
                            is_rowid: false,
                        });
                    }
                    _ => {}
                };
                match rhs {
                    ast::Expr::Column { table, column, .. } => {
                        if *table == table_reference.internal_id {
                            let table_column = &table_reference.table.columns()[*column];
                            cs.constraints.push(Constraint {
                                where_clause_pos: (i, BinaryExprSide::Lhs),
                                operator: opposite_cmp_op(operator),
                                table_col_pos: Some(*column),
                                expr: None,
                                constraining_expr: None,
                                lhs_mask: table_mask_from_expr(lhs, table_references, subqueries)?,
                                selectivity: estimate_constraint_selectivity(
                                    schema,
                                    table_reference,
                                    Some(table_column),
                                    Some(*column),
                                    operator,
                                    lhs,
                                    available_indexes,
                                    table_references,
                                    subqueries,
                                    params,
                                    false,
                                ),
                                usable: true,
                                is_rowid: false,
                            });
                        }
                    }
                    ast::Expr::RowId { table, .. } => {
                        if *table == table_reference.internal_id {
                            let (col, col_pos) = if let Some(alias) = rowid_alias_column {
                                (Some(&table_reference.table.columns()[alias]), Some(alias))
                            } else {
                                (None, None)
                            };
                            cs.constraints.push(Constraint {
                                where_clause_pos: (i, BinaryExprSide::Lhs),
                                operator: opposite_cmp_op(operator),
                                table_col_pos: col_pos,
                                expr: None,
                                constraining_expr: None,
                                lhs_mask: table_mask_from_expr(lhs, table_references, subqueries)?,
                                selectivity: estimate_constraint_selectivity(
                                    schema,
                                    table_reference,
                                    col,
                                    col_pos,
                                    operator,
                                    lhs,
                                    available_indexes,
                                    table_references,
                                    subqueries,
                                    params,
                                    true,
                                ),
                                usable: true,
                                is_rowid: true,
                            });
                        }
                    }
                    _ if expression_matches_table(
                        rhs,
                        table_reference,
                        table_references,
                        subqueries,
                    ) =>
                    {
                        let selectivity = estimate_constraint_selectivity(
                            schema,
                            table_reference,
                            None,
                            None,
                            operator,
                            lhs,
                            available_indexes,
                            table_references,
                            subqueries,
                            params,
                            false,
                        );
                        tracing::debug!(
                            table = table_reference.table.get_name(),
                            where_clause_pos = i,
                            operator = ?operator,
                            lhs_mask = ?table_mask_from_expr(lhs, table_references, subqueries)?,
                            selectivity,
                            "expr constraint (rhs matches table)"
                        );
                        cs.constraints.push(Constraint {
                            where_clause_pos: (i, BinaryExprSide::Lhs),
                            operator: opposite_cmp_op(operator),
                            table_col_pos: None,
                            expr: Some(rhs.clone()),
                            constraining_expr: None,
                            lhs_mask: table_mask_from_expr(lhs, table_references, subqueries)?,
                            selectivity,
                            usable: true,
                            is_rowid: false,
                        });
                    }
                    _ => {}
                };
            }

            // IN expressions are handled separately from binary expressions above because:
            // - as_binary_components returns (&Expr, ConstraintOperator, &Expr) - a single RHS
            // - InList has Vec<Expr> as RHS, SubqueryResult has a different structure entirely
            // - They don't fit the binary expression abstraction without a more complex return type

            // Handle IN list: col IN (val1, val2, ...)
            if let ast::Expr::InList { lhs, not, rhs } = &term.expr {
                let estimated_values = rhs.len() as f64;
                let table_stats = schema
                    .analyze_stats
                    .table_stats(table_reference.table.get_name());
                let row_count = table_stats
                    .and_then(|s| s.row_count)
                    .unwrap_or(params.rows_per_table_fallback as u64)
                    as f64;
                let selectivity = estimate_in_selectivity(estimated_values, row_count, *not);

                match lhs.as_ref() {
                    ast::Expr::Column { table, column, .. }
                        if *table == table_reference.internal_id =>
                    {
                        cs.constraints.push(Constraint {
                            where_clause_pos: (i, BinaryExprSide::Rhs),
                            operator: ConstraintOperator::In {
                                not: *not,
                                estimated_values,
                            },
                            table_col_pos: Some(*column),
                            expr: None,
                            constraining_expr: None,
                            lhs_mask: TableMask::new(), // IN list values are constants
                            selectivity,
                            usable: false, // Cannot be used for index seeks (for now- no reason why it couldn't be, but it's not a good idea to index on IN lists)
                            is_rowid: false,
                        });
                    }
                    ast::Expr::RowId { table, .. } if *table == table_reference.internal_id => {
                        cs.constraints.push(Constraint {
                            where_clause_pos: (i, BinaryExprSide::Rhs),
                            operator: ConstraintOperator::In {
                                not: *not,
                                estimated_values,
                            },
                            table_col_pos: rowid_alias_column,
                            expr: None,
                            constraining_expr: None,
                            lhs_mask: TableMask::new(),
                            selectivity,
                            usable: false,
                            is_rowid: true,
                        });
                    }
                    _ => {}
                }
            }

            // Handle IN subquery: col IN (SELECT ...)
            if let ast::Expr::SubqueryResult {
                subquery_id,
                lhs: Some(lhs_expr),
                not_in,
                query_type: ast::SubqueryType::In { .. },
            } = &term.expr
            {
                // Find the subquery to check if it's correlated
                let subquery = subqueries
                    .iter()
                    .find(|s| s.internal_id == *subquery_id)
                    .expect("subquery not found");
                // Only use as constraint if NOT correlated
                if !subquery.correlated {
                    let estimated_values = params.in_subquery_rows;
                    let table_stats = schema
                        .analyze_stats
                        .table_stats(table_reference.table.get_name());
                    let row_count = table_stats
                        .and_then(|s| s.row_count)
                        .unwrap_or(params.rows_per_table_fallback as u64)
                        as f64;
                    let selectivity = estimate_in_selectivity(estimated_values, row_count, *not_in);

                    match lhs_expr.as_ref() {
                        ast::Expr::Column { table, column, .. }
                            if *table == table_reference.internal_id =>
                        {
                            cs.constraints.push(Constraint {
                                where_clause_pos: (i, BinaryExprSide::Rhs),
                                operator: ConstraintOperator::In {
                                    not: *not_in,
                                    estimated_values,
                                },
                                table_col_pos: Some(*column),
                                expr: None,
                                constraining_expr: None,
                                lhs_mask: TableMask::new(), // non-correlated = no dependencies
                                selectivity,
                                usable: false, // Cannot be used for index seeks
                                is_rowid: false,
                            });
                        }
                        ast::Expr::RowId { table, .. } if *table == table_reference.internal_id => {
                            cs.constraints.push(Constraint {
                                where_clause_pos: (i, BinaryExprSide::Rhs),
                                operator: ConstraintOperator::In {
                                    not: *not_in,
                                    estimated_values,
                                },
                                table_col_pos: rowid_alias_column,
                                expr: None,
                                constraining_expr: None,
                                lhs_mask: TableMask::new(),
                                selectivity,
                                usable: false,
                                is_rowid: true,
                            });
                        }
                        _ => {}
                    }
                }
            }
        }
        // sort equalities first so that index keys will be properly constructed.
        // see e.g.: https://www.solarwinds.com/blog/the-left-prefix-index-rule
        cs.constraints.sort_by(|a, b| {
            if a.operator == ast::Operator::Equals.into() {
                Ordering::Less
            } else if b.operator == ast::Operator::Equals.into() {
                Ordering::Greater
            } else {
                Ordering::Equal
            }
        });

        // For each constraint we found, add a reference to it for each index that may be able to use it.
        for (i, constraint) in cs.constraints.iter_mut().enumerate() {
            // Skip constraints already marked unusable (e.g., IN expressions which can't be index seeks)
            if !constraint.usable {
                continue;
            }

            let constrained_column = constraint
                .table_col_pos
                .and_then(|pos| table_reference.table.columns().get(pos));
            let column_collation = constrained_column.map(|c| c.collation());
            let constraining_expr = constraint.get_constraining_expr_ref(where_clause);
            // Index seek keys must use the same collation as the constrained column.
            match (
                get_collseq_from_expr(constraining_expr, table_references)?,
                column_collation,
            ) {
                (Some(collation), Some(column_collation)) if collation != column_collation => {
                    constraint.usable = false;
                    continue;
                }
                _ => {}
            }

            if constraint.is_rowid
                || rowid_alias_column.is_some_and(|p| constraint.table_col_pos == Some(p))
            {
                let rowid_candidate = cs
                    .candidates
                    .iter_mut()
                    .find_map(|candidate| {
                        if candidate.index.is_none() {
                            Some(candidate)
                        } else {
                            None
                        }
                    })
                    .unwrap();
                rowid_candidate.refs.push(ConstraintRef {
                    constraint_vec_pos: i,
                    index_col_pos: 0,
                    sort_order: SortOrder::Asc,
                });
            }
            for index in available_indexes
                .get(table_reference.table.get_name())
                .unwrap_or(&VecDeque::new())
                .iter()
                .filter(|idx| idx.index_method.is_none())
            {
                if let Some(position_in_index) = match constraint.table_col_pos {
                    Some(pos) => index.column_table_pos_to_index_pos(pos),
                    None => constraint.expr.as_ref().and_then(|e| {
                        let normalized =
                            normalize_expr_for_index_matching(e, table_reference, table_references);
                        index.expression_to_index_pos(&normalized)
                    }),
                } {
                    turso_assert!(
                        constraint.usable,
                        "constraint collation must match table column collation"
                    );
                    if let Some(table_col_pos) = constraint.table_col_pos {
                        let constrained_column = &table_reference.table.columns()[table_col_pos];
                        let table_collation = constrained_column.collation();
                        let index_collation = index.columns[position_in_index]
                            .collation
                            .unwrap_or_default();
                        if table_collation != index_collation {
                            continue;
                        }
                        // Custom type columns encode values as blobs. Blob ordering (memcmp)
                        // doesn't necessarily match the custom type's semantic ordering, so
                        // range constraints (>, <, >=, <=) can't use the index. Equality (=)
                        // still works because encoded(A) == encoded(B) iff A == B.
                        if schema
                            .get_type_def(
                                &constrained_column.ty_str,
                                table_reference.table.is_strict(),
                            )
                            .is_some()
                            && constraint.operator != ast::Operator::Equals.into()
                        {
                            continue;
                        }
                    }
                    if let Some(index_candidate) = cs.candidates.iter_mut().find_map(|candidate| {
                        if candidate.index.as_ref().is_some_and(|i| {
                            Arc::ptr_eq(index, i) && can_use_partial_index(index, where_clause)
                        }) {
                            Some(candidate)
                        } else {
                            None
                        }
                    }) {
                        index_candidate.refs.push(ConstraintRef {
                            constraint_vec_pos: i,
                            index_col_pos: position_in_index,
                            sort_order: index.columns[position_in_index].order,
                        });
                    }
                }
            }
        }

        for candidate in cs.candidates.iter_mut() {
            // Sort by index_col_pos, ascending -- index columns must be consumed in contiguous order.
            candidate.refs.sort_by_key(|cref| cref.index_col_pos);
        }
        cs.candidates.retain(|c| {
            if let Some(idx) = &c.index {
                if idx.where_clause.is_some() && c.refs.is_empty() {
                    // prevent a partial index from even being considered as a scan driver.
                    return false;
                }
            }
            true
        });
        constraints.push(cs);
    }

    Ok(constraints)
}

#[derive(Clone, Debug)]
/// A reference to a [Constraint]s in a [TableConstraints] for single column.
///
/// This is specialized version of [ConstraintRef] which specifically holds range-like constraints:
/// - x = 10 (eq is set)
/// - x >= 10, x > 10 (lower_bound is set)
/// - x <= 10, x < 10 (upper_bound is set)
/// - x > 10 AND x < 20 (both lower_bound and upper_bound are set)
///
/// eq, lower_bound and upper_bound holds None or position of the constraint in the [Constraint] array
pub struct RangeConstraintRef {
    /// position of the column in the table definition
    pub table_col_pos: Option<usize>,
    /// position of the column in the index definition
    pub index_col_pos: usize,
    /// sort order for the column in the index definition
    pub sort_order: SortOrder,
    /// equality constraint
    pub eq: Option<usize>,
    /// lower bound constraint (either > or >=)
    pub lower_bound: Option<usize>,
    /// upper bound constraint (either < or <=)
    pub upper_bound: Option<usize>,
}

#[derive(Debug, Clone)]
/// Represent seek range which can be used in query planning to emit range scan over table or index
pub struct SeekRangeConstraint {
    pub sort_order: SortOrder,
    pub eq: Option<(ast::Operator, ast::Expr, Affinity)>,
    pub lower_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
    pub upper_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
}

impl SeekRangeConstraint {
    pub fn new_eq(sort_order: SortOrder, eq: (ast::Operator, ast::Expr, Affinity)) -> Self {
        Self {
            sort_order,
            eq: Some(eq),
            lower_bound: None,
            upper_bound: None,
        }
    }
    pub fn new_range(
        sort_order: SortOrder,
        lower_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
        upper_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
    ) -> Self {
        turso_assert!(lower_bound.is_some() || upper_bound.is_some());
        Self {
            sort_order,
            eq: None,
            lower_bound,
            upper_bound,
        }
    }
}

impl RangeConstraintRef {
    /// Convert the [RangeConstraintRef] to a [SeekRangeConstraint] usable in a [crate::translate::plan::SeekDef::key].
    pub fn as_seek_range_constraint(
        &self,
        constraints: &[Constraint],
        where_clause: &[WhereTerm],
        referenced_tables: Option<&TableReferences>,
    ) -> SeekRangeConstraint {
        if let Some(eq) = self.eq {
            return SeekRangeConstraint::new_eq(
                self.sort_order,
                constraints[eq].get_constraining_expr(where_clause, referenced_tables),
            );
        }
        SeekRangeConstraint::new_range(
            self.sort_order,
            self.lower_bound
                .map(|x| constraints[x].get_constraining_expr(where_clause, referenced_tables)),
            self.upper_bound
                .map(|x| constraints[x].get_constraining_expr(where_clause, referenced_tables)),
        )
    }
}

/// Find which [Constraint]s are usable for a given join order.
/// Returns a slice of the references to the constraints that are usable.
/// A constraint is considered usable for a given table if all of the other tables referenced by the constraint
/// are on the left side in the join order relative to the table.
pub fn usable_constraints_for_join_order<'a>(
    constraints: &'a [Constraint],
    refs: &'a [ConstraintRef],
    join_order: &[JoinOrderMember],
) -> Vec<RangeConstraintRef> {
    turso_debug_assert!(refs.is_sorted_by_key(|x| x.index_col_pos));

    let table_idx = join_order.last().unwrap().original_idx;
    let lhs_mask = TableMask::from_table_number_iter(
        join_order
            .iter()
            .take(join_order.len() - 1)
            .map(|j| j.original_idx),
    );
    let mut usable: Vec<RangeConstraintRef> = Vec::new();
    let mut current_required_column_pos = 0;
    for cref in refs.iter() {
        let constraint = &constraints[cref.constraint_vec_pos];
        let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_idx);
        if other_side_refers_to_self {
            // For multi-column indexes, a gap at an earlier column position means later
            // columns can't be used (B-tree prefix rule). But if this unusable constraint
            // is at the same column position as one we haven't yet filled, another
            // constraint on the same column might still be usable — so only break when
            // the column position would advance past a gap.
            //
            // Example: given join order [t, o] and evaluating rowid access for `o`:
            //   refs = [(col=0, o.id = oi.order_id),  -- unusable, oi not in join order
            //           (col=0, t.order_id = o.id)]    -- usable
            // Breaking on the first unusable ref would miss the usable constraint,
            // causing a full table scan instead of SeekRowid.
            if cref.index_col_pos != current_required_column_pos {
                break;
            }
            continue;
        }
        let all_required_tables_are_on_left_side = lhs_mask.contains_all(&constraint.lhs_mask);
        if !all_required_tables_are_on_left_side {
            // Same logic as above: skip unusable constraints at the current column
            // position since a usable one may follow; break only at later positions.
            if cref.index_col_pos != current_required_column_pos {
                break;
            }
            continue;
        }
        if Some(cref.index_col_pos) == usable.last().map(|x| x.index_col_pos) {
            // Two constraints on the same index column can be combined into a single range constraint.
            assert_eq!(cref.sort_order, usable.last().unwrap().sort_order);
            assert_eq!(cref.index_col_pos, usable.last().unwrap().index_col_pos);
            assert_eq!(
                constraints[cref.constraint_vec_pos].table_col_pos,
                usable.last().unwrap().table_col_pos
            );
            // if we already have eq constraint - we must not add anything to it
            // otherwise, we can incorrectly consume filters which will not be used in the access path
            if usable.last().unwrap().eq.is_some() {
                continue;
            }
            match constraints[cref.constraint_vec_pos]
                .operator
                .as_ast_operator()
            {
                Some(ast::Operator::Greater) | Some(ast::Operator::GreaterEquals) => {
                    usable.last_mut().unwrap().lower_bound = Some(cref.constraint_vec_pos);
                }
                Some(ast::Operator::Less) | Some(ast::Operator::LessEquals) => {
                    usable.last_mut().unwrap().upper_bound = Some(cref.constraint_vec_pos);
                }
                _ => {}
            }
            continue;
        }
        if cref.index_col_pos != current_required_column_pos {
            // Index columns must be consumed contiguously in the order they appear in the index.
            break;
        }
        if usable.last().is_some_and(|x| x.eq.is_none()) {
            // Usable index key must have 0-n equalities and then a maximum of 1 range constraint with one or both bounds set.
            // If we already have a range constraint before this one, we must not add anything to it
            break;
        }
        let operator = constraints[cref.constraint_vec_pos].operator;
        let table_col_pos = constraints[cref.constraint_vec_pos].table_col_pos;
        if operator == ast::Operator::Equals.into()
            && usable
                .last()
                .is_some_and(|x| x.table_col_pos == table_col_pos)
        {
            // If we already have an equality constraint for this column, we can't use it again
            continue;
        }
        let constraint_group = match operator.as_ast_operator() {
            Some(ast::Operator::Equals) => RangeConstraintRef {
                table_col_pos,
                index_col_pos: cref.index_col_pos,
                sort_order: cref.sort_order,
                eq: Some(cref.constraint_vec_pos),
                lower_bound: None,
                upper_bound: None,
            },
            Some(ast::Operator::Greater) | Some(ast::Operator::GreaterEquals) => {
                RangeConstraintRef {
                    table_col_pos,
                    index_col_pos: cref.index_col_pos,
                    sort_order: cref.sort_order,
                    eq: None,
                    lower_bound: Some(cref.constraint_vec_pos),
                    upper_bound: None,
                }
            }
            Some(ast::Operator::Less) | Some(ast::Operator::LessEquals) => RangeConstraintRef {
                table_col_pos,
                index_col_pos: cref.index_col_pos,
                sort_order: cref.sort_order,
                eq: None,
                lower_bound: None,
                upper_bound: Some(cref.constraint_vec_pos),
            },
            _ => continue,
        };
        usable.push(constraint_group);
        current_required_column_pos += 1;
    }
    usable
}

fn can_use_partial_index(index: &Index, query_where_clause: &[WhereTerm]) -> bool {
    let Some(index_where) = &index.where_clause else {
        // Full index, always usable
        return true;
    };
    // Check if query WHERE contains the exact same predicate
    for term in query_where_clause {
        if exprs_are_equivalent(&term.expr, index_where.as_ref()) {
            return true;
        }
    }
    // TODO: do better to determine if we should use partial index
    false
}

pub fn convert_to_vtab_constraint(
    constraints: &[Constraint],
    join_order: &[JoinOrderMember],
) -> Vec<ConstraintInfo> {
    let table_idx = join_order.last().unwrap().original_idx;
    let lhs_mask = TableMask::from_table_number_iter(
        join_order
            .iter()
            .take(join_order.len() - 1)
            .map(|j| j.original_idx),
    );
    constraints
        .iter()
        .enumerate()
        .filter_map(|(i, constraint)| {
            let table_col_pos = constraint.table_col_pos?;
            let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_idx);
            if other_side_refers_to_self {
                return None;
            }
            let all_required_tables_are_on_left_side = lhs_mask.contains_all(&constraint.lhs_mask);
            to_ext_constraint_op(&constraint.operator).map(|op| ConstraintInfo {
                column_index: table_col_pos as u32,
                op,
                usable: all_required_tables_are_on_left_side,
                index: i,
            })
        })
        .collect()
}

fn to_ext_constraint_op(op: &ConstraintOperator) -> Option<ConstraintOp> {
    let ConstraintOperator::AstNativeOperator(op) = op else {
        return None;
    };
    match op {
        ast::Operator::Equals => Some(ConstraintOp::Eq),
        ast::Operator::Less => Some(ConstraintOp::Lt),
        ast::Operator::LessEquals => Some(ConstraintOp::Le),
        ast::Operator::Greater => Some(ConstraintOp::Gt),
        ast::Operator::GreaterEquals => Some(ConstraintOp::Ge),
        ast::Operator::NotEquals => Some(ConstraintOp::Ne),
        _ => None,
    }
}

fn opposite_cmp_op(op: ConstraintOperator) -> ConstraintOperator {
    let ConstraintOperator::AstNativeOperator(op_inner) = &op else {
        return op;
    };
    match op_inner {
        ast::Operator::Equals => ast::Operator::Equals,
        ast::Operator::Greater => ast::Operator::Less,
        ast::Operator::GreaterEquals => ast::Operator::LessEquals,
        ast::Operator::Less => ast::Operator::Greater,
        ast::Operator::LessEquals => ast::Operator::GreaterEquals,
        ast::Operator::NotEquals => ast::Operator::NotEquals,
        ast::Operator::Is => ast::Operator::Is,
        ast::Operator::IsNot => ast::Operator::IsNot,
        _ => panic!("unexpected operator: {op:?}"),
    }
    .into()
}

/// Result of analyzing a single term for multi-index scan potential.
/// This is a shared intermediate structure used by both OR and AND analysis.
#[derive(Debug)]
pub struct AnalyzedTerm {
    /// The constraint derived from this term.
    pub constraint: Constraint,
    /// The best index for this term, if any.
    pub best_index: Option<Arc<Index>>,
    /// Constraint references for this term.
    pub constraint_refs: Vec<RangeConstraintRef>,
    /// Estimated number of rows from this term.
    pub estimated_rows: f64,
}

/// Analyzes a single binary expression to determine if it can use an index.
///
/// This is a shared helper for both OR and AND multi-index analysis.
/// Returns `Some(AnalyzedTerm)` if the expression is a usable indexed constraint,
/// `None` otherwise.
#[allow(clippy::too_many_arguments)]
fn analyze_binary_term_for_index(
    expr: &ast::Expr,
    where_term_idx: usize,
    table_id: TableInternalId,
    table_reference: &JoinedTable,
    table_name: &str,
    indexes: Option<&VecDeque<Arc<Index>>>,
    rowid_alias_column: Option<usize>,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    table_references: &TableReferences,
    subqueries: &[NonFromClauseSubquery],
    schema: &Schema,
    params: &CostModelParams,
) -> Option<AnalyzedTerm> {
    // Try to extract a binary comparison
    let (lhs, operator, rhs) = as_binary_components(expr).ok().flatten()?;

    // Check if the operator is usable for index seeks
    let is_usable_op = matches!(
        operator.as_ast_operator(),
        Some(
            ast::Operator::Equals
                | ast::Operator::Greater
                | ast::Operator::GreaterEquals
                | ast::Operator::Less
                | ast::Operator::LessEquals
        )
    );

    if !is_usable_op {
        return None;
    }

    // Check if this is an indexable constraint on our table
    let (table_col_pos, constraining_expr, side, is_rowid) = match lhs {
        ast::Expr::Column { table, column, .. } if *table == table_id => {
            (Some(*column), rhs.clone(), BinaryExprSide::Rhs, false)
        }
        ast::Expr::RowId { table, .. } if *table == table_id => {
            (rowid_alias_column, rhs.clone(), BinaryExprSide::Rhs, true)
        }
        _ => match rhs {
            ast::Expr::Column { table, column, .. } if *table == table_id => {
                (Some(*column), lhs.clone(), BinaryExprSide::Lhs, false)
            }
            ast::Expr::RowId { table, .. } if *table == table_id => {
                (rowid_alias_column, lhs.clone(), BinaryExprSide::Lhs, true)
            }
            _ => return None, // Doesn't reference our table
        },
    };

    // Normalize operator direction so it matches the constrained table column.
    // Example: `1 > t.b` constrains `t.b < 1`.
    let operator = if side == BinaryExprSide::Lhs {
        opposite_cmp_op(operator)
    } else {
        operator
    };

    // Find the best index for this constraint
    let (best_index, constraint_refs) = find_best_index_for_constraint(
        table_col_pos,
        operator,
        indexes,
        rowid_alias_column,
        is_rowid,
    );

    // If no index can be used, this term is not indexable
    if constraint_refs.is_empty() {
        return None;
    }

    let table_column = table_col_pos.and_then(|pos| table_reference.table.columns().get(pos));
    let selectivity = estimate_constraint_selectivity(
        schema,
        table_reference,
        table_column,
        table_col_pos,
        operator,
        &constraining_expr,
        available_indexes,
        table_references,
        subqueries,
        params,
        is_rowid,
    );

    let lhs_mask = table_mask_from_expr(&constraining_expr, table_references, subqueries)
        .unwrap_or_else(|_| TableMask::new());

    // Cannot use index seek if the constraining expression references the same table
    // being scanned, since the expression value varies per row and cannot be evaluated
    // before the scan (e.g. TYPEOF(b) NOT BETWEEN a AND a where both columns are from
    // the same table).
    if let Some(table_pos) = table_references
        .joined_tables()
        .iter()
        .position(|t| t.internal_id == table_id)
    {
        if lhs_mask.contains_table(table_pos) {
            return None;
        }
    }

    // Compute the affinity for the constraining expression
    let affinity = if let Some(ast_op) = operator.as_ast_operator() {
        if ast_op.is_comparison() && table_col_pos.is_some() {
            comparison_affinity(lhs, rhs, Some(table_references), None)
        } else {
            Affinity::Blob
        }
    } else {
        Affinity::Blob
    };

    // Store the pre-computed constraining expression for multi-index branches
    let stored_constraining_expr = operator
        .as_ast_operator()
        .map(|ast_op| (ast_op, constraining_expr.clone(), affinity));

    let constraint = Constraint {
        where_clause_pos: (where_term_idx, side),
        operator,
        table_col_pos,
        expr: None,
        constraining_expr: stored_constraining_expr,
        lhs_mask,
        selectivity,
        usable: true,
        is_rowid,
    };

    let row_count = schema
        .analyze_stats
        .table_stats(table_name)
        .and_then(|s| s.row_count)
        .unwrap_or(params.rows_per_table_fallback as u64) as f64;

    Some(AnalyzedTerm {
        constraint,
        best_index,
        constraint_refs,
        estimated_rows: row_count * selectivity,
    })
}

/// Represents the decomposition of an OR clause for multi-index scan analysis.
///
/// For a WHERE clause like `a = 1 OR b = 2`, this structure captures:
/// - The original WHERE term index
/// - Each disjunct (a = 1, b = 2) with its indexability info
#[derive(Debug)]
#[allow(dead_code)]
pub struct OrClauseDecomposition {
    /// Index of the original WHERE term containing the OR expression.
    pub where_term_idx: usize,
    /// The table this OR clause applies to.
    pub table_id: TableInternalId,
    /// The disjuncts (OR branches) in this clause.
    pub disjuncts: Vec<OrDisjunct>,
    /// Whether all disjuncts are indexable (required for multi-index scan).
    pub all_indexable: bool,
}

/// A single disjunct (branch) of an OR expression.
#[derive(Debug)]
#[allow(dead_code)]
pub struct OrDisjunct {
    /// The expression for this disjunct.
    pub expr: ast::Expr,
    /// If indexable, the constraint derived from this disjunct.
    pub constraint: Option<Constraint>,
    /// The best index for this disjunct, if any.
    pub best_index: Option<Arc<Index>>,
    /// Constraint references for this disjunct.
    pub constraint_refs: Vec<RangeConstraintRef>,
    /// Estimated number of rows from this disjunct.
    pub estimated_rows: f64,
}

/// Flattens nested OR expressions into a list of disjuncts.
///
/// For example, `(a OR b) OR c` becomes `[a, b, c]`.
pub fn flatten_or_expr(expr: &ast::Expr) -> Vec<&ast::Expr> {
    match expr {
        ast::Expr::Binary(lhs, ast::Operator::Or, rhs) => {
            let mut result = flatten_or_expr(lhs);
            result.extend(flatten_or_expr(rhs));
            result
        }
        _ => vec![expr],
    }
}

/// Analyzes an OR expression to determine if it can be optimized with a multi-index scan.
///
/// Returns `Some(OrClauseDecomposition)` if the OR term:
/// 1. Is a simple OR of indexable terms
/// 2. All terms reference the same table
/// 3. All terms can use an index
///
/// Returns `None` if the OR term cannot be optimized with a multi-index scan.
#[allow(clippy::too_many_arguments)]
pub fn analyze_or_term_for_multi_index(
    where_term_idx: usize,
    expr: &ast::Expr,
    table_reference: &JoinedTable,
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    table_references: &TableReferences,
    subqueries: &[NonFromClauseSubquery],
    schema: &Schema,
    params: &CostModelParams,
) -> Option<OrClauseDecomposition> {
    // Only consider OR expressions
    let ast::Expr::Binary(_, ast::Operator::Or, _) = expr else {
        return None;
    };

    let disjuncts = flatten_or_expr(expr);

    // Need at least 2 disjuncts for multi-index scan to be useful
    if disjuncts.len() < 2 {
        return None;
    }

    let table_id = table_reference.internal_id;
    let table_name = table_reference.table.get_name();
    let indexes = available_indexes.get(table_name);
    let rowid_alias_column = table_reference
        .columns()
        .iter()
        .position(|c| c.is_rowid_alias());

    let mut analyzed_disjuncts = Vec::new();
    let mut all_indexable = true;

    for disjunct_expr in disjuncts {
        match analyze_binary_term_for_index(
            disjunct_expr,
            where_term_idx,
            table_id,
            table_reference,
            table_name,
            indexes,
            rowid_alias_column,
            available_indexes,
            table_references,
            subqueries,
            schema,
            params,
        ) {
            Some(analyzed) => {
                analyzed_disjuncts.push(OrDisjunct {
                    expr: disjunct_expr.clone(),
                    constraint: Some(analyzed.constraint),
                    best_index: analyzed.best_index,
                    constraint_refs: analyzed.constraint_refs,
                    estimated_rows: analyzed.estimated_rows,
                });
            }
            None => {
                // Not indexable
                all_indexable = false;
                analyzed_disjuncts.push(OrDisjunct {
                    expr: disjunct_expr.clone(),
                    constraint: None,
                    best_index: None,
                    constraint_refs: vec![],
                    estimated_rows: params.rows_per_table_fallback,
                });
            }
        }
    }

    Some(OrClauseDecomposition {
        where_term_idx,
        table_id,
        disjuncts: analyzed_disjuncts,
        all_indexable,
    })
}

/// Find the best index for a single constraint.
fn find_best_index_for_constraint(
    table_col_pos: Option<usize>,
    operator: ConstraintOperator,
    indexes: Option<&VecDeque<Arc<Index>>>,
    rowid_alias_column: Option<usize>,
    is_rowid: bool,
) -> (Option<Arc<Index>>, Vec<RangeConstraintRef>) {
    // Handle implicit rowid (no alias column, table_col_pos is None)
    if is_rowid && table_col_pos.is_none() {
        let constraint_ref = RangeConstraintRef {
            table_col_pos: None,
            index_col_pos: 0,
            sort_order: SortOrder::Asc,
            eq: if operator.as_ast_operator() == Some(ast::Operator::Equals) {
                Some(0)
            } else {
                None
            },
            lower_bound: match operator.as_ast_operator() {
                Some(ast::Operator::Greater | ast::Operator::GreaterEquals) => Some(0),
                _ => None,
            },
            upper_bound: match operator.as_ast_operator() {
                Some(ast::Operator::Less | ast::Operator::LessEquals) => Some(0),
                _ => None,
            },
        };
        return (None, vec![constraint_ref]);
    }

    let Some(col_pos) = table_col_pos else {
        return (None, vec![]);
    };

    // Check rowid index first if this is a rowid constraint
    if rowid_alias_column == Some(col_pos) {
        let constraint_ref = RangeConstraintRef {
            table_col_pos: Some(col_pos),
            index_col_pos: 0,
            sort_order: SortOrder::Asc,
            eq: if operator.as_ast_operator() == Some(ast::Operator::Equals) {
                Some(0)
            } else {
                None
            },
            lower_bound: match operator.as_ast_operator() {
                Some(ast::Operator::Greater | ast::Operator::GreaterEquals) => Some(0),
                _ => None,
            },
            upper_bound: match operator.as_ast_operator() {
                Some(ast::Operator::Less | ast::Operator::LessEquals) => Some(0),
                _ => None,
            },
        };
        return (None, vec![constraint_ref]);
    }

    // Find the best index that has this column as its first column
    if let Some(indexes) = indexes {
        for index in indexes.iter().filter(|idx| idx.index_method.is_none()) {
            if let Some(idx_col_pos) = index.column_table_pos_to_index_pos(col_pos) {
                // For multi-index OR, we prefer indexes where the constraint column
                // is the first column (leftmost prefix)
                if idx_col_pos == 0 {
                    let constraint_ref = RangeConstraintRef {
                        table_col_pos: Some(col_pos),
                        index_col_pos: 0,
                        sort_order: index.columns[0].order,
                        eq: if operator.as_ast_operator() == Some(ast::Operator::Equals) {
                            Some(0)
                        } else {
                            None
                        },
                        lower_bound: match operator.as_ast_operator() {
                            Some(ast::Operator::Greater | ast::Operator::GreaterEquals) => Some(0),
                            _ => None,
                        },
                        upper_bound: match operator.as_ast_operator() {
                            Some(ast::Operator::Less | ast::Operator::LessEquals) => Some(0),
                            _ => None,
                        },
                    };
                    return (Some(index.clone()), vec![constraint_ref]);
                }
            }
        }
    }

    (None, vec![])
}

/// Represents the decomposition of AND terms for multi-index intersection analysis.
///
/// For a WHERE clause like `a = 1 AND b = 2`, this structure captures:
/// - The WHERE term indices involved
/// - Each term with its indexability info
#[derive(Debug)]
pub struct AndClauseDecomposition {
    /// The WHERE term indices that can be combined via intersection.
    pub term_indices: Vec<usize>,
    /// The branches (one per AND term) for this intersection.
    pub branches: Vec<AndBranch>,
}

/// A single branch of an AND intersection, representing one AND term.
#[derive(Debug)]
pub struct AndBranch {
    /// The WHERE term index for this branch.
    pub where_term_idx: usize,
    /// The constraint for this branch.
    pub constraint: Constraint,
    /// The best index for this branch.
    pub index: Option<Arc<Index>>,
    /// Constraint references for this branch.
    pub constraint_refs: Vec<RangeConstraintRef>,
    /// Estimated rows from this branch alone.
    pub estimated_rows: f64,
}

/// Analyzes WHERE terms to determine if they can be optimized with a multi-index AND intersection.
///
/// Returns `Some(AndClauseDecomposition)` if:
/// 1. Multiple AND terms reference the same table
/// 2. Each term can use a DIFFERENT index (no composite index covers multiple terms)
/// 3. The terms are all equality or range constraints
///
/// Returns `None` if the AND terms cannot be optimized with multi-index intersection.
#[allow(clippy::too_many_arguments)]
pub fn analyze_and_terms_for_multi_index(
    table_reference: &JoinedTable,
    where_clause: &[WhereTerm],
    available_indexes: &HashMap<String, VecDeque<Arc<Index>>>,
    table_references: &TableReferences,
    subqueries: &[NonFromClauseSubquery],
    schema: &Schema,
    params: &CostModelParams,
) -> Option<AndClauseDecomposition> {
    let table_id = table_reference.internal_id;
    let table_name = table_reference.table.get_name();
    let indexes = available_indexes.get(table_name);
    let rowid_alias_column = table_reference
        .columns()
        .iter()
        .position(|c| c.is_rowid_alias());

    // Collect AND terms that:
    // 1. Reference this table
    // 2. Are simple binary comparisons (equality or range)
    // 3. Can use an index
    // 4. Are not already consumed
    // 5. Are local constraints (not join conditions)
    let mut candidate_branches: Vec<AndBranch> = Vec::new();
    let mut columns_used: Vec<Option<usize>> = Vec::new();

    for (where_term_idx, term) in where_clause.iter().enumerate() {
        // Skip consumed terms
        if term.consumed {
            continue;
        }

        // Skip OR expressions - those are handled separately
        if matches!(&term.expr, ast::Expr::Binary(_, ast::Operator::Or, _)) {
            continue;
        }

        let Some(analyzed) = analyze_binary_term_for_index(
            &term.expr,
            where_term_idx,
            table_id,
            table_reference,
            table_name,
            indexes,
            rowid_alias_column,
            available_indexes,
            table_references,
            subqueries,
            schema,
            params,
        ) else {
            continue;
        };

        // Skip if the constraining expression references other tables (this is a join condition)
        // Multi-index intersection is only for local constraints, not cross-table conditions
        if !analyzed.constraint.lhs_mask.is_empty() {
            continue;
        }

        columns_used.push(analyzed.constraint.table_col_pos);
        candidate_branches.push(AndBranch {
            where_term_idx,
            constraint: analyzed.constraint,
            index: analyzed.best_index,
            constraint_refs: analyzed.constraint_refs,
            estimated_rows: analyzed.estimated_rows,
        });
    }

    // Need at least 2 branches for intersection to be useful
    if candidate_branches.len() < 2 {
        return None;
    }

    // Check if a composite index already covers multiple columns
    // If so, using a single composite index is likely better than intersection
    if let Some(indexes) = indexes {
        for index in indexes.iter().filter(|idx| idx.index_method.is_none()) {
            let mut columns_covered = 0;
            for (i, col_pos) in columns_used.iter().enumerate() {
                if let Some(col_pos) = col_pos {
                    if let Some(idx_pos) = index.column_table_pos_to_index_pos(*col_pos) {
                        // Check if this is within the usable prefix (contiguous from start)
                        if idx_pos < index.columns.len() {
                            // Check if all previous index columns are also covered
                            let earlier_covered =
                                columns_used[..i].iter().filter_map(|c| *c).any(|c| {
                                    index
                                        .column_table_pos_to_index_pos(c)
                                        .is_some_and(|p| p < idx_pos)
                                });
                            if idx_pos == 0 || earlier_covered {
                                columns_covered += 1;
                            }
                        }
                    }
                }
            }
            // If a composite index covers 2+ of our columns, skip intersection
            if columns_covered >= 2 {
                return None;
            }
        }
    }

    // Filter to keep only branches with unique indexes
    // (intersection only helps when we use multiple distinct indexes)
    let mut unique_branches: Vec<AndBranch> = Vec::new();
    let mut seen_indexes: Vec<Option<String>> = Vec::new();
    for branch in candidate_branches {
        let index_name = branch.index.as_ref().map(|idx| idx.name.clone());
        // Skip if same named index already used (rowid/None can be used multiple times)
        if index_name.is_some() && seen_indexes.contains(&index_name) {
            continue;
        }
        seen_indexes.push(index_name);
        unique_branches.push(branch);
    }

    // Still need at least 2 branches with unique indexes
    if unique_branches.len() < 2 {
        return None;
    }

    let term_indices: Vec<usize> = unique_branches.iter().map(|b| b.where_term_idx).collect();

    Some(AndClauseDecomposition {
        term_indices,
        branches: unique_branches,
    })
}
