"""DuckDB-backed OHLCV cache engine."""

from __future__ import annotations

import logging
import os
from pathlib import Path

import duckdb
import pandas as pd

from dyfinance._constants import ACTION_COLUMNS, ADJ_CLOSE, PRICE_COLUMNS
from dyfinance._utils import ensure_utc_index, normalize_index
from dyfinance._schema import (
    CREATE_CACHE_META_TABLE,
    CREATE_OHLCV_TABLE,
    CREATE_SCHEMA_VERSION_TABLE,
    SCHEMA_VERSION,
)

logger = logging.getLogger(__name__)

_DEFAULT_DB_PATH = Path.home() / ".dyfinance" / "cache.duckdb"

# Cache validation settings
STALE_THRESHOLD = pd.Timedelta(
    hours=float(os.environ.get("DYFINANCE_STALE_HOURS", "6")),
)
SPOT_CHECK_ROWS = 5
ADJ_CLOSE_RTOL = 1e-4


class CacheEngine:
    """Manages DuckDB-backed OHLCV cache."""

    _default_instance: CacheEngine | None = None

    def __init__(self, db_path: str | Path | None = None):
        self._db_path = str(db_path or os.environ.get("DYFINANCE_DB_PATH", _DEFAULT_DB_PATH))
        Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
        self._con = duckdb.connect(self._db_path)
        self._init_schema()

    @classmethod
    def default(cls) -> CacheEngine:
        if cls._default_instance is None:
            cls._default_instance = cls()
        return cls._default_instance

    @classmethod
    def reset_default(cls, db_path: str | Path | None = None) -> CacheEngine:
        """Reset the default instance (useful for testing)."""
        if cls._default_instance is not None:
            cls._default_instance.close()
        cls._default_instance = cls(db_path)
        return cls._default_instance

    def _init_schema(self) -> None:
        self._con.execute(CREATE_SCHEMA_VERSION_TABLE)
        self._con.execute(CREATE_OHLCV_TABLE)
        self._con.execute(CREATE_CACHE_META_TABLE)
        result = self._con.execute("SELECT MAX(version) FROM schema_version").fetchone()
        if result is None or result[0] is None:
            self._con.execute("INSERT INTO schema_version (version) VALUES (?)", [SCHEMA_VERSION])

    def get_cached_ohlcv(
        self,
        ticker: str,
        interval: str,
        start: pd.Timestamp,
        end: pd.Timestamp,
    ) -> tuple[pd.DataFrame, list[tuple[pd.Timestamp, pd.Timestamp]]]:
        """Return cached data and list of missing date ranges.

        Returns:
            (cached_df, missing_ranges) where missing_ranges is a list of
            (start, end) tuples for ranges not yet in the cache.
        """
        meta = self._con.execute(
            "SELECT cached_from, cached_to FROM cache_meta WHERE ticker = ? AND interval = ?",
            [ticker, interval],
        ).fetchone()

        if meta is None:
            return pd.DataFrame(), [(start, end)]

        cached_from = pd.Timestamp(meta[0]).tz_convert("UTC")
        cached_to = pd.Timestamp(meta[1]).tz_convert("UTC")

        missing: list[tuple[pd.Timestamp, pd.Timestamp]] = []
        query_start = start
        query_end = end

        if start < cached_from:
            missing.append((start, cached_from))
            query_start = cached_from
        if end > cached_to:
            missing.append((cached_to, end))
            # cached_to is the last actual data date (inclusive);
            # add 1 day so the ts < ? query still includes it.
            query_end = cached_to + pd.Timedelta(days=1)

        if query_start > query_end:
            return pd.DataFrame(), missing

        df = self._con.execute(
            """
            SELECT ts, open, high, low, close, adj_close, volume,
                   dividends, stock_splits, capital_gains
            FROM ohlcv
            WHERE ticker = ? AND interval = ? AND ts >= ? AND ts < ?
            ORDER BY ts
            """,
            [ticker, interval, query_start.to_pydatetime(), query_end.to_pydatetime()],
        ).fetchdf()

        if not df.empty:
            df = self._df_from_duckdb(df)

        return df, missing

    def store_ohlcv(self, ticker: str, interval: str, df: pd.DataFrame) -> None:
        """Store an OHLCV DataFrame into the DuckDB cache."""
        if df.empty:
            return

        df = df.sort_index()
        insert_df = self._df_to_insert_df(ticker, interval, df)

        self._con.register("_insert_buf", insert_df)
        self._con.execute(
            """
            INSERT OR REPLACE INTO ohlcv
            SELECT ticker, interval, ts, open, high, low, close, adj_close,
                   volume, dividends, stock_splits, capital_gains
            FROM _insert_buf
            """,
        )
        self._con.unregister("_insert_buf")

        normalized = normalize_index(pd.DatetimeIndex(df.index), interval)
        data_start = normalized.min()
        data_end = normalized.max()

        self._con.execute(
            """
            INSERT INTO cache_meta (ticker, interval, cached_from, cached_to)
            VALUES (?, ?, ?, ?)
            ON CONFLICT (ticker, interval) DO UPDATE SET
                cached_from = LEAST(cache_meta.cached_from, excluded.cached_from),
                cached_to   = GREATEST(cache_meta.cached_to, excluded.cached_to),
                fetched_at  = now()
            """,
            [ticker, interval, data_start.to_pydatetime(), data_end.to_pydatetime()],
        )

    @staticmethod
    def _df_to_insert_df(ticker: str, interval: str, df: pd.DataFrame) -> pd.DataFrame:
        """Convert a yfinance-style DataFrame to a DuckDB-ready DataFrame for bulk insert."""
        tmp = pd.DataFrame({
            "ticker": ticker,
            "interval": interval,
            "ts": normalize_index(pd.DatetimeIndex(df.index), interval).to_pydatetime(),
            "open": df.get("Open"),
            "high": df.get("High"),
            "low": df.get("Low"),
            "close": df.get("Close"),
            "adj_close": df.get(ADJ_CLOSE),
            "volume": df.get("Volume", pd.Series(0, index=df.index)).fillna(0).astype("int64"),
            "dividends": df.get(ACTION_COLUMNS[0], pd.Series(0.0, index=df.index)).fillna(0.0),
            "stock_splits": df.get(ACTION_COLUMNS[1], pd.Series(0.0, index=df.index)).fillna(0.0),
            "capital_gains": df.get(ACTION_COLUMNS[2], pd.Series(0.0, index=df.index)).fillna(0.0),
        })
        return tmp

    @staticmethod
    def _df_from_duckdb(df: pd.DataFrame) -> pd.DataFrame:
        """Convert DuckDB result back to yfinance-compatible DataFrame format."""
        df = df.rename(columns={
            "ts": "Date",
            "open": PRICE_COLUMNS[0],
            "high": PRICE_COLUMNS[1],
            "low": PRICE_COLUMNS[2],
            "close": PRICE_COLUMNS[3],
            "adj_close": ADJ_CLOSE,
            "volume": "Volume",
            "dividends": ACTION_COLUMNS[0],
            "stock_splits": ACTION_COLUMNS[1],
            "capital_gains": ACTION_COLUMNS[2],
        })
        df = df.set_index("Date")
        df.index = ensure_utc_index(pd.DatetimeIndex(df.index, name="Date"))
        df["Volume"] = df["Volume"].astype("int64")
        return df

    def get_fetched_at(self, ticker: str, interval: str) -> pd.Timestamp | None:
        """Return the fetched_at timestamp for a (ticker, interval) pair."""
        row = self._con.execute(
            "SELECT fetched_at FROM cache_meta WHERE ticker = ? AND interval = ?",
            [ticker, interval],
        ).fetchone()
        if row is None:
            return None
        ts: pd.Timestamp = pd.Timestamp(row[0]).tz_convert("UTC")
        return ts

    def touch_fetched_at(self, ticker: str, interval: str) -> None:
        """Update fetched_at to now() without changing cached data."""
        self._con.execute(
            "UPDATE cache_meta SET fetched_at = now() WHERE ticker = ? AND interval = ?",
            [ticker, interval],
        )

    def invalidate(self, ticker: str, interval: str | None = None) -> None:
        """Delete cached data for a ticker (all intervals or a specific one)."""
        if interval is not None:
            self._con.execute(
                "DELETE FROM ohlcv WHERE ticker = ? AND interval = ?",
                [ticker, interval],
            )
            self._con.execute(
                "DELETE FROM cache_meta WHERE ticker = ? AND interval = ?",
                [ticker, interval],
            )
        else:
            self._con.execute("DELETE FROM ohlcv WHERE ticker = ?", [ticker])
            self._con.execute("DELETE FROM cache_meta WHERE ticker = ?", [ticker])

    def apply_adj_ratio(self, ticker: str, interval: str, ratio: float) -> None:
        """Apply a uniform Adj Close correction ratio to all cached rows."""
        self._con.execute(
            "UPDATE ohlcv SET adj_close = adj_close * ? "
            "WHERE ticker = ? AND interval = ?",
            [ratio, ticker, interval],
        )
        self.touch_fetched_at(ticker, interval)

    def get_cached_adj_close(
        self, ticker: str, interval: str, n: int = SPOT_CHECK_ROWS,
    ) -> pd.Series:
        """Return the last N cached Adj Close values (by date descending)."""
        df = self._con.execute(
            "SELECT ts, adj_close FROM ohlcv "
            "WHERE ticker = ? AND interval = ? "
            "ORDER BY ts DESC LIMIT ?",
            [ticker, interval, n],
        ).fetchdf()
        if df.empty:
            return pd.Series(dtype="float64")
        df.index = ensure_utc_index(pd.DatetimeIndex(df["ts"]))
        return df["adj_close"].sort_index()

    def get_cached_ohlcv_batch(
        self,
        tickers: list[str],
        interval: str,
        start: pd.Timestamp,
        end: pd.Timestamp,
    ) -> tuple[dict[str, pd.DataFrame], list[str]]:
        """Batch-read cached data for multiple tickers in a single query.

        Returns:
            (cached_dfs, uncovered_tickers) where cached_dfs maps
            ticker -> DataFrame for fully-cached tickers, and
            uncovered_tickers need per-ticker fetch.
        """
        if not tickers:
            return {}, []

        # Batch-check cache_meta
        placeholders = ", ".join(["?"] * len(tickers))
        meta_rows = self._con.execute(
            f"SELECT ticker, cached_from, cached_to FROM cache_meta "
            f"WHERE ticker IN ({placeholders}) AND interval = ?",
            [*tickers, interval],
        ).fetchall()

        meta_map = {
            row[0]: (
                pd.Timestamp(row[1]).tz_convert("UTC"),
                pd.Timestamp(row[2]).tz_convert("UTC"),
            )
            for row in meta_rows
        }

        covered: list[str] = []
        uncovered: list[str] = []
        # Allow up to 5 calendar days gap at the end to account for
        # weekends and holidays (market closed → no new data to fetch).
        end_tolerance = pd.Timedelta(days=5)
        for t in tickers:
            if t not in meta_map:
                uncovered.append(t)
                continue
            cf, ct = meta_map[t]
            if start >= cf and end <= ct + end_tolerance:
                covered.append(t)
            else:
                uncovered.append(t)

        if not covered:
            return {}, uncovered

        # Single query for all covered tickers
        placeholders = ", ".join(["?"] * len(covered))
        df = self._con.execute(
            f"SELECT ticker, ts, open, high, low, close, adj_close, volume, "
            f"dividends, stock_splits, capital_gains "
            f"FROM ohlcv "
            f"WHERE ticker IN ({placeholders}) AND interval = ? "
            f"AND ts >= ? AND ts < ? "
            f"ORDER BY ticker, ts",
            [*covered, interval, start.to_pydatetime(), end.to_pydatetime()],
        ).fetchdf()

        result: dict[str, pd.DataFrame] = {}
        for t in covered:
            t_df = df[df["ticker"] == t].drop(columns=["ticker"]).copy()
            if not t_df.empty:
                result[t] = self._df_from_duckdb(t_df.reset_index(drop=True))
            else:
                result[t] = pd.DataFrame()

        return result, uncovered

    def close(self) -> None:
        self._con.close()
