// 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.

use crate::util::{BenchmarkRun, CommonOpt, QueryResult};
use clap::Args;
use datafusion::physical_plan::execute_stream;
use datafusion::{error::Result, prelude::SessionContext};
use datafusion_common::instant::Instant;
use datafusion_common::{DataFusionError, exec_datafusion_err, exec_err};
use std::path::PathBuf;

use futures::StreamExt;

/// Run the Hash Join benchmark
///
/// This micro-benchmark focuses on the performance characteristics of Hash Joins.
/// It uses simple equality predicates to ensure a hash join is selected.
/// Where we vary selectivity, we do so with additional cheap predicates that
/// do not change the join key (so the physical operator remains HashJoin).
#[derive(Debug, Args, Clone)]
#[command(verbatim_doc_comment)]
pub struct RunOpt {
    /// Query number. If not specified, runs all queries
    #[arg(short, long)]
    query: Option<usize>,

    /// Common options (iterations, batch size, target_partitions, etc.)
    #[command(flatten)]
    common: CommonOpt,

    /// Path to TPC-H SF10 data
    #[arg(short = 'p', long = "path")]
    path: Option<PathBuf>,

    /// If present, write results json here
    #[arg(short = 'o', long = "output")]
    output_path: Option<PathBuf>,
}

struct HashJoinQuery {
    sql: &'static str,
    density: f64,
    prob_hit: f64,
    build_size: &'static str,
    probe_size: &'static str,
    isolate_partitioned_join: bool,
}

/// Inline SQL queries for Hash Join benchmarks
const HASH_QUERIES: &[HashJoinQuery] = &[
    // Q1: Very Small Build Side (Dense)
    // Build Side: nation (25 rows) | Probe Side: customer (1.5M rows)
    HashJoinQuery {
        sql: r###"SELECT n_nationkey FROM nation JOIN customer ON c_nationkey = n_nationkey"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "25",
        probe_size: "1.5M",
        isolate_partitioned_join: false,
    },
    // Q2: Very Small Build Side (Sparse, range < 1024)
    // Build Side: nation (25 rows, range 961) | Probe Side: customer (1.5M rows)
    HashJoinQuery {
        sql: r###"SELECT l.k
    FROM (
      SELECT c_nationkey * 40 as k
      FROM customer
    ) l
    JOIN (
      SELECT n_nationkey * 40 as k FROM nation
    ) s ON l.k = s.k"###,
        density: 0.026,
        prob_hit: 1.0,
        build_size: "25",
        probe_size: "1.5M",
        isolate_partitioned_join: false,
    },
    // Q3: 100% Density, 100% Hit rate
    HashJoinQuery {
        sql: r###"SELECT s_suppkey FROM supplier JOIN lineitem ON s_suppkey = l_suppkey"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q4: 100% Density, 10% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE WHEN l_suppkey % 10 = 0 THEN l_suppkey ELSE l_suppkey + 1000000 END as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 1.0,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q5: 75% Density, 100% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT l_suppkey * 4 / 3 as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 4 / 3 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.75,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q6: 75% Density, 10% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE 
                      WHEN l_suppkey % 10 = 0 THEN l_suppkey * 4 / 3
                      WHEN l_suppkey % 10 < 9 THEN (l_suppkey * 4 / 3 / 4) * 4 + 3
                      ELSE l_suppkey * 4 / 3 + 1000000         
                 END as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 4 / 3 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.75,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q7: 50% Density, 100% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT l_suppkey * 2 as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 2 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.5,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q8: 50% Density, 10% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE 
                      WHEN l_suppkey % 10 = 0 THEN l_suppkey * 2
                      WHEN l_suppkey % 10 < 9 THEN l_suppkey * 2 + 1
                      ELSE l_suppkey * 2 + 1000000
                 END as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 2 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.5,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q9: 20% Density, 100% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT l_suppkey * 5 as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 5 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.2,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q10: 20% Density, 10% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE 
                      WHEN l_suppkey % 10 = 0 THEN l_suppkey * 5
                      WHEN l_suppkey % 10 < 9 THEN l_suppkey * 5 + 1
                      ELSE l_suppkey * 5 + 1000000
                 END as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 5 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.2,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q11: 10% Density, 100% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT l_suppkey * 10 as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 10 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.1,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q12: 10% Density, 10% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE 
                      WHEN l_suppkey % 10 = 0 THEN l_suppkey * 10
                      WHEN l_suppkey % 10 < 9 THEN l_suppkey * 10 + 1
                      ELSE l_suppkey * 10 + 1000000
                 END as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 10 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.1,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q13: 1% Density, 100% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT l_suppkey * 100 as k
          FROM lineitem
        ) l
        JOIN (
          SELECT s_suppkey * 100 as k FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.01,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q14: 1% Density, 10% Hit rate
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE 
                      WHEN l_suppkey % 10 = 0 THEN l_suppkey * 100
                      WHEN l_suppkey % 10 < 9 THEN l_suppkey * 100 + 1
                      ELSE l_suppkey * 100 + 11000000                  -- oob
                 END as k
          FROM lineitem
        ) l
            JOIN (
              SELECT s_suppkey * 100 as k FROM supplier
            ) s ON l.k = s.k"###,
        density: 0.01,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // Q15: 20% Density, 10% Hit rate, 20% Duplicates in Build Side
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CASE 
                      WHEN l_suppkey % 10 = 0 THEN ((l_suppkey % 80000) + 1) * 25 / 4
                      ELSE ((l_suppkey % 80000) + 1) * 25 / 4 + 1
                 END as k
          FROM lineitem
        ) l
        JOIN (
          SELECT CASE 
                      WHEN s_suppkey <= 80000 THEN (s_suppkey * 25) / 4 
                      ELSE ((s_suppkey - 80000) * 25) / 4 
                 END as k 
          FROM supplier
        ) s ON l.k = s.k"###,
        density: 0.2,
        prob_hit: 0.1,
        build_size: "100K_(20%_dups)",
        probe_size: "60M",
        isolate_partitioned_join: false,
    },
    // RightSemi Join benchmarks with Int32 keys
    //
    // Fanout (average build rows matched per probe row, as measured by running
    // the equivalent INNER JOIN under `EXPLAIN ANALYZE` and reading the
    // `HashJoinExec` metrics): 1 for Q16-Q18. Build keys here are primary
    // keys (`n_nationkey`, `s_suppkey`), so each probe row matches at most
    // one build row. `prob_hit` controls what fraction of probe rows find
    // that one match.
    //
    // Fanout still matters because semi joins short-circuit after the first
    // match. Coverage of fanout > 1 (build-side duplicates) is left for a
    // follow-up.
    //
    // Q16: RightSemi, Small build (25 rows), 100% Hit rate
    // Build Side: nation (25 rows) | Probe Side: customer (1.5M rows)
    HashJoinQuery {
        sql: r###"SELECT c.k
        FROM (SELECT CAST(n_nationkey AS INT) as k FROM nation) n
        RIGHT SEMI JOIN (SELECT CAST(c_nationkey AS INT) as k FROM customer) c
        ON n.k = c.k"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "25",
        probe_size: "1.5M_RightSemi",
        isolate_partitioned_join: false,
    },
    // Q17: RightSemi, Medium build (100K rows), 100% Hit rate
    // Build Side: supplier (100K rows) | Probe Side: lineitem (60M rows)
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (SELECT CAST(s_suppkey AS INT) as k FROM supplier) s
        RIGHT SEMI JOIN (SELECT CAST(l_suppkey AS INT) as k FROM lineitem) l
        ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M_RightSemi",
        isolate_partitioned_join: false,
    },
    // Q18: RightSemi, Medium build (100K rows), 10% Hit rate
    // Build Side: supplier (100K rows) | Probe Side: lineitem (60M rows)
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (SELECT CAST(s_suppkey AS INT) as k FROM supplier) s
        RIGHT SEMI JOIN (
          SELECT CAST(CASE WHEN l_suppkey % 10 = 0 THEN l_suppkey ELSE l_suppkey + 1000000 END AS INT) as k
          FROM lineitem
        ) l
        ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M_RightSemi",
        isolate_partitioned_join: false,
    },
    // RightAnti Join benchmarks with Int32 keys
    //
    // Fanout (average build rows matched per probe row, as measured by running
    // the equivalent INNER JOIN under `EXPLAIN ANALYZE` and reading the
    // `HashJoinExec` metrics): 1 for Q19-Q21. Build keys here are primary
    // keys (`n_nationkey`, `s_suppkey`), so each probe row matches at most
    // one build row. `prob_hit` controls what fraction of probe rows find
    // that one match (and are therefore filtered *out* by anti).
    //
    // Fanout still matters because anti joins short-circuit after the first
    // match. Coverage of fanout > 1 (build-side duplicates) is left for a
    // follow-up.
    //
    // Q19: RightAnti, Small build (25 rows), 100% Hit rate (no output)
    // Build Side: nation (25 rows) | Probe Side: customer (1.5M rows)
    HashJoinQuery {
        sql: r###"SELECT c.k
        FROM (SELECT CAST(n_nationkey AS INT) as k FROM nation) n
        RIGHT ANTI JOIN (SELECT CAST(c_nationkey AS INT) as k FROM customer) c
        ON n.k = c.k"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "25",
        probe_size: "1.5M_RightAnti",
        isolate_partitioned_join: false,
    },
    // Q20: RightAnti, Medium build (100K rows), 100% Hit rate (no output)
    // Build Side: supplier (100K rows) | Probe Side: lineitem (60M rows)
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (SELECT CAST(s_suppkey AS INT) as k FROM supplier) s
        RIGHT ANTI JOIN (SELECT CAST(l_suppkey AS INT) as k FROM lineitem) l
        ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "100K",
        probe_size: "60M_RightAnti",
        isolate_partitioned_join: false,
    },
    // Q21: RightAnti, Medium build (100K rows), 10% Hit rate (90% output)
    // Build Side: supplier (100K rows) | Probe Side: lineitem (60M rows)
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (SELECT CAST(s_suppkey AS INT) as k FROM supplier) s
        RIGHT ANTI JOIN (
          SELECT CAST(CASE WHEN l_suppkey % 10 = 0 THEN l_suppkey ELSE l_suppkey + 1000000 END AS INT) as k
          FROM lineitem
        ) l
        ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 0.1,
        build_size: "100K",
        probe_size: "60M_RightAnti",
        isolate_partitioned_join: false,
    },
    // Q22: RightSemi, Medium build (100K rows), ~1% Hit rate, fanout ~100
    //
    // Build Side: supplier (100K rows) collapsed onto 1K distinct keys
    // Probe Side: lineitem (60M rows). Each matching probe row produces many
    // duplicate probe indices before RightSemi deduplication.
    HashJoinQuery {
        sql: r###"SELECT l.k
        FROM (
          SELECT CAST(((s_suppkey - 1) % 1000) + 1 AS INT) as k
          FROM supplier
        ) s
        RIGHT SEMI JOIN (
          SELECT CAST(l_suppkey AS INT) as k
          FROM lineitem
        ) l
        ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 0.01,
        build_size: "100K_(fanout_100)",
        probe_size: "60M_RightSemi",
        isolate_partitioned_join: false,
    },
    // Q23: skewed high-fanout string-key inner join.
    // Build ~32K rows / ~415 distinct keys (fanout ~78), probe ~2.3M rows all
    // carrying the same dominant key — one partition does nearly all the work.
    // Long keys (~28 chars) make per-pair key comparison expensive; count(*)
    // isolates the match path.
    HashJoinQuery {
        sql: r###"SELECT count(*)
        FROM (
          SELECT 'high_fanout_string_join_key_' || CAST((s_suppkey % 415) + 1 AS VARCHAR) as k
          FROM supplier
          WHERE s_suppkey <= 32340
        ) s
        JOIN (
          SELECT 'high_fanout_string_join_key_1' as k
          FROM lineitem
          WHERE l_orderkey % 265 = 0
        ) l ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "32K_(fanout~78)",
        probe_size: "2.3M_long_keys_count",
        isolate_partitioned_join: true,
    },
    // Q24: single-hot-bucket long string-key inner join.
    // Build rows all share one long string key, so each matching probe row fans
    // out to the whole build side. The output is counted to focus on the hash
    // match/equality path without buffering the joined rows.
    HashJoinQuery {
        sql: r###"SELECT count(*)
        FROM (
          SELECT 'single_hot_bucket_string_join_key' as k
          FROM supplier
          WHERE s_suppkey <= 3000
        ) s
        JOIN (
          SELECT 'single_hot_bucket_string_join_key' as k
          FROM lineitem
          WHERE l_orderkey % 3000 = 0
        ) l ON s.k = l.k"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "3K_(single_hot_bucket)",
        probe_size: "20K_long_keys_count",
        isolate_partitioned_join: true,
    },
    // Q25: skewed high-fanout multi-column string-key inner join.
    // This tracks the same candidate-pair filtering path for composite join
    // keys, where the first key is skewed and the second long string key must
    // also be checked before emitting each match.
    HashJoinQuery {
        sql: r###"SELECT count(*)
        FROM (
          SELECT CAST((s_suppkey % 256) + 1 AS INT) as k1,
                 'multi_column_high_fanout_key' as k2
          FROM supplier
          WHERE s_suppkey <= 20000
        ) s
        JOIN (
          SELECT CAST(1 AS INT) as k1,
                 'multi_column_high_fanout_key' as k2
          FROM lineitem
          WHERE l_orderkey % 250 = 0
        ) l ON s.k1 = l.k1 AND s.k2 = l.k2"###,
        density: 1.0,
        prob_hit: 1.0,
        build_size: "20K_(fanout~78_multi_key)",
        probe_size: "240K_multi_key_count",
        isolate_partitioned_join: true,
    },
];

impl RunOpt {
    pub async fn run(self) -> Result<()> {
        println!("Running Hash Join benchmarks with the following options: {self:#?}\n");

        let query_range = match self.query {
            Some(query_id) => {
                if query_id >= 1 && query_id <= HASH_QUERIES.len() {
                    query_id..=query_id
                } else {
                    return exec_err!(
                        "Query {query_id} not found. Available queries: 1 to {}",
                        HASH_QUERIES.len()
                    );
                }
            }
            None => 1..=HASH_QUERIES.len(),
        };

        let mut config = self.common.config()?;
        // Disable join reordering to ensure the optimizer doesn't swap join sides
        config.options_mut().optimizer.join_reordering = false;
        let rt = self.common.build_runtime()?;
        let ctx = SessionContext::new_with_config_rt(config, rt);

        if let Some(path) = &self.path {
            for table in &["lineitem", "supplier", "nation", "customer"] {
                let table_path = path.join(table);
                if !table_path.exists() {
                    return exec_err!(
                        "TPC-H table {} not found at {:?}",
                        table,
                        table_path
                    );
                }
                ctx.register_parquet(
                    *table,
                    table_path.to_str().unwrap(),
                    Default::default(),
                )
                .await?;
            }
        }

        let mut benchmark_run = BenchmarkRun::new();
        benchmark_run.set_memory_pool(&ctx.runtime_env().memory_pool);

        for query_id in query_range {
            let query_index = query_id - 1;
            let query = &HASH_QUERIES[query_index];

            let case_name = format!(
                "Query {}_density={}_prob_hit={}_{}*{}",
                query_id,
                query.density,
                query.prob_hit,
                query.build_size,
                query.probe_size
            );
            benchmark_run.start_new_case(&case_name);

            // For Q23 force Partitioned mode: zero the CollectLeft thresholds
            // so the planner cannot prove the build side is small (as happens
            // when the datasource provides no row-count stats).
            if query.isolate_partitioned_join {
                ctx.sql(
                    "SET datafusion.optimizer.hash_join_single_partition_threshold = 0",
                )
                .await?;
                ctx.sql(
                    "SET datafusion.optimizer.hash_join_single_partition_threshold_rows = 0",
                )
                .await?;
            }

            let query_run = self
                .benchmark_query(query.sql, &query_id.to_string(), &ctx)
                .await;
            match query_run {
                Ok(query_results) => {
                    for iter in query_results {
                        benchmark_run.write_iter(iter.elapsed, iter.row_count);
                    }
                }
                Err(e) => {
                    return Err(DataFusionError::Context(
                        format!("Hash Join benchmark Q{query_id} failed with error:"),
                        Box::new(e),
                    ));
                }
            }
        }

        benchmark_run.maybe_write_json(self.output_path.as_ref())?;
        Ok(())
    }

    /// Validates that the physical plan uses a HashJoin, then executes.
    async fn benchmark_query(
        &self,
        sql: &str,
        query_name: &str,
        ctx: &SessionContext,
    ) -> Result<Vec<QueryResult>> {
        let mut query_results = vec![];

        // Build/validate plan
        let df = ctx.sql(sql).await?;
        let physical_plan = df.create_physical_plan().await?;
        let plan_string = format!("{physical_plan:#?}");

        if !plan_string.contains("HashJoinExec") {
            return Err(exec_datafusion_err!(
                "Query {query_name} does not use Hash Join. Physical plan: {plan_string}"
            ));
        }

        // Execute without buffering
        for i in 0..self.common.iterations {
            let start = Instant::now();
            let row_count = Self::execute_sql_without_result_buffering(sql, ctx).await?;
            let elapsed = start.elapsed();

            println!(
                "Query {query_name} iteration {i} returned {row_count} rows in {elapsed:?}"
            );

            query_results.push(QueryResult { elapsed, row_count });
        }

        Ok(query_results)
    }

    /// Executes the SQL query and drops each batch to avoid result buffering.
    async fn execute_sql_without_result_buffering(
        sql: &str,
        ctx: &SessionContext,
    ) -> Result<usize> {
        let mut row_count = 0;

        let df = ctx.sql(sql).await?;
        let physical_plan = df.create_physical_plan().await?;
        let mut stream = execute_stream(physical_plan, ctx.task_ctx())?;

        while let Some(batch) = stream.next().await {
            row_count += batch?.num_rows();
            // Drop batches immediately to minimize memory pressure
        }

        Ok(row_count)
    }
}
