"""Cointime Economics — dual-backend cointime metrics.

Supports two connection paths:

1. **Default (Supabase)** — uses the hosted Supabase project via the
   PostgREST API with a publishable API key (safe to embed).
2. **Custom (asyncpg)** — direct Postgres connection when the caller
   provides a ``database_url``.
"""

import asyncio
import datetime
import logging
import ssl
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Coroutine, Dict, List, Optional, Tuple

import asyncpg
from supabase import Client as SupabaseClient
from supabase import create_client

from boxtime.models.cointime.cointime_data_point import CointimeDataPoint
from boxtime.models.cointime.cointime_series import CointimeSeries

logger = logging.getLogger(__name__)

# ── Supabase defaults (publishable key — safe to embed) ──────────────
DEFAULT_SUPABASE_URL = "https://eerivxcmacsptlyzuwxs.supabase.co"
DEFAULT_SUPABASE_KEY = "sb_publishable_aqOcj-SJrVA5mz5LcuElZw_dRMKQ0CU"

# PostgREST page size used for paginated fetches.
_PAGE_SIZE = 1000


# ── Helpers ───────────────────────────────────────────────────────────

def _make_ssl_context() -> ssl.SSLContext:
    """Create an SSL context for Supabase pooler connections."""
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    return ctx


def _ts_to_date(ts_ms: int) -> datetime.date:
    """Convert a millisecond-epoch timestamp to a :class:`datetime.date`."""
    return datetime.datetime.fromtimestamp(
        ts_ms / 1000, tz=datetime.timezone.utc
    ).date()


class Cointime:
    """Cointime Economics calculator backed by Supabase or Postgres.

    Reads pre-computed coinblocks created/destroyed/stored and daily
    ERG/USD prices from a database populated by the boxtime indexer.

    **Default (Supabase)**::

        ct = Cointime()

    **Custom Postgres**::

        ct = Cointime(database_url="postgresql://user:pass@host:5432/db")

    :param database_url: If provided, connect directly to Postgres via
        ``asyncpg`` (ignores *supabase_url* / *supabase_key*).
    :param supabase_url: Supabase project URL.
    :param supabase_key: Supabase publishable API key.
    """

    def __init__(
        self,
        database_url: Optional[str] = None,
        *,
        supabase_url: str = DEFAULT_SUPABASE_URL,
        supabase_key: str = DEFAULT_SUPABASE_KEY,
    ):
        self._database_url = database_url
        self._supabase_url = supabase_url
        self._supabase_key = supabase_key

        # Backend state — only one will be initialised.
        self._pool: Optional[asyncpg.Pool] = None  # asyncpg path
        self._sb: Optional[SupabaseClient] = None   # supabase path

    @property
    def _use_asyncpg(self) -> bool:
        return self._database_url is not None

    # ------------------------------------------------------------------
    # Internal infrastructure
    # ------------------------------------------------------------------

    async def _get_pool(self) -> asyncpg.Pool:
        """Lazy-init the asyncpg connection pool."""
        if self._pool is None:
            self._pool = await asyncpg.create_pool(
                self._database_url,
                ssl=_make_ssl_context(),
                min_size=1,
                max_size=10,
            )
        return self._pool

    def _get_sb(self) -> SupabaseClient:
        """Lazy-init the Supabase client."""
        if self._sb is None:
            self._sb = create_client(self._supabase_url, self._supabase_key)
        return self._sb

    @staticmethod
    def _run_sync(coro: Coroutine) -> Any:
        """Run an async coroutine from sync code, safe in any context."""
        with ThreadPoolExecutor(max_workers=1) as pool:
            return pool.submit(asyncio.run, coro).result()

    # ------------------------------------------------------------------
    # Supabase helpers
    # ------------------------------------------------------------------

    def _sb_fetch_single(
        self, table: str, columns: str, **eq_filters: Any
    ) -> Optional[Dict]:
        """Fetch a single row via PostgREST.  Returns *None* on 404."""
        sb = self._get_sb()
        q = sb.table(table).select(columns)
        for col, val in eq_filters.items():
            q = q.eq(col, val)
        resp = q.maybe_single().execute()
        return resp.data

    def _sb_fetch_aggregate(
        self, table: str, select_expr: str,
        gte: Optional[Tuple[str, Any]] = None,
        lte: Optional[Tuple[str, Any]] = None,
    ) -> Optional[Dict]:
        """Fetch a single aggregate row via PostgREST."""
        sb = self._get_sb()
        q = sb.table(table).select(select_expr)
        if gte:
            q = q.gte(gte[0], gte[1])
        if lte:
            q = q.lte(lte[0], lte[1])
        resp = q.execute()
        if not resp.data:
            return None
        return resp.data[0]

    def _sb_fetch_many(
        self, table: str, columns: str,
        gte: Optional[Tuple[str, Any]] = None,
        lte: Optional[Tuple[str, Any]] = None,
        order: Optional[str] = None,
        desc: bool = False,
    ) -> List[Dict]:
        """Paginated fetch of multiple rows via PostgREST."""
        sb = self._get_sb()
        all_rows: List[Dict] = []
        offset = 0
        while True:
            q = sb.table(table).select(columns)
            if gte:
                q = q.gte(gte[0], gte[1])
            if lte:
                q = q.lte(lte[0], lte[1])
            if order:
                q = q.order(order, desc=desc)
            q = q.range(offset, offset + _PAGE_SIZE - 1)
            resp = q.execute()
            rows = resp.data or []
            all_rows.extend(rows)
            if len(rows) < _PAGE_SIZE:
                break
            offset += _PAGE_SIZE
        return all_rows

    # ------------------------------------------------------------------
    # Coinblocks created
    # ------------------------------------------------------------------

    def coinblocks_created(self, height: int) -> Optional[int]:
        """Return coinblocks created at *height*."""
        if self._use_asyncpg:
            return self._run_sync(self._pg_coinblocks_created(height))
        row = self._sb_fetch_single("cointime", "cbc", height=height)
        if row is None:
            return None
        return row["cbc"]

    async def _pg_coinblocks_created(self, height: int) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT cbc FROM cointime WHERE height = $1", height
        )
        return row["cbc"] if row else None

    def total_coinblocks_created(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        """Return total coinblocks created over a height range."""
        if self._use_asyncpg:
            return self._run_sync(
                self._pg_total_coinblocks_created(start_height, end_height)
            )
        row = self._sb_fetch_aggregate(
            "cointime", "cbc.sum()",
            gte=("height", start_height), lte=("height", end_height),
        )
        if row is None or row.get("sum") is None:
            return None
        return row["sum"]

    async def _pg_total_coinblocks_created(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT SUM(cbc) AS total FROM cointime "
            "WHERE height BETWEEN $1 AND $2",
            start_height, end_height,
        )
        if row is None or row["total"] is None:
            return None
        return row["total"]

    # ------------------------------------------------------------------
    # Coinblocks destroyed
    # ------------------------------------------------------------------

    def coinblocks_destroyed(self, height: int) -> Optional[int]:
        """Return coinblocks destroyed at *height*."""
        if self._use_asyncpg:
            return self._run_sync(self._pg_coinblocks_destroyed(height))
        row = self._sb_fetch_single("cointime", "cbd", height=height)
        if row is None:
            return None
        return row["cbd"]

    async def _pg_coinblocks_destroyed(self, height: int) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT cbd FROM cointime WHERE height = $1", height
        )
        return row["cbd"] if row else None

    def total_coinblocks_destroyed(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        """Return total coinblocks destroyed over a height range."""
        if self._use_asyncpg:
            return self._run_sync(
                self._pg_total_coinblocks_destroyed(start_height, end_height)
            )
        row = self._sb_fetch_aggregate(
            "cointime", "cbd.sum()",
            gte=("height", start_height), lte=("height", end_height),
        )
        if row is None or row.get("sum") is None:
            return None
        return row["sum"]

    async def _pg_total_coinblocks_destroyed(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT SUM(cbd) AS total FROM cointime "
            "WHERE height BETWEEN $1 AND $2",
            start_height, end_height,
        )
        if row is None or row["total"] is None:
            return None
        return row["total"]

    # ------------------------------------------------------------------
    # Coinblocks stored
    # ------------------------------------------------------------------

    def coinblocks_stored(self, height: int) -> Optional[int]:
        """Return coinblocks stored at *height*."""
        if self._use_asyncpg:
            return self._run_sync(self._pg_coinblocks_stored(height))
        row = self._sb_fetch_single("cointime", "cbs", height=height)
        if row is None:
            return None
        return row["cbs"]

    async def _pg_coinblocks_stored(self, height: int) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT cbs FROM cointime WHERE height = $1", height
        )
        return row["cbs"] if row else None

    def total_coinblocks_stored(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        """Return total coinblocks stored over a height range."""
        if self._use_asyncpg:
            return self._run_sync(
                self._pg_total_coinblocks_stored(start_height, end_height)
            )
        row = self._sb_fetch_aggregate(
            "cointime", "cbs.sum()",
            gte=("height", start_height), lte=("height", end_height),
        )
        if row is None or row.get("sum") is None:
            return None
        return row["sum"]

    async def _pg_total_coinblocks_stored(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT SUM(cbs) AS total FROM cointime "
            "WHERE height BETWEEN $1 AND $2",
            start_height, end_height,
        )
        if row is None or row["total"] is None:
            return None
        return row["total"]

    # ------------------------------------------------------------------
    # Price
    # ------------------------------------------------------------------

    def get_price(self, date: datetime.date) -> Optional[float]:
        """Return the daily ERG/USD price for *date*."""
        if self._use_asyncpg:
            return self._run_sync(self._pg_get_price(date))
        row = self._sb_fetch_single(
            "erg_prices", "price_usd", date=date.isoformat()
        )
        if row is None:
            return None
        return float(row["price_usd"])

    async def _pg_get_price(self, date: datetime.date) -> Optional[float]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT price_usd FROM erg_prices WHERE date = $1", date
        )
        return float(row["price_usd"]) if row else None

    def get_price_range(
        self,
        start_date: datetime.date,
        end_date: datetime.date,
    ) -> Optional[List[Tuple[datetime.date, float]]]:
        """Return daily ERG/USD prices between two dates (inclusive)."""
        if self._use_asyncpg:
            return self._run_sync(
                self._pg_get_price_range(start_date, end_date)
            )
        rows = self._sb_fetch_many(
            "erg_prices", "date,price_usd",
            gte=("date", start_date.isoformat()),
            lte=("date", end_date.isoformat()),
            order="date",
        )
        if not rows:
            return None
        return [
            (datetime.date.fromisoformat(r["date"]), float(r["price_usd"]))
            for r in rows
        ]

    async def _pg_get_price_range(
        self,
        start_date: datetime.date,
        end_date: datetime.date,
    ) -> Optional[List[Tuple[datetime.date, float]]]:
        pool = await self._get_pool()
        rows = await pool.fetch(
            "SELECT date, price_usd FROM erg_prices "
            "WHERE date BETWEEN $1 AND $2 ORDER BY date",
            start_date, end_date,
        )
        if not rows:
            return None
        return [(r["date"], float(r["price_usd"])) for r in rows]

    # ------------------------------------------------------------------
    # Convenience get_* methods (per-height with optional daily price)
    # ------------------------------------------------------------------

    def get_coinblocks_created(
        self,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Return coinblocks created for a height range.

        When ``with_price=False`` (default), returns all blocks in the
        range with ``price=None``.

        When ``with_price=True``, returns one block per day (the first
        block of each day) enriched with ERG/USD price from ``erg_prices``.
        """
        return self._get_coinblocks("cbc", start_height, end_height, with_price=with_price)

    def get_coinblocks_destroyed(
        self,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Return coinblocks destroyed for a height range.

        See :meth:`get_coinblocks_created` for ``with_price`` behavior.
        """
        return self._get_coinblocks("cbd", start_height, end_height, with_price=with_price)

    def get_coinblocks_stored(
        self,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Return coinblocks stored for a height range.

        See :meth:`get_coinblocks_created` for ``with_price`` behavior.
        """
        return self._get_coinblocks("cbs", start_height, end_height, with_price=with_price)

    def _get_coinblocks(
        self,
        metric: str,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Shared dispatcher for coinblock queries."""
        if self._use_asyncpg:
            return self._run_sync(
                self._pg_get_coinblocks(metric, start_height, end_height, with_price=with_price)
            )
        return self._sb_get_coinblocks(metric, start_height, end_height, with_price=with_price)

    # ── asyncpg path ──────────────────────────────────────────────────

    async def _pg_get_coinblocks(
        self,
        metric: str,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Fetch coinblocks from Postgres.

        If with_price=False: returns all blocks in range, price=None.
        If with_price=True: returns one block per day with price enrichment.
        """
        pool = await self._get_pool()

        # Fetch all rows in height range
        query = f"""
            SELECT height, timestamp, {metric} AS value
            FROM cointime
            WHERE height BETWEEN $1 AND $2
            ORDER BY height
        """
        rows = await pool.fetch(query, start_height, end_height)
        if not rows:
            return None

        if not with_price:
            # Return all blocks without price
            return CointimeSeries(
                heights=[r["height"] for r in rows],
                timestamps=[r["timestamp"] for r in rows],
                values=[r["value"] for r in rows],
                prices=None,
            )

        # Group by day - pick first block of each day
        daily_blocks: Dict[datetime.date, asyncpg.Record] = {}
        for row in rows:
            date = _ts_to_date(row["timestamp"])
            if date not in daily_blocks:
                daily_blocks[date] = row

        if not daily_blocks:
            return None

        # Fetch prices for the date range
        dates = sorted(daily_blocks.keys())
        price_rows = await pool.fetch(
            "SELECT date, price_usd FROM erg_prices WHERE date BETWEEN $1 AND $2",
            dates[0],
            dates[-1],
        )
        prices: Dict[datetime.date, float] = {
            r["date"]: float(r["price_usd"]) for r in price_rows
        }

        # Check if all prices are available
        all_prices_available = all(date in prices for date in dates)
        prices_list: Optional[List[float]] = None
        if all_prices_available:
            prices_list = [prices[date] for date in dates]

        # Build result
        heights: List[int] = []
        timestamps: List[int] = []
        values: List[int] = []
        for date in dates:
            row = daily_blocks[date]
            heights.append(row["height"])
            timestamps.append(row["timestamp"])
            values.append(row["value"])

        return CointimeSeries(
            heights=heights,
            timestamps=timestamps,
            values=values,
            prices=prices_list,
        )

    # ── Supabase path ─────────────────────────────────────────────────

    def _sb_get_coinblocks(
        self,
        metric: str,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Fetch coinblocks from Supabase.

        If with_price=False: returns all blocks in range, price=None.
        If with_price=True: returns one block per day with price enrichment.
        """
        # Fetch all rows in height range
        columns = f"height,timestamp,{metric}"
        ct_rows = self._sb_fetch_many(
            "cointime", columns,
            gte=("height", start_height),
            lte=("height", end_height),
            order="height",
        )
        if not ct_rows:
            return None

        if not with_price:
            # Return all blocks without price
            return CointimeSeries(
                heights=[r["height"] for r in ct_rows],
                timestamps=[r["timestamp"] for r in ct_rows],
                values=[r[metric] for r in ct_rows],
                prices=None,
            )

        # Group by day - pick first block of each day
        daily: Dict[datetime.date, Dict] = {}
        for row in ct_rows:
            date = _ts_to_date(row["timestamp"])
            if date not in daily:
                daily[date] = row

        if not daily:
            return None

        # Fetch prices for the date range
        dates = sorted(daily.keys())
        price_rows = self._sb_fetch_many(
            "erg_prices", "date,price_usd",
            gte=("date", dates[0].isoformat()),
            lte=("date", dates[-1].isoformat()),
            order="date",
        )
        prices: Dict[datetime.date, float] = {
            datetime.date.fromisoformat(r["date"]): float(r["price_usd"])
            for r in price_rows
        }

        # Check if all prices are available
        all_prices_available = all(date in prices for date in dates)
        prices_list: Optional[List[float]] = None
        if all_prices_available:
            prices_list = [prices[date] for date in dates]

        # Build result
        heights: List[int] = []
        timestamps: List[int] = []
        values: List[int] = []
        for date in dates:
            row = daily[date]
            heights.append(row["height"])
            timestamps.append(row["timestamp"])
            values.append(row[metric])

        return CointimeSeries(
            heights=heights,
            timestamps=timestamps,
            values=values,
            prices=prices_list,
        )

    # ------------------------------------------------------------------
    # Cumulative total coinblocks (TCBC, TCBD, TCBS)
    # ------------------------------------------------------------------

    def get_total_coinblocks_created(
        self,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Return cumulative coinblocks created (TCBC) for a height range.

        Returns the absolute cumulative sum of CBC from genesis to each
        block height in the range.

        When ``with_price=False`` (default), returns all blocks in the
        range with ``price=None``.

        When ``with_price=True``, returns one block per day (the first
        block of each day) enriched with ERG/USD price from ``erg_prices``.
        """
        return self._get_total_coinblocks("cbc", start_height, end_height, with_price=with_price)

    def get_total_coinblocks_destroyed(
        self,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Return cumulative coinblocks destroyed (TCBD) for a height range.

        See :meth:`get_total_coinblocks_created` for details.
        """
        return self._get_total_coinblocks("cbd", start_height, end_height, with_price=with_price)

    def get_total_coinblocks_stored(
        self,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Return cumulative coinblocks stored (TCBS) for a height range.

        See :meth:`get_total_coinblocks_created` for details.
        """
        return self._get_total_coinblocks("cbs", start_height, end_height, with_price=with_price)

    def _get_total_coinblocks(
        self,
        metric: str,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Shared dispatcher for cumulative coinblock queries."""
        if self._use_asyncpg:
            return self._run_sync(
                self._pg_get_total_coinblocks(metric, start_height, end_height, with_price=with_price)
            )
        return self._sb_get_total_coinblocks(metric, start_height, end_height, with_price=with_price)

    async def _pg_get_total_coinblocks(
        self,
        metric: str,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Fetch cumulative coinblocks from Postgres."""
        pool = await self._get_pool()

        # Get baseline (sum before start_height)
        baseline_row = await pool.fetchrow(
            f"SELECT SUM({metric}) AS baseline FROM cointime WHERE height < $1",
            start_height
        )
        baseline = baseline_row["baseline"] or 0

        # Fetch all rows in height range
        query = f"""
            SELECT height, timestamp, {metric} AS value
            FROM cointime
            WHERE height BETWEEN $1 AND $2
            ORDER BY height
        """
        rows = await pool.fetch(query, start_height, end_height)
        if not rows:
            return None

        # Compute cumulative values
        heights = [r["height"] for r in rows]
        timestamps = [r["timestamp"] for r in rows]
        cumulative_values: List[int] = []
        running_sum = baseline
        for r in rows:
            running_sum += r["value"]
            cumulative_values.append(running_sum)

        if not with_price:
            return CointimeSeries(
                heights=heights,
                timestamps=timestamps,
                values=cumulative_values,
                prices=None,
            )

        # Group by day - pick first block of each day
        daily_indices: Dict[datetime.date, int] = {}
        for i, r in enumerate(rows):
            date = _ts_to_date(r["timestamp"])
            if date not in daily_indices:
                daily_indices[date] = i

        if not daily_indices:
            return None

        dates = sorted(daily_indices.keys())

        # Fetch prices for the date range
        price_rows = await pool.fetch(
            "SELECT date, price_usd FROM erg_prices WHERE date BETWEEN $1 AND $2",
            dates[0],
            dates[-1],
        )
        prices: Dict[datetime.date, float] = {
            r["date"]: float(r["price_usd"]) for r in price_rows
        }

        # Check if all prices are available
        all_prices_available = all(date in prices for date in dates)
        prices_list: Optional[List[float]] = None
        if all_prices_available:
            prices_list = [prices[date] for date in dates]

        # Build result with daily grouping
        filtered_heights: List[int] = []
        filtered_timestamps: List[int] = []
        filtered_values: List[int] = []
        for date in dates:
            idx = daily_indices[date]
            filtered_heights.append(heights[idx])
            filtered_timestamps.append(timestamps[idx])
            filtered_values.append(cumulative_values[idx])

        return CointimeSeries(
            heights=filtered_heights,
            timestamps=filtered_timestamps,
            values=filtered_values,
            prices=prices_list,
        )

    def _sb_get_total_coinblocks(
        self,
        metric: str,
        start_height: int,
        end_height: int,
        *,
        with_price: bool = False,
    ) -> Optional[CointimeSeries]:
        """Fetch cumulative coinblocks from Supabase."""
        # Get baseline (sum before start_height)
        baseline_row = self._sb_fetch_aggregate(
            "cointime", f"{metric}.sum()",
            lte=("height", start_height - 1),
        )
        baseline = baseline_row.get("sum") or 0 if baseline_row else 0

        # Fetch all rows in height range
        columns = f"height,timestamp,{metric}"
        ct_rows = self._sb_fetch_many(
            "cointime", columns,
            gte=("height", start_height),
            lte=("height", end_height),
            order="height",
        )
        if not ct_rows:
            return None

        # Compute cumulative values
        heights = [r["height"] for r in ct_rows]
        timestamps = [r["timestamp"] for r in ct_rows]
        cumulative_values: List[int] = []
        running_sum = baseline
        for r in ct_rows:
            running_sum += r[metric]
            cumulative_values.append(running_sum)

        if not with_price:
            return CointimeSeries(
                heights=heights,
                timestamps=timestamps,
                values=cumulative_values,
                prices=None,
            )

        # Group by day - pick first block of each day
        daily_indices: Dict[datetime.date, int] = {}
        for i, r in enumerate(ct_rows):
            date = _ts_to_date(r["timestamp"])
            if date not in daily_indices:
                daily_indices[date] = i

        if not daily_indices:
            return None

        dates = sorted(daily_indices.keys())

        # Fetch prices for the date range
        price_rows = self._sb_fetch_many(
            "erg_prices", "date,price_usd",
            gte=("date", dates[0].isoformat()),
            lte=("date", dates[-1].isoformat()),
            order="date",
        )
        prices: Dict[datetime.date, float] = {
            datetime.date.fromisoformat(r["date"]): float(r["price_usd"])
            for r in price_rows
        }

        # Check if all prices are available
        all_prices_available = all(date in prices for date in dates)
        prices_list: Optional[List[float]] = None
        if all_prices_available:
            prices_list = [prices[date] for date in dates]

        # Build result with daily grouping
        filtered_heights: List[int] = []
        filtered_timestamps: List[int] = []
        filtered_values: List[int] = []
        for date in dates:
            idx = daily_indices[date]
            filtered_heights.append(heights[idx])
            filtered_timestamps.append(timestamps[idx])
            filtered_values.append(cumulative_values[idx])

        return CointimeSeries(
            heights=filtered_heights,
            timestamps=filtered_timestamps,
            values=filtered_values,
            prices=prices_list,
        )

    # ------------------------------------------------------------------
    # Utility
    # ------------------------------------------------------------------

    def get_max_height(self) -> Optional[int]:
        """Return the maximum block height available in the database."""
        if self._use_asyncpg:
            return self._run_sync(self._pg_get_max_height())
        row = self._sb_fetch_aggregate("cointime", "height.max()")
        if row is None or row.get("max") is None:
            return None
        return row["max"]

    async def _pg_get_max_height(self) -> Optional[int]:
        pool = await self._get_pool()
        row = await pool.fetchrow(
            "SELECT MAX(height) AS max_h FROM cointime"
        )
        if row is None or row["max_h"] is None:
            return None
        return row["max_h"]

    def close(self) -> None:
        """Close the connection pool / client."""
        if self._pool is not None:
            self._run_sync(self._pool.close())
            self._pool = None
        self._sb = None
