"""Databento data adapter — survivorship-free, point-in-time daily bars across futures/equities/etc.
Implements the DataProvider port.

The cost guard is the point: ALWAYS estimate cost via the metadata API and refuse any fetch above the
configured cap BEFORE downloading (a runaway download once cost $3.5k). databento is an optional extra.
The API key is read from DATABENTO_API_KEY by the client — never passed or logged here.
"""
from __future__ import annotations

import datetime as dt
from typing import TYPE_CHECKING

import numpy as np

from fxhnt.domain.models import AssetClass, Market, PriceSeries

if TYPE_CHECKING:
    import pandas as pd


def _to_utc_ts(value: object) -> "pd.Timestamp":
    """`pd.Timestamp(value)` normalized to UTC whether the input parses tz-NAIVE (localize) or tz-AWARE
    (convert). The single tzinfo guard reused everywhere OPRA timestamps are compared — a bare
    `pd.Timestamp(x).tz_localize("UTC")` raises TypeError on an already-aware input (the C1b clamp gap)."""
    import pandas as pd
    ts = pd.Timestamp(value)
    return ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC")


def _dataset_range_ends(rng: object) -> list[str]:
    """Every available-END value carried by a databento `metadata.get_dataset_range` response, across ALL
    real shapes (the 0.79 return type is `dict[str, str | dict[str, str]]`):
      - a top-level dict `{"start","end"}` with ISO-string values                → [rng["end"]]
      - a per-schema nested dict `{schema: {"start","end"}}` (values are dicts)   → one end per schema
      - a `DatasetRange`-like object exposing a `.end` attribute                  → [obj.end]
    Returns [] ONLY for a genuinely empty/malformed response (no end anywhere) so the caller raises
    `DatabentoRangeUnavailable` on nothing-published, never on a shape that actually carries the range."""
    if not isinstance(rng, dict):                       # DatasetRange-like object (attribute access)
        end = getattr(rng, "end", None)
        return [str(end)] if end else []
    ends: list[str] = []
    top = rng.get("end")
    if isinstance(top, str) and top:                    # top-level {"start","end"}
        ends.append(top)
    elif isinstance(top, dict):                         # defensive: {"end": {schema: end_str}}
        ends.extend(str(v) for v in top.values() if v)
    for v in rng.values():                              # per-schema nesting {schema: {"start","end"}}
        if isinstance(v, dict) and v.get("end"):
            ends.append(str(v["end"]))
    return ends


def _df_to_series(market: Market, df: "pd.DataFrame") -> PriceSeries:
    """Pure transform: an ohlcv-1d dataframe -> PriceSeries with NaN-guarded high/low."""
    dates = tuple(str(idx.date()) for idx in df.index)
    close = df["close"].to_numpy(dtype=float)
    high = df["high"].to_numpy(dtype=float) if "high" in df.columns else close
    low = df["low"].to_numpy(dtype=float) if "low" in df.columns else close
    high = np.where(np.isfinite(high), high, close)        # NaN bar -> flat (close)
    low = np.where(np.isfinite(low), low, close)
    return PriceSeries(market=market, dates=dates, close=close, high=high, low=low)


def _chain_from_definition_df(df: "pd.DataFrame") -> list["OptionContract"]:
    """OPRA `definition` df -> list[OptionContract]. `instrument_class` 'P'/'C'; strike/expiry pretty-scaled
    by to_df()."""
    from fxhnt.domain.models import OptionContract
    out: list[OptionContract] = []
    for row in df.itertuples(index=False):
        cls = str(getattr(row, "instrument_class"))
        right = "P" if cls.upper().startswith("P") else "C"
        out.append(OptionContract(
            osi_symbol=str(getattr(row, "raw_symbol")),
            expiry=getattr(row, "expiration").date(),
            strike=float(getattr(row, "strike_price")),
            right=right,
        ))
    return out


def _bars_from_ohlcv_df(df: "pd.DataFrame") -> dict[str, dict[str, float]]:
    """OPRA `ohlcv-1d` df (index=ts, col `symbol`, col `close`) -> {osi_symbol: {iso_date: close}}."""
    out: dict[str, dict[str, float]] = {}
    for idx, sym, close in zip(df.index, df["symbol"], df["close"], strict=True):
        out.setdefault(str(sym), {})[str(idx.date())] = float(close)
    return out


class DatabentoCostExceeded(RuntimeError):
    """Raised when an estimated fetch cost exceeds the configured cap — the spend circuit-breaker."""


class DatabentoRangeUnavailable(RuntimeError):
    """Raised when a requested option window is not (yet) fully available on OPRA — EITHER it falls entirely
    PAST the dataset's available end (nothing published through `as_of`; a `data_end_after_available_end` /
    `start >= end` clamp), OR the session IS within the published range but its schema has not fully settled
    (`data_schema_not_fully_available`, a still-forming intraday session). ONE clear, CATCHABLE signal in
    place of the raw databento 422s so the nightly freeze / step-back can degrade instead of crash."""


# A partial (still-settling) OPRA session is INSIDE the published range, so the C1a available-end clamp cannot
# prevent it — databento answers with a `422 data_schema_not_fully_available` at query time instead. This is a
# DIFFERENT 422 than the clamp's `data_end_after_available_end`. The freeze step-back needs ONE catchable type,
# so we normalize this specific 422 into DatabentoRangeUnavailable (and ONLY this one — any other client error
# must propagate raw, never be masked as a benign range-unavailable and silently degrade the freeze).
_SESSION_INCOMPLETE_MARKER = "data_schema_not_fully_available"


def _raise_if_session_unavailable(exc: Exception) -> None:
    """If `exc` is the databento `422 data_schema_not_fully_available` (a still-settling session), raise
    DatabentoRangeUnavailable FROM it; otherwise return so the caller re-raises the original unchanged. The
    databento import is local because the package is an optional extra — with it absent there is no
    BentoClientError to normalize, so this is a no-op."""
    try:
        from databento.common.error import BentoClientError
    except Exception:
        return
    if (isinstance(exc, BentoClientError) and getattr(exc, "http_status", None) == 422
            and _SESSION_INCOMPLETE_MARKER in str(exc)):
        raise DatabentoRangeUnavailable(
            f"OPRA session not fully available yet ({_SESSION_INCOMPLETE_MARKER})") from exc


class DatabentoDataProvider:
    name = "databento"

    def __init__(self, *, max_cost_usd: float = 0.50, equity_dataset: str = "DBEQ.BASIC",
                 future_dataset: str = "GLBX.MDP3", option_dataset: str = "OPRA.PILLAR",
                 default_start: str = "2018-05-01") -> None:
        self._max_cost = max_cost_usd
        self._equity_dataset = equity_dataset
        self._future_dataset = future_dataset
        self._option_dataset = option_dataset
        self._default_start = default_start
        # Cached OPRA range end: computed on first use and held for the PROVIDER INSTANCE's lifetime = one
        # nightly freeze run (fine for the one-shot nightly asset; a long-lived provider would go stale and
        # must recreate the provider to refresh — see the cache note on _option_available_end_ts).
        self._option_available_end: "pd.Timestamp | None" = None

    def _option_available_end_ts(self, client) -> "pd.Timestamp":  # type: ignore[no-untyped-def]
        """The OPRA dataset's available END (exclusive), as a UTC Timestamp — fetched via the metadata range
        API ONCE per provider instance and cached (one metadata call per nightly run, not per contract). At
        the 23:30-UTC freeze OPRA only carries today's session to ~22:30 UTC, so this is BEFORE tomorrow —
        clamping queries to it avoids the 422 `data_end_after_available_end`.

        Cache lifetime: `_option_available_end` is memoized for the provider INSTANCE's lifetime (= one
        nightly run). The nightly freeze creates a fresh provider each run, so it is always current; a
        long-lived/reused provider would serve a stale end and must be recreated to refresh."""
        if self._option_available_end is None:
            rng = client.metadata.get_dataset_range(dataset=self._option_dataset)
            ends = _dataset_range_ends(rng)                # every real shape → the END value(s) it carries
            if not ends:                                   # genuinely empty/malformed → catchable signal
                raise DatabentoRangeUnavailable(f"{self._option_dataset}: metadata returned no available end")
            self._option_available_end = max(_to_utc_ts(e) for e in ends)   # latest published end, UTC
        return self._option_available_end

    def last_available_option_date(self) -> "dt.date":
        """OPRA's available-END as a calendar DATE (UTC) — the STARTING candidate for the freeze step-back
        (`_freeze_latest_session`). At the nightly freeze OPRA carries today's still-settling session, so this
        is today's date; the step-back then walks back to the last COMPLETE session. Creates its own client
        and reuses the per-instance `_option_available_end_ts` cache (one metadata round-trip per provider)."""
        import databento as db
        return self._option_available_end_ts(db.Historical()).date()

    def _clamp_option_end(self, client, *, start: str, end_excl: str) -> str:  # type: ignore[no-untyped-def]
        """Clamp an EXCLUSIVE OPRA query `end` down to the dataset's available end so the request never asks
        for data past what OPRA has published. Returns the `end` string to pass to the client. Raises
        `DatabentoRangeUnavailable` if the (clamped) window is empty — `start >= end` — i.e. OPRA has nothing
        through `as_of` (a catchable signal, never a raw 422)."""
        avail_end = self._option_available_end_ts(client)
        end_ts = _to_utc_ts(end_excl)      # tzinfo-guarded: tz-AWARE input no longer raises TypeError (C1b gap)
        start_ts = _to_utc_ts(start)
        if end_ts > avail_end:
            end_ts = avail_end
            end_excl = avail_end.isoformat()
        if start_ts >= end_ts:
            raise DatabentoRangeUnavailable(
                f"{self._option_dataset}: requested start {start} >= available end {end_excl} — "
                f"OPRA has no published data through {start}")
        return end_excl

    def _resolve(self, market: Market) -> tuple[str, str, str]:
        ac = market.asset_class
        if ac == AssetClass.FUTURE:
            return self._future_dataset, f"{market.symbol}.c.0", "continuous"  # front-month continuous
        if ac in (AssetClass.EQUITY, AssetClass.ETF):
            return self._equity_dataset, market.symbol, "raw_symbol"
        if ac == AssetClass.OPTION:
            return self._option_dataset, market.symbol, "raw_symbol"
        raise NotImplementedError(f"databento mapping for asset class {ac} not implemented yet")

    def fetch(self, market: Market, start: str | None = None, end: str | None = None) -> PriceSeries:
        import databento as db

        dataset, symbol, stype = self._resolve(market)
        start = start or self._default_start
        end = end or dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d")
        client = db.Historical()

        cost = client.metadata.get_cost(
            dataset=dataset, symbols=[symbol], schema="ohlcv-1d", start=start, end=end, stype_in=stype,
        )
        if cost > self._max_cost:
            raise DatabentoCostExceeded(
                f"estimated ${cost:.4f} > cap ${self._max_cost:.2f} for {market} ({dataset}/{symbol}) — refusing"
            )

        data = client.timeseries.get_range(
            dataset=dataset, symbols=[symbol], schema="ohlcv-1d", start=start, end=end, stype_in=stype,
        )
        df = data.to_df()
        if df.empty:
            raise RuntimeError(f"databento returned no data for {market} ({dataset}/{symbol})")
        return _df_to_series(market, df)

    def fetch_option_chain(self, underlying_parent: str, as_of: str) -> list["OptionContract"]:
        """Listed XSP contracts on `as_of` via the OPRA `definition` schema (parent symbology, e.g.
        'XSP.OPT'). Cost-guarded."""
        import databento as db
        client = db.Historical()
        try:
            end = (dt.date.fromisoformat(as_of) + dt.timedelta(days=1)).isoformat()
            end = self._clamp_option_end(client, start=as_of, end_excl=end)   # never query past OPRA's published end
            cost = client.metadata.get_cost(dataset=self._option_dataset, symbols=[underlying_parent],
                                            schema="definition", start=as_of, end=end, stype_in="parent")
            if cost > self._max_cost:
                raise DatabentoCostExceeded(
                    f"estimated ${cost:.4f} > cap ${self._max_cost:.2f} for chain {underlying_parent}@{as_of}")
            data = client.timeseries.get_range(dataset=self._option_dataset, symbols=[underlying_parent],
                                               schema="definition", start=as_of, end=end, stype_in="parent")
            return _chain_from_definition_df(data.to_df())
        except Exception as e:  # noqa: BLE001 — normalize the still-settling-session 422 to the catchable type
            _raise_if_session_unavailable(e)   # raises DatabentoRangeUnavailable for that 422; else falls through
            raise

    def fetch_option_chain_range(self, underlying_parent: str, start: str, end: str) -> list["OptionContract"]:
        """ALL distinct contracts active in [start, end] (INCLUSIVE) via the OPRA `definition` schema — the
        BATCHED backfill's ONE definition fetch per month (vs one per day). `definition` emits a record per
        contract per day, so we de-dupe by `raw_symbol`. Cost-guarded."""
        import databento as db
        client = db.Historical()
        end_excl = (dt.date.fromisoformat(end) + dt.timedelta(days=1)).isoformat()
        end_excl = self._clamp_option_end(client, start=start, end_excl=end_excl)   # never past OPRA's end
        cost = client.metadata.get_cost(dataset=self._option_dataset, symbols=[underlying_parent],
                                        schema="definition", start=start, end=end_excl, stype_in="parent")
        if cost > self._max_cost:
            raise DatabentoCostExceeded(
                f"estimated ${cost:.4f} > cap ${self._max_cost:.2f} for chain range {underlying_parent} {start}..{end}")
        data = client.timeseries.get_range(dataset=self._option_dataset, symbols=[underlying_parent],
                                           schema="definition", start=start, end=end_excl, stype_in="parent")
        df = data.to_df()
        if "raw_symbol" in df.columns:
            df = df.drop_duplicates(subset="raw_symbol")
        return _chain_from_definition_df(df)

    _SYMBOL_LIMIT = 2000   # Databento's max symbols per request; the wide freeze band exceeds this

    def fetch_option_bars(self, osi_symbols: list[str], start: str, end: str) -> dict[str, dict[str, float]]:
        """Daily closes for specific OPRA contracts (raw OSI symbols). `end` is INCLUSIVE: Databento's range
        end is EXCLUSIVE and 422s on start>=end, so a single-day slice (start==end, the freeze path) is
        queried as [start, end+1day). CHUNKED at `_SYMBOL_LIMIT` (the wide freeze band has >2000 contracts,
        which 422s 'data_exceeded_maximum_number_of_symbols'). Cost-guarded across ALL chunks BEFORE any
        download (estimate-then-refuse discipline preserved); empty list -> {}."""
        if not osi_symbols:
            return {}
        import databento as db
        client = db.Historical()
        try:
            end_excl = (dt.date.fromisoformat(end) + dt.timedelta(days=1)).isoformat()
            end_excl = self._clamp_option_end(client, start=start, end_excl=end_excl)   # never past OPRA's end
            chunks = [osi_symbols[i:i + self._SYMBOL_LIMIT] for i in range(0, len(osi_symbols), self._SYMBOL_LIMIT)]
            total_cost = sum(
                client.metadata.get_cost(dataset=self._option_dataset, symbols=c, schema="ohlcv-1d",
                                         start=start, end=end_excl, stype_in="raw_symbol")
                for c in chunks)
            if total_cost > self._max_cost:
                raise DatabentoCostExceeded(
                    f"estimated ${total_cost:.4f} > cap ${self._max_cost:.2f} for {len(osi_symbols)} option bars")
            out: dict[str, dict[str, float]] = {}
            for c in chunks:
                data = client.timeseries.get_range(dataset=self._option_dataset, symbols=c, schema="ohlcv-1d",
                                                   start=start, end=end_excl, stype_in="raw_symbol")
                out.update(_bars_from_ohlcv_df(data.to_df()))
            return out
        except Exception as e:  # noqa: BLE001 — normalize the still-settling-session 422 to the catchable type
            _raise_if_session_unavailable(e)   # raises DatabentoRangeUnavailable for that 422; else falls through
            raise
