"""Cointime Economics — coinblocks created metrics."""

import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Coroutine, Dict, List, Optional

import aiohttp

from boxtime.clients.coingecko_client import CoinGeckoClient
from boxtime.clients.ergo_node_client import ErgoNodeClient
from boxtime.models.cointime.cointime_data_point import CointimeDataPoint
from boxtime.models.price.price import PricePoint

logger = logging.getLogger(__name__)

NANO_ERG_FACTOR = 1_000_000_000


def _nano_to_erg(nano: int) -> float:
    """Convert a nanoERG amount to ERG."""
    return nano / NANO_ERG_FACTOR


class Cointime:
    """
    Cointime Economics calculator with concurrent multi-node support.

    Uses async I/O internally for parallelism while exposing a
    synchronous API.  Multiple node URLs enable round-robin load
    balancing across Ergo nodes.

    :param node_urls: List of Ergo node base URLs for round-robin
        load balancing.
    :param coingecko_api_key: Optional CoinGecko Demo API key.  When
        provided, price-denominated methods become available.
    :param max_concurrent: Maximum number of concurrent requests.
    """

    def __init__(
        self,
        node_urls: Optional[List[str]] = None,
        coingecko_api_key: Optional[str] = None,
        max_concurrent: int = 50,
    ):
        self._node_urls = node_urls or ["http://127.0.0.1:9053"]
        self._max_concurrent = max_concurrent
        self._price_client: Optional[CoinGeckoClient] = (
            CoinGeckoClient(coingecko_api_key) if coingecko_api_key else None
        )

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def price_client(self) -> Optional[CoinGeckoClient]:
        """The underlying CoinGecko price client, or ``None``."""
        return self._price_client

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

    @staticmethod
    def _run_concurrent(coro: Coroutine) -> Any:
        """Run an async coroutine from sync code, safe in any context.

        Spins up a background thread with a fresh event loop to avoid
        conflicts with already-running loops (Jupyter, FastAPI, etc.).
        """
        with ThreadPoolExecutor(max_workers=1) as pool:
            return pool.submit(asyncio.run, coro).result()

    def _node_url_for(self, height: int) -> str:
        """Round-robin node selection based on height."""
        return self._node_urls[height % len(self._node_urls)]

    def _make_client(
        self, session: aiohttp.ClientSession, node_url: str
    ) -> ErgoNodeClient:
        """Create an ErgoNodeClient bound to a session and node URL."""
        return ErgoNodeClient(session, node_url)

    async def _async_with_session(
        self, coro_fn: Callable[[aiohttp.ClientSession], Coroutine],
    ) -> Any:
        """Create a temporary session, run *coro_fn(session)*, close."""
        async with aiohttp.ClientSession() as session:
            return await coro_fn(session)

    # ------------------------------------------------------------------
    # Internal async helpers
    # ------------------------------------------------------------------

    async def _async_coinblocks_created(
        self, client: ErgoNodeClient, height: int
    ) -> Optional[int]:
        """Single async CBC fetch."""
        emission = await client.get_emission_at(height)
        if emission is None:
            return None
        return emission.total_coins_issued

    async def _async_coinblocks_destroyed(
        self, client: ErgoNodeClient, height: int
    ) -> Optional[int]:
        """Single async CBD fetch with nested transaction fan-out."""
        tx_ids = await client.get_block_transaction_ids(height)
        if tx_ids is None:
            return None
        # Concurrently fetch all transactions for this height
        txs = await asyncio.gather(
            *(client.get_transaction(tx_id) for tx_id in tx_ids)
        )
        total_cbd = 0
        for tx in txs:
            if tx is None:
                return None
            for inp in tx.inputs:
                if inp.inclusion_height is None:
                    continue
                lifespan = height - inp.inclusion_height
                total_cbd += inp.value * lifespan
        return total_cbd

    async def _async_coinblocks_stored(
        self, client: ErgoNodeClient, height: int
    ) -> Optional[int]:
        """Single async CBS fetch (CBC - CBD in parallel)."""
        cbc, cbd = await asyncio.gather(
            self._async_coinblocks_created(client, height),
            self._async_coinblocks_destroyed(client, height),
        )
        if cbc is None or cbd is None:
            return None
        return cbc - cbd

    async def _fetch_concurrent(
        self,
        start_height: int,
        end_height: int,
        compute_fn: Callable[..., Coroutine],
    ) -> Optional[List[int]]:
        """Run *compute_fn* for each height concurrently across nodes.

        Creates an ``aiohttp.ClientSession`` with a connection pool
        sized to match the semaphore, dispatches tasks with round-robin
        node selection, and gathers results.
        """
        semaphore = asyncio.Semaphore(self._max_concurrent)
        connector = aiohttp.TCPConnector(limit=self._max_concurrent)

        async with aiohttp.ClientSession(connector=connector) as session:

            async def _task(height: int) -> Optional[int]:
                async with semaphore:
                    node_url = self._node_url_for(height)
                    client = self._make_client(session, node_url)
                    return await compute_fn(client, height)

            results = await asyncio.gather(
                *(_task(h) for h in range(start_height, end_height + 1)),
                return_exceptions=True,
            )

        # Check for failures
        for i, result in enumerate(results):
            if isinstance(result, BaseException):
                logger.warning(
                    "Failed at height %d: %s",
                    start_height + i,
                    result,
                )
                return None
            if result is None:
                logger.warning(
                    "Failed to fetch data at height %d",
                    start_height + i,
                )
                return None

        return results

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

    def _require_price_client(self) -> CoinGeckoClient:
        """Return the price client or raise if not configured."""
        if self._price_client is None:
            raise ValueError(
                "CoinGecko API key required for price methods"
            )
        return self._price_client

    def get_price(self) -> Optional[float]:
        """Return the current ERG/USD spot price."""
        return self._require_price_client().get_current_price("usd")

    def get_price_history(
        self, start_height: int, end_height: int
    ) -> Optional[List[PricePoint]]:
        """Return ERG/USD price history between two block heights.

        Fetches the chain slice to obtain timestamps, then queries
        CoinGecko for the corresponding time range.  Granularity is
        determined by CoinGecko (5-min / hourly / daily).
        """
        client = self._require_price_client()

        async def _fetch_chain_slice(session: aiohttp.ClientSession):
            node = self._make_client(session, self._node_urls[0])
            return await node.get_chain_slice(start_height, end_height)

        headers = self._run_concurrent(
            self._async_with_session(_fetch_chain_slice)
        )
        if not headers or len(headers) < 2:
            return None
        from_ts = headers[0].timestamp // 1000
        to_ts = headers[-1].timestamp // 1000
        history = client.get_price_history_range(from_ts, to_ts, "usd")
        if history is None:
            return None
        return history.prices

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

    def coinblocks_created(self, height: int) -> Optional[int]:
        """Return coinblocks created at *height*.

        Coinblocks created at a given block height equals the total
        circulating supply at that height (in nanoERGs).
        """

        async def _fetch(session: aiohttp.ClientSession):
            node_url = self._node_url_for(height)
            client = self._make_client(session, node_url)
            return await self._async_coinblocks_created(client, height)

        return self._run_concurrent(self._async_with_session(_fetch))

    def total_coinblocks_created(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        """Return total coinblocks created over a height range.

        Sums ``coinblocks_created`` for every height from *start_height*
        to *end_height* (inclusive).  Runs concurrently.
        """
        results = self._run_concurrent(
            self._fetch_concurrent(
                start_height, end_height, self._async_coinblocks_created
            )
        )
        if results is None:
            return None
        return sum(results)

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

    def coinblocks_destroyed(self, height: int) -> Optional[int]:
        """Return coinblocks destroyed at *height*.

        For each input spent in the block's transactions, the destroyed
        coinblocks equal ``input.value * (height - input.inclusion_height)``.
        The total CBD is the sum across all inputs.
        """

        async def _fetch(session: aiohttp.ClientSession):
            node_url = self._node_url_for(height)
            client = self._make_client(session, node_url)
            return await self._async_coinblocks_destroyed(client, height)

        return self._run_concurrent(self._async_with_session(_fetch))

    def total_coinblocks_destroyed(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        """Return total coinblocks destroyed over a height range.

        Sums ``coinblocks_destroyed`` for every height from *start_height*
        to *end_height* (inclusive).  Runs concurrently.
        """
        results = self._run_concurrent(
            self._fetch_concurrent(
                start_height, end_height, self._async_coinblocks_destroyed
            )
        )
        if results is None:
            return None
        return sum(results)

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

    def coinblocks_stored(self, height: int) -> Optional[int]:
        """Return coinblocks stored at *height*.

        CBS is the net coinblocks added at each block:
        ``CBS(h) = CBC(h) - CBD(h)``.
        Can be negative when destruction exceeds creation.
        """

        async def _fetch(session: aiohttp.ClientSession):
            node_url = self._node_url_for(height)
            client = self._make_client(session, node_url)
            return await self._async_coinblocks_stored(client, height)

        return self._run_concurrent(self._async_with_session(_fetch))

    def total_coinblocks_stored(
        self, start_height: int, end_height: int
    ) -> Optional[int]:
        """Return total coinblocks stored over a height range.

        Fetches both CBC and CBD per height in a single concurrent batch,
        then sums ``cbc - cbd`` across all heights.
        """
        results = self._run_concurrent(
            self._fetch_concurrent(
                start_height, end_height, self._async_coinblocks_stored
            )
        )
        if results is None:
            return None
        return sum(results)

    # ------------------------------------------------------------------
    # Convenience get_* methods (price-enriched)
    # ------------------------------------------------------------------

    def _build_cointime_data_points(
        self,
        start_height: int,
        end_height: int,
        compute_value: Callable[[int], Optional[int]],
    ) -> Optional[List[CointimeDataPoint]]:
        """Shared logic for the ``get_coinblocks_*`` convenience methods.

        Fetches the chain slice and price history for the given height
        range, then iterates CoinGecko price points.  Only price points
        whose timestamp exactly matches a block header timestamp are
        included in the result.
        """
        client = self._require_price_client()

        async def _fetch_chain_slice(session: aiohttp.ClientSession):
            node = self._make_client(session, self._node_urls[0])
            return await node.get_chain_slice(start_height, end_height)

        headers = self._run_concurrent(
            self._async_with_session(_fetch_chain_slice)
        )
        if not headers:
            return None

        # Build timestamp → height lookup from chain slice
        ts_to_height: Dict[int, int] = {
            h.timestamp: h.height for h in headers
        }

        # Fetch price history covering the same time range
        from_ts = headers[0].timestamp // 1000
        to_ts = headers[-1].timestamp // 1000
        history = client.get_price_history_range(from_ts, to_ts, "usd")
        if history is None or not history.prices:
            return None

        results: List[CointimeDataPoint] = []
        for price_point in history.prices:
            height = ts_to_height.get(price_point.timestamp)
            if height is None:
                continue
            value = compute_value(height)
            if value is None:
                continue
            results.append(
                CointimeDataPoint(
                    height=height,
                    timestamp=price_point.timestamp,
                    value=value,
                    price=price_point.price,
                )
            )

        return results if results else None

    def get_coinblocks_created(
        self, start_height: int, end_height: int
    ) -> Optional[List[CointimeDataPoint]]:
        """Return coinblocks created with ERG/USD price for a height range.

        Each :class:`CointimeDataPoint` bundles height, timestamp,
        coinblocks-created value, and the ERG/USD price at that
        timestamp.  Only heights whose block timestamp exactly matches
        a CoinGecko price timestamp are included.
        """
        return self._build_cointime_data_points(
            start_height, end_height, self.coinblocks_created
        )

    def get_coinblocks_destroyed(
        self, start_height: int, end_height: int
    ) -> Optional[List[CointimeDataPoint]]:
        """Return coinblocks destroyed with ERG/USD price for a height range.

        Each :class:`CointimeDataPoint` bundles height, timestamp,
        coinblocks-destroyed value, and the ERG/USD price at that
        timestamp.  Only heights whose block timestamp exactly matches
        a CoinGecko price timestamp are included.
        """
        return self._build_cointime_data_points(
            start_height, end_height, self.coinblocks_destroyed
        )

    def get_coinblocks_stored(
        self, start_height: int, end_height: int
    ) -> Optional[List[CointimeDataPoint]]:
        """Return coinblocks stored with ERG/USD price for a height range.

        Each :class:`CointimeDataPoint` bundles height, timestamp,
        coinblocks-stored value, and the ERG/USD price at that
        timestamp.  Only heights whose block timestamp exactly matches
        a CoinGecko price timestamp are included.
        """
        return self._build_cointime_data_points(
            start_height, end_height, self.coinblocks_stored
        )
