# app/tests/unit/market_data/acquisition/test_service.py

from __future__ import annotations

from datetime import datetime, timezone
from decimal import Decimal

import pytest

from src.market_data.acquisition.exceptions import (
    InstrumentFeedRegistryError,
    InstrumentReferenceMappingError,
    InstrumentReferenceTransportError,
    InstrumentReferenceValueError,
    CandleFeedRegistryError,
    CandleMappingError,
    CandleTransportError,
    CandleValueError,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
from src.market_data.acquisition.registry import InstrumentFeedRegistry
from src.market_data.acquisition.service import InstrumentAcquisitionService, CandlesAcquisitionService

def _instrument(
    *,
    symbol: str = "BTC/USD_LEVERAGE",
) -> Instrument:
    return Instrument(
        symbol=symbol,
        name=symbol,
        status="TRADING",
        base_asset="BTC",
        quote_asset="USD",
        asset_type="CRYPTOCURRENCY",
        market_type="LEVERAGE",
        market_modes=("REGULAR",),
        order_types=("LIMIT", "MARKET"),
        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,
    )


class StubInstrumentFeed:
    def __init__(
        self,
        *,
        instruments: tuple[Instrument, ...] = (),
        error: Exception | None = None,
    ) -> None:
        self.instruments = instruments
        self.error = error
        self.load_call_count = 0

    def load_instruments(self) -> tuple[Instrument, ...]:
        self.load_call_count += 1

        if self.error is not None:
            raise self.error

        return self.instruments


class RecordingInstrumentFeedRegistry(InstrumentFeedRegistry):
    def __init__(self) -> None:
        super().__init__()
        self.requested_source_names: list[str] = []
        self.get_call_count = 0

    def get(
        self,
        source_name: str,
    ) -> InstrumentFeedProtocol:
        self.get_call_count += 1
        self.requested_source_names.append(source_name)

        return super().get(source_name)


def test_service_loads_instruments_from_registered_feed() -> None:
    registry = InstrumentFeedRegistry()
    instruments = (
        _instrument(),
    )
    feed = StubInstrumentFeed(
        instruments=instruments,
    )

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    result = service.load_instruments("dzengi")

    assert result is instruments


def test_service_passes_source_name_to_registry_without_changes() -> None:
    registry = RecordingInstrumentFeedRegistry()
    feed = StubInstrumentFeed()

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    service.load_instruments("  dzengi  ")

    assert registry.requested_source_names == [
        "  dzengi  ",
    ]


def test_service_calls_registry_once() -> None:
    registry = RecordingInstrumentFeedRegistry()
    feed = StubInstrumentFeed()

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    service.load_instruments("dzengi")

    assert registry.get_call_count == 1


def test_service_calls_feed_once() -> None:
    registry = InstrumentFeedRegistry()
    feed = StubInstrumentFeed()

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    service.load_instruments("dzengi")

    assert feed.load_call_count == 1


def test_service_returns_feed_result_without_copying() -> None:
    registry = InstrumentFeedRegistry()
    instruments = (
        _instrument(),
        _instrument(symbol="ETH/USD_LEVERAGE"),
    )
    feed = StubInstrumentFeed(
        instruments=instruments,
    )

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    result = service.load_instruments("dzengi")

    assert result is instruments


def test_service_preserves_instrument_order() -> None:
    registry = InstrumentFeedRegistry()
    instruments = (
        _instrument(symbol="BTC/USD_LEVERAGE"),
        _instrument(symbol="ETH/USD_LEVERAGE"),
        _instrument(symbol="XRP/USD_LEVERAGE"),
    )
    feed = StubInstrumentFeed(
        instruments=instruments,
    )

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    result = service.load_instruments("dzengi")

    assert tuple(item.symbol for item in result) == (
        "BTC/USD_LEVERAGE",
        "ETH/USD_LEVERAGE",
        "XRP/USD_LEVERAGE",
    )


def test_service_returns_empty_tuple_without_error() -> None:
    registry = InstrumentFeedRegistry()
    feed = StubInstrumentFeed(
        instruments=(),
    )

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    result = service.load_instruments("dzengi")

    assert result == ()


def test_service_preserves_registry_error_without_wrapping() -> None:
    registry = InstrumentFeedRegistry()

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    with pytest.raises(
        InstrumentFeedRegistryError,
    ) as exc_info:
        service.load_instruments("dzengi")

    assert "не зарегистрирован" in str(exc_info.value)


def test_service_does_not_call_feed_when_registry_fails() -> None:
    registry = InstrumentFeedRegistry()
    registered_feed = StubInstrumentFeed()

    registry.register("registered", registered_feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    with pytest.raises(InstrumentFeedRegistryError):
        service.load_instruments("missing")

    assert registered_feed.load_call_count == 0


@pytest.mark.parametrize(
    "error",
    [
        InstrumentReferenceTransportError("Network error."),
        InstrumentReferenceValueError("Invalid value."),
        InstrumentReferenceMappingError("Mapping error."),
    ],
)
def test_service_preserves_feed_error_without_wrapping(
    error: Exception,
) -> None:
    registry = InstrumentFeedRegistry()
    feed = StubInstrumentFeed(
        error=error,
    )

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    with pytest.raises(type(error)) as exc_info:
        service.load_instruments("dzengi")

    assert exc_info.value is error
    assert feed.load_call_count == 1


def test_service_does_not_retry_feed_after_error() -> None:
    registry = InstrumentFeedRegistry()
    original_error = InstrumentReferenceTransportError(
        "Network error."
    )
    feed = StubInstrumentFeed(
        error=original_error,
    )

    registry.register("dzengi", feed)

    service = InstrumentAcquisitionService(
        registry=registry,
    )

    with pytest.raises(InstrumentReferenceTransportError):
        service.load_instruments("dzengi")

    assert feed.load_call_count == 1

# Quote Acquisition Service tests.
from datetime import datetime, timezone

from src.market_data.acquisition.exceptions import (
    QuoteFeedRegistryError,
    QuoteTransportError,
    QuoteValueError,
)
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.protocol import QuoteFeedProtocol
from src.market_data.acquisition.registry import QuoteFeedRegistry
from src.market_data.acquisition.service import QuoteAcquisitionService


def _quote() -> Quote:
    return Quote(
        symbol="BTC/USD_LEVERAGE",
        last_price=Decimal("64159.45"),
        bid_price=Decimal("64159.45"),
        ask_price=Decimal("64159.55"),
        exchange_timestamp=datetime.now(timezone.utc),
        received_at=datetime.now(timezone.utc),
        source="dzengi",
    )


class StubQuoteFeed:
    def __init__(
        self,
        *,
        quote: Quote | None = None,
        error: Exception | None = None,
    ) -> None:
        self.quote = quote or _quote()
        self.error = error
        self.symbols: list[str] = []

    def load_quote(
        self,
        symbol: str,
    ) -> Quote:
        self.symbols.append(symbol)

        if self.error is not None:
            raise self.error

        return self.quote


class RecordingQuoteFeedRegistry(QuoteFeedRegistry):
    def __init__(self) -> None:
        super().__init__()
        self.requested_source_names: list[str] = []
        self.get_call_count = 0

    def get(
        self,
        source_name: str,
    ) -> QuoteFeedProtocol:
        self.get_call_count += 1
        self.requested_source_names.append(source_name)
        return super().get(source_name)


def test_quote_service_loads_quote_from_registered_feed() -> None:
    registry = QuoteFeedRegistry()
    quote = _quote()
    feed = StubQuoteFeed(quote=quote)
    registry.register("dzengi", feed)
    service = QuoteAcquisitionService(registry=registry)

    result = service.load_quote(
        "dzengi",
        "BTC/USD_LEVERAGE",
    )

    assert result is quote


def test_quote_service_passes_source_name_without_changes() -> None:
    registry = RecordingQuoteFeedRegistry()
    registry.register("dzengi", StubQuoteFeed())
    service = QuoteAcquisitionService(registry=registry)

    service.load_quote(
        "  dzengi  ",
        "BTC/USD_LEVERAGE",
    )

    assert registry.requested_source_names == ["  dzengi  "]


def test_quote_service_calls_registry_once() -> None:
    registry = RecordingQuoteFeedRegistry()
    registry.register("dzengi", StubQuoteFeed())
    service = QuoteAcquisitionService(registry=registry)

    service.load_quote("dzengi", "BTC/USD_LEVERAGE")

    assert registry.get_call_count == 1


def test_quote_service_passes_symbol_without_changes() -> None:
    registry = QuoteFeedRegistry()
    feed = StubQuoteFeed()
    registry.register("dzengi", feed)
    service = QuoteAcquisitionService(registry=registry)

    service.load_quote("dzengi", "  btc/usd_leverage  ")

    assert feed.symbols == ["  btc/usd_leverage  "]


def test_quote_service_calls_feed_once() -> None:
    registry = QuoteFeedRegistry()
    feed = StubQuoteFeed()
    registry.register("dzengi", feed)
    service = QuoteAcquisitionService(registry=registry)

    service.load_quote("dzengi", "BTC/USD_LEVERAGE")

    assert feed.symbols == ["BTC/USD_LEVERAGE"]


def test_quote_service_preserves_registry_error() -> None:
    service = QuoteAcquisitionService(
        registry=QuoteFeedRegistry(),
    )

    with pytest.raises(QuoteFeedRegistryError):
        service.load_quote("missing", "BTC/USD_LEVERAGE")


def test_quote_service_does_not_call_registered_feed_when_registry_fails() -> None:
    registry = QuoteFeedRegistry()
    feed = StubQuoteFeed()
    registry.register("registered", feed)
    service = QuoteAcquisitionService(registry=registry)

    with pytest.raises(QuoteFeedRegistryError):
        service.load_quote("missing", "BTC/USD_LEVERAGE")

    assert feed.symbols == []


@pytest.mark.parametrize(
    "error",
    [
        QuoteTransportError("Network error."),
        QuoteValueError("Invalid quote."),
    ],
)
def test_quote_service_preserves_feed_error(
    error: Exception,
) -> None:
    registry = QuoteFeedRegistry()
    feed = StubQuoteFeed(error=error)
    registry.register("dzengi", feed)
    service = QuoteAcquisitionService(registry=registry)

    with pytest.raises(type(error)) as exc_info:
        service.load_quote("dzengi", "BTC/USD_LEVERAGE")

    assert exc_info.value is error
    assert feed.symbols == ["BTC/USD_LEVERAGE"]


def test_quote_service_does_not_retry_after_error() -> None:
    registry = QuoteFeedRegistry()
    feed = StubQuoteFeed(
        error=QuoteTransportError("Network error."),
    )
    registry.register("dzengi", feed)
    service = QuoteAcquisitionService(registry=registry)

    with pytest.raises(QuoteTransportError):
        service.load_quote("dzengi", "BTC/USD_LEVERAGE")

    assert feed.symbols == ["BTC/USD_LEVERAGE"]


# ---------------------------------------------------------------------------
# Candles Acquisition Service tests
# ---------------------------------------------------------------------------


def _make_candle(
    *,
    symbol: str = "BTC/USD",
    interval: str = "1m",
    open_time: datetime | None = None,
    open_price: str = "100",
    high_price: str = "110",
    low_price: str = "90",
    close_price: str = "105",
    volume: str = "12.5",
    source: str = "dzengi",
) -> Candle:
    return Candle(
        symbol=symbol,
        interval=interval,
        open_time=open_time or datetime(2026, 1, 1, tzinfo=timezone.utc),
        open_price=Decimal(open_price),
        high_price=Decimal(high_price),
        low_price=Decimal(low_price),
        close_price=Decimal(close_price),
        volume=Decimal(volume),
        source=source,
    )


class _FakeCandlesFeed:
    def __init__(
        self,
        *,
        result: tuple[Candle, ...] = (),
        error: Exception | None = None,
    ) -> None:
        self.result = result
        self.error = error
        self.calls: list[tuple[str, str, int, str]] = []

    def load_candles(
        self,
        symbol: str,
        *,
        interval: str,
        limit: int,
        price_type: str,
    ) -> tuple[Candle, ...]:
        self.calls.append(
            (
                symbol,
                interval,
                limit,
                price_type,
            )
        )

        if self.error is not None:
            raise self.error

        return self.result


class _FakeCandlesRegistry:
    def __init__(
        self,
        *,
        feed: _FakeCandlesFeed | None = None,
        error: Exception | None = None,
    ) -> None:
        self.feed = feed
        self.error = error
        self.calls: list[str] = []

    def get(
        self,
        source_name: str,
    ) -> _FakeCandlesFeed:
        self.calls.append(source_name)

        if self.error is not None:
            raise self.error

        if self.feed is None:
            raise AssertionError("Candles Feed не настроен.")

        return self.feed


def test_candles_acquisition_service_returns_registered_feed_result() -> None:
    candles = (
        _make_candle(),
    )
    feed = _FakeCandlesFeed(result=candles)
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    result = service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert result == candles


def test_candles_acquisition_service_preserves_result_identity() -> None:
    candles = (
        _make_candle(),
    )
    feed = _FakeCandlesFeed(result=candles)
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    result = service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert result is candles


def test_candles_acquisition_service_passes_source_name_unchanged() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        " DzEnGi ",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert registry.calls == [" DzEnGi "]


def test_candles_acquisition_service_calls_registry_once() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert len(registry.calls) == 1


def test_candles_acquisition_service_passes_symbol_unchanged() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        "dzengi",
        " btc/usd ",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert feed.calls == [
        (
            " btc/usd ",
            "1m",
            100,
            "bid",
        )
    ]


def test_candles_acquisition_service_passes_interval_unchanged() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        "dzengi",
        "BTC/USD",
        interval=" 1M ",
        limit=100,
        price_type="bid",
    )

    assert feed.calls[0][1] == " 1M "


def test_candles_acquisition_service_passes_limit_unchanged() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=123,
        price_type="bid",
    )

    assert feed.calls[0][2] == 123


def test_candles_acquisition_service_passes_price_type_unchanged() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type=" BiD ",
    )

    assert feed.calls[0][3] == " BiD "


def test_candles_acquisition_service_calls_feed_once() -> None:
    feed = _FakeCandlesFeed()
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert len(feed.calls) == 1


def test_candles_acquisition_service_preserves_empty_tuple_identity() -> None:
    candles: tuple[Candle, ...] = ()
    feed = _FakeCandlesFeed(result=candles)
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    result = service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert result is candles


def test_candles_acquisition_service_preserves_candle_order() -> None:
    first = _make_candle(
        open_time=datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc),
    )
    second = _make_candle(
        open_time=datetime(2026, 1, 1, 0, 2, tzinfo=timezone.utc),
    )
    candles = (
        second,
        first,
    )
    feed = _FakeCandlesFeed(result=candles)
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    result = service.load_candles(
        "dzengi",
        "BTC/USD",
        interval="1m",
        limit=100,
        price_type="bid",
    )

    assert result == (
        second,
        first,
    )


def test_candles_acquisition_service_propagates_registry_error() -> None:
    error = CandleFeedRegistryError("Registry error.")
    registry = _FakeCandlesRegistry(error=error)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    with pytest.raises(CandleFeedRegistryError) as exc_info:
        service.load_candles(
            "dzengi",
            "BTC/USD",
            interval="1m",
            limit=100,
            price_type="bid",
        )

    assert exc_info.value is error


def test_candles_acquisition_service_does_not_call_feed_after_registry_error() -> None:
    feed = _FakeCandlesFeed()
    error = CandleFeedRegistryError("Registry error.")
    registry = _FakeCandlesRegistry(
        feed=feed,
        error=error,
    )
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    with pytest.raises(CandleFeedRegistryError):
        service.load_candles(
            "dzengi",
            "BTC/USD",
            interval="1m",
            limit=100,
            price_type="bid",
        )

    assert feed.calls == []


@pytest.mark.parametrize(
    "error",
    [
        CandleTransportError("Transport error."),
        CandleValueError("Value error."),
        CandleMappingError("Mapping error."),
    ],
)
def test_candles_acquisition_service_propagates_feed_error(
    error: Exception,
) -> None:
    feed = _FakeCandlesFeed(error=error)
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    with pytest.raises(type(error)) as exc_info:
        service.load_candles(
            "dzengi",
            "BTC/USD",
            interval="1m",
            limit=100,
            price_type="bid",
        )

    assert exc_info.value is error


def test_candles_acquisition_service_does_not_retry_after_feed_error() -> None:
    error = CandleTransportError("Transport error.")
    feed = _FakeCandlesFeed(error=error)
    registry = _FakeCandlesRegistry(feed=feed)
    service = CandlesAcquisitionService(registry=registry)  # type: ignore[arg-type]

    with pytest.raises(CandleTransportError):
        service.load_candles(
            "dzengi",
            "BTC/USD",
            interval="1m",
            limit=100,
            price_type="bid",
        )

    assert len(registry.calls) == 1
    assert len(feed.calls) == 1