// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

//! SQL Unparser Roundtrip Integration Tests
//!
//! This module tests the [`Unparser`] by running queries through a complete roundtrip:
//! the original SQL is parsed into a logical plan, unparsed back to SQL, then that
//! generated SQL is parsed and executed. The results are compared to verify semantic
//! equivalence.
//!
//! ## Test Strategy
//!
//! Uses real-world benchmark queries (TPC-H and Clickbench) to validate that:
//! 1. The unparser produces syntactically valid SQL
//! 2. The unparsed SQL is semantically equivalent (produces identical results)
//!
//! ## Query Suites
//!
//! - **TPC-H**: Standard decision-support benchmark with 22 complex analytical queries
//! - **Clickbench**: Web analytics benchmark with 43 queries against a denormalized schema
//!
//! [`Unparser`]: datafusion_sql::unparser::Unparser

use std::fs::ReadDir;
use std::future::Future;
use std::sync::Arc;

use arrow::array::RecordBatch;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::Result;
use datafusion::datasource::empty::EmptyTable;
use datafusion::optimizer::{
    OptimizerRule, single_distinct_to_groupby::SingleDistinctToGroupBy,
};
use datafusion::prelude::{ParquetReadOptions, SessionContext};
use datafusion_catalog::memory::MemorySchemaProvider;
use datafusion_catalog::{CatalogProvider, MemoryCatalogProvider, SchemaProvider};
use datafusion_common::Column;
use datafusion_expr::Expr;
use datafusion_sql::unparser::Unparser;
use datafusion_sql::unparser::dialect::{DefaultDialect, DuckDBDialect};
use itertools::Itertools;
use recursive::{set_minimum_stack_size, set_stack_allocation_size};

/// Paths to benchmark query files (supports running from repo root or different working directories).
const BENCHMARK_PATHS: &[&str] = &["../../benchmarks/", "./benchmarks/"];

/// Reads all `.sql` files from a directory and converts them to test queries.
///
/// Skips files that:
/// - Are not regular files
/// - Don't have a `.sql` extension
/// - Contain multiple SQL statements (indicated by `;\n`)
///
/// Multi-statement files are skipped because the unparser doesn't support
/// DML statements like `CREATE VIEW` that appear in multi-statement Clickbench queries.
fn iterate_queries(dir: ReadDir) -> Vec<TestQuery> {
    let mut queries = vec![];
    for entry in dir.flatten() {
        let Ok(file_type) = entry.file_type() else {
            continue;
        };
        if !file_type.is_file() {
            continue;
        }
        let path = entry.path();
        let Some(ext) = path.extension() else {
            continue;
        };
        if ext != "sql" {
            continue;
        }
        let name = path.file_stem().unwrap().to_string_lossy().to_string();
        if let Ok(mut contents) = std::fs::read_to_string(entry.path()) {
            // If the query contains ;\n it has DML statements like CREATE VIEW which the unparser doesn't support; skip it
            contents = contents.trim().to_string();
            if contents.contains(";\n") {
                println!("Skipping query with multiple statements: {name}");
                continue;
            }
            queries.push(TestQuery {
                sql: contents,
                name,
            });
        }
    }
    queries
}

/// A SQL query loaded from a benchmark file for roundtrip testing.
///
/// Each query is identified by its filename (without extension) and contains
/// the full SQL text to be tested.
struct TestQuery {
    /// The SQL query text to test.
    sql: String,
    /// The query identifier (typically the filename without .sql extension).
    name: String,
}

/// Collect SQL for Clickbench queries.
fn clickbench_queries() -> Vec<TestQuery> {
    let mut queries = vec![];
    for path in BENCHMARK_PATHS {
        let dir = format!("{path}queries/clickbench/queries/");
        println!("Reading Clickbench queries from {dir}");
        if let Ok(dir) = std::fs::read_dir(dir) {
            let read = iterate_queries(dir);
            println!("Found {} Clickbench queries", read.len());
            queries.extend(read);
        }
    }
    queries.sort_unstable_by_key(|q| {
        q.name
            .split('q')
            .next_back()
            .and_then(|num| num.parse::<u32>().ok())
    });
    queries
}

/// Collect SQL for TPC-H queries.
fn tpch_queries() -> Vec<TestQuery> {
    let mut queries = vec![];
    for path in BENCHMARK_PATHS {
        let dir = format!("{path}queries/");
        println!("Reading TPC-H queries from {dir}");
        if let Ok(dir) = std::fs::read_dir(dir) {
            let read = iterate_queries(dir);
            queries.extend(read);
        }
    }
    println!("Total TPC-H queries found: {}", queries.len());
    queries.sort_unstable_by_key(|q| q.name.clone());
    queries
}

/// Create a new SessionContext for testing that has all Clickbench tables registered.
///
/// Registers the raw Parquet as `hits_raw`, then creates a `hits` view that
/// casts `EventDate` from UInt16 (day-offset) to DATE. This mirrors the
/// approach used by the benchmark runner in `benchmarks/src/clickbench.rs`.
async fn clickbench_test_context() -> Result<SessionContext> {
    let ctx = SessionContext::new();
    ctx.register_parquet(
        "hits_raw",
        "tests/data/clickbench_hits_10.parquet",
        ParquetReadOptions::default(),
    )
    .await?;
    ctx.sql(
        r#"CREATE VIEW hits AS
           SELECT * EXCEPT ("EventDate"),
                  CAST(CAST("EventDate" AS INTEGER) AS DATE) AS "EventDate"
           FROM hits_raw"#,
    )
    .await?;
    // Sanity check we found the table by querying its schema
    let df = ctx.sql("SELECT * FROM hits LIMIT 1").await?;
    assert!(
        !df.schema().fields().is_empty(),
        "Clickbench 'hits' table not registered correctly"
    );
    Ok(ctx)
}

/// Create a new SessionContext for testing that has all TPC-H tables registered.
async fn tpch_test_context() -> Result<SessionContext> {
    let ctx = SessionContext::new();
    let data_dir = "tests/data/";
    // All tables have the pattern "tpch_<table_name>_small.parquet"
    for table in [
        "customer", "lineitem", "nation", "orders", "part", "partsupp", "region",
        "supplier",
    ] {
        let path = format!("{data_dir}tpch_{table}_small.parquet");
        ctx.register_parquet(table, &path, ParquetReadOptions::default())
            .await?;
        // Sanity check we found the table by querying it's schema, it should not be empty
        // Otherwise if the path is wrong the tests will all fail in confusing ways
        let df = ctx.sql(&format!("SELECT * FROM {table} LIMIT 1")).await?;
        assert!(
            !df.schema().fields().is_empty(),
            "TPC-H '{table}' table not registered correctly"
        );
    }
    Ok(ctx)
}

/// Sorts record batches by all columns for deterministic comparison.
///
/// When comparing query results, we need a canonical ordering so that
/// semantically equivalent results compare as equal. This function sorts
/// by all columns in the schema to achieve that.
async fn sort_batches(
    ctx: &SessionContext,
    batches: Vec<RecordBatch>,
) -> Result<Vec<RecordBatch>> {
    let mut df = ctx.read_batches(batches)?;
    let schema = df.schema().as_arrow().clone();
    let sort_exprs = schema
        .fields()
        .iter()
        // Use Column directly, col() causes the column names to be normalized to lowercase
        .map(|f| {
            Expr::Column(Column::new_unqualified(f.name().to_string())).sort(true, false)
        })
        .collect_vec();
    if !sort_exprs.is_empty() {
        df = df.sort(sort_exprs)?;
    }
    df.collect().await
}

const ISSUE_22961_QUERY: &str = r#"
SELECT * FROM
(
SELECT
        item_id,
        order_id,
        product_id,
        quantity,
        unit_price,
        quantity * unit_price AS line_total
    FROM
        "warehouse"."main"."order_items"
) oi
JOIN (
    SELECT
        order_id,
        customer_id,
        order_date,
        lower(STATUS) AS STATUS,
        lower(channel) AS channel,
        coalesce(discount_pct, 0) AS discount_pct,
        coalesce(shipping_cost, 0) AS shipping_cost,
        STATUS IN ('completed', 'shipped') AS is_fulfilled
    FROM
        "warehouse"."main"."orders"
) o USING (order_id)
JOIN (
    SELECT
        p.product_id,
        p.category_id,
        p.sku,
        p.name AS product_name,
        p.price,
        p.cost,
        p.weight_kg,
        p.is_active,
        p.stock_qty,
        round(p.price - p.cost, 2) AS gross_margin,
        round((p.price - p.cost) / nullif(p.price, 0), 4) AS margin_pct,
        c.name AS category_name
    FROM
        "warehouse"."main"."products" p
        LEFT JOIN "warehouse"."main"."categories" c USING (category_id)
) p USING (product_id)
"#;

fn issue_22961_context() -> Result<SessionContext> {
    let ctx = SessionContext::new();

    let schema_provider = Arc::new(MemorySchemaProvider::new());
    schema_provider.register_table(
        "order_items".to_string(),
        Arc::new(EmptyTable::new(Arc::new(Schema::new(vec![
            Field::new("item_id", DataType::Int32, false),
            Field::new("order_id", DataType::Int32, true),
            Field::new("product_id", DataType::Int32, true),
            Field::new("quantity", DataType::Int32, true),
            Field::new("unit_price", DataType::Decimal128(10, 2), true),
        ])))),
    )?;
    schema_provider.register_table(
        "orders".to_string(),
        Arc::new(EmptyTable::new(Arc::new(Schema::new(vec![
            Field::new("order_id", DataType::Int32, false),
            Field::new("customer_id", DataType::Int32, true),
            Field::new("order_date", DataType::Date32, true),
            Field::new("status", DataType::Utf8, true),
            Field::new("channel", DataType::Utf8, true),
            Field::new("discount_pct", DataType::Decimal128(5, 2), true),
            Field::new("shipping_cost", DataType::Decimal128(8, 2), true),
        ])))),
    )?;
    schema_provider.register_table(
        "products".to_string(),
        Arc::new(EmptyTable::new(Arc::new(Schema::new(vec![
            Field::new("product_id", DataType::Int32, false),
            Field::new("category_id", DataType::Int32, true),
            Field::new("sku", DataType::Utf8, true),
            Field::new("name", DataType::Utf8, true),
            Field::new("price", DataType::Decimal128(10, 2), true),
            Field::new("cost", DataType::Decimal128(10, 2), true),
            Field::new("weight_kg", DataType::Decimal128(6, 3), true),
            Field::new("is_active", DataType::Boolean, true),
            Field::new("stock_qty", DataType::Int32, true),
        ])))),
    )?;
    schema_provider.register_table(
        "categories".to_string(),
        Arc::new(EmptyTable::new(Arc::new(Schema::new(vec![
            Field::new("category_id", DataType::Int32, false),
            Field::new("name", DataType::Utf8, true),
            Field::new("parent_id", DataType::Int32, true),
            Field::new("display_rank", DataType::Int32, true),
        ])))),
    )?;

    let catalog = Arc::new(MemoryCatalogProvider::new());
    catalog.register_schema("main", schema_provider)?;
    ctx.register_catalog("warehouse", catalog);

    Ok(ctx)
}

#[tokio::test]
async fn optimized_duckdb_unparse_preserves_derived_table_scope() -> Result<()> {
    let ctx = issue_22961_context()?;
    let plan = ctx.sql(ISSUE_22961_QUERY).await?.into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert!(!sql.contains(r#""o"."__common_expr_1""#));
    assert!(!sql.contains(r#""o"."__common_expr_2""#));
    assert!(sql.contains(
        r#"ON "oi"."order_id" = "o"."order_id" INNER JOIN (SELECT "p"."product_id""#
    ));

    Ok(())
}

// https://github.com/apache/datafusion/issues/23138
//
// CSE on `coalesce(discount_pct, 0)` factors a shared CAST into an extra inner
// projection, so `SubqueryAlias: o` ends up over two stacked projections. When
// the unparser renders that as nested derived tables it must qualify the
// pass-through `order_id` with a name in scope at each level -- it must not
// rebase it to the outer subquery alias `o`, which is not visible inside the
// inner derived table.
const ISSUE_23138_QUERY: &str = r#"
SELECT * FROM
(
    SELECT order_id FROM "warehouse"."main"."order_items"
) oi
JOIN (
    SELECT order_id, coalesce(discount_pct, 0) AS discount_pct_2
    FROM "warehouse"."main"."orders"
) o USING (order_id)
"#;

#[tokio::test]
async fn optimized_duckdb_unparse_qualifies_nested_passthrough_column() -> Result<()> {
    let ctx = issue_22961_context()?;
    let plan = ctx.sql(ISSUE_23138_QUERY).await?.into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    // The intermediate derived table has no `o` in scope, so the pass-through
    // `order_id` must be unqualified there, not rebased to the subquery alias
    // `o` (which is only the base-table alias one level deeper). The bug emitted
    // `"o"."order_id"` inside that derived table; the fix emits a bare column.
    let expected = concat!(
        r#"SELECT "o"."order_id", "o"."discount_pct_2" "#,
        r#"FROM "warehouse"."main"."order_items" AS "oi" "#,
        r#"INNER JOIN (SELECT "order_id", "#,
        r#"CASE WHEN "__common_expr_1" IS NOT NULL "#,
        r#"THEN "__common_expr_1" ELSE 0.00 END AS "discount_pct_2" "#,
        r#"FROM (SELECT CAST("o"."discount_pct" AS DECIMAL(22,2)) "#,
        r#"AS "__common_expr_1", "o"."order_id" "#,
        r#"FROM "warehouse"."main"."orders" AS "o")) AS "o" "#,
        r#"ON "oi"."order_id" = "o"."order_id""#,
    );
    assert_eq!(sql, expected);

    assert!(
        sql.contains(r#"(SELECT "order_id", CASE WHEN"#),
        "pass-through order_id should be unqualified in derived table: {sql}"
    );
    assert!(
        !sql.contains(r#"(SELECT "o"."order_id", CASE WHEN"#),
        "derived table must not reference out-of-scope alias o: {sql}"
    );

    Ok(())
}

// https://github.com/apache/datafusion/issues/23317
//
// `SingleDistinctToGroupBy` rewrites single DISTINCT aggregates into a
// two-phase aggregate plan. The inner Aggregate defines intermediate fields
// such as `group_alias_0`, `alias1`, and `alias2`. The unparser must preserve
// that inner Aggregate as a derived table before the outer Aggregate
// references those fields.
//
// Without `SingleDistinctToGroupBy`, the Aggregate still sits over an unnamed
// derived Projection. In that SQL scope, base table aliases `cs` and `c` are no
// longer visible, so aggregate expressions must refer to the derived table's
// output columns unqualified.
const ISSUE_23317_QUERY: &str = r#"
WITH cohort AS (
    SELECT
        signup_year,
        sum(customers) AS customers,
        sum(revenue) AS revenue
    FROM
        (
            SELECT
                date_part('year', c.signup_date) AS signup_year,
                count(DISTINCT cs.customer_id) AS customers,
                round(sum(cs.total_revenue), 2) AS revenue
            FROM
                "warehouse"."main"."sales" cs
                JOIN "warehouse"."main"."customers" c USING (customer_id)
            GROUP BY
                1
        )
    GROUP BY
        signup_year
)
SELECT
    *
FROM
    cohort
"#;

const ISSUE_23317_HAVING_QUERY: &str = r#"
SELECT
    date_part('year', c.signup_date) AS signup_year,
    count(DISTINCT cs.customer_id) AS customers
FROM
    "warehouse"."main"."sales" cs
    JOIN "warehouse"."main"."customers" c USING (customer_id)
GROUP BY
    1
HAVING
    count(DISTINCT cs.customer_id) > 0
"#;

const ISSUE_23317_QUALIFY_QUERY: &str = r#"
SELECT
    date_part('year', c.signup_date) AS signup_year,
    count(DISTINCT cs.customer_id) AS customers,
    row_number() OVER (ORDER BY date_part('year', c.signup_date)) AS rn
FROM
    "warehouse"."main"."sales" cs
    JOIN "warehouse"."main"."customers" c USING (customer_id)
GROUP BY
    1
QUALIFY
    rn = 1 AND count(DISTINCT cs.customer_id) > 0
"#;

// https://github.com/apache/datafusion/issues/23668
//
// Extends the #23317 aggregate-scope fix to the window and ORDER BY clauses.
// Reuses issue_23317_context() (same derived-projection shape).

// Window sorting by an aggregate, over a derived-projection input. Already
// correct today; this locks the OVER clause against keeping the out-of-scope
// `cs` qualifier across the refactor.
const ISSUE_23668_WINDOW_QUERY: &str = r#"
SELECT
    date_part('year', c.signup_date) AS signup_year,
    count(DISTINCT cs.customer_id) AS customers,
    row_number() OVER (ORDER BY count(DISTINCT cs.customer_id) DESC) AS rn
FROM
    "warehouse"."main"."sales" cs
    JOIN "warehouse"."main"."customers" c USING (customer_id)
GROUP BY
    1
"#;

// ORDER BY an aggregate that is NOT selected, so it can't use a select alias
// and is unprojected through the Aggregate. It must be normalized like the
// SELECT list, not keep the out-of-scope `cs` qualifier.
const ISSUE_23668_ORDER_BY_QUERY: &str = r#"
SELECT
    date_part('year', c.signup_date) AS signup_year,
    count(DISTINCT cs.customer_id) AS customers
FROM
    "warehouse"."main"."sales" cs
    JOIN "warehouse"."main"."customers" c USING (customer_id)
GROUP BY
    1
ORDER BY
    round(sum(cs.total_revenue), 2) DESC
"#;

// ORDER BY a selected aggregate keeps a top-level Sort (the direct `Sort` arm,
// vs the projection-absorbed one above). It resolves to the select alias, so
// this covers routing only -- the normalization in that arm isn't reachable
// from SQL (an unselected aggregate takes the absorbed path above instead).
const ISSUE_23668_TOP_LEVEL_SORT_QUERY: &str = r#"
SELECT
    date_part('year', c.signup_date) AS signup_year,
    count(DISTINCT cs.customer_id) AS customers
FROM
    "warehouse"."main"."sales" cs
    JOIN "warehouse"."main"."customers" c USING (customer_id)
GROUP BY
    1
ORDER BY
    customers DESC
"#;

fn issue_23317_context() -> Result<SessionContext> {
    let ctx = SessionContext::new();

    let schema_provider = Arc::new(MemorySchemaProvider::new());
    schema_provider.register_table(
        "customers".to_string(),
        Arc::new(EmptyTable::new(Arc::new(Schema::new(vec![
            Field::new("customer_id", DataType::Int32, false),
            Field::new("signup_date", DataType::Date32, true),
        ])))),
    )?;
    schema_provider.register_table(
        "sales".to_string(),
        Arc::new(EmptyTable::new(Arc::new(Schema::new(vec![
            Field::new("customer_id", DataType::Int32, false),
            Field::new("total_revenue", DataType::Decimal128(12, 2), true),
        ])))),
    )?;

    let catalog = Arc::new(MemoryCatalogProvider::new());
    catalog.register_schema("main", schema_provider)?;
    ctx.register_catalog("warehouse", catalog);

    Ok(ctx)
}

async fn assert_issue_23317_unparsed_sql_plans(
    ctx: &SessionContext,
    sql: &str,
) -> Result<()> {
    ctx.sql(sql).await?.into_optimized_plan()?;
    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_preserves_nested_aggregate_scope() -> Result<()> {
    let ctx = issue_23317_context()?;
    let plan = ctx.sql(ISSUE_23317_QUERY).await?.into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains(concat!(
            r#"FROM (SELECT sum("total_revenue") AS "alias2", "#,
            r#"date_part('year', "signup_date") AS "group_alias_0", "#,
            r#""customer_id" AS "alias1" "#
        )),
        "inner aggregate should define the aliases before the outer aggregate uses them: {sql}",
    );
    assert!(
        !sql.contains(r#"date_part('year', "c"."signup_date") AS "group_alias_0""#),
        "inner aggregate must not reference out-of-scope alias c: {sql}",
    );

    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_unqualifies_aggregate_input_projection() -> Result<()> {
    let ctx = issue_23317_context()?;
    assert!(ctx.remove_optimizer_rule(SingleDistinctToGroupBy::new().name()));

    let plan = ctx.sql(ISSUE_23317_QUERY).await?.into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains(
            r#"SELECT date_part('year', "signup_date") AS "signup_year", count(DISTINCT "customer_id") AS "customers", round(sum("total_revenue"), 2) AS "revenue" FROM ("#
        ),
        "aggregate expressions should resolve against the derived projection output: {sql}",
    );
    assert!(
        !sql.contains(r#"date_part('year', "c"."signup_date") AS "signup_year""#),
        "derived aggregate must not reference out-of-scope alias c: {sql}",
    );

    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_having_unqualifies_agg_input() -> Result<()> {
    let ctx = issue_23317_context()?;
    assert!(ctx.remove_optimizer_rule(SingleDistinctToGroupBy::new().name()));

    let plan = ctx
        .sql(ISSUE_23317_HAVING_QUERY)
        .await?
        .into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains(r#"HAVING (count(DISTINCT "customer_id") > 0)"#),
        "HAVING aggregate should resolve against the derived projection output: {sql}",
    );
    assert!(
        !sql.contains(r#"count(DISTINCT "cs"."customer_id")"#),
        "HAVING must not reference out-of-scope alias cs: {sql}",
    );

    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_qualify_unqualifies_agg_input() -> Result<()> {
    let ctx = issue_23317_context()?;
    assert!(ctx.remove_optimizer_rule(SingleDistinctToGroupBy::new().name()));

    let plan = ctx
        .sql(ISSUE_23317_QUALIFY_QUERY)
        .await?
        .into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains("QUALIFY"),
        "expected QUALIFY clause in unparsed SQL: {sql}",
    );
    assert!(
        sql.contains(r#"count(DISTINCT "customer_id") > 0"#),
        "QUALIFY aggregate should resolve against the derived projection output: {sql}",
    );
    assert!(
        !sql.contains(r#"count(DISTINCT "cs"."customer_id")"#),
        "QUALIFY must not reference out-of-scope alias cs: {sql}",
    );

    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_window_over_agg_unqualifies_input() -> Result<()> {
    let ctx = issue_23317_context()?;
    assert!(ctx.remove_optimizer_rule(SingleDistinctToGroupBy::new().name()));

    let plan = ctx
        .sql(ISSUE_23668_WINDOW_QUERY)
        .await?
        .into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains(r#"OVER (ORDER BY count(DISTINCT "customer_id")"#),
        "window ORDER BY aggregate should resolve against the derived projection output: {sql}",
    );
    assert!(
        !sql.contains(r#"count(DISTINCT "cs"."customer_id")"#),
        "window OVER clause must not reference out-of-scope alias cs: {sql}",
    );

    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_order_by_unqualifies_agg_input() -> Result<()> {
    let ctx = issue_23317_context()?;
    assert!(ctx.remove_optimizer_rule(SingleDistinctToGroupBy::new().name()));

    let plan = ctx
        .sql(ISSUE_23668_ORDER_BY_QUERY)
        .await?
        .into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains(r#"ORDER BY round(sum("total_revenue"), 2)"#),
        "ORDER BY aggregate should resolve against the derived projection output: {sql}",
    );
    assert!(
        !sql.contains(r#"sum("cs"."total_revenue")"#),
        "ORDER BY must not reference out-of-scope alias cs: {sql}",
    );

    Ok(())
}

#[tokio::test]
async fn optimized_duckdb_unparse_top_level_sort_over_agg_uses_select_alias() -> Result<()>
{
    let ctx = issue_23317_context()?;
    assert!(ctx.remove_optimizer_rule(SingleDistinctToGroupBy::new().name()));

    let plan = ctx
        .sql(ISSUE_23668_TOP_LEVEL_SORT_QUERY)
        .await?
        .into_optimized_plan()?;
    let dialect = DuckDBDialect::new();
    let unparser = Unparser::new(&dialect);
    let sql = unparser.plan_to_sql(&plan)?.to_string();

    assert_issue_23317_unparsed_sql_plans(&ctx, &sql).await?;

    assert!(
        sql.contains(r#"ORDER BY "customers""#),
        "top-level ORDER BY should resolve to the select alias: {sql}",
    );
    assert!(
        !sql.contains(r#""cs"."customer_id") AS "customers""#),
        "aggregate output must not reference out-of-scope alias cs: {sql}",
    );

    Ok(())
}

/// The outcome of running a single roundtrip test.
///
/// A successful test produces [`TestCaseResult::Success`].
/// All other variants capture different failure modes with enough context to diagnose the issue.
enum TestCaseResult {
    /// The unparsed SQL produced identical results to the original.
    Success,

    /// Both queries executed but produced different results.
    ///
    /// This indicates a semantic bug in the unparser where the generated SQL
    /// has different meaning than the original.
    ResultsMismatch { original: String, unparsed: String },

    /// The unparser failed to convert the logical plan to SQL.
    ///
    /// This may indicate an unsupported SQL feature or a bug in the unparser.
    UnparseError { original: String, error: String },

    /// The original SQL failed to execute.
    ///
    /// This indicates a problem with the test setup (missing tables,
    /// invalid test data) rather than an unparser issue.
    ExecutionError { original: String, error: String },

    /// The unparsed SQL failed to execute, even though the original succeeded.
    ///
    /// This indicates the unparser generated syntactically invalid SQL or SQL
    /// that references non-existent columns/tables.
    UnparsedExecutionError {
        original: String,
        unparsed: String,
        error: String,
    },
}

impl TestCaseResult {
    /// Returns true if the test case represents a failure
    /// (anything other than [`TestCaseResult::Success`]).
    fn is_failure(&self) -> bool {
        !matches!(self, TestCaseResult::Success)
    }

    /// Formats a detailed error message for the test case into a string.
    fn format_error(&self, name: &str) -> String {
        match self {
            TestCaseResult::Success => String::new(),
            TestCaseResult::ResultsMismatch { original, unparsed } => {
                format!(
                    "Results mismatch for {name}.\nOriginal SQL:\n{original}\n\nUnparsed SQL:\n{unparsed}"
                )
            }
            TestCaseResult::UnparseError { original, error } => {
                format!("Unparse error for {name}: {error}\nOriginal SQL:\n{original}")
            }
            TestCaseResult::ExecutionError { original, error } => {
                format!("Execution error for {name}: {error}\nOriginal SQL:\n{original}")
            }
            TestCaseResult::UnparsedExecutionError {
                original,
                unparsed,
                error,
            } => {
                format!(
                    "Unparsed execution error for {name}: {error}\nOriginal SQL:\n{original}\n\nUnparsed SQL:\n{unparsed}"
                )
            }
        }
    }
}

/// Executes a roundtrip test for a single SQL query.
///
/// This is the core test logic that:
/// 1. Parses the original SQL and creates a logical plan
/// 2. Unparses the logical plan back to SQL
/// 3. Executes both the original and unparsed queries
/// 4. Compares the results (sorting if the query has no ORDER BY)
///
/// This always uses [`DefaultDialect`] for unparsing.
///
/// # Arguments
///
/// * `ctx` - Session context with tables registered
/// * `original` - The original SQL query to test
///
/// # Returns
///
/// A [`TestCaseResult`] indicating success or the specific failure mode.
async fn collect_results(ctx: &SessionContext, original: &str) -> TestCaseResult {
    let unparser = Unparser::new(&DefaultDialect {});

    // Parse and create logical plan from original SQL
    let df = match ctx.sql(original).await {
        Ok(df) => df,
        Err(e) => {
            return TestCaseResult::ExecutionError {
                original: original.to_string(),
                error: e.to_string(),
            };
        }
    };

    // Unparse the logical plan back to SQL
    let unparsed = match unparser.plan_to_sql(df.logical_plan()) {
        Ok(sql) => format!("{sql:#}"),
        Err(e) => {
            return TestCaseResult::UnparseError {
                original: original.to_string(),
                error: e.to_string(),
            };
        }
    };

    // Collect results from original query
    let mut expected = match df.collect().await {
        Ok(batches) => batches,
        Err(e) => {
            return TestCaseResult::ExecutionError {
                original: original.to_string(),
                error: e.to_string(),
            };
        }
    };

    // Parse and execute the unparsed SQL
    let actual_df = match ctx.sql(&unparsed).await {
        Ok(df) => df,
        Err(e) => {
            return TestCaseResult::UnparsedExecutionError {
                original: original.to_string(),
                unparsed,
                error: e.to_string(),
            };
        }
    };

    // Collect results from unparsed query
    let mut actual = match actual_df.collect().await {
        Ok(batches) => batches,
        Err(e) => {
            return TestCaseResult::UnparsedExecutionError {
                original: original.to_string(),
                unparsed,
                error: e.to_string(),
            };
        }
    };

    // Always sort for deterministic comparison — even "sorted" results can have
    // tied rows in different order between original and unparsed SQL.
    {
        expected = match sort_batches(ctx, expected).await {
            Ok(batches) => batches,
            Err(e) => {
                return TestCaseResult::ExecutionError {
                    original: original.to_string(),
                    error: format!("Failed to sort expected results: {e}"),
                };
            }
        };
        actual = match sort_batches(ctx, actual).await {
            Ok(batches) => batches,
            Err(e) => {
                return TestCaseResult::UnparsedExecutionError {
                    original: original.to_string(),
                    unparsed,
                    error: format!("Failed to sort actual results: {e}"),
                };
            }
        };
    }

    if expected != actual {
        TestCaseResult::ResultsMismatch {
            original: original.to_string(),
            unparsed,
        }
    } else {
        TestCaseResult::Success
    }
}

/// Runs roundtrip tests for a collection of queries and reports results.
///
/// Iterates through all queries, running each through [`collect_results`].
/// Prints colored status (green checkmark for success, red X for failure)
/// and panics at the end if any tests failed, with detailed error messages.
///
/// # Type Parameters
///
/// * `F` - Factory function that creates fresh session contexts
/// * `Fut` - Future type returned by the context factory
///
/// # Panics
///
/// Panics if any query fails the roundtrip test, displaying all failures.
async fn run_roundtrip_tests<F, Fut>(
    suite_name: &str,
    queries: Vec<TestQuery>,
    create_context: F,
) where
    F: Fn() -> Fut,
    Fut: Future<Output = Result<SessionContext>>,
{
    let mut errors: Vec<String> = vec![];
    for sql in queries {
        let ctx = match create_context().await {
            Ok(ctx) => ctx,
            Err(e) => {
                println!("\x1b[31m✗\x1b[0m {} query: {}", suite_name, sql.name);
                errors.push(format!("Failed to create context for {}: {}", sql.name, e));
                continue;
            }
        };
        let result = collect_results(&ctx, &sql.sql).await;
        if result.is_failure() {
            println!("\x1b[31m✗\x1b[0m {} query: {}", suite_name, sql.name);
            errors.push(result.format_error(&sql.name));
        } else {
            println!("\x1b[32m✓\x1b[0m {} query: {}", suite_name, sql.name);
        }
    }
    if !errors.is_empty() {
        panic!(
            "{} {} test(s) failed:\n\n{}",
            errors.len(),
            suite_name,
            errors.join("\n\n---\n\n")
        );
    }
}

#[tokio::test]
async fn test_clickbench_unparser_roundtrip() {
    run_roundtrip_tests("Clickbench", clickbench_queries(), clickbench_test_context)
        .await;
}

#[tokio::test]
async fn test_tpch_unparser_roundtrip() {
    // Grow stacker segments earlier to avoid deep unparser recursion overflow in q20.
    set_minimum_stack_size(512 * 1024);
    set_stack_allocation_size(8 * 1024 * 1024);
    run_roundtrip_tests("TPC-H", tpch_queries(), tpch_test_context).await;
}
