# app/src/integrations/exchange/market_data_runner.py

from __future__ import annotations

import asyncio
import time
import traceback
from dataclasses import dataclass
from typing import Callable

from src.core.types import JsonDict
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
from src.market_data.acquisition.adapters.dzengi.websocket import (
    DzengiWebSocketQuoteAdapter,
)
from src.market_data.acquisition.exceptions import (
    MarketDataAcquisitionError,
)
from src.storage.quote_store import (
    QUOTE_RUNTIME_SOURCE_NAME,
    get_quote_store,
)
from src.trading.journal.service import JournalService


@dataclass
class MarketRuntimeContext:
    runtime_key: str
    task: asyncio.Task | None
    interval_seconds: int
    symbol_provider: Callable[[], str | None]
    screen: str | None
    action: str
    runtime_label: str | None
    last_market_status: str | None = None

    # Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками.
    last_status_error_key: str | None = None
    last_stream_state: str | None = None
    last_stream_error_key: str | None = None
    last_rest_state: str | None = None
    last_rest_error_key: str | None = None

    last_ws_debug_logged_at: float = 0.0


class MarketDataRunner:
    _runtimes: dict[str, MarketRuntimeContext] = {}

    # Global dedupe runtime-событий между start/stop runtime.
    _global_runtime_event_timestamps: dict[str, float] = {}

    # Минимальный интервал повторного логирования одного runtime-состояния.
    # Состояние в UI может меняться чаще, но журнал не должен разрастаться.
    _runtime_log_cooldown_seconds = 300

    @classmethod
    def get_runtime_state(cls, runtime_key: str = "default") -> dict[str, object]:
        context = cls._runtimes.get(runtime_key)

        if context is None:
            return {
                "stream_state": None,
                "stream_error": None,
                "rest_state": None,
            }

        return {
            "stream_state": context.last_stream_state,
            "stream_error": context.last_stream_error_key,
            "rest_state": context.last_rest_state,
        }
    
    @classmethod
    def _can_log_runtime_event(
        cls,
        event_key: str,
    ) -> bool:
        now = time.monotonic()

        last_logged_at = cls._global_runtime_event_timestamps.get(event_key)

        if last_logged_at is not None:
            if (
                now - last_logged_at
            ) < cls._runtime_log_cooldown_seconds:
                return False

        cls._global_runtime_event_timestamps[event_key] = now
        return True

    @classmethod
    def start(
        cls,
        *,
        symbol_provider: Callable[[], str | None],
        interval_seconds: int = 1,
        runtime_key: str = "default",
        screen: str | None = None,
        action: str = "market_data",
        runtime_label: str | None = None,
    ) -> None:
        existing = cls._runtimes.get(runtime_key)

        if (
            existing is not None
            and existing.task is not None
            and not existing.task.done()
        ):
            existing.symbol_provider = symbol_provider
            existing.interval_seconds = interval_seconds
            existing.screen = screen
            existing.action = action
            existing.runtime_label = runtime_label
            return

        context = MarketRuntimeContext(
            runtime_key=runtime_key,
            task=None,
            interval_seconds=interval_seconds,
            symbol_provider=symbol_provider,
            screen=screen,
            action=action,
            runtime_label=runtime_label,
        )

        cls._runtimes[runtime_key] = context

        cls._log_info(
            context,
            "market_monitor_started",
            "Мониторинг рынка запущен.",
            {"interval_seconds": interval_seconds},
        )

        context.task = asyncio.create_task(cls._worker(context))

    @classmethod
    def stop(cls, runtime_key: str | None = None) -> None:
        if runtime_key is None:
            for key in list(cls._runtimes.keys()):
                cls.stop(key)
            return

        context = cls._runtimes.get(runtime_key)
        if context is None:
            return

        if context.task is not None:
            context.task.cancel()
            context.task = None

        cls._log_info(
            context,
            "market_monitor_stopped",
            "Мониторинг рынка остановлен.",
        )

        cls._runtimes.pop(runtime_key, None)

    @classmethod
    async def _worker(cls, context: MarketRuntimeContext) -> None:
        last_symbol: str | None = None

        try:
            while True:
                symbol = context.symbol_provider()

                if not symbol:
                    await asyncio.sleep(context.interval_seconds)
                    continue

                cache_symbol = cls._cache_symbol(symbol)
                ws_symbol = cls._ws_symbol(symbol)

                if symbol != last_symbol:
                    last_symbol = symbol

                    if not cls._is_cache_symbol_used_by_other_runtime(
                        runtime_key=context.runtime_key,
                        cache_symbol=cache_symbol,
                    ):
                        get_quote_store().clear(
                            source_name=QUOTE_RUNTIME_SOURCE_NAME,
                            symbol=cache_symbol,
                        )

                try:
                    market_status = ExchangeService().get_symbol_market_status(symbol)

                except asyncio.CancelledError:
                    raise

                except Exception as exc:
                    error_key = f"{type(exc).__name__}:{str(exc)}"

                    if context.last_status_error_key != error_key:
                        context.last_status_error_key = error_key

                        cls._log_warning(
                            context,
                            "market_status_unavailable",
                            "Статус рынка временно недоступен.",
                            {
                                "symbol": symbol,
                                "cache_symbol": cache_symbol,
                                "ws_symbol": ws_symbol,
                                "error": str(exc),
                                "error_type": type(exc).__name__,
                                "traceback": traceback.format_exc(limit=5),
                            },
                        )

                    await asyncio.sleep(context.interval_seconds)
                    continue

                if context.last_status_error_key is not None:
                    context.last_status_error_key = None

                    cls._log_info(
                        context,
                        "market_status_restored",
                        "Статус рынка снова доступен.",
                        {
                            "symbol": symbol,
                            "cache_symbol": cache_symbol,
                            "ws_symbol": ws_symbol,
                        },
                    )

                status_key = str(market_status.get("status") or "UNKNOWN")

                if not bool(market_status.get("is_open")):
                    if context.last_market_status != status_key:
                        context.last_market_status = status_key

                        cls._log_warning(
                            context,
                            "market_closed",
                            "Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
                            {
                                "symbol": symbol,
                                "market_status": status_key,
                                "message": market_status.get("message"),
                            },
                        )

                    await asyncio.sleep(context.interval_seconds)
                    continue

                context.last_market_status = status_key

                try:
                    await cls._run_websocket(context, symbol)

                except asyncio.CancelledError:
                    raise

                except Exception as exc:
                    error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"

                    should_log_disconnected = (
                        (
                            context.last_stream_state != "DISCONNECTED"
                            or context.last_stream_error_key != error_key
                        )
                        and cls._can_log_runtime_event(
                            f"market_stream_disconnected:{symbol}:{error_key}"
                        )
                    )

                    context.last_stream_state = "DISCONNECTED"
                    context.last_stream_error_key = error_key

                    if should_log_disconnected:

                        cls._log_warning(
                            context,
                            "market_stream_disconnected",
                            "Live-поток рыночных данных отключён. Используется REST-режим.",
                            {
                                "symbol": symbol,
                                "cache_symbol": cache_symbol,
                                "ws_symbol": ws_symbol,
                                "error": str(exc),
                                "error_type": type(exc).__name__,
                                "traceback": traceback.format_exc(limit=5),
                            },
                        )

                    await cls._rest_fallback_once(context, symbol)
                    await asyncio.sleep(context.interval_seconds)

        except asyncio.CancelledError:
            # stop() уже пишет market_monitor_stopped.
            # Здесь не логируем, чтобы в журнале не было дубля остановки.
            raise

    @classmethod
    async def _run_websocket(
        cls,
        context: MarketRuntimeContext,
        symbol: str,
    ) -> None:
        cache_symbol = cls._cache_symbol(symbol)
        ws_symbol = cls._ws_symbol(symbol)

        valid_payload_count = 0
        invalid_payload_count = 0
        adapter = DzengiWebSocketQuoteAdapter()

        async for payload in ExchangeWebSocketClient().stream_depth(
            ws_symbol,
            interval_seconds=context.interval_seconds,
        ):
            current_symbol = context.symbol_provider()
            if current_symbol and current_symbol != symbol:
                break

            try:
                quote = adapter.map_message(payload)
            except MarketDataAcquisitionError:
                invalid_payload_count += 1

                if invalid_payload_count >= 5:
                    raise RuntimeError(
                        "WebSocket depth stream does not contain valid quotes."
                    )

                continue

            if quote.symbol.strip().upper() != cache_symbol.strip().upper():
                invalid_payload_count += 1

                if invalid_payload_count >= 5:
                    raise RuntimeError(
                        "WebSocket depth stream returned another symbol."
                    )

                continue

            invalid_payload_count = 0
            best_bid = float(quote.bid_price)
            best_ask = float(quote.ask_price)

            if valid_payload_count == 0:
                should_log_connected = (
                    context.last_stream_state != "CONNECTED"
                    and cls._can_log_runtime_event(
                        f"market_stream_connected:{symbol}"
                    )
                )

                context.last_stream_state = "CONNECTED"
                context.last_stream_error_key = None
                context.last_rest_state = None
                context.last_rest_error_key = None

                if should_log_connected:
                    cls._log_info(
                        context,
                        "market_stream_connected",
                        "Live-поток рыночных данных подключён.",
                        {
                            "requested_symbol": symbol,
                            "cache_symbol": cache_symbol,
                            "ws_symbol": ws_symbol,
                            "bid_price": best_bid,
                            "ask_price": best_ask,
                            "payload_keys": list(payload.keys()),
                            "payload_preview": cls._safe_payload_preview(
                                cls._extract_depth_payload(payload)
                            ),
                        },
                    )

            valid_payload_count += 1

            get_quote_store().set(
                QUOTE_RUNTIME_SOURCE_NAME,
                quote,
                runtime_key=context.runtime_key,
            )

            cls._log_ws_depth_debug(
                context=context,
                symbol=symbol,
                cache_symbol=cache_symbol,
                ws_symbol=ws_symbol,
                best_bid=best_bid,
                best_ask=best_ask,
                valid_payload_count=valid_payload_count,
            )

    @classmethod
    async def _rest_fallback_once(
        cls,
        context: MarketRuntimeContext,
        symbol: str,
    ) -> None:
        try:
            await asyncio.to_thread(
                ExchangeService().refresh_quote_cache,
                symbol,
                runtime_key=context.runtime_key,
            )

            should_log_rest_available = (
                context.last_rest_state != "AVAILABLE"
                and cls._can_log_runtime_event(
                    f"market_rest_fallback_available:{symbol}"
                )
            )

            context.last_rest_state = "AVAILABLE"
            context.last_rest_error_key = None

            if should_log_rest_available:

                cls._log_info(
                    context,
                    "market_rest_fallback_available",
                    "REST-режим рыночных данных доступен. Live-поток пока недоступен.",
                    {
                        "symbol": symbol,
                        "cache_symbol": cls._cache_symbol(symbol),
                        "ws_symbol": cls._ws_symbol(symbol),
                    },
                )

        except Exception as exc:
            error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"

            should_log_rest_unavailable = (
                (
                    context.last_rest_state != "UNAVAILABLE"
                    or context.last_rest_error_key != error_key
                )
                and cls._can_log_runtime_event(
                    f"market_rest_fallback_unavailable:{symbol}:{error_key}"
                )
            )

            context.last_rest_state = "UNAVAILABLE"
            context.last_rest_error_key = error_key

            if should_log_rest_unavailable:

                cls._log_error(
                    context,
                    "market_rest_fallback_unavailable",
                    "Live-поток отключён. REST-режим рыночных данных недоступен.",
                    {
                        "symbol": symbol,
                        "error": str(exc),
                        "error_type": type(exc).__name__,
                        "traceback": traceback.format_exc(limit=5),
                    },
                )

    @classmethod
    def _is_cache_symbol_used_by_other_runtime(
        cls,
        *,
        runtime_key: str,
        cache_symbol: str,
    ) -> bool:
        for key, context in cls._runtimes.items():
            if key == runtime_key:
                continue

            try:
                symbol = context.symbol_provider()
            except Exception:
                continue

            if symbol and cls._cache_symbol(symbol) == cache_symbol:
                return True

        return False

    @classmethod
    def _cache_symbol(cls, symbol: str) -> str:
        try:
            validation = ExchangeService().validate_symbol(symbol)
            if validation.is_valid:
                return validation.normalized_symbol

        except Exception:
            pass

        return symbol

    @classmethod
    def _ws_symbol(cls, symbol: str) -> str:
        return cls._cache_symbol(symbol)

    @classmethod
    def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
        data: object = payload

        for key in ("payload", "Payload"):
            if isinstance(data, dict) and isinstance(data.get(key), dict):
                data = data.get(key)

        if isinstance(data, dict):
            for key in ("payload", "Payload"):
                nested = data.get(key)
                if isinstance(nested, dict):
                    return nested

            return data

        return payload

    @classmethod
    def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
        preview: JsonDict = {}

        for key, value in payload.items():
            if key in {"bids", "asks"} and isinstance(value, list):
                preview[key] = value[:2]

            elif key == "payload" and isinstance(value, dict):
                inner_preview: JsonDict = {}

                for inner_key, inner_value in value.items():
                    if (
                        inner_key in {"bids", "asks"}
                        and isinstance(inner_value, list)
                    ):
                        inner_preview[inner_key] = inner_value[:2]
                    else:
                        inner_preview[inner_key] = inner_value

                preview[key] = inner_preview

            else:
                preview[key] = value

        return preview

    @classmethod
    def _log_ws_depth_debug(
        cls,
        *,
        context: MarketRuntimeContext,
        symbol: str,
        cache_symbol: str,
        ws_symbol: str,
        best_bid: float,
        best_ask: float,
        valid_payload_count: int,
    ) -> None:
        now = time.monotonic()

        if now - context.last_ws_debug_logged_at < 60:
            return

        context.last_ws_debug_logged_at = now

        cls._log_debug(
            context,
            "ws_depth_alive",
            "WS depth поток активен.",
            {
                "symbol": symbol,
                "cache_symbol": cache_symbol,
                "ws_symbol": ws_symbol,
                "runtime_key": context.runtime_key,
                "bid_price": best_bid,
                "ask_price": best_ask,
                "spread_percent": cls._spread_percent(best_bid, best_ask),
                "valid_payload_count": valid_payload_count,
                "source": f"ws_depth:{context.runtime_key}",
            },
        )

    @classmethod
    def _spread_percent(cls, bid_price: float, ask_price: float) -> float | None:
        mid_price = (bid_price + ask_price) / 2

        if mid_price <= 0:
            return None

        return round(((ask_price - bid_price) / mid_price) * 100, 5)

    @classmethod
    def _log_debug(
        cls,
        context: MarketRuntimeContext,
        event_type: str,
        message: str,
        payload: JsonDict | None = None,
    ) -> None:
        try:
            if context.screen:
                JournalService().log_ui_debug(
                    event_type=event_type,
                    message=cls._message(context, message),
                    screen=context.screen,
                    action=context.action,
                    payload=cls._payload(context, payload),
                )
                return

            JournalService().log_debug(
                event_type,
                cls._message(context, message),
                cls._payload(context, payload),
            )

        except Exception:
            pass
        
    @classmethod
    def _message(
        cls,
        context: MarketRuntimeContext,
        message: str,
    ) -> str:
        return message

    @classmethod
    def _payload(
        cls,
        context: MarketRuntimeContext,
        payload: JsonDict | None = None,
    ) -> JsonDict:
        result: JsonDict = dict(payload or {})
        result.setdefault("runtime_key", context.runtime_key)

        if context.screen:
            result.setdefault("runtime_screen", context.screen)

        if context.runtime_label:
            result.setdefault("runtime_label", context.runtime_label)

        return result

    @classmethod
    def _log_info(
        cls,
        context: MarketRuntimeContext,
        event_type: str,
        message: str,
        payload: JsonDict | None = None,
    ) -> None:
        try:
            if context.screen:
                JournalService().log_ui_info(
                    event_type=event_type,
                    message=cls._message(context, message),
                    screen=context.screen,
                    action=context.action,
                    payload=cls._payload(context, payload),
                )
                return

            JournalService().log_info(
                event_type,
                cls._message(context, message),
                cls._payload(context, payload),
            )

        except Exception:
            pass

    @classmethod
    def _log_warning(
        cls,
        context: MarketRuntimeContext,
        event_type: str,
        message: str,
        payload: JsonDict | None = None,
    ) -> None:
        try:
            if context.screen:
                JournalService().log_ui_warning(
                    event_type=event_type,
                    message=cls._message(context, message),
                    screen=context.screen,
                    action=context.action,
                    payload=cls._payload(context, payload),
                )
                return

            JournalService().log_warning(
                event_type,
                cls._message(context, message),
                cls._payload(context, payload),
            )

        except Exception:
            pass

    @classmethod
    def _log_error(
        cls,
        context: MarketRuntimeContext,
        event_type: str,
        message: str,
        payload: JsonDict | None = None,
    ) -> None:
        try:
            error_type = None
            raw_error = None

            if payload:
                error_type = payload.get("error_type")
                raw_error = payload.get("error")

            if context.screen:
                JournalService().log_ui_error(
                    event_type=event_type,
                    message=cls._message(context, message),
                    screen=context.screen,
                    action=context.action,
                    payload=cls._payload(context, payload),
                    error_type=str(error_type) if error_type is not None else None,
                    raw_error=str(raw_error) if raw_error is not None else None,
                )
                return

            JournalService().log_error(
                event_type,
                cls._message(context, message),
                cls._payload(context, payload),
            )

        except Exception:
            pass