# app/src/integrations/exchange/service.py
 
from __future__ import annotations

import time
import socket
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.integrations.exchange.balance_parser import parse_account_balances
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.mock_data import (
    mock_balance_summary,
    mock_exchange_health,
    mock_quote,
)
from src.integrations.exchange.models import (
    BalanceSummary,
    ExchangeHealth,
    ExecutionPriceSnapshot,
    PrivateAuthHealth,
    SymbolValidationResult,
    TimeSyncStatus,
    TradingFee,
)
from src.integrations.exchange.private_client import ExchangePrivateClient
from src.integrations.exchange.rest_client import ExchangeRestClient
from src.integrations.exchange.status import (
    ExchangeRuntimeStatus,
    build_market_stale_status,
    build_account_auth_status,
    build_exchange_error_status,
    build_invalid_symbol_status,
    build_market_status_from_symbol_status,
    build_mock_exchange_status,
    classify_exchange_error,
)
from src.market_data.acquisition.adapters.dzengi.rest import (
    DzengiCandlesDocumentSource,
    DzengiInstrumentDocumentSource,
    DzengiQuoteDocumentSource,
)
from src.market_data.acquisition.feeds.candles_feed import CandlesFeed
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
from src.market_data.acquisition.handlers.candles_handler import (
    DzengiCandlesDocumentHandler,
)
from src.market_data.acquisition.handlers.instrument_handler import (
    DzengiInstrumentDocumentHandler,
)
from src.market_data.acquisition.handlers.quotes_handler import (
    DzengiQuoteDocumentHandler,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.registry import (
    CandlesFeedRegistry,
    InstrumentFeedRegistry,
    QuoteFeedRegistry,
)
from src.market_data.acquisition.service import (
    CandlesAcquisitionService,
    InstrumentAcquisitionService,
    QuoteAcquisitionService,
)
from src.market_data.acquisition.symbols import (
    normalize_symbol,
    resolve_symbol_index,
)
from src.storage.instrument_store import (
    InMemoryInstrumentStore,
    InstrumentStoreProtocol,
)
from src.storage.quote_store import (
    QUOTE_RUNTIME_SOURCE_NAME,
    get_quote_store,
)
from src.trading.journal.service import JournalService


_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
_QUOTE_SOURCE_NAME = "dzengi"
_CANDLES_SOURCE_NAME = "dzengi"


class ExchangeService:
    _instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore()

    _execution_cache_max_age_seconds = 2.0
    _default_runtime_key = "auto"

    # Dedupe одинаковых ошибок биржи, чтобы журнал не разрастался.
    _exchange_error_log_ttl_seconds = 300
    _last_exchange_error_logs: dict[str, float] = {}
    _active_exchange_error_keys: set[str] = set()

    # Dedupe account/time-sync событий.
    _active_auth_error_key: str | None = None
    _active_time_sync_error_key: str | None = None

    def __init__(self) -> None:
        self.settings = load_settings()
        self.journal = JournalService()

    # Вернуть статус торговой сессии инструмента в legacy-dict формате для текущего UI.
    def get_symbol_market_status(self, symbol: str | None = None) -> dict[str, object]:
        return self.get_symbol_runtime_status(symbol).as_dict()

    # Вернуть typed runtime status инструмента: открыт, перерыв, нет доступа, неверный символ.
    def get_symbol_runtime_status(
        self,
        symbol: str | None = None,
    ) -> ExchangeRuntimeStatus:
        symbol_to_use = symbol or self.settings.default_symbol

        if not self.settings.exchange_enabled:
            return build_mock_exchange_status(symbol=symbol_to_use)

        try:
            validation = self.validate_symbol(symbol_to_use)

        except Exception as exc:
            self._log_exchange_error(
                endpoint="symbol_market_status",
                exc=exc,
                symbol=symbol_to_use,
            )

            return build_exchange_error_status(exc)

        if not validation.is_valid:
            return build_invalid_symbol_status(
                symbol=symbol_to_use,
                message=validation.message,
            )

        symbol_info = validation.symbol_info

        status = build_market_status_from_symbol_status(
            raw_status=getattr(symbol_info, "status", None),
            symbol=validation.normalized_symbol,
        )

        if not status.is_open:
            return status

        try:
            quote = self._get_fresh_quote(
                validation.normalized_symbol,
            )
        except Exception:
            return status

        exchange_timestamp_ms = (
            int(quote.exchange_timestamp.timestamp() * 1000)
            if quote.exchange_timestamp is not None
            else None
        )
        age_seconds = self._exchange_timestamp_age_seconds(
            exchange_timestamp_ms
        )

        if age_seconds is not None and age_seconds > 60:
            return build_market_stale_status(
                symbol=validation.normalized_symbol,
                age_seconds=age_seconds,
                updated_at=self._format_exchange_time(
                    exchange_timestamp_ms
                ),
            )

        return status
    
    def _exchange_timestamp_age_seconds(
        self,
        raw_timestamp: NumericLike | None,
    ) -> float | None:
        timestamp = safe_float(raw_timestamp)

        if timestamp is None or timestamp <= 0:
            return None

        try:
            server_time_ms = self.get_exchange_server_time_ms()
            return max(0.0, round((server_time_ms - int(timestamp)) / 1000, 3))
        except Exception:
            local_time_ms = int(datetime.now(ZoneInfo("UTC")).timestamp() * 1000)
            return max(0.0, round((local_time_ms - int(timestamp)) / 1000, 3))

    def get_trading_fee(self, symbol: str | None = None) -> TradingFee:
        symbol_to_use = symbol or self.settings.default_symbol

        if not self.settings.exchange_enabled:
            return TradingFee(
                symbol=symbol_to_use,
                name=symbol_to_use,
                fee_percent=0.0,
            )

        validation = self.validate_symbol(symbol_to_use)
        if not validation.is_valid:
            raise ExchangeError(validation.message)

        client = ExchangeRestClient()

        try:
            payload = client.get_payload(
                "/api/v1/tradingFees",
                params={"symbol": validation.normalized_symbol},
            )
        except Exception as exc:
            self._log_exchange_error(
                endpoint="tradingFees",
                exc=exc,
                symbol=validation.normalized_symbol,
            )
            raise ExchangeError(f"Не удалось получить комиссию: {exc}") from exc

        fee_items = self._extract_trading_fee_items(payload)

        for item in fee_items:
            fee = self._parse_trading_fee_item(item)
            if fee is not None and normalize_symbol(fee.symbol) == validation.normalized_symbol:
                return fee

        raise ExchangeError(
            f"Комиссия для символа '{validation.normalized_symbol}' не найдена."
        )

    def get_overnight_fee_countdown(
        self,
        symbol: str | None = None,
    ) -> str | None:
        try:
            fee = self.get_trading_fee(symbol)

            timestamp = fee.overnight_fee_timestamp

            if timestamp is None or timestamp <= 0:
                return None

            now_ms = self.get_exchange_server_time_ms()

            remaining_seconds = int(
                max(
                    0,
                    (timestamp - now_ms) / 1000,
                )
            )

            hours = remaining_seconds // 3600
            minutes = (remaining_seconds % 3600) // 60

            return f"{hours}ч {minutes:02d}м"

        except Exception:
            return None
        
    def _extract_trading_fee_items(
        self,
        payload: object,
    ) -> list[object]:

        if isinstance(payload, list):
            return payload

        if isinstance(payload, dict):
            raw_payload = payload.get("payload")

            if isinstance(raw_payload, list):
                return raw_payload

        return []

    def _parse_trading_fee_item(self, item: object) -> TradingFee | None:
        if not isinstance(item, dict):
            return None

        overnight_rates = item.get("overnightRates")
        if not isinstance(overnight_rates, dict):
            overnight_rates = {}

        return TradingFee(
            symbol=self._safe_str(item.get("symbol")),
            name=self._safe_str(item.get("name")),
            fee_percent=safe_float(item.get("fee")),
            overnight_long_rate=safe_float(overnight_rates.get("longRate")),
            overnight_short_rate=safe_float(overnight_rates.get("shortRate")),
            overnight_fee_timestamp=int(safe_float(item.get("overnightFeeTimestamp")) or 0)
            if item.get("overnightFeeTimestamp") is not None
            else None,
        )

    # Логировать info-событие биржи без падения основного сценария.
    def _log_info(
        self,
        event_type: str,
        message: str,
        payload: dict[str, object] | None = None,
    ) -> None:
        try:
            self.journal.log_info(event_type, message, payload)
        except Exception:
            pass

    # Логировать warning-событие биржи без падения основного сценария.
    def _log_warning(
        self,
        event_type: str,
        message: str,
        payload: dict[str, object] | None = None,
    ) -> None:
        try:
            self.journal.log_warning(event_type, message, payload)
        except Exception:
            pass

    # Логировать error-событие биржи без падения основного сценария.
    def _log_error(
        self,
        event_type: str,
        message: str,
        payload: dict[str, object] | None = None,
    ) -> None:
        try:
            self.journal.log_error(event_type, message, payload)
        except Exception:
            pass

    # Логировать ошибку API с единым payload.
    # Одинаковые ошибки пишем в журнал не чаще одного раза за TTL.
    def _log_exchange_error(
        self,
        *,
        endpoint: str,
        exc: Exception,
        symbol: str | None = None,
        extra_payload: dict[str, object] | None = None,
    ) -> None:
        error_type = classify_exchange_error(exc)
        raw_error = str(exc)

        dedupe_key = (
            f"exchange_request_error:"
            f"{error_type}:"
            f"{raw_error}"
        )

        now = time.monotonic()
        last_logged_at = type(self)._last_exchange_error_logs.get(dedupe_key)

        if (
            last_logged_at is not None
            and now - last_logged_at < type(self)._exchange_error_log_ttl_seconds
        ):
            return

        type(self)._last_exchange_error_logs[dedupe_key] = now
        type(self)._active_exchange_error_keys.add(dedupe_key)

        payload: dict[str, object] = {
            "endpoint": endpoint,
            "symbol": symbol,
            "exchange_name": self.settings.exchange_name,
            "error_type": error_type,
            "raw_error": raw_error,
            "dedupe_key": dedupe_key,
            "dedupe_ttl_seconds": type(self)._exchange_error_log_ttl_seconds,
        }

        if extra_payload:
            payload.update(extra_payload)

        self._log_error(
            "exchange_request_error",
            raw_error,
            payload,
        )

    # Логировать ошибку доступа к аккаунту человекочитаемо и без дублей.
    def _log_account_auth_error(
        self,
        *,
        exc: Exception,
        endpoint: str = "private/account_info",
    ) -> None:
        status = build_account_auth_status(exc)
        raw_error = status.raw_error or str(exc)
        error_key = f"auth:{raw_error}"

        if type(self)._active_auth_error_key == error_key:
            return

        type(self)._active_auth_error_key = error_key

        self._log_error(
            "exchange_auth_error",
            "Ошибка доступа к аккаунту. Проверь API-ключ, Secret Key, IP whitelist и права доступа.",
            {
                "endpoint": endpoint,
                "exchange_name": self.settings.exchange_name,
                "error_type": classify_exchange_error(exc),
                "raw_error": raw_error,
                "reason": status.reason,
            },
        )

    # Логировать восстановление доступа к аккаунту один раз.
    def _log_account_auth_restored(self) -> None:
        if type(self)._active_auth_error_key is None:
            return

        type(self)._active_auth_error_key = None

        self._log_info(
            "exchange_auth_restored",
            "Доступ к аккаунту восстановлен.",
            {
                "endpoint": "private/account_info",
                "exchange_name": self.settings.exchange_name,
            },
        )

    # Логировать рассинхронизацию времени один раз до восстановления.
    def _log_time_sync_error(self, sync: TimeSyncStatus) -> None:
        drift = sync.drift_seconds
        drift_key = "unknown" if drift is None else str(round(float(drift), 0))
        error_key = f"time_sync:{drift_key}"

        if type(self)._active_time_sync_error_key == error_key:
            return

        type(self)._active_time_sync_error_key = error_key

        self._log_error(
            "exchange_time_sync_error",
            "Время сервера отличается от времени биржи.",
            {
                "hostname": sync.hostname,
                "local_ip": sync.local_ip,
                "local_time": sync.local_time,
                "exchange_time": sync.exchange_time,
                "drift_seconds": sync.drift_seconds,
                "message": sync.message,
            },
        )

    # Логировать восстановление синхронизации времени один раз.
    def _log_time_sync_restored(self, sync: TimeSyncStatus) -> None:
        if type(self)._active_time_sync_error_key is None:
            return

        type(self)._active_time_sync_error_key = None

        self._log_info(
            "exchange_time_sync_restored",
            "Время сервера снова синхронизировано с биржей.",
            {
                "hostname": sync.hostname,
                "local_ip": sync.local_ip,
                "local_time": sync.local_time,
                "exchange_time": sync.exchange_time,
                "drift_seconds": sync.drift_seconds,
            },
        )

    # Отформатировать timestamp биржи в локальное время приложения.
    def _format_exchange_time(self, raw_timestamp: NumericLike | None) -> str:
        timestamp = safe_float(raw_timestamp)

        if timestamp is None or timestamp <= 0:
            return "n/a"

        try:
            dt_utc = datetime.fromtimestamp(
                int(timestamp) / 1000,
                tz=ZoneInfo("UTC"),
            )
            dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz))
            return dt_local.strftime("%d.%m.%Y %H:%M:%S")
        except Exception:
            return "n/a"

    # Вернуть человекочитаемое имя источника цены.
    def _source_name(self) -> str:
        return (
            "dzengi-demo-api"
            if "demo" in self.settings.exchange_base_url.lower()
            else "dzengi-api"
        )

    # Нормализовать runtime_key для market cache.
    def _runtime_key(self, runtime_key: str | None) -> str:
        return (runtime_key or self._default_runtime_key).strip().lower()

    # Получить канонические свечи инструмента через Candles Feed.
    def get_candles(
        self,
        symbol: str | None = None,
        *,
        interval: str = "1m",
        limit: int = 200,
        price_type: str = "bid",
    ) -> tuple[Candle, ...]:
        symbol_to_use = symbol or self.settings.default_symbol

        if limit <= 0:
            limit = 200

        if limit > 200:
            limit = 200

        if interval not in {"1m", "5m", "15m", "1h"}:
            raise ExchangeError(f"Unsupported kline interval: {interval}")

        normalized_price_type = price_type.strip().lower()

        if normalized_price_type not in {"bid", "ask"}:
            normalized_price_type = "bid"

        if not self.settings.exchange_enabled:
            raise ExchangeError(
                "Candles are not available in mock exchange mode."
            )

        validation = self.validate_symbol(symbol_to_use)
        if not validation.is_valid:
            raise ExchangeError(validation.message)

        try:
            return self._load_candles_via_acquisition(
                symbol=validation.normalized_symbol,
                interval=interval,
                limit=limit,
                price_type=normalized_price_type,
            )
        except Exception as exc:
            self._log_exchange_error(
                endpoint="klines",
                exc=exc,
                symbol=validation.normalized_symbol,
                extra_payload={
                    "interval": interval,
                    "limit": limit,
                    "price_type": normalized_price_type,
                },
            )
            raise ExchangeError(str(exc)) from exc

    # Собрать Candles acquisition pipeline и вернуть канонические модели Candle.
    def _load_candles_via_acquisition(
        self,
        *,
        symbol: str,
        interval: str,
        limit: int,
        price_type: str,
    ) -> tuple[Candle, ...]:
        source = DzengiCandlesDocumentSource()
        handler = DzengiCandlesDocumentHandler()

        feed = CandlesFeed(
            source=source,
            handler=handler,
        )

        registry = CandlesFeedRegistry()
        registry.register(
            _CANDLES_SOURCE_NAME,
            feed,
        )

        acquisition_service = CandlesAcquisitionService(
            registry=registry,
        )

        return acquisition_service.load_candles(
            _CANDLES_SOURCE_NAME,
            symbol,
            interval=interval,
            limit=limit,
            price_type=price_type,
        )

    # Проверить публичную доступность биржи.
    def get_health(self) -> ExchangeHealth:
        if not self.settings.exchange_enabled:
            return mock_exchange_health()

        status = self.get_symbol_runtime_status(self.settings.default_symbol)

        if not status.is_available:
            return ExchangeHealth(
                ok=False,
                mode="real_exchange_unavailable",
                message=status.message,
            )

        if not status.is_open:
            return ExchangeHealth(
                ok=False,
                mode="real_market_closed",
                message=status.message,
            )

        try:
            quote = self._get_fresh_quote(
                str(status.symbol or self.settings.default_symbol)
            )
        except ExchangeError as exc:
            return ExchangeHealth(
                ok=False,
                mode="real_error",
                message=f"Ошибка подключения к API: {exc}",
            )

        return ExchangeHealth(
            ok=True,
            mode="real_public_api",
            message=(
                f"Public API OK. Цена {quote.symbol}: "
                f"{float(quote.last_price):.2f}"
            ),
        )

    # Проверить доступность приватного API и валидность ключей аккаунта.
    def get_private_auth_health(self) -> PrivateAuthHealth:
        if not self.settings.exchange_enabled:
            exc = ExchangeError("Интеграция с биржей выключена.")
            self._log_account_auth_error(exc=exc)

            return PrivateAuthHealth(
                ok=False,
                message=str(exc),
            )

        if not self.settings.exchange_api_key or not self.settings.exchange_api_secret:
            exc = ExchangeError("EXCHANGE_API_KEY / EXCHANGE_API_SECRET не заданы.")
            self._log_account_auth_error(exc=exc)

            return PrivateAuthHealth(
                ok=False,
                message=str(exc),
            )

        try:
            payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
            balances = parse_account_balances(payload)

        except Exception as exc:
            self._log_account_auth_error(exc=exc)
            status = build_account_auth_status(exc)

            return PrivateAuthHealth(
                ok=False,
                message=status.raw_error or status.message,
            )

        self._log_account_auth_restored()

        return PrivateAuthHealth(
            ok=True,
            message=f"Private API OK. Балансов получено: {len(balances)}",
        )

    # Получить каноническую текущую котировку из Store или REST Quotes Feed.
    def get_quote(
        self,
        symbol: str | None = None,
        *,
        runtime_key: str | None = None,
    ) -> Quote:
        symbol_to_use = symbol or self.settings.default_symbol
        normalized_runtime_key = self._runtime_key(runtime_key)

        if not self.settings.exchange_enabled:
            return mock_quote(symbol_to_use)

        validation = self.validate_symbol(symbol_to_use)
        if not validation.is_valid:
            raise ExchangeError(validation.message)

        cached_quote = get_quote_store().get(
            QUOTE_RUNTIME_SOURCE_NAME,
            validation.normalized_symbol,
            runtime_key=normalized_runtime_key,
        )

        if (
            cached_quote is not None
            and self._quote_age_seconds(cached_quote)
            <= self._execution_cache_max_age_seconds
        ):
            return cached_quote

        quote = self._get_fresh_quote(validation.normalized_symbol)
        get_quote_store().set(
            QUOTE_RUNTIME_SOURCE_NAME,
            quote,
            runtime_key=normalized_runtime_key,
        )
        return quote

    # Принудительно обновить Quote cache через свежий REST Quotes Feed.
    def refresh_quote_cache(
        self,
        symbol: str | None = None,
        *,
        runtime_key: str | None = None,
    ) -> Quote:
        symbol_to_use = symbol or self.settings.default_symbol
        normalized_runtime_key = self._runtime_key(runtime_key)

        if not self.settings.exchange_enabled:
            quote = mock_quote(symbol_to_use)
        else:
            validation = self.validate_symbol(symbol_to_use)
            if not validation.is_valid:
                raise ExchangeError(validation.message)

            quote = self._get_fresh_quote(
                validation.normalized_symbol
            )

        get_quote_store().set(
            QUOTE_RUNTIME_SOURCE_NAME,
            quote,
            runtime_key=normalized_runtime_key,
        )
        return quote

    # Получить snapshot, пригодный для execution layer.
    def get_execution_snapshot(
        self,
        symbol: str | None = None,
        *,
        runtime_key: str | None = None,
    ) -> ExecutionPriceSnapshot:
        symbol_to_use = symbol or self.settings.default_symbol
        normalized_runtime_key = self._runtime_key(runtime_key)

        if not self.settings.exchange_enabled:
            quote = mock_quote(symbol_to_use)
            return self._execution_snapshot_from_quote(
                quote,
                source=quote.source,
                age_seconds=0.0,
            )

        validation = self.validate_symbol(symbol_to_use)
        if not validation.is_valid:
            raise ExchangeError(validation.message)

        quote = get_quote_store().get(
            QUOTE_RUNTIME_SOURCE_NAME,
            validation.normalized_symbol,
            runtime_key=normalized_runtime_key,
        )

        if quote is not None:
            age_seconds = self._quote_age_seconds(quote)

            if age_seconds <= self._execution_cache_max_age_seconds:
                return self._execution_snapshot_from_quote(
                    quote,
                    source=f"{quote.source}:fresh_cache",
                    age_seconds=round(age_seconds, 3),
                )

        quote = self._get_fresh_quote(
            validation.normalized_symbol
        )
        get_quote_store().set(
            QUOTE_RUNTIME_SOURCE_NAME,
            quote,
            runtime_key=normalized_runtime_key,
        )

        return self._execution_snapshot_from_quote(
            quote,
            source="rest_fallback",
            age_seconds=round(
                self._quote_age_seconds(quote),
                3,
            ),
        )

    def _execution_snapshot_from_quote(
        self,
        quote: Quote,
        *,
        source: str,
        age_seconds: float,
    ) -> ExecutionPriceSnapshot:
        timestamp = (
            quote.exchange_timestamp
            if quote.exchange_timestamp is not None
            else quote.received_at
        )

        if timestamp.tzinfo is None:
            timestamp = timestamp.replace(tzinfo=timezone.utc)

        updated_at = timestamp.astimezone(
            ZoneInfo(self.settings.tz)
        ).strftime("%d.%m.%Y %H:%M:%S")

        return ExecutionPriceSnapshot(
            symbol=quote.symbol,
            last_price=float(quote.last_price),
            bid_price=float(quote.bid_price),
            ask_price=float(quote.ask_price),
            updated_at=updated_at,
            source=source,
            is_fresh=(
                age_seconds
                <= self._execution_cache_max_age_seconds
            ),
            age_seconds=age_seconds,
        )

    def _quote_age_seconds(self, quote: Quote) -> float:
        received_at = quote.received_at
        if received_at.tzinfo is None:
            received_at = received_at.replace(tzinfo=timezone.utc)

        return max(
            0.0,
            (
                datetime.now(timezone.utc)
                - received_at.astimezone(timezone.utc)
            ).total_seconds(),
        )

    def _get_fresh_quote(self, normalized_symbol: str) -> Quote:
        try:
            return self._load_quote_via_acquisition(normalized_symbol)
        except Exception as exc:
            self._log_exchange_error(
                endpoint="ticker/24hr",
                exc=exc,
                symbol=normalized_symbol,
            )
            raise ExchangeError(str(exc)) from exc

    # Получить live-балансы аккаунта.
    def get_balance_summary(self) -> list[BalanceSummary]:
        if not self.settings.exchange_enabled:
            return mock_balance_summary()

        auth_health = self.get_private_auth_health()
        if not auth_health.ok:
            raise ExchangeError(auth_health.message)

        try:
            payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
            balances = parse_account_balances(payload)
        except Exception as exc:
            self._log_exchange_error(
                endpoint="private/account_info",
                exc=exc,
                extra_payload={
                    "default_symbol": self.settings.default_symbol,
                },
            )
            raise ExchangeError(f"Не удалось получить баланс: {exc}") from exc

        if not balances:
            self._log_warning(
                "balance_summary_empty",
                "Баланс получен, но список активов пуст или не распознан.",
                {
                    "exchange_name": self.settings.exchange_name,
                    "default_symbol": self.settings.default_symbol,
                },
            )
            raise ExchangeError("Баланс получен, но список активов пуст или не распознан.")

        self._log_info(
            "balance_summary_loaded",
            f"Баланс успешно получен. Активов: {len(balances)}",
            {
                "exchange_name": self.settings.exchange_name,
                "assets_count": len(balances),
            },
        )

        return balances

    # Получить канонический справочник инструментов через Instrument Store.
    def get_instruments(self) -> tuple[Instrument, ...]:
        if not self.settings.exchange_enabled:
            return ()

        instrument_store = type(self)._instrument_store

        instruments = instrument_store.get(
            _INSTRUMENT_REFERENCE_SOURCE_NAME
        )

        if instruments is not None:
            return instruments

        try:
            instruments = self._load_instruments_via_acquisition()
        except Exception as exc:
            self._log_exchange_error(
                endpoint="exchangeInfo",
                exc=exc,
            )
            raise ExchangeError(str(exc)) from exc

        instrument_store.set(
            _INSTRUMENT_REFERENCE_SOURCE_NAME,
            instruments,
        )

        return instruments

    # Собрать Quotes acquisition pipeline и вернуть каноническую модель Quote.
    def _load_quote_via_acquisition(
        self,
        symbol: str,
    ) -> Quote:
        source = DzengiQuoteDocumentSource()
        handler = DzengiQuoteDocumentHandler()

        feed = QuotesFeed(
            source=source,
            handler=handler,
        )

        registry = QuoteFeedRegistry()
        registry.register(
            _QUOTE_SOURCE_NAME,
            feed,
        )

        acquisition_service = QuoteAcquisitionService(
            registry=registry,
        )

        return acquisition_service.load_quote(
            _QUOTE_SOURCE_NAME,
            symbol,
        )

    # Собрать acquisition pipeline и вернуть канонические модели Instrument.
    def _load_instruments_via_acquisition(
        self,
    ) -> tuple[Instrument, ...]:
        source = DzengiInstrumentDocumentSource()
        handler = DzengiInstrumentDocumentHandler()

        feed = InstrumentFeed(
            source=source,
            handler=handler,
        )

        registry = InstrumentFeedRegistry()
        registry.register(
            _INSTRUMENT_REFERENCE_SOURCE_NAME,
            feed,
        )

        acquisition_service = InstrumentAcquisitionService(
            registry=registry,
        )

        return acquisition_service.load_instruments(
            _INSTRUMENT_REFERENCE_SOURCE_NAME
        )

    # Безопасно привести значение к строке.
    def _safe_str(self, value: object, default: str = "") -> str:
        if value is None:
            return default

        return str(value).strip()

    # Проверить, существует ли инструмент на бирже.
    def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
        requested = normalize_symbol(raw_symbol)

        if not requested:
            return SymbolValidationResult(
                requested_symbol=requested,
                normalized_symbol="",
                is_valid=False,
                message="Символ пустой.",
                symbol_info=None,
            )

        if not self.settings.exchange_enabled:
            return SymbolValidationResult(
                requested_symbol=requested,
                normalized_symbol=requested,
                is_valid=True,
                message="Mock mode active.",
                symbol_info=None,
            )

        instruments = self.get_instruments()

        matched_index = resolve_symbol_index(
            requested,
            [
                instrument.symbol
                for instrument in instruments
            ],
        )

        if matched_index is not None:
            instrument = instruments[matched_index]

            return SymbolValidationResult(
                requested_symbol=requested,
                normalized_symbol=normalize_symbol(
                    instrument.symbol
                ),
                is_valid=True,
                message="Символ найден в exchangeInfo.",
                symbol_info=instrument,
            )

        return SymbolValidationResult(
            requested_symbol=requested,
            normalized_symbol=requested,
            is_valid=False,
            message=(
                f"Символ '{requested}' "
                "не найден в exchangeInfo."
            ),
            symbol_info=None,
        )

    def get_exchange_server_time_ms(self) -> int:
        payload = ExchangeRestClient().get_json("/api/v1/time")

        inner = payload.get("payload")
        if isinstance(inner, dict):
            value = inner.get("serverTime")
        else:
            value = payload.get("serverTime")

        server_time = safe_float(value)

        if server_time is None:
            raise ExchangeError("Field 'serverTime' is missing in time response.")

        return int(server_time)


    def get_time_sync_status(self) -> TimeSyncStatus:
        hostname = socket.gethostname()
        local_ip = self._get_local_ip()

        local_dt = datetime.now(ZoneInfo("UTC"))
        local_ms = int(local_dt.timestamp() * 1000)

        try:
            exchange_ms = self.get_exchange_server_time_ms()
            drift_seconds = round((local_ms - exchange_ms) / 1000, 3)
            ok = abs(drift_seconds) <= 3

            sync = TimeSyncStatus(
                ok=ok,
                local_time=self._format_exchange_time(local_ms),
                exchange_time=self._format_exchange_time(exchange_ms),
                drift_seconds=drift_seconds,
                hostname=hostname,
                local_ip=local_ip,
                message=(
                    "Время сервера синхронизировано."
                    if ok
                    else "Время сервера отличается от времени биржи."
                ),
            )

            if sync.ok:
                self._log_time_sync_restored(sync)
            else:
                self._log_time_sync_error(sync)

            return sync

        except Exception as exc:
            self._log_exchange_error(
                endpoint="time",
                exc=exc,
            )

            sync = TimeSyncStatus(
                ok=False,
                local_time=self._format_exchange_time(local_ms),
                exchange_time=None,
                drift_seconds=None,
                hostname=hostname,
                local_ip=local_ip,
                message="Не удалось получить время биржи.",
            )

            self._log_time_sync_error(sync)

            return sync


    def _get_local_ip(self) -> str | None:
        try:
            hostname = socket.gethostname()

            addresses = socket.gethostbyname_ex(hostname)[2]

            private_ips: list[str] = []

            for ip in addresses:
                if (
                    ip.startswith("192.168.")
                    or ip.startswith("10.")
                    or (
                        ip.startswith("172.")
                        and 16 <= int(ip.split(".")[1]) <= 31
                    )
                ):
                    private_ips.append(ip)

            if private_ips:
                return private_ips[0]

            for ip in addresses:
                if not ip.startswith("127."):
                    return ip

        except Exception:
            pass

        return None
