"""Cache utilities for Bloomberg data (paths, adapters, lookup).

Provides path resolution, cache adapters, and cache lookup utilities
for Bloomberg intraday bar data and reference data (BDP/BDS).
"""

from __future__ import annotations

from datetime import datetime, timezone
import json
import logging
import os
from pathlib import Path
import sys
from typing import TYPE_CHECKING

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

from xbbg import const
from xbbg.core.config import overrides
from xbbg.core.domain import contracts
from xbbg.io.convert import is_empty, to_pandas

if TYPE_CHECKING:
    from xbbg.markets.bloomberg import ExchangeInfo

logger = logging.getLogger(__name__)

# Module-level flag to log default cache location only once
_default_cache_logged = False


# ============================================================================
# Path Resolution
# ============================================================================


def get_cache_root() -> str:
    """Get the cache root directory path.

    Returns BBG_ROOT if set, otherwise returns a platform-specific default cache location.
    Logs an INFO message once when default location is first used.

    Returns:
        str: Cache root directory path, or empty string if no cache location available.
    """
    global _default_cache_logged

    # Check if BBG_ROOT is explicitly set
    bbg_root = os.environ.get(overrides.BBG_ROOT, "")
    if bbg_root:
        return bbg_root

    # Use platform-specific default cache location
    try:
        home = Path.home()
    except RuntimeError:
        # Fallback if home directory cannot be determined
        # Use current directory as last resort
        home = Path.cwd()

    if sys.platform == "win32":
        # Windows: Use APPDATA if available, otherwise use user home
        appdata = os.environ.get("APPDATA", "")
        default_cache = Path(appdata) / "xbbg" if appdata else home / ".xbbg"
    else:
        # Linux/Mac: Use .cache directory if it exists, otherwise use .xbbg in home
        cache_dir = home / ".cache"
        default_cache = cache_dir / "xbbg" if cache_dir.exists() else home / ".xbbg"

    # Log once when default is first used (WARNING because caching behavior may be unexpected)
    if not _default_cache_logged:
        logger.warning(
            "BBG_ROOT not set. Using default cache location: %s. "
            "Set BBG_ROOT environment variable to use a custom location.",
            default_cache,
        )
        _default_cache_logged = True

    return str(default_cache)


def bar_file(
    ticker: str,
    dt,
    typ: str = "TRADE",
    interval: int = 1,
    interval_has_seconds: bool = False,
) -> str:
    """Data file location for Bloomberg historical data.

    Args:
        ticker: ticker name
        dt: date
        typ: [TRADE, BID, ASK, BID_BEST, ASK_BEST, BEST_BID, BEST_ASK]
        interval: bar interval (in minutes by default, or seconds if interval_has_seconds=True)
        interval_has_seconds: if True, interval is in seconds

    Returns:
        str: File location (uses default cache location if BBG_ROOT not set).

    Note:
        Cache path includes interval to avoid mixing data from different intervals.
        Format: {BBG_ROOT}/{asset}/{ticker}/{typ}/{interval_str}/{date}.parq
        where interval_str is e.g., "1m" for 1-minute bars or "10s" for 10-second bars.
    """
    data_path_str = get_cache_root()
    if not data_path_str:
        return ""
    data_path = Path(data_path_str)
    asset = ticker.split()[-1]
    proper_ticker = ticker.replace("/", "_")
    cur_dt = pd.Timestamp(dt).strftime("%Y-%m-%d")
    # Include interval in path to avoid cache collisions between different intervals
    interval_str = f"{interval}s" if interval_has_seconds else f"{interval}m"
    return (data_path / asset / proper_ticker / typ / interval_str / f"{cur_dt}.parq").as_posix()


def multi_day_bar_files(
    ticker: str,
    start_datetime: str | pd.Timestamp,
    end_datetime: str | pd.Timestamp,
    typ: str = "TRADE",
    interval: int = 1,
    interval_has_seconds: bool = False,
) -> list[tuple[str, str]]:
    """Get list of (date_str, file_path) tuples for a multi-day range.

    Args:
        ticker: Ticker name
        start_datetime: Start of range
        end_datetime: End of range
        typ: Event type
        interval: bar interval (in minutes by default, or seconds if interval_has_seconds=True)
        interval_has_seconds: if True, interval is in seconds

    Returns:
        List of (date_str, file_path) tuples for each day in range.
        Returns empty list if cache root is not available.
    """
    data_path_str = get_cache_root()
    if not data_path_str:
        return []

    start_dt = pd.Timestamp(start_datetime).normalize()
    end_dt = pd.Timestamp(end_datetime).normalize()

    dates = pd.date_range(start=start_dt, end=end_dt, freq="D")

    return [
        (
            dt.strftime("%Y-%m-%d"),
            bar_file(
                ticker=ticker,
                dt=dt,
                typ=typ,
                interval=interval,
                interval_has_seconds=interval_has_seconds,
            ),
        )
        for dt in dates
    ]


def save_intraday(
    data: pd.DataFrame,
    ticker: str,
    dt,
    typ: str = "TRADE",
    interval: int = 1,
    interval_has_seconds: bool = False,
    **kwargs,
):
    """Check whether data is done for the day and save.

    Args:
        data: data
        ticker: ticker
        dt: date
        typ: [TRADE, BID, ASK, BID_BEST, ASK_BEST, BEST_BID, BEST_ASK]
        interval: bar interval (in minutes by default, or seconds if interval_has_seconds=True)
        interval_has_seconds: if True, interval is in seconds
        **kwargs: Additional options forwarded to timing/logging helpers.
    """
    cur_dt = pd.Timestamp(dt).strftime("%Y-%m-%d")
    interval_str = f"{interval}s" if interval_has_seconds else f"{interval}m"
    info = f"{ticker} / {cur_dt} / {typ} / {interval_str}"
    data_file = bar_file(
        ticker=ticker,
        dt=dt,
        typ=typ,
        interval=interval,
        interval_has_seconds=interval_has_seconds,
    )
    if not data_file:
        return

    if data.empty:
        logger.warning("No data to save for %s (empty DataFrame)", info)
        return

    exch = const.exch_info(ticker=ticker, **kwargs)
    if exch.empty:
        return

    end_time = pd.Timestamp(const.market_timing(ticker=ticker, dt=dt, timing="FINISHED", **kwargs)).tz_localize(exch.tz)
    now = pd.Timestamp("now", tz=exch.tz) - pd.Timedelta("1h")

    if end_time > now:
        logger.debug(
            "Skipping cache save for %s: market close time %s is less than 1 hour ago (%s), data may be incomplete",
            info,
            end_time,
            now,
        )
        return

    if logger.isEnabledFor(logging.INFO):
        logger.info("Saving intraday data to cache: %s (%d rows)", data_file, len(data))
    else:
        logger.info("Saving intraday data to cache: %s", data_file)
    Path(data_file).parent.mkdir(parents=True, exist_ok=True)
    # Use PyArrow for parquet I/O (backend-agnostic)
    table = pa.Table.from_pandas(data)
    pq.write_table(table, data_file)


# ============================================================================
# Cache Adapters
# ============================================================================


class BarCacheAdapter:
    """Cache adapter for intraday bar data (parquet-based)."""

    def load(
        self,
        request: contracts.DataRequest,
        session_window: contracts.SessionWindow,
    ) -> pd.DataFrame | None:
        """Load cached bar data if available."""
        # Handle multi-day requests
        if request.is_multi_day():
            return self._load_multi_day(request)

        # Single-day request: require valid session window
        if not session_window.is_valid():
            return None

        data_file = bar_file(
            ticker=request.ticker,
            dt=request.dt,
            typ=request.event_type,
            interval=request.interval,
            interval_has_seconds=request.interval_has_seconds,
        )
        if not data_file:
            # BBG_ROOT not set - debug message logged in save() method if needed
            return None
        if not Path(data_file).exists():
            return None

        try:
            from xbbg.utils import pipeline

            # Use PyArrow for parquet I/O, then convert to pandas for pipeline
            table = pq.read_table(data_file)
            res = (
                table.to_pandas()
                .pipe(pipeline.add_ticker, ticker=request.ticker)
                .loc[session_window.start_time : session_window.end_time]
            )

            if not res.empty:
                logger.debug("Loading cached Bloomberg intraday data from: %s", data_file)
                return res
        except Exception as e:
            logger.warning("Cache load failed for %s (file may be corrupt): %s", data_file, e)

        return None

    def _load_multi_day(
        self,
        request: contracts.DataRequest,
    ) -> pd.DataFrame | None:
        """Load multi-day data from individual day caches.

        Returns concatenated DataFrame only if ALL days are cached.
        Returns None if any day is missing (triggers full Bloomberg fetch).

        Args:
            request: Data request with start_datetime and end_datetime.

        Returns:
            Concatenated DataFrame if all days cached, None otherwise.
        """
        from xbbg.utils import pipeline

        if request.start_datetime is None or request.end_datetime is None:
            return None

        day_files = multi_day_bar_files(
            ticker=request.ticker,
            start_datetime=request.start_datetime,
            end_datetime=request.end_datetime,
            typ=request.event_type,
            interval=request.interval,
            interval_has_seconds=request.interval_has_seconds,
        )

        if not day_files:
            return None

        # Check if all files exist first (fail fast)
        missing_days = [dt for dt, path in day_files if not Path(path).exists()]
        if missing_days:
            logger.debug(
                "Multi-day cache miss: %d of %d days missing for %s (first missing: %s)",
                len(missing_days),
                len(day_files),
                request.ticker,
                missing_days[:3],
            )
            return None

        # All files exist - load and concatenate
        dfs = []
        for _dt_str, path in day_files:
            try:
                # Use PyArrow for parquet I/O
                table = pq.read_table(path)
                df = table.to_pandas()
                dfs.append(df)
            except Exception as e:
                logger.warning("Failed to load cache file %s (file may be corrupt): %s", path, e)
                return None  # Fail entire load if any file is corrupt

        if not dfs:
            return None

        result = pd.concat(dfs, axis=0).sort_index().pipe(pipeline.add_ticker, ticker=request.ticker)

        # Filter to exact datetime range requested
        assert request.start_datetime is not None
        assert request.end_datetime is not None
        start_ts = pd.Timestamp(request.start_datetime)
        end_ts = pd.Timestamp(request.end_datetime)

        # Handle timezone: localize filter timestamps to match index timezone
        if result.index.tz is not None:
            start_ts = (
                start_ts.tz_localize(result.index.tz) if start_ts.tz is None else start_ts.tz_convert(result.index.tz)
            )
            end_ts = end_ts.tz_localize(result.index.tz) if end_ts.tz is None else end_ts.tz_convert(result.index.tz)

        result = result.loc[start_ts:end_ts]

        if not result.empty:
            logger.debug(
                "Multi-day cache hit: loaded %d rows from %d cached days for %s",
                len(result),
                len(day_files),
                request.ticker,
            )
            return result

        return None

    def save(
        self,
        data,
        request: contracts.DataRequest,
        session_window: contracts.SessionWindow,
    ) -> None:
        """Save bar data to cache."""
        if is_empty(data):
            logger.warning("No data to save for %s / %s", request.ticker, request.to_date_string())
            return

        # Convert to pandas for cache storage (cache always uses parquet via pandas)
        data = to_pandas(data)

        # Handle multi-day requests: split and save each day separately
        if request.is_multi_day():
            self._save_multi_day(data, request)
            return

        # Get cache root (uses default if BBG_ROOT not set)
        data_path_str = get_cache_root()
        if not data_path_str:
            return

        ctx_kwargs = request.context.to_kwargs() if request.context else {}
        save_intraday(
            data=data[request.ticker] if request.ticker in data.columns else data,
            ticker=request.ticker,
            dt=request.dt,
            typ=request.event_type,
            interval=request.interval,
            interval_has_seconds=request.interval_has_seconds,
            **ctx_kwargs,
        )

    def _save_multi_day(
        self,
        data: pd.DataFrame,
        request: contracts.DataRequest,
    ) -> None:
        """Save multi-day data as individual day files.

        Splits the DataFrame by date and saves each day separately,
        allowing future single-day or multi-day requests to reuse the cache.

        Args:
            data: DataFrame with DatetimeIndex or "time" column containing multiple days.
            request: Original data request.
        """
        ctx_kwargs = request.context.to_kwargs() if request.context else {}

        # Get the raw data (remove ticker level if MultiIndex columns)
        if (
            isinstance(data.columns, pd.MultiIndex) and request.ticker in data.columns.get_level_values(0)
        ) or request.ticker in data.columns:
            raw_data = data[request.ticker]
        else:
            raw_data = data

        # Handle long format data with RangeIndex and "time" column
        if isinstance(raw_data.index, pd.RangeIndex) and "time" in raw_data.columns:
            # Convert "time" column to datetime and extract date
            time_col = pd.to_datetime(raw_data["time"])
            date_col = time_col.dt.date

            # Group by date
            saved_count = 0
            for date in date_col.unique():
                day_mask = date_col == date
                day_data = raw_data[day_mask].copy()

                if is_empty(day_data):
                    continue

                # Convert to format expected by save_intraday (DatetimeIndex)
                day_data_indexed = day_data.set_index(pd.DatetimeIndex(pd.to_datetime(day_data["time"])))
                day_data_indexed = day_data_indexed.drop(columns=["time", "ticker"], errors="ignore")

                save_intraday(
                    data=day_data_indexed,
                    ticker=request.ticker,
                    dt=date,
                    typ=request.event_type,
                    interval=request.interval,
                    interval_has_seconds=request.interval_has_seconds,
                    **ctx_kwargs,
                )
                saved_count += 1
        else:
            # Standard format with DatetimeIndex
            grouped = raw_data.groupby(raw_data.index.date)  # type: ignore[union-attr]
            saved_count = 0

            for date, day_data in grouped:
                if is_empty(day_data):
                    continue

                # Use existing save_intraday which handles market timing checks
                save_intraday(
                    data=day_data,
                    ticker=request.ticker,
                    dt=date,
                    typ=request.event_type,
                    interval=request.interval,
                    interval_has_seconds=request.interval_has_seconds,
                    **ctx_kwargs,
                )
                saved_count += 1

        if saved_count > 0:
            logger.debug(
                "Saved multi-day data: %d days cached for %s",
                saved_count,
                request.ticker,
            )


# ============================================================================
# Exchange Metadata Cache
# ============================================================================


def exchange_cache_file() -> str:
    """Return path to exchange metadata cache file.

    Returns:
        str: Path to exchanges.parquet file, or empty string if cache root unavailable.
    """
    root = get_cache_root()
    if not root:
        return ""
    return str(Path(root) / "cache" / "exchanges.parquet")


def save_exchange_info(info: ExchangeInfo) -> None:
    """Save single ExchangeInfo to cache (upsert by ticker).

    Args:
        info: ExchangeInfo dataclass to save.
    """
    save_exchange_infos([info])


def save_exchange_infos(infos: list[ExchangeInfo]) -> None:
    """Bulk save multiple ExchangeInfo entries.

    Performs upsert: existing entries with matching tickers are replaced,
    new entries are appended.

    Args:
        infos: List of ExchangeInfo dataclasses to save.
    """
    if not infos:
        return

    cache_file = exchange_cache_file()
    if not cache_file:
        logger.debug("Cache root not available, skipping exchange info save")
        return

    # Set cached_at timestamp for all entries
    now = datetime.now(timezone.utc)

    # Convert ExchangeInfo list to records
    new_records = []
    for info in infos:
        new_records.append(
            {
                "ticker": info.ticker,
                "mic": info.mic,
                "exch_code": info.exch_code,
                "timezone": info.timezone,
                "utc_offset": info.utc_offset,
                "sessions": json.dumps(info.sessions),
                "source": info.source,
                "cached_at": now,
            }
        )

    new_df = pd.DataFrame(new_records)
    new_tickers = set(new_df["ticker"].tolist())

    # Load existing cache and merge
    if Path(cache_file).exists():
        try:
            existing_table = pq.read_table(cache_file)
            existing_df = existing_table.to_pandas()
            # Remove entries that will be replaced
            existing_df = existing_df[~existing_df["ticker"].isin(new_tickers)]
            # Concatenate
            merged_df = pd.concat([existing_df, new_df], ignore_index=True)
        except Exception as e:
            logger.warning("Failed to read existing exchange cache, overwriting: %s", e)
            merged_df = new_df
    else:
        merged_df = new_df

    # Ensure directory exists
    Path(cache_file).parent.mkdir(parents=True, exist_ok=True)

    # Write to parquet
    table = pa.Table.from_pandas(merged_df, preserve_index=False)
    pq.write_table(table, cache_file)
    logger.debug("Saved %d exchange info entries to cache: %s", len(infos), cache_file)


def load_exchange_info(ticker: str, max_age_hours: float = 24.0) -> ExchangeInfo | None:
    """Load cached ExchangeInfo if exists and not stale.

    Args:
        ticker: Ticker symbol to look up.
        max_age_hours: Maximum age in hours before cache is considered stale.
            Use float('inf') to ignore staleness.

    Returns:
        ExchangeInfo if found and not stale, None otherwise.
    """
    from xbbg.markets.bloomberg import ExchangeInfo

    cache_file = exchange_cache_file()
    if not cache_file or not Path(cache_file).exists():
        return None

    try:
        table = pq.read_table(cache_file)
        df = table.to_pandas()
    except Exception as e:
        logger.warning("Failed to read exchange cache: %s", e)
        return None

    if df.empty:
        return None

    # Filter by ticker
    matches = df[df["ticker"] == ticker]
    if matches.empty:
        return None

    row = matches.iloc[0]

    # Check staleness
    cached_at = row.get("cached_at")
    if cached_at is not None and max_age_hours != float("inf"):
        if pd.isna(cached_at):
            return None
        # Handle timezone-aware and naive timestamps
        if hasattr(cached_at, "tzinfo") and cached_at.tzinfo is not None:
            now = datetime.now(timezone.utc)
        else:
            cached_at = pd.Timestamp(cached_at).to_pydatetime()
            now = datetime.now(timezone.utc)
            cached_at = cached_at.replace(tzinfo=timezone.utc)
        age_hours = (now - cached_at).total_seconds() / 3600
        if age_hours > max_age_hours:
            logger.debug("Exchange cache stale for %s (%.1f hours old)", ticker, age_hours)
            return None

    # Deserialize sessions from JSON
    sessions_str = row.get("sessions", "{}")
    try:
        sessions = json.loads(sessions_str) if sessions_str else {}
    except (json.JSONDecodeError, TypeError):
        sessions = {}

    # Convert sessions values to tuples
    sessions_dict: dict[str, tuple[str, str]] = {}
    for k, v in sessions.items():
        if isinstance(v, (list, tuple)) and len(v) == 2:
            sessions_dict[k] = (str(v[0]), str(v[1]))

    return ExchangeInfo(
        ticker=row["ticker"],
        mic=row.get("mic") if pd.notna(row.get("mic")) else None,
        exch_code=row.get("exch_code") if pd.notna(row.get("exch_code")) else None,
        timezone=row.get("timezone", "UTC"),
        utc_offset=float(row["utc_offset"]) if pd.notna(row.get("utc_offset")) else None,
        sessions=sessions_dict,
        source=row.get("source", "cache"),
        cached_at=_parse_cached_at(cached_at),
    )


def _parse_cached_at(cached_at: object) -> datetime | None:
    """Parse cached_at value to datetime, handling NaT and None."""
    if cached_at is None:
        return None
    try:
        if pd.isna(cached_at):
            return None
        if not isinstance(cached_at, (str, int, float, datetime, pd.Timestamp)):
            return None
        ts = pd.Timestamp(cached_at)
        if pd.isna(ts):
            return None
        result = ts.to_pydatetime()
        # to_pydatetime can return NaT for NaT input
        if pd.isna(result):
            return None
        return result
    except Exception:
        return None


# ============================================================================
# Cache Lookup
# ============================================================================
