from __future__ import annotations

import http.client
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, time as dt_time, timedelta
from threading import Lock
from typing import Any, ClassVar

from backend.bootstrap.config import tushare_code as _stock_market_code
from backend.data.providers.ifind_client import IfindError, IfindHttpClient


class ChartDataError(RuntimeError):
    pass


TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
BROWSER_USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/138.0.0.0 Safari/537.36"
)
INDEX_SECIDS = {
    "000001.SH": "1.000001",
    "399001.SZ": "0.399001",
    "399006.SZ": "0.399006",
}


class MarketChartClient:
    """Prefer iFinD for display charts and retain Eastmoney as a last resort."""

    def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
        self.ifind = ifind
        self.fallback = fallback

    def stock_intraday(self, code: str) -> dict[str, Any]:
        normalized = str(code or "").strip()
        if not re.fullmatch(r"\d{6}", normalized):
            raise ChartDataError("Invalid stock code")
        ifind_code = _stock_market_code(normalized)
        try:
            return self._ifind_intraday(ifind_code, "stock", normalized)
        except (IfindError, ChartDataError):
            return self.fallback.stock_intraday(normalized)

    def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
        normalized = str(code or "").strip()
        if not re.fullmatch(r"\d{6}", normalized):
            raise ChartDataError("Invalid stock code")
        return self._ifind_daily(_stock_market_code(normalized), end_date, limit)

    def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
        normalized = str(identifier or "").strip().upper()
        if normalized not in INDEX_SECIDS:
            raise ChartDataError("Unsupported index")
        return self._ifind_daily(normalized, end_date, limit)

    def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
        normalized = str(identifier or "").strip().upper()
        if not normalized:
            raise ChartDataError("Invalid board code")
        return self._ifind_daily(normalized, end_date, limit)

    def index_intraday(self, identifier: str) -> dict[str, Any]:
        normalized = str(identifier or "").strip().upper()
        if normalized not in INDEX_SECIDS:
            raise ChartDataError("Unsupported index")
        try:
            return self._ifind_intraday(normalized, "index", normalized)
        except (IfindError, ChartDataError):
            return self.fallback.index_intraday(normalized)

    def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
        normalized = str(identifier or "").strip().upper()
        try:
            return self._ifind_intraday(normalized, "board", normalized, name)
        except (IfindError, ChartDataError):
            return self.fallback.board_intraday(normalized, name)

    def _ifind_intraday(
        self,
        ifind_code: str,
        entity_type: str,
        identifier: str,
        name: str = "",
    ) -> dict[str, Any]:
        if not self.ifind.configured:
            raise ChartDataError("iFinD is not configured")
        now = datetime.now().astimezone()
        rows: list[dict[str, Any]] = []
        for offset in range(0, 8):
            candidate = now.date() - timedelta(days=offset)
            if candidate.weekday() >= 5:
                continue
            display_date = candidate.isoformat()
            rows = self.ifind.intraday(
                ifind_code,
                f"{display_date} 09:30:00",
                f"{display_date} 15:00:00",
                cache_ttl=20 if offset == 0 else 6 * 60 * 60,
            )
            if rows:
                break
        points = [point for row in rows if (point := _ifind_point(row))]
        if not points:
            raise ChartDataError("No iFinD intraday chart data returned")
        latest_date = points[-1]["date"]
        points = [point for point in points if point["date"] == latest_date]
        previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
        return {
            "entity_type": entity_type,
            "identifier": identifier,
            "name": name,
            "code": identifier,
            "trade_date": latest_date,
            "previous_close": previous_close,
            "points": points,
            "source": "ifind",
        }

    def _ifind_daily(
        self, ifind_code: str, end_date: str, limit: int
    ) -> list[dict[str, Any]]:
        if not self.ifind.configured:
            raise ChartDataError("iFinD is not configured")
        compact_end = str(end_date or "").replace("-", "")
        if not re.fullmatch(r"\d{8}", compact_end):
            raise ChartDataError("Invalid chart end date")
        end = datetime.strptime(compact_end, "%Y%m%d")
        start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
        try:
            rows = self.ifind.history(
                ifind_code,
                ["open", "high", "low", "close", "volume", "amount"],
                start,
                compact_end,
                cache_ttl=300,
            )
        except IfindError as exc:
            raise ChartDataError("No iFinD daily chart data returned") from exc
        normalized = []
        for row in rows:
            stamp = str(row.get("time") or "").strip()
            trade_date = stamp[:10]
            close = _number(row.get("close"))
            if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
                continue
            normalized.append(
                {
                    "trade_date": trade_date,
                    "open": _number(row.get("open")),
                    "high": _number(row.get("high")),
                    "low": _number(row.get("low")),
                    "close": close,
                    "volume": _number(row.get("volume")),
                    "amount_billion": _number(row.get("amount")) / 100_000_000,
                }
            )
        normalized.sort(key=lambda row: row["trade_date"])
        for index, row in enumerate(normalized):
            previous = normalized[index - 1]["close"] if index > 0 else 0
            row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0

        market_now = datetime.now().astimezone()
        today = market_now.strftime("%Y%m%d")
        market_open = (
            market_now.weekday() < 5
            and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
        )
        today_display = market_now.date().isoformat()
        if normalized and normalized[-1]["trade_date"] == today_display:
            current_bar = normalized[-1]
            current_bar_is_valid = (
                current_bar["open"] > 0
                and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
                and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
                and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
            )
            if not market_open or not current_bar_is_valid:
                normalized.pop()
        if compact_end == today and market_open:
            try:
                quote_rows = self.ifind.real_time(
                    ifind_code,
                    ["open", "high", "low", "latest", "preClose", "volume", "amount"],
                    cache_ttl=10,
                )
                quote = quote_rows[0] if quote_rows else {}
                latest = _number(quote.get("latest"))
                previous = _number(quote.get("preClose"))
                open_price = _number(quote.get("open"))
                high = _number(quote.get("high"))
                low = _number(quote.get("low"))
                volume = _number(quote.get("volume"))
                amount = _number(quote.get("amount"))
                quote_date = str(quote.get("time") or "")[:10].replace("-", "")
                quote_is_current = not quote_date or quote_date == today
                has_market_activity = volume > 0 or amount > 0
                if (
                    latest > 0
                    and open_price > 0
                    and high >= max(open_price, latest)
                    and 0 < low <= min(open_price, latest)
                    and has_market_activity
                    and quote_is_current
                ):
                    realtime = {
                        "trade_date": end.strftime("%Y-%m-%d"),
                        "open": open_price,
                        "high": high,
                        "low": low,
                        "close": latest,
                        "change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
                        "volume": volume,
                        "amount_billion": amount / 100_000_000,
                        "realtime": True,
                    }
                    if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
                        normalized[-1] = realtime
                    else:
                        normalized.append(realtime)
            except IfindError:
                pass
        if not normalized:
            raise ChartDataError("No iFinD daily chart data returned")
        return normalized[-max(20, min(180, int(limit))):]

    def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
        today = datetime.now().astimezone().date().isoformat()
        if trade_date == today:
            try:
                quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
                value = _number((quote[0] if quote else {}).get("preClose"))
                if value > 0:
                    return value
            except IfindError:
                pass
        end = datetime.strptime(trade_date, "%Y-%m-%d")
        try:
            rows = self.ifind.history(
                code,
                ["close"],
                (end - timedelta(days=12)).strftime("%Y%m%d"),
                end.strftime("%Y%m%d"),
                cache_ttl=6 * 60 * 60,
            )
            closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
            if len(closes) >= 2:
                return closes[-2]
        except IfindError:
            pass
        return fallback


@dataclass
class EastmoneyChartClient:
    """Isolated display-only minute chart source.

    The returned data must not be used by market snapshots, scoring, screening,
    or divination. Its only consumer is a chart-rendering endpoint.
    """

    timeout: int = 6
    cache_ttl_seconds: int = 20
    retry_attempts: int = 2
    _cache: ClassVar[dict[str, dict[str, Any]]] = {}
    _cache_lock: ClassVar[Lock] = Lock()
    _board_catalog: ClassVar[dict[str, dict[str, str]]] = {}
    _board_catalog_at: ClassVar[float] = 0.0
    _board_catalog_lock: ClassVar[Lock] = Lock()

    def stock_intraday(self, code: str) -> dict[str, Any]:
        normalized = str(code or "").strip()
        if not re.fullmatch(r"\d{6}", normalized):
            raise ChartDataError("Invalid stock code")
        market = "1" if normalized.startswith(("5", "6", "9")) else "0"
        return self._intraday(f"{market}.{normalized}", "stock", normalized)

    def index_intraday(self, identifier: str) -> dict[str, Any]:
        normalized = str(identifier or "").strip().upper()
        secid = INDEX_SECIDS.get(normalized)
        if not secid:
            raise ChartDataError("Unsupported index")
        return self._intraday(secid, "index", normalized)

    def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
        normalized = str(identifier or "").strip().upper()
        if re.fullmatch(r"BK\d{4}", normalized):
            board_code = normalized
        else:
            board_code = self._resolve_board_code(name or identifier)
        return self._intraday(f"90.{board_code}", "board", board_code)

    def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]:
        cache_key = f"{entity_type}:{identifier}"
        cached = self._get_cached(cache_key)
        if cached is not None:
            return cached

        payload = self._request_json(
            TRENDS_URL,
            {
                "secid": secid,
                "fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
                "fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
                "iscr": "0",
                "ndays": "1",
            },
            "https://quote.eastmoney.com/",
        )
        data = payload.get("data") or {}
        points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
        if not points:
            raise ChartDataError("No intraday chart data returned")

        result = {
            "entity_type": entity_type,
            "identifier": identifier,
            "name": str(data.get("name") or ""),
            "code": str(data.get("code") or identifier),
            "trade_date": points[-1]["date"],
            "previous_close": _number(data.get("preClose")),
            "points": points,
        }
        with self._cache_lock:
            self._cache[cache_key] = {"created_at": time.time(), "payload": result}
        return result

    def _get_cached(self, cache_key: str) -> dict[str, Any] | None:
        with self._cache_lock:
            cached = self._cache.get(cache_key)
        if not cached:
            return None
        if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds:
            with self._cache_lock:
                self._cache.pop(cache_key, None)
            return None
        return dict(cached["payload"])

    def _resolve_board_code(self, name: str) -> str:
        normalized = _normalize_name(name)
        if not normalized:
            raise ChartDataError("Board name is required")
        catalog = self._load_board_catalog()
        item = catalog.get(normalized)
        if not item:
            raise ChartDataError("No matching chart board")
        return item["code"]

    def _load_board_catalog(self) -> dict[str, dict[str, str]]:
        now = time.time()
        with self._board_catalog_lock:
            if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60:
                return dict(self._board_catalog)

        rows: list[dict[str, Any]] = []
        for board_type in ("1", "2", "3"):
            for page in range(1, 6):
                payload = self._request_json(
                    BOARD_LIST_URL,
                    {
                        "pn": str(page),
                        "pz": "100",
                        "po": "1",
                        "np": "1",
                        "fltt": "2",
                        "invt": "2",
                        "fid": "f3",
                        "fs": f"m:90+t:{board_type}",
                        "fields": "f12,f14",
                    },
                    "https://quote.eastmoney.com/center/boardlist.html",
                )
                page_rows = (payload.get("data") or {}).get("diff") or []
                rows.extend(page_rows)
                if len(page_rows) < 100:
                    break

        catalog: dict[str, dict[str, str]] = {}
        for row in rows:
            code = str(row.get("f12") or "").strip().upper()
            board_name = str(row.get("f14") or "").strip()
            if re.fullmatch(r"BK\d{4}", code) and board_name:
                catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name})
        if not catalog:
            raise ChartDataError("Board chart directory is unavailable")
        with self._board_catalog_lock:
            type(self)._board_catalog = catalog
            type(self)._board_catalog_at = now
        return dict(catalog)

    def _request_json(
        self, url: str, params: dict[str, str], referer: str
    ) -> dict[str, Any]:
        request_url = f"{url}?{urllib.parse.urlencode(params)}"
        last_error: Exception | None = None
        for attempt in range(max(1, int(self.retry_attempts))):
            request = urllib.request.Request(
                request_url,
                headers={
                    "Accept": "application/json,text/plain,*/*",
                    "Connection": "close",
                    "Referer": referer,
                    "User-Agent": BROWSER_USER_AGENT,
                },
            )
            try:
                with urllib.request.urlopen(request, timeout=self.timeout) as response:
                    payload = json.loads(response.read().decode("utf-8"))
                if not isinstance(payload, dict):
                    raise ChartDataError("Invalid intraday chart response")
                return payload
            except (
                urllib.error.URLError,
                TimeoutError,
                ConnectionError,
                OSError,
                http.client.HTTPException,
                json.JSONDecodeError,
                ChartDataError,
            ) as exc:
                last_error = exc
                if attempt + 1 < self.retry_attempts:
                    time.sleep(0.12)
        raise ChartDataError("Intraday chart request failed") from last_error


def _parse_trend(raw: Any) -> dict[str, Any] | None:
    fields = str(raw or "").split(",")
    if len(fields) < 8 or " " not in fields[0]:
        return None
    stamp = fields[0].strip()
    trade_date, trade_time = stamp.split(" ", 1)
    close = _number(fields[2])
    if close <= 0:
        return None
    return {
        "date": trade_date,
        "time": trade_time[:5],
        "open": _number(fields[1]),
        "close": close,
        "high": _number(fields[3]),
        "low": _number(fields[4]),
        "volume": _number(fields[5]),
        "amount": _number(fields[6]),
        "average": _number(fields[7]),
    }


def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
    stamp = str(row.get("time") or "").strip()
    if " " not in stamp:
        return None
    trade_date, trade_time = stamp.split(" ", 1)
    close = _number(row.get("close"))
    if close <= 0:
        return None
    return {
        "date": trade_date,
        "time": trade_time[:5],
        "open": _number(row.get("open")),
        "close": close,
        "high": _number(row.get("high")),
        "low": _number(row.get("low")),
        "volume": _number(row.get("volume")),
        "amount": _number(row.get("amount")),
        "average": _number(row.get("avgPrice")),
    }


def _number(value: Any) -> float:
    try:
        return float(value or 0)
    except (TypeError, ValueError):
        return 0.0


def _normalize_name(value: Any) -> str:
    normalized = re.sub(r"[\s·・（）()\-_/]", "", str(value or "")).casefold()
    return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized)
