from __future__ import annotations

import copy
import http.client
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Any, ClassVar


class RealtimeAggregateError(RuntimeError):
    pass


EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
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"
)


@dataclass
class WebRealtimeAggregator:
    timeout: int = 8
    retry_attempts: int = 3
    retry_delay_seconds: float = 0.2
    response_cache_ttl_seconds: int = 90
    _sector_cache: ClassVar[dict[str, Any]] = {}
    _sector_cache_lock: ClassVar[Lock] = Lock()
    _response_cache: ClassVar[dict[str, dict[str, Any]]] = {}
    _response_cache_lock: ClassVar[Lock] = Lock()

    def health_snapshot(self, sector: str = "") -> dict[str, Any]:
        started = time.perf_counter()
        sources: dict[str, dict[str, Any]] = {}
        indices: list[dict[str, Any]] = []
        sector_payload: dict[str, Any] | None = None

        indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices)
        if sector.strip():
            sector_payload, sources["eastmoney_sector"] = self._capture(
                lambda: self.eastmoney_sector(sector)
            )
        ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool)
        xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool)

        index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []]
        now = datetime.now().astimezone()
        max_skew = 120 if now.hour >= 15 else 15
        index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew
        ready = (
            bool(indices)
            and len(indices) == 3
            and index_consistent
            and (not sector.strip() or bool(sector_payload))
        )
        return {
            "ready": ready,
            "isolated": True,
            "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
            "elapsed_ms": round((time.perf_counter() - started) * 1000),
            "indices": indices or [],
            "index_consistent": index_consistent,
            "sector": sector_payload,
            "sources": sources,
            "observations": {
                "ths_limit_pool": ths_observation,
                "xgb_limit_pool": xgb_observation,
            },
            "policy": {
                "integration": "heaven_realtime_fallback",
                "max_index_time_skew_seconds": max_skew,
                "notice": "聚合源仅作为盘中观势的实时指数与板块外显，主行情快照仍由Tushare维护。",
            },
        }

    def eastmoney_indices(self) -> list[dict[str, Any]]:
        try:
            payload = self._get_json(
                EASTMONEY_INDEX_URL,
                {
                    "secids": "1.000001,0.399001,0.399006",
                    "fltt": "2",
                    "invt": "2",
                    "fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
                },
                referer="https://quote.eastmoney.com/",
            )
        except RealtimeAggregateError:
            return self.tencent_indices()
        cache_meta = payload.get("_aggregate_cache") or {}
        rows = list((payload.get("data") or {}).get("diff") or [])
        result = []
        for row in rows:
            code = str(row.get("f12") or "")
            if code not in {"000001", "399001", "399006"}:
                continue
            epoch = int(_number(row.get("f124")))
            result.append(
                {
                    "code": code,
                    "name": row.get("f14") or code,
                    "price": _number(row.get("f2")),
                    "change": _number(row.get("f3")),
                    "change_amount": _number(row.get("f4")),
                    "open": _number(row.get("f17")),
                    "high": _number(row.get("f15")),
                    "low": _number(row.get("f16")),
                    "previous_close": _number(row.get("f18")),
                    "amount_billion": round(_number(row.get("f6")) / 100000000, 2),
                    "quote_time_epoch": epoch,
                    "quote_time": (
                        datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
                        if epoch else ""
                    ),
                    "source": (
                        "eastmoney_push2_cache" if cache_meta else "eastmoney_push2"
                    ),
                    "cache_age_seconds": cache_meta.get("age_seconds", 0),
                }
            )
        if len(result) != 3:
            raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
        return result

    def tencent_indices(self) -> list[dict[str, Any]]:
        raw, cache_age = self._get_text(
            TENCENT_INDEX_URL,
            referer="https://gu.qq.com/",
            encoding="gb18030",
        )
        result = []
        for line in raw.splitlines():
            if '="' not in line:
                continue
            fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
            if len(fields) < 38:
                continue
            code = fields[2]
            if code not in {"000001", "399001", "399006"}:
                continue
            try:
                quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
            except ValueError as exc:
                raise RealtimeAggregateError(
                    f"Tencent returned invalid quote time for {code}"
                ) from exc
            result.append(
                {
                    "code": code,
                    "name": fields[1] or code,
                    "price": _number(fields[3]),
                    "change": _number(fields[32]),
                    "change_amount": _number(fields[31]),
                    "open": _number(fields[5]),
                    "high": _number(fields[33]),
                    "low": _number(fields[34]),
                    "previous_close": _number(fields[4]),
                    "amount_billion": round(_number(fields[37]) / 10000, 2),
                    "quote_time_epoch": int(quote_time.timestamp()),
                    "quote_time": quote_time.isoformat(timespec="seconds"),
                    "source": "tencent_qt_cache" if cache_age else "tencent_qt",
                    "cache_age_seconds": cache_age,
                }
            )
        if len(result) != 3:
            raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices")
        return result

    def eastmoney_sector(self, query: str) -> dict[str, Any]:
        target = _normalize_sector(query)
        candidates = self._eastmoney_sector_catalog()
        matched = _match_sector(candidates, target)
        if not matched:
            raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
        epoch = int(_number(matched.get("f124")))
        return {
            "code": matched.get("f12") or "",
            "name": matched.get("f14") or query,
            "price": _number(matched.get("f2")),
            "change": _number(matched.get("f3")),
            "change_amount": _number(matched.get("f4")),
            "turnover_rate": _number(matched.get("f8")),
            "up_count": int(_number(matched.get("f104"))),
            "down_count": int(_number(matched.get("f105"))),
            "leader": matched.get("f128") or "--",
            "leader_code": matched.get("f140") or "",
            "leading_pct": _number(matched.get("f136")),
            "quote_time_epoch": epoch,
            "quote_time": (
                datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
                if epoch else ""
            ),
            "source": "eastmoney_push2",
            "match_query": query,
        }

    def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]:
        now = time.time()
        with self._sector_cache_lock:
            cached = self._sector_cache.get("eastmoney")
        if cached and now - float(cached.get("created_at") or 0) < 600:
            return list(cached.get("rows") or [])

        def load_page(page: int) -> list[dict[str, Any]]:
            payload = self._get_json(
                EASTMONEY_SECTOR_URL,
                {
                    "pn": str(page),
                    "pz": "100",
                    "po": "1",
                    "np": "1",
                    "fltt": "2",
                    "invt": "2",
                    "fid": "f3",
                    "fs": "m:90+t:2",
                    "fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124",
                },
                referer="https://quote.eastmoney.com/center/boardlist.html",
            )
            return list((payload.get("data") or {}).get("diff") or [])

        with ThreadPoolExecutor(max_workers=5) as executor:
            pages = list(executor.map(load_page, range(1, 6)))
        rows = [row for page in pages for row in page]
        if not rows:
            raise RealtimeAggregateError("Eastmoney sector catalog is empty")
        with self._sector_cache_lock:
            self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows}
        return rows

    def ths_limit_pool(self) -> dict[str, Any]:
        payload = self._get_json(
            THS_LIMIT_URL,
            {"page": "1", "limit": "3", "field": "199112"},
            referer="https://data.10jqka.com.cn/limit_up/",
        )
        data = payload.get("data") or payload
        return {
            "available": True,
            "keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [],
            "source": "ths_web_dataapi",
        }

    def xgb_limit_pool(self) -> dict[str, Any]:
        payload = self._get_json(
            XGB_POOL_URL,
            {"pool_name": "limit_up"},
            referer="https://xuangubao.cn/",
        )
        data = payload.get("data") or {}
        rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or []
        return {
            "available": True,
            "count": len(rows) if isinstance(rows, list) else 0,
            "source": "xuangubao_web_api",
        }

    def _capture(self, operation):
        started = time.perf_counter()
        try:
            value = operation()
            return value, {
                "ok": True,
                "elapsed_ms": round((time.perf_counter() - started) * 1000),
                "error": "",
            }
        except Exception as exc:
            return None, {
                "ok": False,
                "elapsed_ms": round((time.perf_counter() - started) * 1000),
                "error": str(exc)[:500],
            }

    def _get_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
        attempts = max(1, int(self.retry_attempts))
        for attempt in range(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:
                    content_type = response.headers.get("Content-Type", "")
                    raw = response.read().decode("utf-8", errors="replace")
                if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")):
                    raise RealtimeAggregateError(
                        f"non-JSON response: {raw[:120].strip()}"
                    )
                payload = json.loads(raw)
                if not isinstance(payload, dict):
                    raise RealtimeAggregateError("unexpected response shape")
                if payload.get("rc") not in (None, 0):
                    raise RealtimeAggregateError(f"provider rc={payload.get('rc')}")
                with self._response_cache_lock:
                    self._response_cache[request_url] = {
                        "created_at": time.time(),
                        "payload": copy.deepcopy(payload),
                    }
                return payload
            except (
                urllib.error.URLError,
                TimeoutError,
                ConnectionError,
                OSError,
                http.client.HTTPException,
                json.JSONDecodeError,
                RealtimeAggregateError,
            ) as exc:
                last_error = exc
                if attempt + 1 < attempts and self.retry_delay_seconds > 0:
                    time.sleep(self.retry_delay_seconds * (attempt + 1))

        now = time.time()
        with self._response_cache_lock:
            cached = self._response_cache.get(request_url)
        cache_age = now - float((cached or {}).get("created_at") or 0)
        if cached and cache_age <= self.response_cache_ttl_seconds:
            payload = copy.deepcopy(cached.get("payload") or {})
            payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)}
            return payload
        raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error

    def _get_text(
        self,
        request_url: str,
        referer: str,
        encoding: str = "utf-8",
    ) -> tuple[str, float]:
        cache_key = f"text:{request_url}"
        last_error: Exception | None = None
        attempts = max(1, int(self.retry_attempts))
        for attempt in range(attempts):
            request = urllib.request.Request(
                request_url,
                headers={
                    "Accept": "text/plain,*/*",
                    "Connection": "close",
                    "Referer": referer,
                    "User-Agent": BROWSER_USER_AGENT,
                },
            )
            try:
                with urllib.request.urlopen(request, timeout=self.timeout) as response:
                    raw = response.read().decode(encoding, errors="replace")
                if not raw.strip():
                    raise RealtimeAggregateError("empty text response")
                with self._response_cache_lock:
                    self._response_cache[cache_key] = {
                        "created_at": time.time(),
                        "payload": raw,
                    }
                return raw, 0
            except (
                urllib.error.URLError,
                TimeoutError,
                ConnectionError,
                OSError,
                http.client.HTTPException,
                RealtimeAggregateError,
            ) as exc:
                last_error = exc
                if attempt + 1 < attempts and self.retry_delay_seconds > 0:
                    time.sleep(self.retry_delay_seconds * (attempt + 1))

        now = time.time()
        with self._response_cache_lock:
            cached = self._response_cache.get(cache_key)
        cache_age = now - float((cached or {}).get("created_at") or 0)
        if cached and cache_age <= self.response_cache_ttl_seconds:
            return str(cached.get("payload") or ""), round(cache_age, 1)
        raise RealtimeAggregateError(
            f"text request failed after {attempts} attempts: {last_error}"
        ) from last_error


def _normalize_sector(value: Any) -> str:
    text = str(value or "").strip().replace(" ", "")
    for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "（A股）"):
        text = text.replace(suffix, "")
    aliases = {"元器件": "元件", "电子元器件": "元件"}
    return aliases.get(text, text)


def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None:
    exact = [row for row in rows if _normalize_sector(row.get("f14")) == target]
    if exact:
        return min(exact, key=lambda row: len(str(row.get("f14") or "")))
    fuzzy = [
        row for row in rows
        if target and (
            target in _normalize_sector(row.get("f14"))
            or _normalize_sector(row.get("f14")) in target
        )
    ]
    return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None


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