"""FX rate fetcher with Redis-backed cache.

The universe endpoint returns prices in each ticker's *local* currency
(USD for NYSE, EUR for Paris, GBP for LSE-after-pence-normalisation,
etc.). The browser needs FX rates to convert these into the pie's base
currency for P/L computation.

Rates are expressed against a USD pivot: `fx[CCY]` = "how many CCY for
1 USD". USD itself is always 1.0. To convert X-currency value to
Y-currency: `value_y = value_x * fx[Y] / fx[X]`.

Yahoo's `=X` symbols give the right shape: `USDGBP=X` returns GBP per
USD. Rates are cached in Redis for 1 hour (FX doesn't move much for
display-purpose P/L; intraday moves are noise at the second decimal).
"""
from __future__ import annotations

import json
from typing import Iterable

import httpx

from app.logging import get_logger
from app.redis_client import get_redis
from app.services.market import fetch_yahoo


log = get_logger("fx")


_CACHE_KEY = "fx:rates:v1"
_CACHE_TTL_SECONDS = 3600   # 1 hour


# Synonyms / shorthand currencies that should resolve to a canonical
# code before lookup. "GBp" (pence) is normalised to GBP at the
# universe endpoint, but we still set up the mapping defensively.
_CANONICALISE = {
    "GBP.": "GBP",
    "GBX": "GBP",
    "GBp": "GBP",
}


def _canonical(ccy: str) -> str:
    return _CANONICALISE.get(ccy, ccy)


async def _fetch_one(client: httpx.AsyncClient, ccy: str) -> float | None:
    """Yahoo: `USD<ccy>=X` returns units of <ccy> per 1 USD."""
    q = await fetch_yahoo(client, f"USD{ccy}=X", ccy, "")
    if q.price is None or q.price <= 0:
        return None
    return float(q.price)


async def get_rates(currencies: Iterable[str]) -> dict[str, float]:
    """Return `{ccy: units-per-USD}` for every currency requested.

    USD is always 1.0. Unknown / fetch-failed currencies are omitted
    rather than poisoned — callers must check membership before
    converting (browser falls back to "no conversion" for missing
    pairs, which keeps the panel readable even when FX is degraded).

    Cached in Redis for 1 hour; live fetches happen only on cache miss
    or when the cached set doesn't cover all needed currencies."""
    wanted = {_canonical(c) for c in currencies if c}
    wanted.add("USD")   # pivot — always present

    r = get_redis()
    cached_raw = await r.get(_CACHE_KEY)
    cached: dict[str, float] = {}
    if cached_raw:
        try:
            cached = json.loads(cached_raw)
        except Exception:
            cached = {}

    missing = wanted - set(cached.keys())
    if not missing:
        return {c: cached[c] for c in wanted}

    # Fetch any missing rates in parallel. Keep the existing cache to
    # avoid re-fetching unchanged currencies.
    rates = dict(cached)
    rates["USD"] = 1.0
    fetch_list = [c for c in missing if c != "USD"]

    if fetch_list:
        async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
            import asyncio
            results = await asyncio.gather(
                *(_fetch_one(client, c) for c in fetch_list),
                return_exceptions=True,
            )
        for c, val in zip(fetch_list, results):
            if isinstance(val, Exception):
                log.warning("fx.fetch_failed", ccy=c, error=str(val)[:120])
                continue
            if val is not None:
                rates[c] = val

    # Persist (merged) cache.
    await r.set(_CACHE_KEY, json.dumps(rates), ex=_CACHE_TTL_SECONDS)
    log.info("fx.cache_refreshed", count=len(rates))
    return {c: rates[c] for c in wanted if c in rates}
