"""Query builder for DeFiStream API."""

from __future__ import annotations

from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any

from .exceptions import ValidationError

# Maximum time range for raw trades queries (7 days in seconds)
_MAX_RAW_TRADES_RANGE = 7 * 24 * 3600

# Maximum time range for book depth queries (31 days in seconds)
_MAX_BOOK_DEPTH_RANGE = 31 * 24 * 3600

# Maximum time range for OHLCV queries (31 days in seconds)
_MAX_OHLCV_RANGE = 31 * 24 * 3600

# Maximum time range for exchange data queries (31 days in seconds)
_MAX_EXCHANGE_DATA_RANGE = 31 * 24 * 3600


def _parse_timestamp_to_epoch(value: Any) -> float:
    """Parse a timestamp string or number to Unix epoch seconds."""
    if isinstance(value, (int, float)):
        return float(value)
    s = str(value).strip()
    try:
        return float(s)
    except ValueError:
        pass
    for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
        try:
            dt = datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
            return dt.timestamp()
        except ValueError:
            continue
    raise ValueError(f"Cannot parse timestamp: {value!r}")

if TYPE_CHECKING:
    from .client import BaseClient
    from .models import CostEstimate, LinkInfo


# Label/category parameter names that need SQL safety checks
_LABEL_CATEGORY_PARAMS = frozenset({
    "involving_label", "involving_category",
    "sender_label", "sender_category",
    "receiver_label", "receiver_category",
    "exclude_involving_label", "exclude_involving_category",
    "exclude_sender_label", "exclude_sender_category",
    "exclude_receiver_label", "exclude_receiver_category",
})

# Mutual exclusivity groups: within each slot, only one key is allowed
_INVOLVING_SLOT = ("involving", "involving_label", "involving_category")
_SENDER_SLOT = ("sender", "sender_label", "sender_category")
_RECEIVER_SLOT = ("receiver", "receiver_label", "receiver_category")
_EXCLUDE_INVOLVING_SLOT = ("exclude_involving", "exclude_involving_label", "exclude_involving_category")
_EXCLUDE_SENDER_SLOT = ("exclude_sender", "exclude_sender_label", "exclude_sender_category")
_EXCLUDE_RECEIVER_SLOT = ("exclude_receiver", "exclude_receiver_label", "exclude_receiver_category")


def _normalize_multi(*args: str) -> str:
    """Join varargs into a comma-separated string.

    Accepts one or more strings. If a single pre-joined string is passed
    (e.g. "a,b") it passes through unchanged.

    Raises:
        ValueError: If no arguments are provided.
    """
    if not args:
        raise ValueError("At least one value is required")
    return ",".join(args)


def _validate_sql_safety(params: dict[str, Any]) -> None:
    """Reject label/category values containing quotes or backslashes.

    Raises:
        ValidationError: If any label/category value contains ' or \\.
    """
    for key in _LABEL_CATEGORY_PARAMS:
        value = params.get(key)
        if value is not None and isinstance(value, str):
            if "'" in value or "\\" in value:
                raise ValidationError(
                    f"Parameter '{key}' contains unsafe characters (single quote or backslash)"
                )


def _validate_mutual_exclusivity(params: dict[str, Any]) -> None:
    """Enforce mutual exclusivity between filter slots.

    Within each slot (involving, sender, receiver) only one key is allowed.
    Cross-slot: involving* cannot combine with sender* or receiver*.

    Raises:
        ValidationError: If conflicting parameters are present.
    """
    def _check_slot(slot: tuple[str, ...], slot_name: str) -> str | None:
        present = [k for k in slot if k in params]
        if len(present) > 1:
            raise ValidationError(
                f"Cannot combine {present[0]} with {present[1]} — "
                f"pick one {slot_name} filter"
            )
        return present[0] if present else None

    involving_key = _check_slot(_INVOLVING_SLOT, "involving")
    sender_key = _check_slot(_SENDER_SLOT, "sender")
    receiver_key = _check_slot(_RECEIVER_SLOT, "receiver")

    if involving_key and (sender_key or receiver_key):
        other = sender_key or receiver_key
        raise ValidationError(
            f"Cannot combine {involving_key} with {other} — "
            f"use involving* or sender*/receiver*, not both"
        )

    # Exclude filter mutual exclusivity
    excl_involving_key = _check_slot(_EXCLUDE_INVOLVING_SLOT, "exclude_involving")
    excl_sender_key = _check_slot(_EXCLUDE_SENDER_SLOT, "exclude_sender")
    excl_receiver_key = _check_slot(_EXCLUDE_RECEIVER_SLOT, "exclude_receiver")

    if excl_involving_key and (excl_sender_key or excl_receiver_key):
        other = excl_sender_key or excl_receiver_key
        raise ValidationError(
            f"Cannot combine {excl_involving_key} with {other} — "
            f"use exclude_involving* or exclude_sender*/exclude_receiver*, not both"
        )


class QueryBuilder:
    """
    Builder for constructing and executing DeFiStream API queries.

    Query is only executed when a terminal method is called:
    - as_dict() - returns list of dictionaries
    - as_df() - returns pandas DataFrame (default) or polars DataFrame
    - as_file() - saves to CSV, Parquet, or JSON file

    Example:
        query = client.erc20.transfers("USDT").network("ETH").block_range(21000000, 21010000)
        query = query.min_amount(1000).sender("0x...")
        df = query.as_df()  # pandas DataFrame
        df = query.as_df("polars")  # polars DataFrame
        query.as_file("transfers.csv")  # save to CSV
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}
        self._verbose = False
        self._with_value = False
        self._ignore_non_existing = False

    def _copy_with(self, **updates: Any) -> "QueryBuilder":
        """Create a copy with updated parameters."""
        new_builder = QueryBuilder(self._client, self._endpoint, self._params.copy())
        new_builder._verbose = self._verbose
        new_builder._with_value = self._with_value
        new_builder._ignore_non_existing = self._ignore_non_existing
        for key, value in updates.items():
            if key == "verbose":
                new_builder._verbose = value
            elif key == "with_value":
                new_builder._with_value = value
            elif key == "ignore_non_existing":
                new_builder._ignore_non_existing = value
            elif value is not None:
                new_builder._params[key] = value
        return new_builder

    # Network and block range
    def network(self, network: str) -> "QueryBuilder":
        """Set the network (ETH, ARB, BASE, OP, POLYGON, etc.)."""
        return self._copy_with(network=network)

    def start_block(self, block: int) -> "QueryBuilder":
        """Set the starting block number."""
        return self._copy_with(block_start=block)

    def end_block(self, block: int) -> "QueryBuilder":
        """Set the ending block number."""
        return self._copy_with(block_end=block)

    def block_range(self, start: int, end: int) -> "QueryBuilder":
        """Set both start and end block numbers."""
        return self._copy_with(block_start=start, block_end=end)

    # Time range
    def start_time(self, timestamp: str) -> "QueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "QueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "QueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    # ERC20 and Native Token filters
    def sender(self, *addresses: str) -> "QueryBuilder":
        """Filter by sender address (ERC20, Native Token). Accepts multiple addresses."""
        return self._copy_with(sender=_normalize_multi(*addresses))

    def receiver(self, *addresses: str) -> "QueryBuilder":
        """Filter by receiver address (ERC20, Native Token). Accepts multiple addresses."""
        return self._copy_with(receiver=_normalize_multi(*addresses))

    def from_address(self, *addresses: str) -> "QueryBuilder":
        """Filter by sender address (alias for sender). Accepts multiple addresses."""
        return self.sender(*addresses)

    def to_address(self, *addresses: str) -> "QueryBuilder":
        """Filter by receiver address (alias for receiver). Accepts multiple addresses."""
        return self.receiver(*addresses)

    def involving(self, *addresses: str) -> "QueryBuilder":
        """Filter by any involved address (all protocols). Accepts multiple addresses."""
        return self._copy_with(involving=_normalize_multi(*addresses))

    def involving_label(self, *labels: str) -> "QueryBuilder":
        """Filter by label on any involved address (all protocols)."""
        return self._copy_with(involving_label=_normalize_multi(*labels))

    def involving_category(self, *categories: str) -> "QueryBuilder":
        """Filter by category on any involved address (all protocols)."""
        return self._copy_with(involving_category=_normalize_multi(*categories))

    def sender_label(self, *labels: str) -> "QueryBuilder":
        """Filter sender by label (ERC20, Native Token)."""
        return self._copy_with(sender_label=_normalize_multi(*labels))

    def sender_category(self, *categories: str) -> "QueryBuilder":
        """Filter sender by category (ERC20, Native Token)."""
        return self._copy_with(sender_category=_normalize_multi(*categories))

    def receiver_label(self, *labels: str) -> "QueryBuilder":
        """Filter receiver by label (ERC20, Native Token)."""
        return self._copy_with(receiver_label=_normalize_multi(*labels))

    def receiver_category(self, *categories: str) -> "QueryBuilder":
        """Filter receiver by category (ERC20, Native Token)."""
        return self._copy_with(receiver_category=_normalize_multi(*categories))

    # Negative (exclude) filters
    def exclude_sender(self, *addresses: str) -> "QueryBuilder":
        """Exclude transfers from these sender addresses (ERC20, Native Token)."""
        return self._copy_with(exclude_sender=_normalize_multi(*addresses))

    def exclude_sender_label(self, *labels: str) -> "QueryBuilder":
        """Exclude senders matching label (ERC20, Native Token)."""
        return self._copy_with(exclude_sender_label=_normalize_multi(*labels))

    def exclude_sender_category(self, *categories: str) -> "QueryBuilder":
        """Exclude senders matching category (ERC20, Native Token)."""
        return self._copy_with(exclude_sender_category=_normalize_multi(*categories))

    def exclude_receiver(self, *addresses: str) -> "QueryBuilder":
        """Exclude transfers to these receiver addresses (ERC20, Native Token)."""
        return self._copy_with(exclude_receiver=_normalize_multi(*addresses))

    def exclude_receiver_label(self, *labels: str) -> "QueryBuilder":
        """Exclude receivers matching label (ERC20, Native Token)."""
        return self._copy_with(exclude_receiver_label=_normalize_multi(*labels))

    def exclude_receiver_category(self, *categories: str) -> "QueryBuilder":
        """Exclude receivers matching category (ERC20, Native Token)."""
        return self._copy_with(exclude_receiver_category=_normalize_multi(*categories))

    def exclude_involving(self, *addresses: str) -> "QueryBuilder":
        """Exclude events involving these addresses (all protocols)."""
        return self._copy_with(exclude_involving=_normalize_multi(*addresses))

    def exclude_involving_label(self, *labels: str) -> "QueryBuilder":
        """Exclude by label on any involved address (all protocols)."""
        return self._copy_with(exclude_involving_label=_normalize_multi(*labels))

    def exclude_involving_category(self, *categories: str) -> "QueryBuilder":
        """Exclude by category on any involved address (all protocols)."""
        return self._copy_with(exclude_involving_category=_normalize_multi(*categories))

    def min_amount(self, amount: float) -> "QueryBuilder":
        """Filter by minimum amount (ERC20, Native Token)."""
        return self._copy_with(min_amount=amount)

    def max_amount(self, amount: float) -> "QueryBuilder":
        """Filter by maximum amount (ERC20, Native Token)."""
        return self._copy_with(max_amount=amount)

    # ERC20 specific
    def token(self, *symbols: str) -> "QueryBuilder":
        """Set token symbol(s) or address (ERC20). Accepts multiple known symbols for multi-token queries."""
        return self._copy_with(token=_normalize_multi(*symbols))

    # AAVE specific
    def eth_market_type(self, market_type: str) -> "QueryBuilder":
        """Set AAVE market type for ETH network: 'Core', 'Prime', or 'EtherFi'. Default: 'Core'."""
        return self._copy_with(eth_market_type=market_type)

    # Uniswap specific
    def symbol0(self, symbol: str) -> "QueryBuilder":
        """Set first token symbol (Uniswap)."""
        return self._copy_with(symbol0=symbol)

    def symbol1(self, symbol: str) -> "QueryBuilder":
        """Set second token symbol (Uniswap)."""
        return self._copy_with(symbol1=symbol)

    def fee(self, fee_tier: int) -> "QueryBuilder":
        """Set fee tier (Uniswap): 100, 500, 3000, 10000."""
        return self._copy_with(fee=fee_tier)

    # Verbose mode
    def verbose(self, enabled: bool = True) -> "QueryBuilder":
        """Include all metadata fields (tx_hash, tx_id, log_index, network, name)."""
        return self._copy_with(verbose=enabled)

    # Value enrichment
    def with_value(self, enabled: bool = True) -> "QueryBuilder":
        """Enrich events with USD value data (adds ``value_usd`` column)."""
        return self._copy_with(with_value=enabled)

    # Multi-token tolerance
    def ignore_non_existing(self, enabled: bool = True) -> "QueryBuilder":
        """Skip tokens that don't exist on the target network instead of returning an error. Only applies to multi-token queries."""
        return self._copy_with(ignore_non_existing=enabled)

    # Build final params
    def _build_params(self) -> dict[str, Any]:
        """Build the final query parameters."""
        params = self._params.copy()
        _validate_sql_safety(params)
        _validate_mutual_exclusivity(params)
        if self._verbose:
            params["verbose"] = "true"
        if self._with_value:
            params["with_value"] = "true"
        if self._ignore_non_existing:
            params["ignore_non_existing"] = "true"
        return params

    # Terminal methods - execute the query
    def as_dict(self) -> list[dict[str, Any]]:
        """
        Execute query and return results as list of dictionaries.

        Uses JSON format from API. Limited to 10,000 blocks.

        Returns:
            List of event dictionaries
        """
        params = self._build_params()
        return self._client._request("GET", self._endpoint, params=params)

    def as_df(self, library: str = "pandas") -> Any:
        """
        Execute query and return results as DataFrame.

        Args:
            library: DataFrame library to use - "pandas" (default) or "polars"

        Returns:
            pandas.DataFrame or polars.DataFrame

        Example:
            df = query.as_df()  # pandas DataFrame
            df = query.as_df("polars")  # polars DataFrame
        """
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    def as_file(self, path: str, format: str | None = None) -> None:
        """
        Execute query and save results to file.

        Format is automatically determined by file extension, or can be
        explicitly specified.

        Args:
            path: File path to save to
            format: File format - "csv", "parquet", or "json".
                   If None, determined from file extension.

        Example:
            query.as_file("transfers.csv")  # CSV format
            query.as_file("transfers.parquet")  # Parquet format
            query.as_file("transfers.json")  # JSON format
            query.as_file("transfers", format="csv")  # Explicit format
        """
        # Determine format from extension or explicit parameter
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                format = "json"
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
                )

        if format not in ("csv", "parquet", "json"):
            raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")

        params = self._build_params()

        if format == "json":
            # For JSON, fetch as dict and write manually
            import json
            results = self.as_dict()
            with open(path, "w") as f:
                json.dump(results, f, indent=2)
        else:
            # For CSV and Parquet, use API format parameter
            params["format"] = format
            self._client._request("GET", self._endpoint, params=params, output_file=path)

    def as_link(self, format: str = "csv") -> "LinkInfo":
        """
        Execute query and return a download link instead of data.

        Uploads the result to a file sharing service and returns link info.
        The link can be passed to other tools or libraries for downloading.

        Args:
            format: File format - "csv" or "parquet" (default: "csv")

        Returns:
            LinkInfo with filename, link, and expiry

        Example:
            link_info = query.as_link(format="parquet")
            print(link_info.link)  # https://dl.defistream.dev/...

            # Use with polars
            import polars as pl
            df = pl.read_parquet(link_info.link)
        """
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        result = self._client._request("GET", self._endpoint, params=params)

        return LinkInfo(
            filename=result["filename"],
            link=result["link"],
            expiry=result["expiry"],
            size=result["size"],
        )

    def calculate_cost(self) -> "CostEstimate":
        """
        Estimate the cost of this query without executing it.

        Calls the ``POST /v1/calculate-cost`` endpoint to preview how many
        blocks the query will cost, without deducting from your quota.

        Returns:
            CostEstimate with query, cost, quota_remaining, and quota_remaining_after

        Example:
            estimate = query.calculate_cost()
            print(f"Cost: {estimate.cost} blocks")
            print(f"Remaining after: {estimate.quota_remaining_after}")
        """
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    # Aggregate transition
    def aggregate(self, group_by: str = "time", period: str = "1h") -> "AggregateQueryBuilder":
        """Transition to an aggregate query.

        Appends ``/aggregate`` to the endpoint and includes ``group_by`` and
        ``period`` as query parameters.

        Args:
            group_by: Aggregation axis — ``"time"`` or ``"block"``.
            period: Bucket size, e.g. ``"1h"``, ``"100b"``.

        Returns:
            An :class:`AggregateQueryBuilder` that inherits all current filters.
        """
        builder = AggregateQueryBuilder(
            self._client,
            self._endpoint.rstrip("/") + "/aggregate",
            self._params.copy(),
        )
        builder._verbose = self._verbose
        builder._with_value = self._with_value
        builder._params["group_by"] = group_by
        builder._params["period"] = period
        return builder

    def __repr__(self) -> str:
        return f"QueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"


class AggregateQueryBuilder(QueryBuilder):
    """Builder for aggregate (bucketed) queries.

    Returned by :pymeth:`QueryBuilder.aggregate`. Overrides terminal methods
    so that JSON responses extract from the ``"data"`` key instead of
    ``"events"``.
    """

    def _copy_with(self, **updates: Any) -> "AggregateQueryBuilder":
        """Create a copy preserving aggregate builder type."""
        new_builder = AggregateQueryBuilder(self._client, self._endpoint, self._params.copy())
        new_builder._verbose = self._verbose
        new_builder._with_value = self._with_value
        for key, value in updates.items():
            if key == "verbose":
                new_builder._verbose = value
            elif key == "with_value":
                new_builder._with_value = value
            elif value is not None:
                new_builder._params[key] = value
        return new_builder

    # Terminal methods — extract from "data" key
    def as_dict(self) -> list[dict[str, Any]]:
        """Execute aggregate query and return results as list of dictionaries."""
        params = self._build_params()
        return self._client._request(
            "GET", self._endpoint, params=params, response_key="data",
        )

    def as_df(self, library: str = "pandas") -> Any:
        """Execute aggregate query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library,
        )

    def as_file(self, path: str, format: str | None = None) -> None:
        """Execute aggregate query and save results to file."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                format = "json"
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
                )

        if format not in ("csv", "parquet", "json"):
            raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")

        params = self._build_params()

        if format == "json":
            import json as _json
            results = self.as_dict()
            with open(path, "w") as f:
                _json.dump(results, f, indent=2)
        else:
            params["format"] = format
            self._client._request("GET", self._endpoint, params=params, output_file=path)

    def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute aggregate query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        result = self._client._request("GET", self._endpoint, params=params)

        return LinkInfo(
            filename=result["filename"],
            link=result["link"],
            expiry=result["expiry"],
            size=result["size"],
        )

    def __repr__(self) -> str:
        return f"AggregateQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"


class AsyncQueryBuilder:
    """
    Async builder for constructing and executing DeFiStream API queries.

    Query is only executed when a terminal method is called:
    - as_dict() - returns list of dictionaries
    - as_df() - returns pandas DataFrame (default) or polars DataFrame
    - as_file() - saves to CSV, Parquet, or JSON file

    Example:
        query = client.erc20.transfers("USDT").network("ETH").block_range(21000000, 21010000)
        df = await query.as_df()  # pandas DataFrame
        df = await query.as_df("polars")  # polars DataFrame
        await query.as_file("transfers.csv")  # save to CSV
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}
        self._verbose = False
        self._with_value = False
        self._ignore_non_existing = False

    def _copy_with(self, **updates: Any) -> "AsyncQueryBuilder":
        """Create a copy with updated parameters."""
        new_builder = AsyncQueryBuilder(self._client, self._endpoint, self._params.copy())
        new_builder._verbose = self._verbose
        new_builder._with_value = self._with_value
        new_builder._ignore_non_existing = self._ignore_non_existing
        for key, value in updates.items():
            if key == "verbose":
                new_builder._verbose = value
            elif key == "with_value":
                new_builder._with_value = value
            elif key == "ignore_non_existing":
                new_builder._ignore_non_existing = value
            elif value is not None:
                new_builder._params[key] = value
        return new_builder

    # Network and block range
    def network(self, network: str) -> "AsyncQueryBuilder":
        """Set the network (ETH, ARB, BASE, OP, POLYGON, etc.)."""
        return self._copy_with(network=network)

    def start_block(self, block: int) -> "AsyncQueryBuilder":
        """Set the starting block number."""
        return self._copy_with(block_start=block)

    def end_block(self, block: int) -> "AsyncQueryBuilder":
        """Set the ending block number."""
        return self._copy_with(block_end=block)

    def block_range(self, start: int, end: int) -> "AsyncQueryBuilder":
        """Set both start and end block numbers."""
        return self._copy_with(block_start=start, block_end=end)

    # Time range
    def start_time(self, timestamp: str) -> "AsyncQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "AsyncQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "AsyncQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    # ERC20 and Native Token filters
    def sender(self, *addresses: str) -> "AsyncQueryBuilder":
        """Filter by sender address (ERC20, Native Token). Accepts multiple addresses."""
        return self._copy_with(sender=_normalize_multi(*addresses))

    def receiver(self, *addresses: str) -> "AsyncQueryBuilder":
        """Filter by receiver address (ERC20, Native Token). Accepts multiple addresses."""
        return self._copy_with(receiver=_normalize_multi(*addresses))

    def from_address(self, *addresses: str) -> "AsyncQueryBuilder":
        """Filter by sender address (alias for sender). Accepts multiple addresses."""
        return self.sender(*addresses)

    def to_address(self, *addresses: str) -> "AsyncQueryBuilder":
        """Filter by receiver address (alias for receiver). Accepts multiple addresses."""
        return self.receiver(*addresses)

    def involving(self, *addresses: str) -> "AsyncQueryBuilder":
        """Filter by any involved address (all protocols). Accepts multiple addresses."""
        return self._copy_with(involving=_normalize_multi(*addresses))

    def involving_label(self, *labels: str) -> "AsyncQueryBuilder":
        """Filter by label on any involved address (all protocols)."""
        return self._copy_with(involving_label=_normalize_multi(*labels))

    def involving_category(self, *categories: str) -> "AsyncQueryBuilder":
        """Filter by category on any involved address (all protocols)."""
        return self._copy_with(involving_category=_normalize_multi(*categories))

    def sender_label(self, *labels: str) -> "AsyncQueryBuilder":
        """Filter sender by label (ERC20, Native Token)."""
        return self._copy_with(sender_label=_normalize_multi(*labels))

    def sender_category(self, *categories: str) -> "AsyncQueryBuilder":
        """Filter sender by category (ERC20, Native Token)."""
        return self._copy_with(sender_category=_normalize_multi(*categories))

    def receiver_label(self, *labels: str) -> "AsyncQueryBuilder":
        """Filter receiver by label (ERC20, Native Token)."""
        return self._copy_with(receiver_label=_normalize_multi(*labels))

    def receiver_category(self, *categories: str) -> "AsyncQueryBuilder":
        """Filter receiver by category (ERC20, Native Token)."""
        return self._copy_with(receiver_category=_normalize_multi(*categories))

    # Negative (exclude) filters
    def exclude_sender(self, *addresses: str) -> "AsyncQueryBuilder":
        """Exclude transfers from these sender addresses (ERC20, Native Token)."""
        return self._copy_with(exclude_sender=_normalize_multi(*addresses))

    def exclude_sender_label(self, *labels: str) -> "AsyncQueryBuilder":
        """Exclude senders matching label (ERC20, Native Token)."""
        return self._copy_with(exclude_sender_label=_normalize_multi(*labels))

    def exclude_sender_category(self, *categories: str) -> "AsyncQueryBuilder":
        """Exclude senders matching category (ERC20, Native Token)."""
        return self._copy_with(exclude_sender_category=_normalize_multi(*categories))

    def exclude_receiver(self, *addresses: str) -> "AsyncQueryBuilder":
        """Exclude transfers to these receiver addresses (ERC20, Native Token)."""
        return self._copy_with(exclude_receiver=_normalize_multi(*addresses))

    def exclude_receiver_label(self, *labels: str) -> "AsyncQueryBuilder":
        """Exclude receivers matching label (ERC20, Native Token)."""
        return self._copy_with(exclude_receiver_label=_normalize_multi(*labels))

    def exclude_receiver_category(self, *categories: str) -> "AsyncQueryBuilder":
        """Exclude receivers matching category (ERC20, Native Token)."""
        return self._copy_with(exclude_receiver_category=_normalize_multi(*categories))

    def exclude_involving(self, *addresses: str) -> "AsyncQueryBuilder":
        """Exclude events involving these addresses (all protocols)."""
        return self._copy_with(exclude_involving=_normalize_multi(*addresses))

    def exclude_involving_label(self, *labels: str) -> "AsyncQueryBuilder":
        """Exclude by label on any involved address (all protocols)."""
        return self._copy_with(exclude_involving_label=_normalize_multi(*labels))

    def exclude_involving_category(self, *categories: str) -> "AsyncQueryBuilder":
        """Exclude by category on any involved address (all protocols)."""
        return self._copy_with(exclude_involving_category=_normalize_multi(*categories))

    def min_amount(self, amount: float) -> "AsyncQueryBuilder":
        """Filter by minimum amount (ERC20, Native Token)."""
        return self._copy_with(min_amount=amount)

    def max_amount(self, amount: float) -> "AsyncQueryBuilder":
        """Filter by maximum amount (ERC20, Native Token)."""
        return self._copy_with(max_amount=amount)

    # ERC20 specific
    def token(self, *symbols: str) -> "AsyncQueryBuilder":
        """Set token symbol(s) or address (ERC20). Accepts multiple known symbols for multi-token queries."""
        return self._copy_with(token=_normalize_multi(*symbols))

    # AAVE specific
    def eth_market_type(self, market_type: str) -> "AsyncQueryBuilder":
        """Set AAVE market type for ETH network: 'Core', 'Prime', or 'EtherFi'. Default: 'Core'."""
        return self._copy_with(eth_market_type=market_type)

    # Uniswap specific
    def symbol0(self, symbol: str) -> "AsyncQueryBuilder":
        """Set first token symbol (Uniswap)."""
        return self._copy_with(symbol0=symbol)

    def symbol1(self, symbol: str) -> "AsyncQueryBuilder":
        """Set second token symbol (Uniswap)."""
        return self._copy_with(symbol1=symbol)

    def fee(self, fee_tier: int) -> "AsyncQueryBuilder":
        """Set fee tier (Uniswap): 100, 500, 3000, 10000."""
        return self._copy_with(fee=fee_tier)

    # Verbose mode
    def verbose(self, enabled: bool = True) -> "AsyncQueryBuilder":
        """Include all metadata fields (tx_hash, tx_id, log_index, network, name)."""
        return self._copy_with(verbose=enabled)

    # Value enrichment
    def with_value(self, enabled: bool = True) -> "AsyncQueryBuilder":
        """Enrich events with USD value data (adds ``value_usd`` column)."""
        return self._copy_with(with_value=enabled)

    # Multi-token tolerance
    def ignore_non_existing(self, enabled: bool = True) -> "AsyncQueryBuilder":
        """Skip tokens that don't exist on the target network instead of returning an error. Only applies to multi-token queries."""
        return self._copy_with(ignore_non_existing=enabled)

    # Build final params
    def _build_params(self) -> dict[str, Any]:
        """Build the final query parameters."""
        params = self._params.copy()
        _validate_sql_safety(params)
        _validate_mutual_exclusivity(params)
        if self._verbose:
            params["verbose"] = "true"
        if self._with_value:
            params["with_value"] = "true"
        if self._ignore_non_existing:
            params["ignore_non_existing"] = "true"
        return params

    # Terminal methods - execute the query (async)
    async def as_dict(self) -> list[dict[str, Any]]:
        """
        Execute query and return results as list of dictionaries.

        Uses JSON format from API. Limited to 10,000 blocks.

        Returns:
            List of event dictionaries
        """
        params = self._build_params()
        return await self._client._request("GET", self._endpoint, params=params)

    async def as_df(self, library: str = "pandas") -> Any:
        """
        Execute query and return results as DataFrame.

        Args:
            library: DataFrame library to use - "pandas" (default) or "polars"

        Returns:
            pandas.DataFrame or polars.DataFrame

        Example:
            df = await query.as_df()  # pandas DataFrame
            df = await query.as_df("polars")  # polars DataFrame
        """
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return await self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    async def as_file(self, path: str, format: str | None = None) -> None:
        """
        Execute query and save results to file.

        Format is automatically determined by file extension, or can be
        explicitly specified.

        Args:
            path: File path to save to
            format: File format - "csv", "parquet", or "json".
                   If None, determined from file extension.

        Example:
            await query.as_file("transfers.csv")  # CSV format
            await query.as_file("transfers.parquet")  # Parquet format
            await query.as_file("transfers.json")  # JSON format
            await query.as_file("transfers", format="csv")  # Explicit format
        """
        # Determine format from extension or explicit parameter
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                format = "json"
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
                )

        if format not in ("csv", "parquet", "json"):
            raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")

        params = self._build_params()

        if format == "json":
            # For JSON, fetch as dict and write manually
            import json
            results = await self.as_dict()
            with open(path, "w") as f:
                json.dump(results, f, indent=2)
        else:
            # For CSV and Parquet, use API format parameter
            params["format"] = format
            await self._client._request("GET", self._endpoint, params=params, output_file=path)

    async def as_link(self, format: str = "csv") -> "LinkInfo":
        """
        Execute query and return a download link instead of data.

        Args:
            format: File format - "csv" or "parquet" (default: "csv")

        Returns:
            LinkInfo with filename, link, and expiry
        """
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        result = await self._client._request("GET", self._endpoint, params=params)

        return LinkInfo(
            filename=result["filename"],
            link=result["link"],
            expiry=result["expiry"],
            size=result["size"],
        )

    async def calculate_cost(self) -> "CostEstimate":
        """
        Estimate the cost of this query without executing it.

        Calls the ``POST /v1/calculate-cost`` endpoint to preview how many
        blocks the query will cost, without deducting from your quota.

        Returns:
            CostEstimate with query, cost, quota_remaining, and quota_remaining_after

        Example:
            estimate = await query.calculate_cost()
            print(f"Cost: {estimate.cost} blocks")
            print(f"Remaining after: {estimate.quota_remaining_after}")
        """
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = await self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    # Aggregate transition
    def aggregate(self, group_by: str = "time", period: str = "1h") -> "AsyncAggregateQueryBuilder":
        """Transition to an async aggregate query.

        Appends ``/aggregate`` to the endpoint and includes ``group_by`` and
        ``period`` as query parameters.

        Args:
            group_by: Aggregation axis — ``"time"`` or ``"block"``.
            period: Bucket size, e.g. ``"1h"``, ``"100b"``.

        Returns:
            An :class:`AsyncAggregateQueryBuilder` that inherits all current filters.
        """
        builder = AsyncAggregateQueryBuilder(
            self._client,
            self._endpoint.rstrip("/") + "/aggregate",
            self._params.copy(),
        )
        builder._verbose = self._verbose
        builder._with_value = self._with_value
        builder._params["group_by"] = group_by
        builder._params["period"] = period
        return builder

    def __repr__(self) -> str:
        return f"AsyncQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"


class AsyncAggregateQueryBuilder(AsyncQueryBuilder):
    """Async builder for aggregate (bucketed) queries.

    Returned by :pymeth:`AsyncQueryBuilder.aggregate`. Overrides terminal
    methods so that JSON responses extract from the ``"data"`` key instead of
    ``"events"``.
    """

    def _copy_with(self, **updates: Any) -> "AsyncAggregateQueryBuilder":
        """Create a copy preserving async aggregate builder type."""
        new_builder = AsyncAggregateQueryBuilder(self._client, self._endpoint, self._params.copy())
        new_builder._verbose = self._verbose
        new_builder._with_value = self._with_value
        for key, value in updates.items():
            if key == "verbose":
                new_builder._verbose = value
            elif key == "with_value":
                new_builder._with_value = value
            elif value is not None:
                new_builder._params[key] = value
        return new_builder

    # Terminal methods — extract from "data" key
    async def as_dict(self) -> list[dict[str, Any]]:
        """Execute aggregate query and return results as list of dictionaries."""
        params = self._build_params()
        return await self._client._request(
            "GET", self._endpoint, params=params, response_key="data",
        )

    async def as_df(self, library: str = "pandas") -> Any:
        """Execute aggregate query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return await self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library,
        )

    async def as_file(self, path: str, format: str | None = None) -> None:
        """Execute aggregate query and save results to file."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                format = "json"
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
                )

        if format not in ("csv", "parquet", "json"):
            raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")

        params = self._build_params()

        if format == "json":
            import json as _json
            results = await self.as_dict()
            with open(path, "w") as f:
                _json.dump(results, f, indent=2)
        else:
            params["format"] = format
            await self._client._request("GET", self._endpoint, params=params, output_file=path)

    async def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute async aggregate query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        result = await self._client._request("GET", self._endpoint, params=params)

        return LinkInfo(
            filename=result["filename"],
            link=result["link"],
            expiry=result["expiry"],
            size=result["size"],
        )

    def __repr__(self) -> str:
        return f"AsyncAggregateQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"


# Valid OHLCV window sizes
_VALID_OHLCV_WINDOWS = frozenset({"1m", "5m", "15m", "30m", "1h", "4h", "1d"})


class TradeQueryBuilder:
    """
    Builder for raw trade tick data from Binance.

    CSV and Parquet formats only — JSON is not supported.
    Query is only executed when a terminal method is called.
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}

    def _copy_with(self, **updates: Any) -> "TradeQueryBuilder":
        new_builder = TradeQueryBuilder(self._client, self._endpoint, self._params.copy())
        for key, value in updates.items():
            if value is not None:
                new_builder._params[key] = value
        return new_builder

    def token(self, symbol: str) -> "TradeQueryBuilder":
        """Set the token symbol (e.g. 'BTC', 'ETH')."""
        return self._copy_with(token=symbol)

    def start_time(self, timestamp: str) -> "TradeQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "TradeQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "TradeQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    def skip_id(self, trade_id: int) -> "TradeQueryBuilder":
        """Skip trades with ID <= trade_id (for pagination)."""
        return self._copy_with(skip_id=trade_id)

    def _build_params(self) -> dict[str, Any]:
        params = self._params.copy()
        since = params.get("since")
        until = params.get("until")
        if since is not None and until is not None:
            since_ts = _parse_timestamp_to_epoch(since)
            until_ts = _parse_timestamp_to_epoch(until)
            if (until_ts - since_ts) > _MAX_RAW_TRADES_RANGE:
                raise ValidationError(
                    "Raw trades queries are limited to a maximum range of 7 days. "
                    "Please narrow your time range."
                )
        return params

    def as_dict(self) -> list[dict[str, Any]]:
        """Not supported for raw trades (CSV/Parquet only)."""
        raise ValidationError("Raw trades do not support JSON format")

    def as_df(self, library: str = "pandas") -> Any:
        """Execute query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    def as_file(self, path: str, format: str | None = None) -> None:
        """Execute query and save results to file (CSV or Parquet only)."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                raise ValidationError("Raw trades do not support JSON format")
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet) or specify format explicitly."
                )

        if format == "json":
            raise ValidationError("Raw trades do not support JSON format")

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        self._client._request("GET", self._endpoint, params=params, output_file=path)

    def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        response = self._client._client.get(self._endpoint, params=params)
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()

        return LinkInfo(
            filename=data["filename"],
            link=data["link"],
            expiry=data["expiry"],
            size=data["size"],
        )

    def calculate_cost(self) -> "CostEstimate":
        """Estimate the cost of this query without executing it."""
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    def __repr__(self) -> str:
        return f"TradeQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r})"


class ExchangeDataQueryBuilder:
    """
    Builder for exchange data endpoints (book depth, open interest, funding rate, long/short ratios).

    CSV and Parquet formats only — JSON is not supported.
    Query is only executed when a terminal method is called.
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
        max_range: int | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}
        self._max_range = max_range

    def _copy_with(self, **updates: Any) -> "ExchangeDataQueryBuilder":
        new_builder = ExchangeDataQueryBuilder(
            self._client, self._endpoint, self._params.copy(), self._max_range,
        )
        for key, value in updates.items():
            if value is not None:
                new_builder._params[key] = value
        return new_builder

    def token(self, symbol: str) -> "ExchangeDataQueryBuilder":
        """Set the token symbol (e.g. 'BTC', 'ETH')."""
        return self._copy_with(token=symbol)

    def start_time(self, timestamp: str) -> "ExchangeDataQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "ExchangeDataQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "ExchangeDataQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    def _build_params(self) -> dict[str, Any]:
        params = self._params.copy()
        if self._max_range is not None:
            since = params.get("since")
            until = params.get("until")
            if since is not None and until is not None:
                since_ts = _parse_timestamp_to_epoch(since)
                until_ts = _parse_timestamp_to_epoch(until)
                if (until_ts - since_ts) > self._max_range:
                    days = self._max_range // (24 * 3600)
                    raise ValidationError(
                        f"This query is limited to a maximum range of {days} days. "
                        "Please narrow your time range."
                    )
        return params

    def as_dict(self) -> list[dict[str, Any]]:
        """Not supported for exchange data (CSV/Parquet only)."""
        raise ValidationError("Exchange data endpoints do not support JSON format")

    def as_df(self, library: str = "pandas") -> Any:
        """Execute query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    def as_file(self, path: str, format: str | None = None) -> None:
        """Execute query and save results to file (CSV or Parquet only)."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                raise ValidationError("Exchange data endpoints do not support JSON format")
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet) or specify format explicitly."
                )

        if format == "json":
            raise ValidationError("Exchange data endpoints do not support JSON format")

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        self._client._request("GET", self._endpoint, params=params, output_file=path)

    def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        response = self._client._client.get(self._endpoint, params=params)
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()

        return LinkInfo(
            filename=data["filename"],
            link=data["link"],
            expiry=data["expiry"],
            size=data["size"],
        )

    def calculate_cost(self) -> "CostEstimate":
        """Estimate the cost of this query without executing it."""
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    def __repr__(self) -> str:
        return f"ExchangeDataQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r})"


class OHLCVQueryBuilder:
    """
    Builder for OHLCV candle data from Binance.

    Supports all formats (JSON, CSV, Parquet).
    Maximum time range: 31 days.
    Query is only executed when a terminal method is called.
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}

    def _copy_with(self, **updates: Any) -> "OHLCVQueryBuilder":
        new_builder = OHLCVQueryBuilder(self._client, self._endpoint, self._params.copy())
        for key, value in updates.items():
            if value is not None:
                new_builder._params[key] = value
        return new_builder

    def token(self, symbol: str) -> "OHLCVQueryBuilder":
        """Set the token symbol (e.g. 'BTC', 'ETH')."""
        return self._copy_with(token=symbol)

    def start_time(self, timestamp: str) -> "OHLCVQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "OHLCVQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "OHLCVQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    def window(self, size: str) -> "OHLCVQueryBuilder":
        """Set the candle window size (e.g. '1h', '4h', '1d').

        Valid values: '1m', '5m', '15m', '30m', '1h', '4h', '1d'.
        """
        if size not in _VALID_OHLCV_WINDOWS:
            valid = ", ".join(sorted(_VALID_OHLCV_WINDOWS))
            raise ValidationError(f"Invalid window '{size}'. Must be one of: {valid}")
        return self._copy_with(window=size)

    def _build_params(self) -> dict[str, Any]:
        params = self._params.copy()
        since = params.get("since")
        until = params.get("until")
        if since is not None and until is not None:
            since_ts = _parse_timestamp_to_epoch(since)
            until_ts = _parse_timestamp_to_epoch(until)
            if (until_ts - since_ts) > _MAX_OHLCV_RANGE:
                raise ValidationError(
                    "OHLCV queries are limited to a maximum range of 31 days. "
                    "Please narrow your time range."
                )
        return params

    def as_dict(self) -> list[dict[str, Any]]:
        """Execute query and return results as list of dictionaries."""
        params = self._build_params()
        params["format"] = "json"
        return self._client._request(
            "GET", self._endpoint, params=params, response_key="data"
        )

    def as_df(self, library: str = "pandas") -> Any:
        """Execute query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    def as_file(self, path: str, format: str | None = None) -> None:
        """Execute query and save results to file."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                format = "json"
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
                )

        if format not in ("csv", "parquet", "json"):
            raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")

        params = self._build_params()

        if format == "json":
            import json as _json
            results = self.as_dict()
            with open(path, "w") as f:
                _json.dump(results, f, indent=2)
        else:
            params["format"] = format
            self._client._request("GET", self._endpoint, params=params, output_file=path)

    def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        response = self._client._client.get(self._endpoint, params=params)
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()

        return LinkInfo(
            filename=data["filename"],
            link=data["link"],
            expiry=data["expiry"],
            size=data["size"],
        )

    def calculate_cost(self) -> "CostEstimate":
        """Estimate the cost of this query without executing it."""
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    def __repr__(self) -> str:
        return f"OHLCVQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r})"


class AsyncTradeQueryBuilder:
    """
    Async builder for raw trade tick data from Binance.

    CSV and Parquet formats only — JSON is not supported.
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}

    def _copy_with(self, **updates: Any) -> "AsyncTradeQueryBuilder":
        new_builder = AsyncTradeQueryBuilder(self._client, self._endpoint, self._params.copy())
        for key, value in updates.items():
            if value is not None:
                new_builder._params[key] = value
        return new_builder

    def token(self, symbol: str) -> "AsyncTradeQueryBuilder":
        """Set the token symbol (e.g. 'BTC', 'ETH')."""
        return self._copy_with(token=symbol)

    def start_time(self, timestamp: str) -> "AsyncTradeQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "AsyncTradeQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "AsyncTradeQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    def skip_id(self, trade_id: int) -> "AsyncTradeQueryBuilder":
        """Skip trades with ID <= trade_id (for pagination)."""
        return self._copy_with(skip_id=trade_id)

    def _build_params(self) -> dict[str, Any]:
        params = self._params.copy()
        since = params.get("since")
        until = params.get("until")
        if since is not None and until is not None:
            since_ts = _parse_timestamp_to_epoch(since)
            until_ts = _parse_timestamp_to_epoch(until)
            if (until_ts - since_ts) > _MAX_RAW_TRADES_RANGE:
                raise ValidationError(
                    "Raw trades queries are limited to a maximum range of 7 days. "
                    "Please narrow your time range."
                )
        return params

    async def as_dict(self) -> list[dict[str, Any]]:
        """Not supported for raw trades (CSV/Parquet only)."""
        raise ValidationError("Raw trades do not support JSON format")

    async def as_df(self, library: str = "pandas") -> Any:
        """Execute query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return await self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    async def as_file(self, path: str, format: str | None = None) -> None:
        """Execute query and save results to file (CSV or Parquet only)."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                raise ValidationError("Raw trades do not support JSON format")
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet) or specify format explicitly."
                )

        if format == "json":
            raise ValidationError("Raw trades do not support JSON format")

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        await self._client._request("GET", self._endpoint, params=params, output_file=path)

    async def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        response = await self._client._client.get(self._endpoint, params=params)
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return LinkInfo(
            filename=data["filename"],
            link=data["link"],
            expiry=data["expiry"],
            size=data["size"],
        )

    async def calculate_cost(self) -> "CostEstimate":
        """Estimate the cost of this query without executing it."""
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = await self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    def __repr__(self) -> str:
        return f"AsyncTradeQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r})"


class AsyncExchangeDataQueryBuilder:
    """
    Async builder for exchange data endpoints (book depth, open interest, funding rate, long/short ratios).

    CSV and Parquet formats only — JSON is not supported.
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
        max_range: int | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}
        self._max_range = max_range

    def _copy_with(self, **updates: Any) -> "AsyncExchangeDataQueryBuilder":
        new_builder = AsyncExchangeDataQueryBuilder(
            self._client, self._endpoint, self._params.copy(), self._max_range,
        )
        for key, value in updates.items():
            if value is not None:
                new_builder._params[key] = value
        return new_builder

    def token(self, symbol: str) -> "AsyncExchangeDataQueryBuilder":
        """Set the token symbol (e.g. 'BTC', 'ETH')."""
        return self._copy_with(token=symbol)

    def start_time(self, timestamp: str) -> "AsyncExchangeDataQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "AsyncExchangeDataQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "AsyncExchangeDataQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    def _build_params(self) -> dict[str, Any]:
        params = self._params.copy()
        if self._max_range is not None:
            since = params.get("since")
            until = params.get("until")
            if since is not None and until is not None:
                since_ts = _parse_timestamp_to_epoch(since)
                until_ts = _parse_timestamp_to_epoch(until)
                if (until_ts - since_ts) > self._max_range:
                    days = self._max_range // (24 * 3600)
                    raise ValidationError(
                        f"This query is limited to a maximum range of {days} days. "
                        "Please narrow your time range."
                    )
        return params

    async def as_dict(self) -> list[dict[str, Any]]:
        """Not supported for exchange data (CSV/Parquet only)."""
        raise ValidationError("Exchange data endpoints do not support JSON format")

    async def as_df(self, library: str = "pandas") -> Any:
        """Execute query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return await self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    async def as_file(self, path: str, format: str | None = None) -> None:
        """Execute query and save results to file (CSV or Parquet only)."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                raise ValidationError("Exchange data endpoints do not support JSON format")
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet) or specify format explicitly."
                )

        if format == "json":
            raise ValidationError("Exchange data endpoints do not support JSON format")

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        await self._client._request("GET", self._endpoint, params=params, output_file=path)

    async def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        response = await self._client._client.get(self._endpoint, params=params)
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return LinkInfo(
            filename=data["filename"],
            link=data["link"],
            expiry=data["expiry"],
            size=data["size"],
        )

    async def calculate_cost(self) -> "CostEstimate":
        """Estimate the cost of this query without executing it."""
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = await self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    def __repr__(self) -> str:
        return f"AsyncExchangeDataQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r})"


class AsyncOHLCVQueryBuilder:
    """
    Async builder for OHLCV candle data from Binance.

    Supports all formats (JSON, CSV, Parquet).
    Maximum time range: 31 days.
    """

    def __init__(
        self,
        client: "BaseClient",
        endpoint: str,
        initial_params: dict[str, Any] | None = None,
    ):
        self._client = client
        self._endpoint = endpoint
        self._params: dict[str, Any] = initial_params or {}

    def _copy_with(self, **updates: Any) -> "AsyncOHLCVQueryBuilder":
        new_builder = AsyncOHLCVQueryBuilder(self._client, self._endpoint, self._params.copy())
        for key, value in updates.items():
            if value is not None:
                new_builder._params[key] = value
        return new_builder

    def token(self, symbol: str) -> "AsyncOHLCVQueryBuilder":
        """Set the token symbol (e.g. 'BTC', 'ETH')."""
        return self._copy_with(token=symbol)

    def start_time(self, timestamp: str) -> "AsyncOHLCVQueryBuilder":
        """Set the starting time (ISO format or Unix timestamp)."""
        return self._copy_with(since=timestamp)

    def end_time(self, timestamp: str) -> "AsyncOHLCVQueryBuilder":
        """Set the ending time (ISO format or Unix timestamp)."""
        return self._copy_with(until=timestamp)

    def time_range(self, start: str, end: str) -> "AsyncOHLCVQueryBuilder":
        """Set both start and end times."""
        return self._copy_with(since=start, until=end)

    def window(self, size: str) -> "AsyncOHLCVQueryBuilder":
        """Set the candle window size (e.g. '1h', '4h', '1d').

        Valid values: '1m', '5m', '15m', '30m', '1h', '4h', '1d'.
        """
        if size not in _VALID_OHLCV_WINDOWS:
            valid = ", ".join(sorted(_VALID_OHLCV_WINDOWS))
            raise ValidationError(f"Invalid window '{size}'. Must be one of: {valid}")
        return self._copy_with(window=size)

    def _build_params(self) -> dict[str, Any]:
        params = self._params.copy()
        since = params.get("since")
        until = params.get("until")
        if since is not None and until is not None:
            since_ts = _parse_timestamp_to_epoch(since)
            until_ts = _parse_timestamp_to_epoch(until)
            if (until_ts - since_ts) > _MAX_OHLCV_RANGE:
                raise ValidationError(
                    "OHLCV queries are limited to a maximum range of 31 days. "
                    "Please narrow your time range."
                )
        return params

    async def as_dict(self) -> list[dict[str, Any]]:
        """Execute query and return results as list of dictionaries."""
        params = self._build_params()
        params["format"] = "json"
        return await self._client._request(
            "GET", self._endpoint, params=params, response_key="data"
        )

    async def as_df(self, library: str = "pandas") -> Any:
        """Execute query and return results as DataFrame."""
        if library not in ("pandas", "polars"):
            raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
        params = self._build_params()
        params["format"] = "parquet"
        return await self._client._request(
            "GET", self._endpoint, params=params, as_dataframe=library
        )

    async def as_file(self, path: str, format: str | None = None) -> None:
        """Execute query and save results to file."""
        if format is None:
            if path.endswith(".csv"):
                format = "csv"
            elif path.endswith(".parquet"):
                format = "parquet"
            elif path.endswith(".json"):
                format = "json"
            else:
                raise ValueError(
                    f"Cannot determine format from path '{path}'. "
                    "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
                )

        if format not in ("csv", "parquet", "json"):
            raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")

        params = self._build_params()

        if format == "json":
            import json as _json
            results = await self.as_dict()
            with open(path, "w") as f:
                _json.dump(results, f, indent=2)
        else:
            params["format"] = format
            await self._client._request("GET", self._endpoint, params=params, output_file=path)

    async def as_link(self, format: str = "csv") -> "LinkInfo":
        """Execute query and return a download link."""
        from .models import LinkInfo

        if format not in ("csv", "parquet"):
            raise ValueError(f"format must be 'csv' or 'parquet', got '{format}'")

        params = self._build_params()
        params["format"] = format
        params["link"] = "true"

        response = await self._client._client.get(self._endpoint, params=params)
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return LinkInfo(
            filename=data["filename"],
            link=data["link"],
            expiry=data["expiry"],
            size=data["size"],
        )

    async def calculate_cost(self) -> "CostEstimate":
        """Estimate the cost of this query without executing it."""
        from urllib.parse import urlencode
        from .models import CostEstimate

        params = self._build_params()
        query_string = urlencode(params)
        query_path = f"/v1{self._endpoint}"
        if query_string:
            query_path += f"?{query_string}"

        response = await self._client._client.post(
            "/calculate-cost",
            json={"query": query_path},
        )
        if response.status_code >= 400:
            self._client._handle_error_response(response)
        data = response.json()
        return CostEstimate(
            query=data["query"],
            cost=data["cost"],
            quota_remaining=data["quota_remaining"],
            quota_remaining_after=data["quota_remaining_after"],
            breakdown=data.get("breakdown"),
        )

    def __repr__(self) -> str:
        return f"AsyncOHLCVQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r})"
