//! Lint rule definitions.
//!
//! Each rule implements the `LintRule` trait and detects a specific SQL anti-pattern.

use sqlparser::ast::*;
use sqlparser::dialect::GenericDialect;
use sqlparser::parser::Parser;

use super::types::{LintFinding, LintSeverity};
use crate::schema::types::SchemaDefinition;

/// Trait for a lint rule that checks SQL for anti-patterns.
pub trait LintRule: Send + Sync {
    /// Rule code (e.g., "L001").
    fn code(&self) -> &str;
    /// Human-readable rule name.
    fn name(&self) -> &str;
    /// Check the SQL and return any findings.
    fn check(&self, sql: &str, schema: &SchemaDefinition) -> Vec<LintFinding>;
}

/// Return all built-in lint rules.
pub fn all_rules() -> Vec<Box<dyn LintRule>> {
    vec![
        Box::new(SelectStar),
        Box::new(MissingWhereUpdate),
        Box::new(MissingWhereDelete),
        Box::new(CartesianProduct),
        Box::new(SelectDistinctSmell),
        Box::new(OrderByInSubquery),
        Box::new(ImplicitTypeCoercion),
        Box::new(LikeLeadingWildcard),
        Box::new(NotNullComparison),
        Box::new(CountStarVsColumn),
        Box::new(RedundantDistinctGroupBy),
        Box::new(MissingTableAlias),
        Box::new(CorrelatedSubquery),
        Box::new(NonSargablePredicate),
        Box::new(NotInNullable),
        Box::new(UnusedCte),
        Box::new(UnionWithoutAll),
        Box::new(ImplicitCrossJoin),
        Box::new(SelectStarInSubquery),
        Box::new(OrInJoin),
        Box::new(OrderByWithoutLimitSubquery),
        Box::new(LargeInList),
        Box::new(MissingPartitionFilter),
        Box::new(NonEquiJoin),
        Box::new(WindowWithoutPartition),
        Box::new(GroupByPrimaryKey),
    ]
}

// ---------------------------------------------------------------------------
// Helper: parse SQL, returning empty vec on failure
// ---------------------------------------------------------------------------
fn parse_statements(sql: &str) -> Vec<Statement> {
    Parser::parse_sql(&GenericDialect, sql).unwrap_or_default()
}

/// Estimate the line number (1-based) for a character offset in SQL.
fn line_of_offset(sql: &str, offset: usize) -> usize {
    sql[..offset.min(sql.len())].matches('\n').count() + 1
}

// ---------------------------------------------------------------------------
// Generic tree walkers for SetExpr / Query / TableFactor
// ---------------------------------------------------------------------------

/// Walk a `SetExpr` tree, calling `visitor` on every `Select` node.
/// Recurses through `SetOperation` (UNION/INTERSECT/EXCEPT) and nested `Query`.
fn walk_set_expr<F>(set_expr: &SetExpr, visitor: &mut F)
where
    F: FnMut(&Select),
{
    match set_expr {
        SetExpr::Select(select) => visitor(select),
        SetExpr::SetOperation { left, right, .. } => {
            walk_set_expr(left, visitor);
            walk_set_expr(right, visitor);
        }
        SetExpr::Query(q) => walk_set_expr(&q.body, visitor),
        _ => {}
    }
}

/// For a given `Select`, call `visitor` on every derived-table (subquery) in
/// FROM and JOIN clauses.
fn for_each_from_subquery<F>(select: &Select, visitor: &mut F)
where
    F: FnMut(&Query),
{
    for twj in &select.from {
        if let TableFactor::Derived { subquery, .. } = &twj.relation {
            visitor(subquery);
        }
        for join in &twj.joins {
            if let TableFactor::Derived { subquery, .. } = &join.relation {
                visitor(subquery);
            }
        }
    }
}

/// Find the approximate character offset of a keyword in SQL (case-insensitive).
fn find_keyword_offset(sql: &str, keyword: &str) -> Option<usize> {
    let upper = sql.to_uppercase();
    upper.find(&keyword.to_uppercase())
}

/// Extract table name from a TableFactor.
fn table_factor_name(tf: &TableFactor) -> Option<String> {
    match tf {
        TableFactor::Table { name, .. } => Some(name.to_string()),
        _ => None,
    }
}

/// Extract alias from a TableFactor.
fn table_factor_alias(tf: &TableFactor) -> Option<String> {
    match tf {
        TableFactor::Table { alias, .. } => alias.as_ref().map(|a| a.name.value.clone()),
        _ => None,
    }
}

/// Check if a JoinOperator has a constraint (ON/USING).
fn join_has_constraint(op: &JoinOperator) -> bool {
    match op {
        JoinOperator::Join(c)
        | JoinOperator::Inner(c)
        | JoinOperator::Left(c)
        | JoinOperator::LeftOuter(c)
        | JoinOperator::Right(c)
        | JoinOperator::RightOuter(c)
        | JoinOperator::FullOuter(c)
        | JoinOperator::Semi(c)
        | JoinOperator::LeftSemi(c)
        | JoinOperator::RightSemi(c)
        | JoinOperator::Anti(c)
        | JoinOperator::LeftAnti(c)
        | JoinOperator::RightAnti(c) => !matches!(c, JoinConstraint::None),
        JoinOperator::CrossJoin | JoinOperator::CrossApply | JoinOperator::OuterApply => true, // intentional
        JoinOperator::AsOf { .. } => true,
    }
}

/// Recursively check if an expression contains a LIKE with a leading wildcard.
fn find_like_leading_wildcards(expr: &Expr, sql: &str) -> Vec<LintFinding> {
    let mut findings = Vec::new();
    match expr {
        Expr::Like { pattern, .. } | Expr::ILike { pattern, .. } => {
            if let Expr::Value(ref v) = **pattern {
                let val_str = format!("{v}");
                if val_str.starts_with("'%") || val_str.starts_with("'_%") {
                    let offset = find_keyword_offset(sql, "LIKE");
                    findings.push(LintFinding {
                        code: "L008".to_string(),
                        rule: "like_leading_wildcard".to_string(),
                        severity: LintSeverity::Warning,
                        message: "LIKE pattern with leading wildcard prevents index usage"
                            .to_string(),
                        suggestion: Some(
                            "Consider full-text search or restructuring the query".to_string(),
                        ),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                }
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            findings.extend(find_like_leading_wildcards(left, sql));
            findings.extend(find_like_leading_wildcards(right, sql));
        }
        Expr::Nested(inner) => {
            findings.extend(find_like_leading_wildcards(inner, sql));
        }
        _ => {}
    }
    findings
}

/// Recursively find `column = NULL` or `column != NULL` patterns.
fn find_null_comparisons(expr: &Expr, sql: &str) -> Vec<LintFinding> {
    let mut findings = Vec::new();
    match expr {
        Expr::BinaryOp { left, op, right } => {
            let is_eq_neq = matches!(op, BinaryOperator::Eq | BinaryOperator::NotEq);
            let left_is_null =
                matches!(**left, Expr::Value(ref val) if matches!(val.value, Value::Null));
            let right_is_null =
                matches!(**right, Expr::Value(ref val) if matches!(val.value, Value::Null));

            if is_eq_neq && (left_is_null || right_is_null) {
                let suggestion = if matches!(op, BinaryOperator::Eq) {
                    "Use IS NULL instead of = NULL"
                } else {
                    "Use IS NOT NULL instead of != NULL"
                };
                let offset = find_keyword_offset(sql, "NULL");
                findings.push(LintFinding {
                    code: "L009".to_string(),
                    rule: "not_null_comparison".to_string(),
                    severity: LintSeverity::Error,
                    message: "Comparison with NULL using = or != always yields NULL".to_string(),
                    suggestion: Some(suggestion.to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }

            findings.extend(find_null_comparisons(left, sql));
            findings.extend(find_null_comparisons(right, sql));
        }
        Expr::Nested(inner) => {
            findings.extend(find_null_comparisons(inner, sql));
        }
        _ => {}
    }
    findings
}

// ===========================================================================
// L001: SELECT *
// ===========================================================================
pub struct SelectStar;

impl LintRule for SelectStar {
    fn code(&self) -> &str {
        "L001"
    }
    fn name(&self) -> &str {
        "select_star"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                check_select_star_in_query(query, sql, &mut findings);
            }
        }
        findings
    }
}

fn check_select_star_in_query(query: &Query, sql: &str, findings: &mut Vec<LintFinding>) {
    walk_set_expr(&query.body, &mut |select| {
        for item in &select.projection {
            if matches!(item, SelectItem::Wildcard(_)) {
                let offset = find_keyword_offset(sql, "SELECT");
                findings.push(LintFinding {
                    code: "L001".to_string(),
                    rule: "select_star".to_string(),
                    severity: LintSeverity::Warning,
                    message: "SELECT * is used — consider listing columns explicitly".to_string(),
                    suggestion: Some(
                        "Replace * with specific column names for clarity and performance"
                            .to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        // Check subqueries in FROM
        for_each_from_subquery(select, &mut |subquery| {
            check_select_star_in_query(subquery, sql, findings);
        });
    });
}

// ===========================================================================
// L002: UPDATE without WHERE
// ===========================================================================
pub struct MissingWhereUpdate;

impl LintRule for MissingWhereUpdate {
    fn code(&self) -> &str {
        "L002"
    }
    fn name(&self) -> &str {
        "missing_where_update"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Update { selection, .. } = stmt
                && selection.is_none()
            {
                let offset = find_keyword_offset(sql, "UPDATE");
                findings.push(LintFinding {
                    code: "L002".to_string(),
                    rule: "missing_where_update".to_string(),
                    severity: LintSeverity::Error,
                    message: "UPDATE without WHERE clause will modify all rows".to_string(),
                    suggestion: Some("Add a WHERE clause to limit affected rows".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        findings
    }
}

// ===========================================================================
// L003: DELETE without WHERE
// ===========================================================================
pub struct MissingWhereDelete;

impl LintRule for MissingWhereDelete {
    fn code(&self) -> &str {
        "L003"
    }
    fn name(&self) -> &str {
        "missing_where_delete"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Delete(delete) = stmt
                && delete.selection.is_none()
            {
                let offset = find_keyword_offset(sql, "DELETE");
                findings.push(LintFinding {
                    code: "L003".to_string(),
                    rule: "missing_where_delete".to_string(),
                    severity: LintSeverity::Error,
                    message: "DELETE without WHERE clause will delete all rows".to_string(),
                    suggestion: Some("Add a WHERE clause to limit affected rows".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        findings
    }
}

// ===========================================================================
// L004: Cartesian product (JOIN without ON)
// ===========================================================================
pub struct CartesianProduct;

impl LintRule for CartesianProduct {
    fn code(&self) -> &str {
        "L004"
    }
    fn name(&self) -> &str {
        "cartesian_product"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                check_cartesian_in_set_expr(&query.body, sql, &mut findings);
            }
        }
        findings
    }
}

fn check_cartesian_in_set_expr(body: &SetExpr, sql: &str, findings: &mut Vec<LintFinding>) {
    if let SetExpr::Select(select) = body {
        // Check for comma-separated tables in FROM without WHERE
        if select.from.len() > 1 && select.selection.is_none() {
            let offset = find_keyword_offset(sql, "FROM");
            findings.push(LintFinding {
                code: "L004".to_string(),
                rule: "cartesian_product".to_string(),
                severity: LintSeverity::Warning,
                message: "Multiple tables in FROM without WHERE may produce a cartesian product"
                    .to_string(),
                suggestion: Some("Add a WHERE or JOIN condition to relate the tables".to_string()),
                line: offset.map(|o| line_of_offset(sql, o)),
                column: None,
            });
        }

        // Check JOINs without ON condition
        for table_with_joins in &select.from {
            for join in &table_with_joins.joins {
                if !join_has_constraint(&join.join_operator) {
                    let right_name =
                        table_factor_name(&join.relation).unwrap_or_else(|| "?".to_string());
                    let offset = find_keyword_offset(sql, "JOIN");
                    findings.push(LintFinding {
                        code: "L004".to_string(),
                        rule: "cartesian_product".to_string(),
                        severity: LintSeverity::Warning,
                        message: format!(
                            "JOIN with {right_name} has no ON condition — creates cartesian product"
                        ),
                        suggestion: Some(
                            "Add an ON clause to specify the join condition".to_string(),
                        ),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                }
            }
        }
    }
}

// ===========================================================================
// L005: SELECT DISTINCT smell
// ===========================================================================
pub struct SelectDistinctSmell;

impl LintRule for SelectDistinctSmell {
    fn code(&self) -> &str {
        "L005"
    }
    fn name(&self) -> &str {
        "select_distinct_smell"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
                && select.distinct.is_some()
            {
                // Check if there are JOINs — DISTINCT with JOINs is a smell
                let has_joins = select.from.iter().any(|t| !t.joins.is_empty());
                if has_joins {
                    let offset = find_keyword_offset(sql, "DISTINCT");
                    findings.push(LintFinding {
                                code: "L005".to_string(),
                                rule: "select_distinct_smell".to_string(),
                                severity: LintSeverity::Info,
                                message: "SELECT DISTINCT with JOINs may indicate incorrect join conditions".to_string(),
                                suggestion: Some("Review JOIN conditions — DISTINCT may be masking duplicate rows from a bad join".to_string()),
                                line: offset.map(|o| line_of_offset(sql, o)),
                                column: None,
                            });
                }
            }
        }
        findings
    }
}

// ===========================================================================
// L006: ORDER BY in subquery
// ===========================================================================
pub struct OrderByInSubquery;

impl LintRule for OrderByInSubquery {
    fn code(&self) -> &str {
        "L006"
    }
    fn name(&self) -> &str {
        "order_by_in_subquery"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                // Check subqueries within the main query (not the top-level ORDER BY)
                check_subquery_order_by(&query.body, sql, &mut findings);
            }
        }
        findings
    }
}

fn check_subquery_order_by(body: &SetExpr, sql: &str, findings: &mut Vec<LintFinding>) {
    walk_set_expr(body, &mut |select| {
        // Check subqueries in FROM clause
        for_each_from_subquery(select, &mut |subquery| {
            if subquery.order_by.is_some() {
                let offset = find_keyword_offset(sql, "ORDER BY");
                findings.push(LintFinding {
                    code: "L006".to_string(),
                    rule: "order_by_in_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message:
                        "ORDER BY in subquery is usually wasteful — the outer query controls ordering"
                            .to_string(),
                    suggestion: Some(
                        "Remove ORDER BY from subquery unless used with LIMIT".to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
            // Recurse into the subquery body
            check_subquery_order_by(&subquery.body, sql, findings);
        });

        // Check subqueries in WHERE clause
        if let Some(ref selection) = select.selection {
            check_expr_subquery_order_by(selection, sql, findings);
        }
    });
}

fn check_expr_subquery_order_by(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    match expr {
        Expr::Subquery(query) => {
            if query.order_by.is_some() && query.limit.is_none() {
                let offset = find_keyword_offset(sql, "ORDER BY");
                findings.push(LintFinding {
                    code: "L006".to_string(),
                    rule: "order_by_in_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "ORDER BY in subquery without LIMIT is wasteful".to_string(),
                    suggestion: Some("Remove ORDER BY from subquery or add LIMIT".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            check_expr_subquery_order_by(left, sql, findings);
            check_expr_subquery_order_by(right, sql, findings);
        }
        Expr::Nested(inner) => {
            check_expr_subquery_order_by(inner, sql, findings);
        }
        Expr::InSubquery { subquery, .. } => {
            if subquery.order_by.is_some() && subquery.limit.is_none() {
                let offset = find_keyword_offset(sql, "ORDER BY");
                findings.push(LintFinding {
                    code: "L006".to_string(),
                    rule: "order_by_in_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "ORDER BY in subquery without LIMIT is wasteful".to_string(),
                    suggestion: Some("Remove ORDER BY from subquery or add LIMIT".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::Exists { subquery, .. } => {
            if subquery.order_by.is_some() {
                let offset = find_keyword_offset(sql, "ORDER BY");
                findings.push(LintFinding {
                    code: "L006".to_string(),
                    rule: "order_by_in_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "ORDER BY inside EXISTS subquery is wasteful".to_string(),
                    suggestion: Some("Remove ORDER BY from EXISTS subquery".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        _ => {}
    }
}

// ===========================================================================
// L007: Implicit type coercion
// ===========================================================================
pub struct ImplicitTypeCoercion;

impl LintRule for ImplicitTypeCoercion {
    fn code(&self) -> &str {
        "L007"
    }
    fn name(&self) -> &str {
        "implicit_type_coercion"
    }
    fn check(&self, sql: &str, schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                // Build alias -> table mapping
                let alias_map = build_alias_map(&select.from);

                // Check WHERE clause
                if let Some(ref selection) = select.selection {
                    check_type_coercion(selection, schema, &alias_map, sql, &mut findings);
                }

                // Check JOIN ON conditions
                for twj in &select.from {
                    for join in &twj.joins {
                        if let Some(on_expr) = extract_join_on_expr(&join.join_operator) {
                            check_type_coercion(on_expr, schema, &alias_map, sql, &mut findings);
                        }
                    }
                }
            }
        }
        findings
    }
}

/// Extract the ON expression from a JoinOperator, if any.
fn extract_join_on_expr(op: &JoinOperator) -> Option<&Expr> {
    let constraint = match op {
        JoinOperator::Join(c)
        | JoinOperator::Inner(c)
        | JoinOperator::Left(c)
        | JoinOperator::LeftOuter(c)
        | JoinOperator::Right(c)
        | JoinOperator::RightOuter(c)
        | JoinOperator::FullOuter(c)
        | JoinOperator::Semi(c)
        | JoinOperator::LeftSemi(c)
        | JoinOperator::RightSemi(c)
        | JoinOperator::Anti(c)
        | JoinOperator::LeftAnti(c)
        | JoinOperator::RightAnti(c) => Some(c),
        _ => None,
    };
    match constraint {
        Some(JoinConstraint::On(expr)) => Some(expr),
        _ => None,
    }
}

/// Build a mapping from alias/table_name -> table_name.
fn build_alias_map(from: &[TableWithJoins]) -> Vec<(String, String)> {
    let mut map = Vec::new();
    for twj in from {
        if let Some(name) = table_factor_name(&twj.relation) {
            let lower_name = name.to_lowercase();
            if let Some(alias) = table_factor_alias(&twj.relation) {
                map.push((alias.to_lowercase(), lower_name.clone()));
            }
            map.push((lower_name.clone(), lower_name));
        }
        for join in &twj.joins {
            if let Some(name) = table_factor_name(&join.relation) {
                let lower_name = name.to_lowercase();
                if let Some(alias) = table_factor_alias(&join.relation) {
                    map.push((alias.to_lowercase(), lower_name.clone()));
                }
                map.push((lower_name.clone(), lower_name));
            }
        }
    }
    map
}

/// Resolve a column reference to its data type.
fn resolve_column_type<'a>(
    parts: &[&str],
    schema: &'a SchemaDefinition,
    alias_map: &[(String, String)],
) -> Option<&'a str> {
    if parts.len() == 2 {
        let qualifier = parts[0].to_lowercase();
        let col_name = parts[1].to_lowercase();
        let table_name = alias_map
            .iter()
            .find(|(a, _)| *a == qualifier)
            .map(|(_, t)| t.as_str())
            .unwrap_or(&qualifier);
        schema.tables.get(table_name).and_then(|t| {
            t.columns
                .iter()
                .find(|c| c.name.to_lowercase() == col_name)
                .map(|c| c.data_type.as_str())
        })
    } else if parts.len() == 1 {
        let col_name = parts[0].to_lowercase();
        // Search all tables referenced
        for (_, table_name) in alias_map {
            if let Some(t) = schema.tables.get(table_name)
                && let Some(col) = t.columns.iter().find(|c| c.name.to_lowercase() == col_name)
            {
                return Some(col.data_type.as_str());
            }
        }
        None
    } else {
        None
    }
}

/// Classify a data type string into a category for coercion checking.
fn type_category(data_type: &str) -> &'static str {
    let upper = data_type.to_uppercase();
    if upper.contains("INT") || upper.contains("SERIAL") {
        "integer"
    } else if upper.contains("FLOAT")
        || upper.contains("DOUBLE")
        || upper.contains("REAL")
        || upper.contains("NUMERIC")
        || upper.contains("DECIMAL")
    {
        "numeric"
    } else if upper.contains("CHAR")
        || upper.contains("TEXT")
        || upper.contains("STRING")
        || upper == "VARCHAR"
    {
        "string"
    } else if upper.contains("BOOL") {
        "boolean"
    } else if upper.contains("TIMESTAMP") || upper.contains("DATETIME") {
        "timestamp"
    } else if upper.contains("DATE") {
        "date"
    } else if upper.contains("TIME") {
        "time"
    } else {
        "other"
    }
}

/// Check an expression for comparisons between columns of different type categories.
fn check_type_coercion(
    expr: &Expr,
    schema: &SchemaDefinition,
    alias_map: &[(String, String)],
    sql: &str,
    findings: &mut Vec<LintFinding>,
) {
    if let Expr::BinaryOp { left, op, right } = expr {
        if matches!(
            op,
            BinaryOperator::Eq
                | BinaryOperator::NotEq
                | BinaryOperator::Lt
                | BinaryOperator::LtEq
                | BinaryOperator::Gt
                | BinaryOperator::GtEq
        ) {
            let left_type = expr_column_type(left, schema, alias_map);
            let right_type = expr_column_type(right, schema, alias_map);

            if let (Some(lt), Some(rt)) = (left_type, right_type) {
                let lcat = type_category(lt);
                let rcat = type_category(rt);
                if lcat != rcat && lcat != "other" && rcat != "other" {
                    let offset = find_keyword_offset(sql, &format!("{op}"));
                    findings.push(LintFinding {
                        code: "L007".to_string(),
                        rule: "implicit_type_coercion".to_string(),
                        severity: LintSeverity::Warning,
                        message: format!(
                            "Comparing {lcat} column ({lt}) with {rcat} column ({rt}) — implicit type coercion"
                        ),
                        suggestion: Some("Use CAST to make the type conversion explicit".to_string()),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                }
            }
        }

        check_type_coercion(left, schema, alias_map, sql, findings);
        check_type_coercion(right, schema, alias_map, sql, findings);
    } else if let Expr::Nested(inner) = expr {
        check_type_coercion(inner, schema, alias_map, sql, findings);
    }
}

/// Get the data type of a column referenced in an expression.
fn expr_column_type<'a>(
    expr: &Expr,
    schema: &'a SchemaDefinition,
    alias_map: &[(String, String)],
) -> Option<&'a str> {
    match expr {
        Expr::Identifier(ident) => resolve_column_type(&[&ident.value], schema, alias_map),
        Expr::CompoundIdentifier(idents) => {
            let parts: Vec<&str> = idents.iter().map(|i| i.value.as_str()).collect();
            resolve_column_type(&parts, schema, alias_map)
        }
        _ => None,
    }
}

// ===========================================================================
// L008: LIKE with leading wildcard
// ===========================================================================
pub struct LikeLeadingWildcard;

impl LintRule for LikeLeadingWildcard {
    fn code(&self) -> &str {
        "L008"
    }
    fn name(&self) -> &str {
        "like_leading_wildcard"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
                && let Some(ref selection) = select.selection
            {
                findings.extend(find_like_leading_wildcards(selection, sql));
            }
        }
        findings
    }
}

// ===========================================================================
// L009: NULL comparison with = or !=
// ===========================================================================
pub struct NotNullComparison;

impl LintRule for NotNullComparison {
    fn code(&self) -> &str {
        "L009"
    }
    fn name(&self) -> &str {
        "not_null_comparison"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            match stmt {
                Statement::Query(query) => {
                    if let SetExpr::Select(select) = &*query.body
                        && let Some(ref selection) = select.selection
                    {
                        findings.extend(find_null_comparisons(selection, sql));
                    }
                }
                Statement::Update {
                    selection: Some(selection),
                    ..
                } => {
                    findings.extend(find_null_comparisons(selection, sql));
                }
                Statement::Delete(delete) => {
                    if let Some(ref selection) = delete.selection {
                        findings.extend(find_null_comparisons(selection, sql));
                    }
                }
                _ => {}
            }
        }
        findings
    }
}

// ===========================================================================
// L010: COUNT(column) vs COUNT(*)
// ===========================================================================
pub struct CountStarVsColumn;

impl LintRule for CountStarVsColumn {
    fn code(&self) -> &str {
        "L010"
    }
    fn name(&self) -> &str {
        "count_star_vs_column"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                check_count_in_set_expr(&query.body, sql, &mut findings);
            }
        }
        findings
    }
}

fn check_count_in_set_expr(body: &SetExpr, sql: &str, findings: &mut Vec<LintFinding>) {
    if let SetExpr::Select(select) = body {
        for item in &select.projection {
            match item {
                SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
                    check_count_expr(expr, sql, findings);
                }
                _ => {}
            }
        }
    }
}

fn check_count_expr(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    if let Expr::Function(func) = expr {
        let func_name = func.name.to_string().to_uppercase();
        if func_name == "COUNT"
            && let FunctionArguments::List(ref arg_list) = func.args
        {
            // Check if it's COUNT(column) where column is not *
            for arg in &arg_list.args {
                if matches!(
                    arg,
                    FunctionArg::Unnamed(FunctionArgExpr::Expr(
                        Expr::Identifier(_) | Expr::CompoundIdentifier(_),
                    ))
                ) {
                    let offset = find_keyword_offset(sql, "COUNT");
                    findings.push(LintFinding {
                            code: "L010".to_string(),
                            rule: "count_star_vs_column".to_string(),
                            severity: LintSeverity::Info,
                            message:
                                "COUNT(column) excludes NULLs — did you mean COUNT(*)?"
                                    .to_string(),
                            suggestion: Some(
                                "Use COUNT(*) to count all rows, or keep COUNT(column) if NULLs should be excluded"
                                    .to_string(),
                            ),
                            line: offset.map(|o| line_of_offset(sql, o)),
                            column: None,
                        });
                }
            }
        }
    }
}

// ===========================================================================
// L011: DISTINCT with GROUP BY (redundant)
// ===========================================================================
pub struct RedundantDistinctGroupBy;

impl LintRule for RedundantDistinctGroupBy {
    fn code(&self) -> &str {
        "L011"
    }
    fn name(&self) -> &str {
        "redundant_distinct_with_group_by"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                let has_distinct = select.distinct.is_some();
                let has_group_by = matches!(
                    select.group_by,
                    GroupByExpr::Expressions(ref exprs, _) if !exprs.is_empty()
                ) || matches!(select.group_by, GroupByExpr::All(_));

                if has_distinct && has_group_by {
                    let offset = find_keyword_offset(sql, "DISTINCT");
                    findings.push(LintFinding {
                        code: "L011".to_string(),
                        rule: "redundant_distinct_with_group_by".to_string(),
                        severity: LintSeverity::Info,
                        message: "DISTINCT is redundant when GROUP BY is used".to_string(),
                        suggestion: Some(
                            "Remove DISTINCT — GROUP BY already produces unique groups".to_string(),
                        ),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                }
            }
        }
        findings
    }
}

// ===========================================================================
// L012: Missing table alias in multi-table query
// ===========================================================================
pub struct MissingTableAlias;

impl LintRule for MissingTableAlias {
    fn code(&self) -> &str {
        "L012"
    }
    fn name(&self) -> &str {
        "missing_table_alias"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                // Count total tables (FROM + JOINs)
                let mut tables_without_alias = Vec::new();
                let mut total_tables = 0;

                for twj in &select.from {
                    total_tables += 1;
                    if table_factor_alias(&twj.relation).is_none()
                        && let Some(name) = table_factor_name(&twj.relation)
                    {
                        tables_without_alias.push(name);
                    }
                    for join in &twj.joins {
                        total_tables += 1;
                        if table_factor_alias(&join.relation).is_none()
                            && let Some(name) = table_factor_name(&join.relation)
                        {
                            tables_without_alias.push(name);
                        }
                    }
                }

                if total_tables > 1 && !tables_without_alias.is_empty() {
                    let offset = find_keyword_offset(sql, "FROM");
                    findings.push(LintFinding {
                        code: "L012".to_string(),
                        rule: "missing_table_alias".to_string(),
                        severity: LintSeverity::Info,
                        message: format!(
                            "Multi-table query without aliases for: {}",
                            tables_without_alias.join(", ")
                        ),
                        suggestion: Some(
                            "Add short aliases to tables for readability (e.g., users AS u)"
                                .to_string(),
                        ),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                }
            }
        }
        findings
    }
}

// ===========================================================================
// L013: Correlated subquery (N+1 pattern)
// ===========================================================================
pub struct CorrelatedSubquery;

impl LintRule for CorrelatedSubquery {
    fn code(&self) -> &str {
        "L013"
    }
    fn name(&self) -> &str {
        "correlated_subquery"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                // Collect outer table names and aliases
                let mut outer_refs: Vec<String> = Vec::new();
                for twj in &select.from {
                    if let Some(name) = table_factor_name(&twj.relation) {
                        outer_refs.push(name.to_lowercase());
                    }
                    if let Some(alias) = table_factor_alias(&twj.relation) {
                        outer_refs.push(alias.to_lowercase());
                    }
                    for join in &twj.joins {
                        if let Some(name) = table_factor_name(&join.relation) {
                            outer_refs.push(name.to_lowercase());
                        }
                        if let Some(alias) = table_factor_alias(&join.relation) {
                            outer_refs.push(alias.to_lowercase());
                        }
                    }
                }

                // Check WHERE / HAVING for correlated subqueries
                if let Some(ref selection) = select.selection {
                    check_correlated_subquery(selection, &outer_refs, sql, &mut findings);
                }
                if let Some(ref having) = select.having {
                    check_correlated_subquery(having, &outer_refs, sql, &mut findings);
                }
            }
        }
        findings
    }
}

/// Check if an expression contains a correlated subquery referencing outer tables.
fn check_correlated_subquery(
    expr: &Expr,
    outer_refs: &[String],
    sql: &str,
    findings: &mut Vec<LintFinding>,
) {
    match expr {
        Expr::InSubquery { subquery, .. } => {
            let sub_sql = subquery.to_string().to_lowercase();
            if outer_refs.iter().any(|r| sub_sql.contains(r.as_str())) {
                let offset = find_keyword_offset(sql, "IN");
                findings.push(LintFinding {
                    code: "L013".to_string(),
                    rule: "correlated_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "Correlated subquery detected — may cause N+1 query pattern"
                        .to_string(),
                    suggestion: Some(
                        "Consider rewriting as a JOIN for better performance".to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::Exists { subquery, .. } => {
            let sub_sql = subquery.to_string().to_lowercase();
            if outer_refs.iter().any(|r| sub_sql.contains(r.as_str())) {
                let offset = find_keyword_offset(sql, "EXISTS");
                findings.push(LintFinding {
                    code: "L013".to_string(),
                    rule: "correlated_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "Correlated subquery detected — may cause N+1 query pattern"
                        .to_string(),
                    suggestion: Some(
                        "Consider rewriting as a JOIN for better performance".to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::Subquery(subquery) => {
            let sub_sql = subquery.to_string().to_lowercase();
            if outer_refs.iter().any(|r| sub_sql.contains(r.as_str())) {
                let offset = find_keyword_offset(sql, "SELECT");
                findings.push(LintFinding {
                    code: "L013".to_string(),
                    rule: "correlated_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "Correlated scalar subquery detected — may cause N+1 query pattern"
                        .to_string(),
                    suggestion: Some(
                        "Consider rewriting as a JOIN for better performance".to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            check_correlated_subquery(left, outer_refs, sql, findings);
            check_correlated_subquery(right, outer_refs, sql, findings);
        }
        Expr::Nested(inner) => {
            check_correlated_subquery(inner, outer_refs, sql, findings);
        }
        _ => {}
    }
}

// ===========================================================================
// L014: Non-SARGable predicate (function wrapping column)
// ===========================================================================
pub struct NonSargablePredicate;

impl LintRule for NonSargablePredicate {
    fn code(&self) -> &str {
        "L014"
    }
    fn name(&self) -> &str {
        "non_sargable_predicate"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
                && let Some(ref selection) = select.selection
            {
                find_non_sargable(selection, sql, &mut findings);
            }
        }
        findings
    }
}

/// Check if an expression contains a non-SARGable pattern like FUNC(col) = val.
fn find_non_sargable(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    match expr {
        Expr::BinaryOp { left, op, right } => {
            if matches!(
                op,
                BinaryOperator::Eq
                    | BinaryOperator::NotEq
                    | BinaryOperator::Lt
                    | BinaryOperator::LtEq
                    | BinaryOperator::Gt
                    | BinaryOperator::GtEq
            ) {
                let left_is_func_on_col = is_function_on_column(left);
                let right_is_func_on_col = is_function_on_column(right);

                // Flag if one side is function(column) and the other is NOT a function on column
                if left_is_func_on_col && !right_is_func_on_col {
                    let func_name = extract_function_name(left).unwrap_or_default();
                    let offset = find_keyword_offset(sql, &func_name);
                    findings.push(LintFinding {
                        code: "L014".to_string(),
                        rule: "non_sargable_predicate".to_string(),
                        severity: LintSeverity::Warning,
                        message: format!(
                            "Wrapping column in {func_name}() prevents index usage (non-SARGable)"
                        ),
                        suggestion: Some(
                            "Rewrite to avoid wrapping the column in a function, or use a functional index".to_string(),
                        ),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                } else if right_is_func_on_col && !left_is_func_on_col {
                    let func_name = extract_function_name(right).unwrap_or_default();
                    let offset = find_keyword_offset(sql, &func_name);
                    findings.push(LintFinding {
                        code: "L014".to_string(),
                        rule: "non_sargable_predicate".to_string(),
                        severity: LintSeverity::Warning,
                        message: format!(
                            "Wrapping column in {func_name}() prevents index usage (non-SARGable)"
                        ),
                        suggestion: Some(
                            "Rewrite to avoid wrapping the column in a function, or use a functional index".to_string(),
                        ),
                        line: offset.map(|o| line_of_offset(sql, o)),
                        column: None,
                    });
                }
            }
            find_non_sargable(left, sql, findings);
            find_non_sargable(right, sql, findings);
        }
        Expr::Nested(inner) => {
            find_non_sargable(inner, sql, findings);
        }
        _ => {}
    }
}

/// Check if an expression is a function wrapping a column identifier.
fn is_function_on_column(expr: &Expr) -> bool {
    if let Expr::Function(func) = expr
        && let FunctionArguments::List(ref arg_list) = func.args
    {
        return arg_list.args.iter().any(|arg| {
            matches!(
                arg,
                FunctionArg::Unnamed(FunctionArgExpr::Expr(
                    Expr::Identifier(_) | Expr::CompoundIdentifier(_)
                ))
            )
        });
    }
    false
}

/// Extract the function name from a Function expression.
fn extract_function_name(expr: &Expr) -> Option<String> {
    if let Expr::Function(func) = expr {
        Some(func.name.to_string().to_uppercase())
    } else {
        None
    }
}

// ===========================================================================
// L015: NOT IN (subquery) with nullable column risk
// ===========================================================================
pub struct NotInNullable;

impl LintRule for NotInNullable {
    fn code(&self) -> &str {
        "L015"
    }
    fn name(&self) -> &str {
        "not_in_nullable"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
                && let Some(ref selection) = select.selection
            {
                find_not_in_subquery(selection, sql, &mut findings);
            }
        }
        findings
    }
}

/// Recursively find NOT IN (subquery) patterns.
fn find_not_in_subquery(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    match expr {
        Expr::InSubquery { negated: true, .. } => {
            let offset = find_keyword_offset(sql, "NOT IN");
            findings.push(LintFinding {
                code: "L015".to_string(),
                rule: "not_in_nullable".to_string(),
                severity: LintSeverity::Error,
                message: "NOT IN (subquery) returns no rows if any subquery value is NULL"
                    .to_string(),
                suggestion: Some(
                    "Use NOT EXISTS instead, which handles NULLs correctly".to_string(),
                ),
                line: offset.map(|o| line_of_offset(sql, o)),
                column: None,
            });
        }
        Expr::BinaryOp { left, right, .. } => {
            find_not_in_subquery(left, sql, findings);
            find_not_in_subquery(right, sql, findings);
        }
        Expr::Nested(inner) => {
            find_not_in_subquery(inner, sql, findings);
        }
        _ => {}
    }
}

// ===========================================================================
// L016: Unused CTE
// ===========================================================================
pub struct UnusedCte;

impl LintRule for UnusedCte {
    fn code(&self) -> &str {
        "L016"
    }
    fn name(&self) -> &str {
        "unused_cte"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let Some(ref with) = query.with
            {
                let cte_names: Vec<String> = with
                    .cte_tables
                    .iter()
                    .map(|cte| cte.alias.name.value.clone())
                    .collect();

                // Get the main query body as string (excluding the WITH clause)
                let body_sql = query.body.to_string().to_lowercase();
                // Also check ORDER BY, LIMIT etc. via full query minus CTEs
                let full_query_str = query.to_string().to_lowercase();

                for cte_name in &cte_names {
                    let lower_name = cte_name.to_lowercase();
                    // Check if the CTE name appears in the body or other CTEs
                    // (a CTE can reference earlier CTEs)
                    let body_has_ref = body_sql.contains(&lower_name);
                    // Check if other CTEs reference this one
                    let other_cte_refs = with.cte_tables.iter().any(|other| {
                        other.alias.name.value.to_lowercase() != lower_name
                            && other.query.to_string().to_lowercase().contains(&lower_name)
                    });
                    // Also check if referenced after the body (e.g., in ORDER BY clause)
                    let order_by_ref = if let Some(ref ob) = query.order_by {
                        ob.to_string().to_lowercase().contains(&lower_name)
                    } else {
                        false
                    };

                    if !body_has_ref && !other_cte_refs && !order_by_ref {
                        // Check one more time in the full query string after the WITH definition
                        // This catches edge cases like UNION queries referencing CTEs
                        let with_str =
                            format!("with {}", with.to_string().to_lowercase().trim_start());
                        let after_with = full_query_str
                            .find(&body_sql)
                            .map(|pos| &full_query_str[pos..])
                            .unwrap_or(&body_sql);
                        if !after_with.contains(&lower_name) {
                            let offset = find_keyword_offset(sql, cte_name);
                            findings.push(LintFinding {
                                code: "L016".to_string(),
                                rule: "unused_cte".to_string(),
                                severity: LintSeverity::Info,
                                message: format!(
                                    "CTE '{cte_name}' is defined but never referenced"
                                ),
                                suggestion: Some(
                                    "Remove the unused CTE or reference it in the main query"
                                        .to_string(),
                                ),
                                line: offset.map(|o| line_of_offset(sql, o)),
                                column: None,
                            });
                        }
                        let _ = with_str; // suppress unused warning
                    }
                }
            }
        }
        findings
    }
}

// ===========================================================================
// L017: UNION without ALL (unnecessary dedup sort)
// ===========================================================================
pub struct UnionWithoutAll;

impl LintRule for UnionWithoutAll {
    fn code(&self) -> &str {
        "L017"
    }
    fn name(&self) -> &str {
        "union_without_all"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                check_union_without_all(&query.body, sql, &mut findings);
            }
        }
        findings
    }
}

/// Recursively check for UNION without ALL in set expressions.
fn check_union_without_all(body: &SetExpr, sql: &str, findings: &mut Vec<LintFinding>) {
    if let SetExpr::SetOperation {
        op: SetOperator::Union,
        set_quantifier,
        left,
        right,
    } = body
    {
        if matches!(
            set_quantifier,
            SetQuantifier::None | SetQuantifier::Distinct
        ) {
            let offset = find_keyword_offset(sql, "UNION");
            findings.push(LintFinding {
                code: "L017".to_string(),
                rule: "union_without_all".to_string(),
                severity: LintSeverity::Warning,
                message: "UNION without ALL forces a deduplication sort — consider UNION ALL"
                    .to_string(),
                suggestion: Some("Use UNION ALL if duplicate removal is not needed".to_string()),
                line: offset.map(|o| line_of_offset(sql, o)),
                column: None,
            });
        }
        check_union_without_all(left, sql, findings);
        check_union_without_all(right, sql, findings);
    }
}

// ===========================================================================
// L018: Implicit cross join (comma-separated tables in FROM)
// ===========================================================================
pub struct ImplicitCrossJoin;

impl LintRule for ImplicitCrossJoin {
    fn code(&self) -> &str {
        "L018"
    }
    fn name(&self) -> &str {
        "implicit_cross_join"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
                && select.from.len() > 1
            {
                let table_names: Vec<String> = select
                    .from
                    .iter()
                    .filter_map(|twj| table_factor_name(&twj.relation))
                    .collect();
                let offset = find_keyword_offset(sql, "FROM");
                findings.push(LintFinding {
                    code: "L018".to_string(),
                    rule: "implicit_cross_join".to_string(),
                    severity: LintSeverity::Warning,
                    message: format!(
                        "Implicit cross join — comma-separated tables: {}",
                        table_names.join(", ")
                    ),
                    suggestion: Some(
                        "Use explicit JOIN syntax instead of comma-separated tables".to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        findings
    }
}

// ===========================================================================
// L019: SELECT * in subquery (not top-level)
// ===========================================================================
pub struct SelectStarInSubquery;

impl LintRule for SelectStarInSubquery {
    fn code(&self) -> &str {
        "L019"
    }
    fn name(&self) -> &str {
        "select_star_in_subquery"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                walk_set_expr(&query.body, &mut |select| {
                    // Check subqueries in FROM clause (derived tables)
                    for_each_from_subquery(select, &mut |subquery| {
                        if let SetExpr::Select(inner_select) = &*subquery.body {
                            check_select_items_for_star(
                                &inner_select.projection,
                                sql,
                                &mut findings,
                            );
                        }
                    });
                    // Check subqueries in WHERE clause
                    if let Some(ref selection) = select.selection {
                        check_expr_select_star(selection, sql, &mut findings);
                    }
                    // Check subquery expressions in projection
                    for item in &select.projection {
                        match item {
                            SelectItem::UnnamedExpr(expr)
                            | SelectItem::ExprWithAlias { expr, .. } => {
                                check_expr_select_star(expr, sql, &mut findings);
                            }
                            _ => {}
                        }
                    }
                });
            }
        }
        findings
    }
}

/// Check projection items for SELECT * and emit L019 findings.
fn check_select_items_for_star(
    projection: &[SelectItem],
    sql: &str,
    findings: &mut Vec<LintFinding>,
) {
    for item in projection {
        if matches!(item, SelectItem::Wildcard(_)) {
            let offset = find_keyword_offset(sql, "SELECT *");
            findings.push(LintFinding {
                code: "L019".to_string(),
                rule: "select_star_in_subquery".to_string(),
                severity: LintSeverity::Warning,
                message: "SELECT * inside a subquery — consider listing columns explicitly"
                    .to_string(),
                suggestion: Some(
                    "Replace * with specific column names in the subquery".to_string(),
                ),
                line: offset.map(|o| line_of_offset(sql, o)),
                column: None,
            });
        }
    }
}

/// Check expressions for subqueries containing SELECT *.
fn check_expr_select_star(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    match expr {
        Expr::Subquery(query)
        | Expr::Exists {
            subquery: query, ..
        } => {
            if let SetExpr::Select(inner_select) = &*query.body {
                check_select_items_for_star(&inner_select.projection, sql, findings);
            }
        }
        Expr::InSubquery { subquery, .. } => {
            if let SetExpr::Select(inner_select) = &*subquery.body {
                check_select_items_for_star(&inner_select.projection, sql, findings);
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            check_expr_select_star(left, sql, findings);
            check_expr_select_star(right, sql, findings);
        }
        Expr::Nested(inner) => {
            check_expr_select_star(inner, sql, findings);
        }
        _ => {}
    }
}

// ===========================================================================
// L020: OR condition in JOIN ON clause
// ===========================================================================
pub struct OrInJoin;

impl LintRule for OrInJoin {
    fn code(&self) -> &str {
        "L020"
    }
    fn name(&self) -> &str {
        "or_in_join"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                for twj in &select.from {
                    for join in &twj.joins {
                        if let Some(on_expr) = extract_join_on_expr(&join.join_operator)
                            && expr_contains_or(on_expr)
                        {
                            let offset = find_keyword_offset(sql, "OR");
                            findings.push(LintFinding {
                                        code: "L020".to_string(),
                                        rule: "or_in_join".to_string(),
                                        severity: LintSeverity::Warning,
                                        message: "OR condition in JOIN ON clause — may cause cartesian-like behavior".to_string(),
                                        suggestion: Some(
                                            "Consider splitting into separate JOINs or moving the OR to a WHERE clause".to_string(),
                                        ),
                                        line: offset.map(|o| line_of_offset(sql, o)),
                                        column: None,
                                    });
                        }
                    }
                }
            }
        }
        findings
    }
}

/// Recursively check if an expression contains an OR operator.
fn expr_contains_or(expr: &Expr) -> bool {
    match expr {
        Expr::BinaryOp {
            op: BinaryOperator::Or,
            ..
        } => true,
        Expr::BinaryOp { left, right, .. } => expr_contains_or(left) || expr_contains_or(right),
        Expr::Nested(inner) => expr_contains_or(inner),
        _ => false,
    }
}

// ===========================================================================
// L021: ORDER BY in subquery without LIMIT
// ===========================================================================
pub struct OrderByWithoutLimitSubquery;

impl LintRule for OrderByWithoutLimitSubquery {
    fn code(&self) -> &str {
        "L021"
    }
    fn name(&self) -> &str {
        "order_by_without_limit_subquery"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt {
                walk_set_expr(&query.body, &mut |select| {
                    // Check subqueries in FROM
                    for_each_from_subquery(select, &mut |subquery| {
                        if subquery.order_by.is_some() && subquery.limit.is_none() {
                            let offset = find_keyword_offset(sql, "ORDER BY");
                            findings.push(LintFinding {
                                code: "L021".to_string(),
                                rule: "order_by_without_limit_subquery".to_string(),
                                severity: LintSeverity::Warning,
                                message:
                                    "ORDER BY in subquery without LIMIT adds cost with no benefit"
                                        .to_string(),
                                suggestion: Some(
                                    "Add LIMIT or remove ORDER BY from the subquery".to_string(),
                                ),
                                line: offset.map(|o| line_of_offset(sql, o)),
                                column: None,
                            });
                        }
                    });
                    // Check subqueries in WHERE
                    if let Some(ref selection) = select.selection {
                        check_order_by_no_limit_expr(selection, sql, &mut findings);
                    }
                });
            }
        }
        findings
    }
}

/// Check expressions for subqueries with ORDER BY but no LIMIT.
fn check_order_by_no_limit_expr(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    match expr {
        Expr::Subquery(query)
        | Expr::Exists {
            subquery: query, ..
        } => {
            if query.order_by.is_some() && query.limit.is_none() {
                let offset = find_keyword_offset(sql, "ORDER BY");
                findings.push(LintFinding {
                    code: "L021".to_string(),
                    rule: "order_by_without_limit_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "ORDER BY in subquery without LIMIT adds cost with no benefit"
                        .to_string(),
                    suggestion: Some("Add LIMIT or remove ORDER BY from the subquery".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::InSubquery { subquery, .. } => {
            if subquery.order_by.is_some() && subquery.limit.is_none() {
                let offset = find_keyword_offset(sql, "ORDER BY");
                findings.push(LintFinding {
                    code: "L021".to_string(),
                    rule: "order_by_without_limit_subquery".to_string(),
                    severity: LintSeverity::Warning,
                    message: "ORDER BY in subquery without LIMIT adds cost with no benefit"
                        .to_string(),
                    suggestion: Some("Add LIMIT or remove ORDER BY from the subquery".to_string()),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            check_order_by_no_limit_expr(left, sql, findings);
            check_order_by_no_limit_expr(right, sql, findings);
        }
        Expr::Nested(inner) => {
            check_order_by_no_limit_expr(inner, sql, findings);
        }
        _ => {}
    }
}

// ===========================================================================
// L022: Large IN list (>100 values)
// ===========================================================================
pub struct LargeInList;

impl LintRule for LargeInList {
    fn code(&self) -> &str {
        "L022"
    }
    fn name(&self) -> &str {
        "large_in_list"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
                && let Some(ref selection) = select.selection
            {
                find_large_in_list(selection, sql, &mut findings);
            }
        }
        findings
    }
}

/// Recursively find IN lists with more than 100 values.
fn find_large_in_list(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    match expr {
        Expr::InList { list, .. } => {
            if list.len() > 100 {
                let offset = find_keyword_offset(sql, "IN");
                findings.push(LintFinding {
                    code: "L022".to_string(),
                    rule: "large_in_list".to_string(),
                    severity: LintSeverity::Warning,
                    message: format!(
                        "IN list has {} values — consider using a temporary table or JOIN",
                        list.len()
                    ),
                    suggestion: Some(
                        "Use a temporary table or CTE with JOIN instead of a large IN list"
                            .to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            find_large_in_list(left, sql, findings);
            find_large_in_list(right, sql, findings);
        }
        Expr::Nested(inner) => {
            find_large_in_list(inner, sql, findings);
        }
        _ => {}
    }
}

// ===========================================================================
// L023: Missing partition filter
// ===========================================================================
pub struct MissingPartitionFilter;

impl LintRule for MissingPartitionFilter {
    fn code(&self) -> &str {
        "L023"
    }
    fn name(&self) -> &str {
        "missing_partition_filter"
    }
    fn check(&self, sql: &str, schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                // Collect all referenced table names
                let alias_map = build_alias_map(&select.from);

                // Collect all column references in WHERE clause
                let where_columns: Vec<String> = select
                    .selection
                    .as_ref()
                    .map(collect_column_refs)
                    .unwrap_or_default();

                // For each referenced table, check partition columns
                for (_, table_name) in &alias_map {
                    if let Some(table_def) = schema.tables.get(table_name)
                        && let Some(ref stats) = table_def.statistics
                        && stats.partitioned
                        && !stats.partition_columns.is_empty()
                    {
                        let has_partition_filter = stats
                            .partition_columns
                            .iter()
                            .any(|pc| where_columns.iter().any(|wc| wc.eq_ignore_ascii_case(pc)));
                        if !has_partition_filter {
                            let offset = find_keyword_offset(sql, table_name);
                            findings.push(LintFinding {
                                            code: "L023".to_string(),
                                            rule: "missing_partition_filter".to_string(),
                                            severity: LintSeverity::Warning,
                                            message: format!(
                                                "Table '{table_name}' is partitioned by [{}] but no partition filter found in WHERE",
                                                stats.partition_columns.join(", ")
                                            ),
                                            suggestion: Some(
                                                "Add a filter on the partition column to avoid full table scan".to_string(),
                                            ),
                                            line: offset.map(|o| line_of_offset(sql, o)),
                                            column: None,
                                        });
                        }
                    }
                }
            }
        }
        findings
    }
}

/// Collect all column name references from an expression (lowercased).
fn collect_column_refs(expr: &Expr) -> Vec<String> {
    let mut refs = Vec::new();
    match expr {
        Expr::Identifier(ident) => {
            refs.push(ident.value.to_lowercase());
        }
        Expr::CompoundIdentifier(idents) => {
            // Add the last part (column name)
            if let Some(last) = idents.last() {
                refs.push(last.value.to_lowercase());
            }
        }
        Expr::BinaryOp { left, right, .. } => {
            refs.extend(collect_column_refs(left));
            refs.extend(collect_column_refs(right));
        }
        Expr::Nested(inner) => {
            refs.extend(collect_column_refs(inner));
        }
        Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
            refs.extend(collect_column_refs(inner));
        }
        Expr::InList { expr, list, .. } => {
            refs.extend(collect_column_refs(expr));
            for item in list {
                refs.extend(collect_column_refs(item));
            }
        }
        Expr::Between {
            expr, low, high, ..
        } => {
            refs.extend(collect_column_refs(expr));
            refs.extend(collect_column_refs(low));
            refs.extend(collect_column_refs(high));
        }
        _ => {}
    }
    refs
}

// ===========================================================================
// L024: Non-equi join (JOIN ON with non-equality operator)
// ===========================================================================
pub struct NonEquiJoin;

impl LintRule for NonEquiJoin {
    fn code(&self) -> &str {
        "L024"
    }
    fn name(&self) -> &str {
        "non_equi_join"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                for twj in &select.from {
                    for join in &twj.joins {
                        if let Some(on_expr) = extract_join_on_expr(&join.join_operator)
                            && has_non_equi_condition(on_expr)
                        {
                            let offset = find_keyword_offset(sql, "ON");
                            findings.push(LintFinding {
                                        code: "L024".to_string(),
                                        rule: "non_equi_join".to_string(),
                                        severity: LintSeverity::Warning,
                                        message: "Non-equi join condition detected — may produce large result sets".to_string(),
                                        suggestion: Some(
                                            "Verify that a non-equality join (>, <, LIKE, etc.) is intentional".to_string(),
                                        ),
                                        line: offset.map(|o| line_of_offset(sql, o)),
                                        column: None,
                                    });
                        }
                    }
                }
            }
        }
        findings
    }
}

/// Check if a join condition uses non-equality operators.
fn has_non_equi_condition(expr: &Expr) -> bool {
    match expr {
        Expr::BinaryOp {
            op, left, right, ..
        } => {
            let is_non_equi = matches!(
                op,
                BinaryOperator::Lt
                    | BinaryOperator::LtEq
                    | BinaryOperator::Gt
                    | BinaryOperator::GtEq
            );
            // Check if both sides reference columns (not just a constant comparison)
            if is_non_equi && is_column_ref(left) && is_column_ref(right) {
                return true;
            }
            has_non_equi_condition(left) || has_non_equi_condition(right)
        }
        Expr::Like { expr: left, .. } | Expr::ILike { expr: left, .. } => is_column_ref(left),
        Expr::Nested(inner) => has_non_equi_condition(inner),
        _ => false,
    }
}

/// Check if an expression is a column reference (identifier or compound identifier).
fn is_column_ref(expr: &Expr) -> bool {
    matches!(expr, Expr::Identifier(_) | Expr::CompoundIdentifier(_))
}

// ===========================================================================
// L025: Window function without PARTITION BY
// ===========================================================================
pub struct WindowWithoutPartition;

impl LintRule for WindowWithoutPartition {
    fn code(&self) -> &str {
        "L025"
    }
    fn name(&self) -> &str {
        "window_without_partition"
    }
    fn check(&self, sql: &str, _schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                for item in &select.projection {
                    match item {
                        SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
                            find_window_without_partition(expr, sql, &mut findings);
                        }
                        _ => {}
                    }
                }
            }
        }
        findings
    }
}

/// Check if an expression is a window function without PARTITION BY.
fn find_window_without_partition(expr: &Expr, sql: &str, findings: &mut Vec<LintFinding>) {
    if let Expr::Function(func) = expr
        && let Some(ref over) = func.over
    {
        let func_name = func.name.to_string().to_uppercase();
        let is_window_func = matches!(
            func_name.as_str(),
            "ROW_NUMBER"
                | "RANK"
                | "DENSE_RANK"
                | "NTILE"
                | "LAG"
                | "LEAD"
                | "FIRST_VALUE"
                | "LAST_VALUE"
                | "NTH_VALUE"
                | "CUME_DIST"
                | "PERCENT_RANK"
        );
        if is_window_func {
            let missing_partition = match over {
                WindowType::WindowSpec(spec) => spec.partition_by.is_empty(),
                WindowType::NamedWindow(_) => false, // can't check named windows statically
            };
            if missing_partition {
                let offset = find_keyword_offset(sql, &func_name);
                findings.push(LintFinding {
                    code: "L025".to_string(),
                    rule: "window_without_partition".to_string(),
                    severity: LintSeverity::Info,
                    message: format!(
                        "{func_name}() without PARTITION BY operates over the entire result set"
                    ),
                    suggestion: Some(
                        "Add PARTITION BY to scope the window function to logical groups"
                            .to_string(),
                    ),
                    line: offset.map(|o| line_of_offset(sql, o)),
                    column: None,
                });
            }
        }
    }
}

// ===========================================================================
// L026: GROUP BY on a primary key column
// ===========================================================================
pub struct GroupByPrimaryKey;

impl LintRule for GroupByPrimaryKey {
    fn code(&self) -> &str {
        "L026"
    }
    fn name(&self) -> &str {
        "group_by_primary_key"
    }
    fn check(&self, sql: &str, schema: &SchemaDefinition) -> Vec<LintFinding> {
        let stmts = parse_statements(sql);
        let mut findings = Vec::new();

        for stmt in &stmts {
            if let Statement::Query(query) = stmt
                && let SetExpr::Select(select) = &*query.body
            {
                let alias_map = build_alias_map(&select.from);

                if let GroupByExpr::Expressions(ref exprs, _) = select.group_by {
                    for gb_expr in exprs {
                        // Extract column name from the GROUP BY expression
                        let (qualifier, col_name) = match gb_expr {
                            Expr::Identifier(ident) => (None, Some(ident.value.to_lowercase())),
                            Expr::CompoundIdentifier(idents) if idents.len() == 2 => (
                                Some(idents[0].value.to_lowercase()),
                                Some(idents[1].value.to_lowercase()),
                            ),
                            _ => (None, None),
                        };

                        if let Some(col) = col_name {
                            // Resolve table name
                            let table_names: Vec<&str> = if let Some(ref q) = qualifier {
                                alias_map
                                    .iter()
                                    .filter(|(a, _)| a == q)
                                    .map(|(_, t)| t.as_str())
                                    .collect()
                            } else {
                                alias_map.iter().map(|(_, t)| t.as_str()).collect()
                            };

                            for table_name in table_names {
                                if let Some(table_def) = schema.tables.get(table_name)
                                    && let Some(ref pk) = table_def.primary_key
                                    && pk.iter().any(|k| k.eq_ignore_ascii_case(&col))
                                {
                                    let offset = find_keyword_offset(sql, "GROUP BY");
                                    findings.push(LintFinding {
                                                    code: "L026".to_string(),
                                                    rule: "group_by_primary_key".to_string(),
                                                    severity: LintSeverity::Info,
                                                    message: format!(
                                                        "GROUP BY on primary key column '{col}' in table '{table_name}' — each group has exactly one row"
                                                    ),
                                                    suggestion: Some(
                                                        "GROUP BY on a primary key is a no-op — remove it or group by a different column".to_string(),
                                                    ),
                                                    line: offset.map(|o| line_of_offset(sql, o)),
                                                    column: None,
                                                });
                                }
                            }
                        }
                    }
                }
            }
        }
        findings
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::schema::types::{
        ColumnDef as SchemaColumnDef, SchemaDefinition, SqlDialect, TableDef, TableStatistics,
    };
    use std::collections::HashMap;

    fn empty_schema() -> SchemaDefinition {
        SchemaDefinition {
            version: "1".to_string(),
            dialect: SqlDialect::Generic,
            tables: HashMap::new(),
            database: None,
            schema_name: None,
            glossary: None,
        }
    }

    fn test_schema() -> SchemaDefinition {
        let mut tables = HashMap::new();
        tables.insert(
            "users".to_string(),
            TableDef {
                description: None,
                database: None,
                schema: None,
                columns: vec![
                    SchemaColumnDef {
                        name: "id".to_string(),
                        data_type: "INT".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                    SchemaColumnDef {
                        name: "name".to_string(),
                        data_type: "VARCHAR".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                ],
                primary_key: None,
                foreign_keys: vec![],
                statistics: None,

                clustering_keys: vec![],
            },
        );
        tables.insert(
            "orders".to_string(),
            TableDef {
                description: None,
                database: None,
                schema: None,
                columns: vec![
                    SchemaColumnDef {
                        name: "id".to_string(),
                        data_type: "INT".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                    SchemaColumnDef {
                        name: "user_id".to_string(),
                        data_type: "VARCHAR".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                ],
                primary_key: None,
                foreign_keys: vec![],
                statistics: None,

                clustering_keys: vec![],
            },
        );
        SchemaDefinition {
            version: "1".to_string(),
            dialect: SqlDialect::Generic,
            tables,
            database: None,
            schema_name: None,
            glossary: None,
        }
    }

    #[test]
    fn test_l001_select_star() {
        let rule = SelectStar;
        let findings = rule.check("SELECT * FROM users", &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L001");
    }

    #[test]
    fn test_l001_no_star() {
        let rule = SelectStar;
        let findings = rule.check("SELECT id, name FROM users", &empty_schema());
        assert!(findings.is_empty());
    }

    #[test]
    fn test_l002_update_no_where() {
        let rule = MissingWhereUpdate;
        let findings = rule.check("UPDATE users SET name = 'test'", &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L002");
    }

    #[test]
    fn test_l002_update_with_where() {
        let rule = MissingWhereUpdate;
        let findings = rule.check(
            "UPDATE users SET name = 'test' WHERE id = 1",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    #[test]
    fn test_l003_delete_no_where() {
        let rule = MissingWhereDelete;
        let findings = rule.check("DELETE FROM users", &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L003");
    }

    #[test]
    fn test_l009_null_comparison() {
        let rule = NotNullComparison;
        let findings = rule.check("SELECT * FROM users WHERE name = NULL", &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L009");
    }

    #[test]
    fn test_l010_count_column() {
        let rule = CountStarVsColumn;
        let findings = rule.check("SELECT COUNT(name) FROM users", &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L010");
    }

    #[test]
    fn test_l011_distinct_group_by() {
        let rule = RedundantDistinctGroupBy;
        let findings = rule.check(
            "SELECT DISTINCT name FROM users GROUP BY name",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L011");
    }

    #[test]
    fn test_l012_missing_alias() {
        let rule = MissingTableAlias;
        let findings = rule.check(
            "SELECT * FROM users JOIN orders ON users.id = orders.user_id",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L012");
    }

    #[test]
    fn test_l012_with_aliases() {
        let rule = MissingTableAlias;
        let findings = rule.check(
            "SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    #[test]
    fn test_l007_type_coercion() {
        let schema = test_schema();
        let rule = ImplicitTypeCoercion;
        let findings = rule.check(
            "SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
            &schema,
        );
        // u.id is INT, o.user_id is VARCHAR — should detect coercion
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L007");
    }

    fn test_schema_with_stats() -> SchemaDefinition {
        let mut tables = HashMap::new();
        tables.insert(
            "users".to_string(),
            TableDef {
                description: None,
                database: None,
                schema: None,
                columns: vec![
                    SchemaColumnDef {
                        name: "id".to_string(),
                        data_type: "INT".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                    SchemaColumnDef {
                        name: "name".to_string(),
                        data_type: "VARCHAR".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                ],
                primary_key: Some(vec!["id".to_string()]),
                foreign_keys: vec![],
                statistics: Some(TableStatistics {
                    row_count: Some(1_000_000),
                    avg_row_bytes: Some(200),
                    partitioned: false,
                    partition_columns: vec![],
                    correlated_columns: vec![],
                    ..Default::default()
                }),

                clustering_keys: vec![],
            },
        );
        tables.insert(
            "events".to_string(),
            TableDef {
                description: None,
                database: None,
                schema: None,
                columns: vec![
                    SchemaColumnDef {
                        name: "id".to_string(),
                        data_type: "INT".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                    SchemaColumnDef {
                        name: "event_date".to_string(),
                        data_type: "DATE".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                    SchemaColumnDef {
                        name: "user_id".to_string(),
                        data_type: "INT".to_string(),
                        nullable: false,
                        description: None,
                        tags: vec![],
                        synonyms: vec![],

                        statistics: None,
                    },
                ],
                primary_key: None,
                foreign_keys: vec![],
                statistics: Some(TableStatistics {
                    row_count: Some(10_000_000),
                    avg_row_bytes: Some(100),
                    partitioned: true,
                    partition_columns: vec!["event_date".to_string()],
                    correlated_columns: vec![],
                    ..Default::default()
                }),

                clustering_keys: vec![],
            },
        );
        SchemaDefinition {
            version: "1".to_string(),
            dialect: SqlDialect::Generic,
            tables,
            database: None,
            schema_name: None,
            glossary: None,
        }
    }

    // -----------------------------------------------------------------------
    // L013: Correlated subquery
    // -----------------------------------------------------------------------
    #[test]
    fn test_l013_correlated_exists() {
        let rule = CorrelatedSubquery;
        let findings = rule.check(
            "SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L013");
    }

    #[test]
    fn test_l013_no_correlation() {
        let rule = CorrelatedSubquery;
        let findings = rule.check(
            "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L014: Non-SARGable predicate
    // -----------------------------------------------------------------------
    #[test]
    fn test_l014_function_on_column() {
        let rule = NonSargablePredicate;
        let findings = rule.check(
            "SELECT * FROM users WHERE UPPER(name) = 'ALICE'",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L014");
    }

    #[test]
    fn test_l014_no_function_wrap() {
        let rule = NonSargablePredicate;
        let findings = rule.check("SELECT * FROM users WHERE name = 'Alice'", &empty_schema());
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L015: NOT IN (subquery) nullable risk
    // -----------------------------------------------------------------------
    #[test]
    fn test_l015_not_in_subquery() {
        let rule = NotInNullable;
        let findings = rule.check(
            "SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders)",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L015");
    }

    #[test]
    fn test_l015_in_subquery_no_negation() {
        let rule = NotInNullable;
        let findings = rule.check(
            "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L016: Unused CTE
    // -----------------------------------------------------------------------
    #[test]
    fn test_l016_unused_cte() {
        let rule = UnusedCte;
        let findings = rule.check(
            "WITH active_users AS (SELECT * FROM users WHERE id > 0) SELECT * FROM orders",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L016");
    }

    #[test]
    fn test_l016_used_cte() {
        let rule = UnusedCte;
        let findings = rule.check(
            "WITH active_users AS (SELECT * FROM users WHERE id > 0) SELECT * FROM active_users",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L017: UNION without ALL
    // -----------------------------------------------------------------------
    #[test]
    fn test_l017_union_without_all() {
        let rule = UnionWithoutAll;
        let findings = rule.check(
            "SELECT id FROM users UNION SELECT id FROM orders",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L017");
    }

    #[test]
    fn test_l017_union_all() {
        let rule = UnionWithoutAll;
        let findings = rule.check(
            "SELECT id FROM users UNION ALL SELECT id FROM orders",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L018: Implicit cross join
    // -----------------------------------------------------------------------
    #[test]
    fn test_l018_comma_join() {
        let rule = ImplicitCrossJoin;
        let findings = rule.check(
            "SELECT * FROM users, orders WHERE users.id = orders.user_id",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L018");
    }

    #[test]
    fn test_l018_explicit_join() {
        let rule = ImplicitCrossJoin;
        let findings = rule.check(
            "SELECT * FROM users JOIN orders ON users.id = orders.user_id",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L019: SELECT * in subquery
    // -----------------------------------------------------------------------
    #[test]
    fn test_l019_star_in_subquery() {
        let rule = SelectStarInSubquery;
        let findings = rule.check("SELECT id FROM (SELECT * FROM users) sub", &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L019");
    }

    #[test]
    fn test_l019_no_star_in_subquery() {
        let rule = SelectStarInSubquery;
        let findings = rule.check(
            "SELECT id FROM (SELECT id, name FROM users) sub",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L020: OR in JOIN ON
    // -----------------------------------------------------------------------
    #[test]
    fn test_l020_or_in_join() {
        let rule = OrInJoin;
        let findings = rule.check(
            "SELECT * FROM users u JOIN orders o ON u.id = o.user_id OR u.name = o.user_id",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L020");
    }

    #[test]
    fn test_l020_and_in_join() {
        let rule = OrInJoin;
        let findings = rule.check(
            "SELECT * FROM users u JOIN orders o ON u.id = o.user_id AND u.id > 0",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L021: ORDER BY in subquery without LIMIT
    // -----------------------------------------------------------------------
    #[test]
    fn test_l021_order_by_no_limit() {
        let rule = OrderByWithoutLimitSubquery;
        let findings = rule.check(
            "SELECT * FROM (SELECT * FROM users ORDER BY id) sub",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L021");
    }

    #[test]
    fn test_l021_order_by_with_limit() {
        let rule = OrderByWithoutLimitSubquery;
        let findings = rule.check(
            "SELECT * FROM (SELECT * FROM users ORDER BY id LIMIT 10) sub",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L022: Large IN list
    // -----------------------------------------------------------------------
    #[test]
    fn test_l022_large_in_list() {
        let rule = LargeInList;
        // Build an IN list with 101 values
        let values: Vec<String> = (1..=101).map(|i| i.to_string()).collect();
        let sql = format!("SELECT * FROM users WHERE id IN ({})", values.join(", "));
        let findings = rule.check(&sql, &empty_schema());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L022");
    }

    #[test]
    fn test_l022_small_in_list() {
        let rule = LargeInList;
        let findings = rule.check(
            "SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5)",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L023: Missing partition filter
    // -----------------------------------------------------------------------
    #[test]
    fn test_l023_no_partition_filter() {
        let schema = test_schema_with_stats();
        let rule = MissingPartitionFilter;
        let findings = rule.check("SELECT * FROM events WHERE user_id = 1", &schema);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L023");
    }

    #[test]
    fn test_l023_has_partition_filter() {
        let schema = test_schema_with_stats();
        let rule = MissingPartitionFilter;
        let findings = rule.check(
            "SELECT * FROM events WHERE event_date = '2024-01-01'",
            &schema,
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L024: Non-equi join
    // -----------------------------------------------------------------------
    #[test]
    fn test_l024_non_equi_join() {
        let rule = NonEquiJoin;
        let findings = rule.check(
            "SELECT * FROM users u JOIN orders o ON u.id > o.user_id",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L024");
    }

    #[test]
    fn test_l024_equi_join() {
        let rule = NonEquiJoin;
        let findings = rule.check(
            "SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L025: Window function without PARTITION BY
    // -----------------------------------------------------------------------
    #[test]
    fn test_l025_window_no_partition() {
        let rule = WindowWithoutPartition;
        let findings = rule.check(
            "SELECT id, ROW_NUMBER() OVER (ORDER BY id) FROM users",
            &empty_schema(),
        );
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L025");
    }

    #[test]
    fn test_l025_window_with_partition() {
        let rule = WindowWithoutPartition;
        let findings = rule.check(
            "SELECT id, ROW_NUMBER() OVER (PARTITION BY name ORDER BY id) FROM users",
            &empty_schema(),
        );
        assert!(findings.is_empty());
    }

    // -----------------------------------------------------------------------
    // L026: GROUP BY primary key
    // -----------------------------------------------------------------------
    #[test]
    fn test_l026_group_by_pk() {
        let schema = test_schema_with_stats();
        let rule = GroupByPrimaryKey;
        let findings = rule.check("SELECT id, COUNT(*) FROM users GROUP BY id", &schema);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].code, "L026");
    }

    #[test]
    fn test_l026_group_by_non_pk() {
        let schema = test_schema_with_stats();
        let rule = GroupByPrimaryKey;
        let findings = rule.check("SELECT name, COUNT(*) FROM users GROUP BY name", &schema);
        assert!(findings.is_empty());
    }
}
