"""LSEG Data Library client — fetches bond data via Platform session (App Key)."""
from __future__ import annotations

import logging
import time
from typing import Any, Optional

logger = logging.getLogger(__name__)

# Market data fields accessible via ld.get_data for this subscription
_MARKET_FIELDS = [
    "TR.YieldToMaturity",
    "TR.YieldToWorst",
    "TR.ZSpread",
    "TR.OASpread",
    "TR.BidPrice",
    "TR.AskPrice",
    "TR.AssetSwapSpread",
    "TR.TRBCIndustryGroup",
    "TR.TRBCIndustry",
    "TR.HeadquartersCountry",
    "TR.ModifiedDuration",
]

# Map ld.get_data column name → normalized key
_MARKET_FIELD_MAP = {
    "Yield to Maturity":         "ytm",
    "Yield to Worst":            "ytw",
    "Z Spread":                  "z_spread",
    "OA Spread":                 "oas",
    "Bid Price":                 "bid_price",
    "Ask Price":                 "ask_price",
    "Asset Swap Spread":         "asset_swap_spread",
    "TRBC Industry Group Name":  "trbc_ig",
    "TRBC Industry Name":        "trbc_industry",
    "Country of Headquarters":   "country",
    "Modified Duration":         "duration",
}

# Reference data fields accessible via discovery.search for this subscription
_SEARCH_SELECT = (
    "RIC,ISIN,IssuerCommonName,IssuerName,"
    "MaturityDate,CouponRate,CouponFrequency,CouponType,"
    "MoodysRating,SPRating,Currency,Price,IssueDate,AssetType,"
    "RCSTRBC2012Genealogy"
)

_NULL_STRINGS = {"nan", "none", "n/a", "<na>", "", "no permission"}


def _clean(val) -> Optional[str]:
    if val is None:
        return None
    s = str(val).strip()
    return None if s.lower() in _NULL_STRINGS else s


def _to_float(val) -> Optional[float]:
    cleaned = _clean(val)
    if cleaned is None:
        return None
    try:
        return float(str(cleaned).replace(",", ""))
    except (ValueError, TypeError):
        return None


def _to_date_str(val) -> Optional[str]:
    if val is None:
        return None
    s = str(val).strip()
    if s.lower() in _NULL_STRINGS:
        return None
    # Return just the date portion (strip time component)
    return s.split(" ")[0] if " " in s else s


class LsegApiClient:
    """
    Fetches bond data from LSEG via the lseg-data Python library (Platform session).
    Requires LSEG_APP_KEY + LSEG_USERNAME + LSEG_PASSWORD in environment / .env file.

    Strategy: discovery.search supplies static reference data (coupon, maturity, rating,
    currency); ld.get_data supplies live market data (YTM, Z-spread, bid/ask). Both are
    merged per instrument.
    """

    def __init__(self, app_key: str, username: str, password: str):
        self._app_key = app_key
        self._username = username
        self._password = password
        self._session = None

    def _open(self):
        if self._session is not None:
            return
        try:
            import lseg.data as ld
            from lseg.data.session import platform

            session = platform.Definition(
                app_key=self._app_key,
                grant=platform.GrantPassword(
                    username=self._username,
                    password=self._password,
                ),
                signon_control=True,
            ).get_session()
            session.open()
            ld.session.set_default(session)

            self._session = session
            logger.info("LSEG: Platform session opened")
        except Exception as exc:
            logger.error("LSEG: failed to open session — %s", exc)
            raise

    def close(self):
        if self._session is not None:
            try:
                self._session.close()
                logger.info("LSEG: session closed")
            except Exception:
                pass
            self._session = None

    def search_bonds_by_issuer(self, issuer_name: str, max_results: int = 100) -> list[dict]:
        """
        Search LSEG for bonds by issuer. Returns a list of reference-data dicts,
        each keyed by 'ric', plus static fields (coupon, maturity, rating, etc.).
        """
        self._open()
        try:
            import lseg.data as ld

            def _run_search(filter_str: str) -> list:
                r = ld.discovery.search(
                    filter=filter_str,
                    top=max_results,
                    select=_SEARCH_SELECT,
                )
                if r is None or r.empty:
                    return []
                out = []
                for _, row in r.iterrows():
                    ric = _clean(row.get("RIC"))
                    if not ric:
                        continue
                    out.append({
                        "ric":           ric,
                        "isin":          _clean(row.get("ISIN")),
                        "name":          _clean(row.get("IssuerCommonName")),
                        "issuer":        _clean(row.get("IssuerName")),
                        "coupon":        _to_float(row.get("CouponRate")),
                        "maturity_date": _to_date_str(row.get("MaturityDate")),
                        "issue_date":    _to_date_str(row.get("IssueDate")),
                        "coupon_freq":   _to_float(row.get("CouponFrequency")),
                        "coupon_type":   _clean(row.get("CouponType")),
                        "rating_moodys": _clean(row.get("MoodysRating")),
                        "rating_sp":     _clean(row.get("SPRating")),
                        "currency":      _clean(row.get("Currency")),
                        "price":         _to_float(row.get("Price")),
                        "genealogy":     _clean(row.get("RCSTRBC2012Genealogy")),
                    })
                return out

            # Prefer exact IssuerCommonName match (avoids similarly-named entities like
            # "Apple Bank" when searching for "Apple Inc").
            safe_name = issuer_name.replace("'", "''")
            records = _run_search(
                f"AssetType eq 'CORP' and CouponType ne 'FXDI'"
                f" and IssuerCommonName eq '{safe_name}'"
            )
            if not records:
                # Fall back to free-text query= when the exact name produces no match
                logger.info("LSEG: exact name match empty for '%s', trying free-text", issuer_name)
                r2 = ld.discovery.search(
                    query=issuer_name,
                    filter="AssetType eq 'CORP' and CouponType ne 'FXDI'",
                    top=max_results,
                    select=_SEARCH_SELECT,
                )
                if r2 is not None and not r2.empty:
                    for _, row in r2.iterrows():
                        ric = _clean(row.get("RIC"))
                        if ric:
                            records.append({
                                "ric":           ric,
                                "isin":          _clean(row.get("ISIN")),
                                "name":          _clean(row.get("IssuerCommonName")),
                                "issuer":        _clean(row.get("IssuerName")),
                                "coupon":        _to_float(row.get("CouponRate")),
                                "maturity_date": _to_date_str(row.get("MaturityDate")),
                                "issue_date":    _to_date_str(row.get("IssueDate")),
                                "coupon_freq":   _to_float(row.get("CouponFrequency")),
                                "coupon_type":   _clean(row.get("CouponType")),
                                "rating_moodys": _clean(row.get("MoodysRating")),
                                "rating_sp":     _clean(row.get("SPRating")),
                                "currency":      _clean(row.get("Currency")),
                                "price":         _to_float(row.get("Price")),
                                "genealogy":     _clean(row.get("RCSTRBC2012Genealogy")),
                            })

            logger.info("LSEG: found %d bonds for '%s'", len(records), issuer_name)
            return records
        except Exception as exc:
            logger.error("LSEG: search failed for '%s' — %s", issuer_name, exc)
            return []

    _BATCH_SIZE = 5
    _MAX_RETRIES = 3
    _RETRY_BASE_DELAY = 1.0  # seconds; doubles each attempt

    def _get_data_with_retry(self, ld: Any, instruments: list[str]) -> Optional[Any]:
        """Call ld.get_data with exponential-backoff retries. Returns DataFrame or None."""
        delay = self._RETRY_BASE_DELAY
        for attempt in range(self._MAX_RETRIES):
            try:
                return ld.get_data(instruments, _MARKET_FIELDS)
            except Exception as exc:
                if attempt < self._MAX_RETRIES - 1:
                    logger.warning(
                        "LSEG: get_data attempt %d/%d failed for %s — %s; retrying in %.0fs",
                        attempt + 1, self._MAX_RETRIES, instruments, exc, delay,
                    )
                    time.sleep(delay)
                    delay *= 2
                else:
                    logger.warning(
                        "LSEG: get_data gave up after %d attempts for %s — %s",
                        self._MAX_RETRIES, instruments, exc,
                    )
        return None

    def _fetch_market_data(self, rics: list[str]) -> dict[str, dict]:
        """
        Fetch live market data for a list of RICs in batches.
        Each batch is retried up to _MAX_RETRIES times; batches that still fail
        are retried one RIC at a time to maximise recovery.
        Returns a dict keyed by RIC.
        """
        import lseg.data as ld
        import pandas as pd

        market: dict[str, dict] = {}
        for i in range(0, len(rics), self._BATCH_SIZE):
            batch = rics[i: i + self._BATCH_SIZE]
            df = self._get_data_with_retry(ld, batch)

            if df is None and len(batch) > 1:
                # Batch failed even after retries — fall back to one RIC at a time
                logger.info("LSEG: falling back to per-RIC requests for batch %d-%d", i, i + len(batch))
                frames = []
                for ric in batch:
                    single = self._get_data_with_retry(ld, [ric])
                    if single is not None:
                        frames.append(single)
                df = pd.concat(frames, ignore_index=True) if frames else None

            if df is None or df.empty:
                continue

            for _, row in df.iterrows():
                ric = _clean(row.get("Instrument") or row.get("instrument"))
                if not ric:
                    continue
                entry: dict = {}
                for col_name, norm_key in _MARKET_FIELD_MAP.items():
                    raw = row.get(col_name)
                    if norm_key in {"trbc_ig", "trbc_industry", "country"}:
                        entry[norm_key] = _clean(raw)
                    else:
                        entry[norm_key] = _to_float(raw)
                market[ric] = entry

        return market

    def fetch_bonds(self, instruments: list[str]) -> list[dict]:
        """
        Fetch bond data for a list of RICs (used when ISINs/RICs are given directly,
        without a prior search step — no reference data available in this path).
        """
        if not instruments:
            return []
        self._open()
        market = self._fetch_market_data(instruments)

        records = []
        for ric in instruments:
            m = market.get(ric, {})
            records.append({
                "ric":           ric,
                "isin":          ric,
                "name":          None,
                "issuer":        None,
                "coupon":        None,
                "maturity_date": None,
                "issue_date":    None,
                "coupon_freq":   None,
                "coupon_type":   None,
                "rating_moodys": None,
                "currency":      None,
                "price":         m.get("bid_price"),
                "ytm":           m.get("ytm"),
                "ytw":           m.get("ytw"),
                "z_spread":      m.get("z_spread"),
                "oas":           m.get("oas"),
                "asset_swap_spread": m.get("asset_swap_spread"),
                "country":       m.get("country"),
                "trbc_ig":       m.get("trbc_ig"),
                "trbc_industry": m.get("trbc_industry"),
                "duration":      m.get("duration"),
            })

        logger.info("LSEG API: fetched %d bonds", len(records))
        return records

    def search_bonds_by_query(self, query: str, top: int = 300) -> list[dict]:
        """
        Search for bonds using a generic text query (e.g. sector name).
        Returns reference-data dicts only — no market data enrichment.
        """
        self._open()
        try:
            import lseg.data as ld
            results = ld.discovery.search(
                query=query,
                filter="AssetType eq 'CORP' and CouponType ne 'FXDI'",
                top=top,
                select=_SEARCH_SELECT,
            )
            if results is None or results.empty:
                return []
            records = []
            for _, row in results.iterrows():
                ric = _clean(row.get("RIC"))
                if not ric:
                    continue
                records.append({
                    "ric":           ric,
                    "isin":          _clean(row.get("ISIN")),
                    "name":          _clean(row.get("IssuerCommonName")),
                    "issuer":        _clean(row.get("IssuerName")),
                    "coupon":        _to_float(row.get("CouponRate")),
                    "maturity_date": _to_date_str(row.get("MaturityDate")),
                    "issue_date":    _to_date_str(row.get("IssueDate")),
                    "coupon_freq":   _to_float(row.get("CouponFrequency")),
                    "coupon_type":   _clean(row.get("CouponType")),
                    "rating_moodys": _clean(row.get("MoodysRating")),
                    "currency":      _clean(row.get("Currency")),
                    "price":         _to_float(row.get("Price")),
                })
            logger.info("LSEG: query '%s' → %d bonds", query, len(records))
            return records
        except Exception as exc:
            logger.error("LSEG: query search failed for '%s' — %s", query, exc)
            return []

    def search_issuer_names(self, query: str, top: int = 20) -> list[dict]:
        """Return unique issuers matching a text query with their ultimate parent — used for autocomplete."""
        self._open()
        try:
            import lseg.data as ld
            results = ld.discovery.search(
                query=query,
                filter="AssetType eq 'CORP' and CouponType ne 'FXDI'",
                top=top,
                select="IssuerCommonName,UltimateParentCommonName",
            )
            if results is None or results.empty:
                return []
            seen: set[str] = set()
            items: list[dict] = []
            for _, row in results.iterrows():
                n = _clean(row.get("IssuerCommonName"))
                p = _clean(row.get("UltimateParentCommonName"))
                if n and n not in seen:
                    seen.add(n)
                    items.append({"name": n, "parent": p})
            return items
        except Exception as exc:
            logger.error("LSEG: issuer name search failed for '%s' — %s", query, exc)
            return []

    def lookup_issuer_gics(self, issuer_name: str) -> dict:
        """
        Look up GICS classification for an issuer via their listed equity.
        GICS is an equity-level concept — bond RICs don't carry it directly.
        Step 1: discovery search for the company's equity to get its RIC.
        Step 2: ld.get_data on that equity RIC for GICS Sector/IG/Industry names.
        Returns {"sector", "industry_group", "industry"} or empty dict on failure.
        """
        self._open()
        try:
            import lseg.data as ld
            eq = ld.discovery.search(
                query=issuer_name,
                filter="AssetType eq '[EQUITY]'",
                top=3,
                select="RIC,IssuerCommonName",
            )
            if eq is None or eq.empty:
                logger.warning("LSEG: no equity found for GICS lookup of '%s'", issuer_name)
                return {}
            equity_ric = _clean(eq.iloc[0].get("RIC"))
            if not equity_ric:
                return {}

            df = ld.get_data(
                [equity_ric],
                ["TR.GICSSector", "TR.GICSIndustryGroup", "TR.GICSIndustry"],
            )
            if df is None or df.empty:
                return {}
            row = df.iloc[0]
            names = {
                "sector":         _clean(row.get("GICS Sector Name"))         or "",
                "industry_group": _clean(row.get("GICS Industry Group Name")) or "",
                "industry":       _clean(row.get("GICS Industry Name"))        or "",
            }
            logger.info(
                "LSEG: GICS for '%s' (via %s) → %s",
                issuer_name, equity_ric, names,
            )
            return names
        except Exception as exc:
            logger.error("LSEG: GICS equity lookup failed for '%s' — %s", issuer_name, exc)
            return {}

    def search_peers_by_sector(self, sector_prefix: str, top: int = 300) -> list[dict]:
        """
        Search for peer bonds in the same TRBC sector using a genealogy code prefix filter.
        More reliable than free-text search — uses exact TRBC classification codes so the
        full peer universe is captured regardless of issuer name phrasing.

        sector_prefix: first N segments of RCSTRBC2012Genealogy joined by backslashes,
          e.g. 'B:180\\B:181\\B:182' for Industry Group (sector) level,
               'B:180\\B:181\\B:182\\B:183' for Industry (sub-sector) level.
        """
        self._open()
        try:
            import lseg.data as ld
            # OData filter requires backslashes escaped as \\
            escaped = sector_prefix.replace("\\", "\\\\")
            results = ld.discovery.search(
                view=ld.discovery.Views.GOV_CORP_INSTRUMENTS,
                filter=(
                    f"AssetType eq 'CORP' and CouponType ne 'FXDI'"
                    f" and Currency eq 'USD'"
                    f" and startswith(RCSTRBC2012Genealogy,'{escaped}')"
                ),
                top=top,
                select=_SEARCH_SELECT,
            )
            if results is None or results.empty:
                return []
            records = []
            for _, row in results.iterrows():
                ric = _clean(row.get("RIC"))
                if not ric:
                    continue
                records.append({
                    "ric":              ric,
                    "isin":             _clean(row.get("ISIN")),
                    "name":             _clean(row.get("IssuerCommonName")),
                    "issuer":           _clean(row.get("IssuerName")),
                    "coupon":           _to_float(row.get("CouponRate")),
                    "maturity_date":    _to_date_str(row.get("MaturityDate")),
                    "issue_date":       _to_date_str(row.get("IssueDate")),
                    "coupon_freq":      _to_float(row.get("CouponFrequency")),
                    "coupon_type":      _clean(row.get("CouponType")),
                    "rating_moodys":    _clean(row.get("MoodysRating")),
                    "rating_sp":        _clean(row.get("SPRating")),
                    "currency":         _clean(row.get("Currency")),
                    "price":            _to_float(row.get("Price")),
                    "genealogy":        _clean(row.get("RCSTRBC2012Genealogy")),
                })
            logger.info("LSEG: genealogy prefix '%s' → %d bonds", sector_prefix, len(records))
            return records
        except Exception as exc:
            logger.error("LSEG: sector search failed for '%s' — %s", sector_prefix, exc)
            return []

    def search_peers_by_gics(self, sector_code: str, ig_code: Optional[str] = None, top: int = 300) -> list[dict]:
        """Search peer bonds filtered by GICS Sector (and optionally Industry Group) code."""
        self._open()
        try:
            import lseg.data as ld
            gics_filter = f"GICSSectorCode eq '{sector_code}'"
            if ig_code:
                gics_filter += f" and GICSIndustryGroupCode eq '{ig_code}'"
            results = ld.discovery.search(
                view=ld.discovery.Views.GOV_CORP_INSTRUMENTS,
                filter=(
                    f"AssetType eq 'CORP' and CouponType ne 'FXDI'"
                    f" and Currency eq 'USD'"
                    f" and {gics_filter}"
                ),
                top=top,
                select=_SEARCH_SELECT,
            )
            if results is None or results.empty:
                return []
            records = []
            for _, row in results.iterrows():
                ric = _clean(row.get("RIC"))
                if not ric:
                    continue
                records.append({
                    "ric":              ric,
                    "isin":             _clean(row.get("ISIN")),
                    "name":             _clean(row.get("IssuerCommonName")),
                    "issuer":           _clean(row.get("IssuerName")),
                    "coupon":           _to_float(row.get("CouponRate")),
                    "maturity_date":    _to_date_str(row.get("MaturityDate")),
                    "issue_date":       _to_date_str(row.get("IssueDate")),
                    "coupon_freq":      _to_float(row.get("CouponFrequency")),
                    "coupon_type":      _clean(row.get("CouponType")),
                    "rating_moodys":    _clean(row.get("MoodysRating")),
                    "rating_sp":        _clean(row.get("SPRating")),
                    "currency":         _clean(row.get("Currency")),
                    "price":            _to_float(row.get("Price")),
                    "genealogy":        _clean(row.get("RCSTRBC2012Genealogy")),
                })
            logger.info("LSEG: GICS sector=%s ig=%s → %d bonds", sector_code, ig_code, len(records))
            return records
        except Exception as exc:
            logger.error("LSEG: GICS peer search failed sector=%s — %s", sector_code, exc)
            return []

    _BUCKET_TO_MOODY: dict[str, list[str]] = {
        "AAA": ["Aaa"],
        "AA":  ["Aa1", "Aa2", "Aa3"],
        "A":   ["A1", "A2", "A3"],
        "BBB": ["Baa1", "Baa2", "Baa3"],
        "BB":  ["Ba1", "Ba2", "Ba3"],
        "B":   ["B1", "B2", "B3"],
        "CCC": ["Caa1", "Caa2", "Caa3"],
    }

    def search_peers_by_rating(self, rating_bucket: str, top: int = 300) -> list[dict]:
        """Search for USD corporate bonds in the same Moody's rating bucket (cross-sector)."""
        moody_ratings = self._BUCKET_TO_MOODY.get(rating_bucket, [])
        if not moody_ratings:
            return []
        self._open()
        try:
            import lseg.data as ld
            rating_filter = " or ".join(f"MoodysRating eq '{r}'" for r in moody_ratings)
            results = ld.discovery.search(
                view=ld.discovery.Views.GOV_CORP_INSTRUMENTS,
                filter=(
                    f"AssetType eq 'CORP' and CouponType ne 'FXDI'"
                    f" and Currency eq 'USD'"
                    f" and ({rating_filter})"
                ),
                top=top,
                select=_SEARCH_SELECT,
            )
            if results is None or results.empty:
                return []
            records = []
            for _, row in results.iterrows():
                ric = _clean(row.get("RIC"))
                if not ric:
                    continue
                records.append({
                    "ric":           ric,
                    "isin":          _clean(row.get("ISIN")),
                    "name":          _clean(row.get("IssuerCommonName")),
                    "issuer":        _clean(row.get("IssuerName")),
                    "coupon":        _to_float(row.get("CouponRate")),
                    "maturity_date": _to_date_str(row.get("MaturityDate")),
                    "issue_date":    _to_date_str(row.get("IssueDate")),
                    "coupon_freq":   _to_float(row.get("CouponFrequency")),
                    "coupon_type":   _clean(row.get("CouponType")),
                    "rating_moodys": _clean(row.get("MoodysRating")),
                    "rating_sp":     _clean(row.get("SPRating")),
                    "currency":      _clean(row.get("Currency")),
                    "price":         _to_float(row.get("Price")),
                    "genealogy":     _clean(row.get("RCSTRBC2012Genealogy")),
                })
            logger.info("LSEG: rating bucket '%s' (cross-sector) → %d bonds", rating_bucket, len(records))
            return records
        except Exception as exc:
            logger.error("LSEG: rating peer search failed bucket=%s — %s", rating_bucket, exc)
            return []

    def fetch_market_data(self, rics: list[str]) -> dict[str, dict]:
        """Public wrapper for batch market data fetch."""
        self._open()
        return self._fetch_market_data(rics)

    def fetch_bonds_for_issuers(self, issuer_names: list[str]) -> list[dict]:
        """
        Search for bonds by issuer, capture reference data from search, then
        enrich with live market data from get_data. Merges both into one record.
        """
        self._open()

        # Step 1: search → reference data + RIC list
        ref_by_ric: dict[str, dict] = {}
        from app.config import settings
        for name in issuer_names:
            for bond in self.search_bonds_by_issuer(name, max_results=settings.bonds_per_issuer):
                ric = bond["ric"]
                if ric not in ref_by_ric:
                    ref_by_ric[ric] = bond

        if not ref_by_ric:
            logger.warning("LSEG: no bonds found for any of the requested issuers")
            return []

        rics = list(ref_by_ric.keys())
        logger.info("LSEG: fetching market data for %d bonds across %d issuers",
                    len(rics), len(issuer_names))

        # Step 2: get_data → live market data
        market = self._fetch_market_data(rics)

        # Step 3: merge
        records = []
        for ric, ref in ref_by_ric.items():
            m = market.get(ric, {})
            records.append({
                "isin":              ref.get("isin") or ric,
                "name":              ref.get("name"),
                "issuer":            ref.get("issuer"),
                "coupon":            ref.get("coupon"),
                "maturity_date":     ref.get("maturity_date"),
                "issue_date":        ref.get("issue_date"),
                "coupon_freq":       ref.get("coupon_freq"),
                "coupon_type":       ref.get("coupon_type"),
                "rating_moodys":     ref.get("rating_moodys"),
                "rating_sp":         ref.get("rating_sp"),
                "currency":          ref.get("currency"),
                "price":             ref.get("price") or m.get("bid_price"),
                "ric":               ric,
                "ytm":               m.get("ytm"),
                "ytw":               m.get("ytw"),
                "z_spread":          m.get("z_spread"),
                "oas":               m.get("oas"),
                "asset_swap_spread":  m.get("asset_swap_spread"),
                "country":            m.get("country") or ref.get("country"),
                "trbc_ig":            m.get("trbc_ig"),
                "trbc_industry":      m.get("trbc_industry"),
                "duration":           m.get("duration"),
                "genealogy":          ref.get("genealogy"),
            })

        logger.info("LSEG API: merged %d bonds (ref + market data)", len(records))
        return records
