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

"""
Clickbench benchmark for Paimon file formats.

Downloads the Clickbench hits.parquet dataset and compares compression ratios
and read performance across Parquet, ORC, and Vortex formats in Paimon tables.

Usage:
    python benchmarks/clickbench_format.py [--rows N] [--data-path PATH]

    --rows N         Number of rows to use (default: 1_000_000, use 0 for full dataset)
    --data-path PATH Path to existing hits.parquet (skips download if provided)
"""

import argparse
import os
import random
import shutil
import sys
import tempfile
import time
from pathlib import Path

import pyarrow as pa
import pyarrow.parquet as pq

# Add project root to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from pypaimon import CatalogFactory, Schema
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.utils.range import Range

CLICKBENCH_URL = "https://datasets.clickhouse.com/hits_compatible/hits.parquet"

# Formats to benchmark
FORMATS = ["parquet", "orc", "lance", "vortex"]


def download_clickbench(dest_path: str):
    """Download Clickbench hits.parquet if not already present."""
    if os.path.exists(dest_path):
        print(f"[INFO] Using cached dataset: {dest_path}")
        return
    print(f"[INFO] Downloading Clickbench dataset to {dest_path} ...")
    print(f"[INFO] URL: {CLICKBENCH_URL}")
    print("[INFO] This is ~14GB, may take a while.")

    import urllib.request
    urllib.request.urlretrieve(CLICKBENCH_URL, dest_path)
    print("[INFO] Download complete.")


def load_data(data_path: str, max_rows: int) -> pa.Table:
    """Load Clickbench parquet data, optionally limiting rows."""
    print(f"[INFO] Loading data from {data_path} ...")
    pf = pq.ParquetFile(data_path)
    if max_rows > 0:
        # Read enough row groups to satisfy max_rows
        batches = []
        total = 0
        for rg_idx in range(pf.metadata.num_row_groups):
            rg = pf.read_row_group(rg_idx)
            batches.append(rg)
            total += rg.num_rows
            if total >= max_rows:
                break
        table = pa.concat_tables(batches)
        if table.num_rows > max_rows:
            table = table.slice(0, max_rows)
    else:
        table = pf.read()

    # Cast unsigned integer types to signed (Paimon doesn't support unsigned)
    uint_to_int = {
        pa.uint8(): pa.int16(),
        pa.uint16(): pa.int32(),
        pa.uint32(): pa.int64(),
        pa.uint64(): pa.int64(),
    }
    new_fields = []
    for field in table.schema:
        if field.type in uint_to_int:
            new_fields.append((field.name, uint_to_int[field.type]))
    if new_fields:
        print(f"[INFO] Casting {len(new_fields)} unsigned int columns to signed")
        for name, target_type in new_fields:
            table = table.set_column(
                table.schema.get_field_index(name),
                pa.field(name, target_type, nullable=table.schema.field(name).nullable),
                table.column(name).cast(target_type),
            )

    print(f"[INFO] Loaded {table.num_rows:,} rows, {table.num_columns} columns")
    print(f"[INFO] In-memory size: {table.nbytes / 1024 / 1024:.1f} MB")
    return table


def get_dir_size(path: str) -> int:
    """Get total size of all files under a directory."""
    total = 0
    for dirpath, _, filenames in os.walk(path):
        for f in filenames:
            fp = os.path.join(dirpath, f)
            if os.path.isfile(fp):
                total += os.path.getsize(fp)
    return total


def write_paimon_table(catalog, table_name: str, pa_schema: pa.Schema,
                       data: pa.Table, file_format: str) -> dict:
    """Write data to a Paimon table and return metrics."""
    schema = Schema.from_pyarrow_schema(pa_schema, options={
        'file.format': file_format,
        'data-evolution.enabled': 'true',
        'row-tracking.enabled': 'true',
    })
    catalog.create_table(f'default.{table_name}', schema, False)
    table = catalog.get_table(f'default.{table_name}')

    write_builder = table.new_batch_write_builder()
    table_write = write_builder.new_write()
    table_commit = write_builder.new_commit()

    t0 = time.time()
    table_write.write_arrow(data)
    table_commit.commit(table_write.prepare_commit())
    write_time = time.time() - t0

    table_write.close()
    table_commit.close()

    return {
        'write_time': write_time,
    }


def read_paimon_table(catalog, table_name: str) -> dict:
    """Read all data from a Paimon table and return metrics."""
    table = catalog.get_table(f'default.{table_name}')
    read_builder = table.new_read_builder()
    table_scan = read_builder.new_scan()
    table_read = read_builder.new_read()

    t0 = time.time()
    result = table_read.to_arrow(table_scan.plan().splits())
    read_time = time.time() - t0

    return {
        'read_time': read_time,
        'num_rows': result.num_rows,
    }


def point_lookup_paimon_table(catalog, table_name: str, num_lookups: int, rows_per_lookup: int) -> dict:
    """Run row-ID-based point lookups on a data-evolution Paimon table."""
    table = catalog.get_table(f'default.{table_name}')
    read_builder = table.new_read_builder()
    scan = read_builder.new_scan()
    splits = scan.plan().splits()

    # Determine total row range from splits
    total_rows = sum(s.row_count for s in splits)

    # Generate random row ID ranges for lookups
    random.seed(42)
    lookup_ranges = []
    for _ in range(num_lookups):
        start = random.randint(0, total_rows - rows_per_lookup)
        lookup_ranges.append(Range(start, start + rows_per_lookup - 1))

    total_result_rows = 0
    t0 = time.time()
    for rng in lookup_ranges:
        indexed_splits = []
        for s in splits:
            # Intersect the lookup range with each split's file ranges
            file_ranges = []
            for f in s.files:
                if f.first_row_id is not None:
                    file_ranges.append(Range(f.first_row_id, f.first_row_id + f.row_count - 1))
            split_range = Range.and_([rng], file_ranges)
            if split_range:
                indexed_splits.append(IndexedSplit(s, [rng]))

        if indexed_splits:
            read = read_builder.new_read()
            result = read.to_arrow(indexed_splits)
            total_result_rows += result.num_rows

    lookup_time = time.time() - t0

    return {
        'lookup_time': lookup_time,
        'num_lookups': num_lookups,
        'total_rows': total_result_rows,
    }


def predicate_lookup_paimon_table(catalog, table_name: str, num_lookups: int, rows_per_lookup: int) -> dict:
    """Run predicate-based point lookups using _ROW_ID filter on a data-evolution Paimon table."""
    table = catalog.get_table(f'default.{table_name}')

    # Get total rows to generate random ranges
    rb = table.new_read_builder()
    splits = rb.new_scan().plan().splits()
    total_rows = sum(s.row_count for s in splits)

    # Build predicate builder with _ROW_ID in projection
    all_cols = [f.name for f in table.fields] + ['_ROW_ID']
    pb = table.new_read_builder().with_projection(all_cols).new_predicate_builder()

    random.seed(42)
    total_result_rows = 0
    t0 = time.time()
    for _ in range(num_lookups):
        start = random.randint(0, total_rows - rows_per_lookup)
        end = start + rows_per_lookup - 1
        pred = pb.between('_ROW_ID', start, end)
        filtered_rb = table.new_read_builder().with_filter(pred)
        scan = filtered_rb.new_scan()
        read = filtered_rb.new_read()
        result = read.to_arrow(scan.plan().splits())
        total_result_rows += result.num_rows

    lookup_time = time.time() - t0

    return {
        'lookup_time': lookup_time,
        'num_lookups': num_lookups,
        'total_rows': total_result_rows,
    }


def run_benchmark(data: pa.Table, warehouse_dir: str):
    """Run the compression benchmark across all formats."""
    catalog = CatalogFactory.create({'warehouse': warehouse_dir})
    catalog.create_database('default', True)

    pa_schema = data.schema
    in_memory_mb = data.nbytes / 1024 / 1024
    results = {}

    num_lookups = 20
    rows_per_lookup = 100

    for fmt in FORMATS:
        table_name = f"clickbench_{fmt}"
        print(f"\n{'='*60}")
        print(f"  Format: {fmt.upper()}")
        print(f"{'='*60}")

        # Write
        print(f"  Writing {data.num_rows:,} rows ...")
        write_metrics = write_paimon_table(catalog, table_name, pa_schema, data, fmt)
        print(f"  Write time: {write_metrics['write_time']:.2f}s")

        # Measure on-disk size
        table = catalog.get_table(f'default.{table_name}')
        table_path = table.table_path
        disk_size = get_dir_size(table_path)
        disk_mb = disk_size / 1024 / 1024
        ratio = in_memory_mb / disk_mb if disk_mb > 0 else 0
        print(f"  On-disk size: {disk_mb:.1f} MB  (ratio: {ratio:.2f}x)")

        # Full read
        print("  Reading back ...")
        read_metrics = read_paimon_table(catalog, table_name)
        print(f"  Read time: {read_metrics['read_time']:.2f}s")
        print(f"  Rows read: {read_metrics['num_rows']:,}")

        # Point lookups by row ID
        print(f"  Point lookups ({num_lookups} queries, {rows_per_lookup} rows each) ...")
        lookup_metrics = point_lookup_paimon_table(catalog, table_name, num_lookups, rows_per_lookup)
        print(f"  Lookup time: {lookup_metrics['lookup_time']:.2f}s")
        print(f"  Total rows matched: {lookup_metrics['total_rows']:,}")

        # Predicate-based point lookups by _ROW_ID
        print(f"  Predicate lookups ({num_lookups} queries, {rows_per_lookup} rows each) ...")
        pred_metrics = predicate_lookup_paimon_table(catalog, table_name, num_lookups, rows_per_lookup)
        print(f"  Predicate lookup time: {pred_metrics['lookup_time']:.2f}s")
        print(f"  Total rows matched: {pred_metrics['total_rows']:,}")

        results[fmt] = {
            'write_time': write_metrics['write_time'],
            'read_time': read_metrics['read_time'],
            'disk_mb': disk_mb,
            'ratio': ratio,
            'lookup_time': lookup_metrics['lookup_time'],
            'lookup_rows': lookup_metrics['total_rows'],
            'pred_lookup_time': pred_metrics['lookup_time'],
            'pred_lookup_rows': pred_metrics['total_rows'],
        }

    return results


def print_summary(results: dict, in_memory_mb: float, num_rows: int):
    """Print a summary comparison table."""
    print(f"\n{'='*80}")
    print("  CLICKBENCH COMPRESSION BENCHMARK SUMMARY")
    print(f"  Rows: {num_rows:,}  |  In-memory: {in_memory_mb:.1f} MB")
    print(f"{'='*80}")
    print(f"  {'Format':<10} {'Disk (MB)':>10} {'Ratio':>8} {'Write (s)':>10} "
          f"{'Read (s)':>10} {'Lookup (s)':>11} {'Pred (s)':>10}")
    print(f"  {'-'*69}")

    for fmt in FORMATS:
        if fmt in results:
            r = results[fmt]
            print(f"  {fmt:<10} {r['disk_mb']:>10.1f} {r['ratio']:>7.2f}x {r['write_time']:>10.2f} "
                  f"{r['read_time']:>10.2f} {r['lookup_time']:>11.2f} {r['pred_lookup_time']:>10.2f}")

    # Vortex vs Parquet comparison
    if 'vortex' in results and 'parquet' in results:
        v = results['vortex']
        p = results['parquet']
        print("\n  Vortex vs Parquet:")
        print(f"    Size:   {v['disk_mb'] / p['disk_mb'] * 100:.1f}% of Parquet")
        print(f"    Write:  {v['write_time'] / p['write_time']:.2f}x")
        print(f"    Read:   {v['read_time'] / p['read_time']:.2f}x")
        print(f"    Lookup: {v['lookup_time'] / p['lookup_time']:.2f}x")
        print(f"    Pred:   {v['pred_lookup_time'] / p['pred_lookup_time']:.2f}x")


def main():
    parser = argparse.ArgumentParser(description="Clickbench compression benchmark for Paimon")
    parser.add_argument("--rows", type=int, default=3_000_000,
                        help="Number of rows to use (0 = full dataset, default: 3000000)")
    parser.add_argument("--data-path", type=str, default=None,
                        help="Path to existing hits.parquet (skips download)")
    args = parser.parse_args()

    # Resolve data path
    if args.data_path:
        data_path = args.data_path
    else:
        cache_dir = os.path.join(Path.home(), ".cache", "paimon-bench")
        os.makedirs(cache_dir, exist_ok=True)
        data_path = os.path.join(cache_dir, "hits.parquet")
        download_clickbench(data_path)

    data = load_data(data_path, args.rows)
    in_memory_mb = data.nbytes / 1024 / 1024

    warehouse_dir = tempfile.mkdtemp(prefix="paimon_bench_")
    print(f"[INFO] Warehouse: {warehouse_dir}")

    try:
        results = run_benchmark(data, warehouse_dir)
        print_summary(results, in_memory_mb, data.num_rows)
    finally:
        shutil.rmtree(warehouse_dir, ignore_errors=True)


if __name__ == "__main__":
    main()
