# app/tests/unit/integrations/exchange/test_service_instruments.py

from __future__ import annotations

from collections.abc import Iterator
from decimal import Decimal
from types import SimpleNamespace
from typing import cast

import pytest

from src.core.config import Settings
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.market_data.acquisition.exceptions import (
    InstrumentReferenceTransportError,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.storage.instrument_store import InMemoryInstrumentStore


_SOURCE_NAME = "dzengi"


def _settings(
    *,
    exchange_enabled: bool = True,
) -> Settings:
    return cast(
        Settings,
        SimpleNamespace(
            exchange_enabled=exchange_enabled,
            exchange_name="dzengi",
        ),
    )


def _service(
    *,
    exchange_enabled: bool = True,
) -> ExchangeService:
    service = object.__new__(ExchangeService)
    service.settings = _settings(
        exchange_enabled=exchange_enabled,
    )

    return service


def _instrument(
    *,
    symbol: str = "BTC/USD_LEVERAGE",
    name: str = "BTC/USD",
    base_asset: str = "BTC",
) -> Instrument:
    return Instrument(
        symbol=symbol,
        name=name,
        status="TRADING",
        base_asset=base_asset,
        quote_asset="USD",
        asset_type="CRYPTOCURRENCY",
        market_type="LEVERAGE",
        market_modes=("REGULAR",),
        order_types=("LIMIT", "MARKET", "STOP"),
        base_asset_precision=4,
        quote_asset_precision=4,
        tick_size=Decimal("0.05"),
        tick_value=Decimal("3878.86"),
        step_size=Decimal("0.0001"),
        min_qty=Decimal("0.0001"),
        max_qty=Decimal("1000"),
        min_notional=Decimal("1"),
        country=None,
        sector=None,
        industry=None,
        trading_hours=None,
    )


@pytest.fixture(autouse=True)
def reset_instrument_storage() -> Iterator[None]:
    original_store = ExchangeService._instrument_store

    ExchangeService._instrument_store = InMemoryInstrumentStore()

    yield

    ExchangeService._instrument_store.clear()
    ExchangeService._instrument_store = original_store


def test_get_instruments_returns_empty_tuple_when_exchange_disabled(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service(
        exchange_enabled=False,
    )

    def fail_if_called() -> tuple[Instrument, ...]:
        raise AssertionError(
            "Acquisition must not run when exchange is disabled."
        )

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        fail_if_called,
    )

    result = service.get_instruments()

    assert result == ()
    assert isinstance(result, tuple)


def test_exchange_disabled_does_not_read_existing_store(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service(
        exchange_enabled=False,
    )

    instruments = (
        _instrument(),
    )

    ExchangeService._instrument_store.set(
        _SOURCE_NAME,
        instruments,
    )

    def fail_get(
        source_name: str,
    ) -> tuple[Instrument, ...] | None:
        del source_name

        raise AssertionError(
            "Instrument Store must not be read "
            "when exchange is disabled."
        )

    monkeypatch.setattr(
        ExchangeService._instrument_store,
        "get",
        fail_get,
    )

    assert service.get_instruments() == ()


def test_store_hit_returns_same_tuple_object(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    instruments = (
        _instrument(),
    )

    ExchangeService._instrument_store.set(
        _SOURCE_NAME,
        instruments,
    )

    def fail_if_called() -> tuple[Instrument, ...]:
        raise AssertionError(
            "Acquisition must not run on store hit."
        )

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        fail_if_called,
    )

    result = service.get_instruments()

    assert result is instruments


def test_store_miss_runs_acquisition_once(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    instruments = (
        _instrument(),
    )

    call_count = 0

    def load_instruments() -> tuple[Instrument, ...]:
        nonlocal call_count

        call_count += 1
        return instruments

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        load_instruments,
    )

    result = service.get_instruments()

    assert result is instruments
    assert call_count == 1


def test_loaded_instruments_are_saved_in_store(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    instruments = (
        _instrument(),
    )

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        lambda: instruments,
    )

    result = service.get_instruments()

    stored = ExchangeService._instrument_store.get(
        _SOURCE_NAME
    )

    assert result is instruments
    assert stored is instruments


def test_second_get_instruments_call_uses_store(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    instruments = (
        _instrument(),
    )

    call_count = 0

    def load_instruments() -> tuple[Instrument, ...]:
        nonlocal call_count

        call_count += 1
        return instruments

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        load_instruments,
    )

    first = service.get_instruments()
    second = service.get_instruments()

    assert first is instruments
    assert second is instruments
    assert call_count == 1


def test_empty_tuple_in_store_is_valid_cache_hit(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    ExchangeService._instrument_store.set(
        _SOURCE_NAME,
        (),
    )

    def fail_if_called() -> tuple[Instrument, ...]:
        raise AssertionError(
            "Stored empty tuple must be treated as cache hit."
        )

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        fail_if_called,
    )

    result = service.get_instruments()

    assert result == ()


def test_multiple_service_instances_share_instrument_store(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    first_service = _service()
    second_service = _service()

    instruments = (
        _instrument(),
    )

    call_count = 0

    def load_instruments() -> tuple[Instrument, ...]:
        nonlocal call_count

        call_count += 1
        return instruments

    monkeypatch.setattr(
        first_service,
        "_load_instruments_via_acquisition",
        load_instruments,
    )

    monkeypatch.setattr(
        second_service,
        "_load_instruments_via_acquisition",
        load_instruments,
    )

    first = first_service.get_instruments()
    second = second_service.get_instruments()

    assert first is instruments
    assert second is instruments
    assert call_count == 1


def test_acquisition_error_is_wrapped_in_exchange_error(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    original_error = InstrumentReferenceTransportError(
        "Network error."
    )

    def raise_error() -> tuple[Instrument, ...]:
        raise original_error

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        raise_error,
    )

    monkeypatch.setattr(
        service,
        "_log_exchange_error",
        lambda **_: None,
    )

    with pytest.raises(
        ExchangeError,
        match=r"Network error",
    ) as exc_info:
        service.get_instruments()

    assert exc_info.value.__cause__ is original_error


def test_acquisition_error_is_logged_as_exchange_info(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    original_error = InstrumentReferenceTransportError(
        "Network error."
    )

    logged_calls: list[dict[str, object]] = []

    def raise_error() -> tuple[Instrument, ...]:
        raise original_error

    def record_error(
        *,
        endpoint: str,
        exc: Exception,
        symbol: str | None = None,
        extra_payload: dict[str, object] | None = None,
    ) -> None:
        logged_calls.append(
            {
                "endpoint": endpoint,
                "exc": exc,
                "symbol": symbol,
                "extra_payload": extra_payload,
            }
        )

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        raise_error,
    )

    monkeypatch.setattr(
        service,
        "_log_exchange_error",
        record_error,
    )

    with pytest.raises(ExchangeError):
        service.get_instruments()

    assert logged_calls == [
        {
            "endpoint": "exchangeInfo",
            "exc": original_error,
            "symbol": None,
            "extra_payload": None,
        }
    ]


def test_acquisition_error_does_not_fill_store(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    service = _service()

    def raise_error() -> tuple[Instrument, ...]:
        raise InstrumentReferenceTransportError(
            "Network error."
        )

    monkeypatch.setattr(
        service,
        "_load_instruments_via_acquisition",
        raise_error,
    )

    monkeypatch.setattr(
        service,
        "_log_exchange_error",
        lambda **_: None,
    )

    with pytest.raises(ExchangeError):
        service.get_instruments()

    assert ExchangeService._instrument_store.get(
        _SOURCE_NAME
    ) is None


def test_exchange_service_has_no_legacy_projection_cache() -> None:
    assert not hasattr(
        ExchangeService,
        "_exchange_symbols_projection_cache",
    )