'''
IB historical-data request regressions.

'''
from datetime import (
    UTC,
    datetime,
)
from types import SimpleNamespace

from ib_async import RequestError
import numpy as np
from pendulum import (
    datetime as pdatetime,
    duration,
)
import pytest
import trio

from piker.brokers import DataUnavailable
from piker.brokers.ib import feed as ib_feed
from piker.brokers.ib.api import Client
from piker.brokers.ib.feed import get_bars


@pytest.mark.parametrize(
    ('end_dt', 'expected'),
    [
        (None, ''),
        (
            datetime(2026, 7, 28, 23, 30, tzinfo=UTC),
            '20260728-23:30:00',
        ),
        (
            pdatetime(2026, 7, 28, 23, 30, tz='UTC'),
            '20260728-23:30:00',
        ),
        (
            pdatetime(
                2026,
                7,
                28,
                23,
                30,
                tz='America/New_York',
            ),
            '20260729-03:30:00',
        ),
    ],
)
def test_history_end_datetime_uses_ib_utc_format(
    end_dt: datetime|None,
    expected: str,
) -> None:
    '''
    Reverse history boundaries must use IB's explicit UTC syntax.

    ``ib_async`` adds a trailing ``UTC`` zone to aware datetimes.
    The live IB gateway rejected that form with error 10314 while
    filling an MNQ gap. Invoke ``Client.bars()`` with stdlib and
    Pendulum boundaries, then capture the request kwargs.
    Prove each becomes IB's UTC-dash form while latest requests stay
    blank.

    '''
    request: dict = {}

    class FakeIB:
        async def reqHistoricalDataAsync(self, **kwargs) -> list:
            request.update(kwargs)
            return []

    client = SimpleNamespace(ib=FakeIB())

    async def find_contracts(fqme: str) -> list:
        return [SimpleNamespace()]

    client.find_contracts = find_contracts

    async def main() -> None:
        await Client.bars(
            client,
            fqme='mnq.cme.20260918',
            sample_period_s=60,
            end_dt=end_dt,
        )

    trio.run(main)
    assert request['endDateTime'] == expected


def test_empty_history_frame_completes() -> None:
    '''
    A blank IB response must release the history request slot.

    ``get_bars()`` previously returned from its query task without
    signalling the parent, which then reset the feed forever. That
    kept serialized SHM repair from running. Return IB's empty
    response and prove the request completes explicitly
    with no result instead of relying on a reset timeout.

    '''
    class Proxy:
        '''
        Return one empty IB history response.

        '''
        async def bars(self, **kwargs):
            '''
            Model ``Client.bars()`` during a venue data gap.

            '''
            return (
                [],
                np.empty(0),
                duration(seconds=2000),
            )

    async def main() -> None:
        with trio.fail_after(0.1):
            result, timedout = await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
            )
        assert result is None
        assert not timedout

    trio.run(main)


def test_repeated_ib_cancellation_is_bounded() -> None:
    '''
    Repeated IB cancellation must not monopolize history forever.

    IB can answer a historical request with error 162 cancellation.
    The old retry loop had no limit, so reverse backfill could retain
    the backend's sole request slot and prevent final null repair.
    Always raise that response and prove the configured retry bound
    terminates with ``DataUnavailable`` deterministically.

    '''
    class Proxy:
        '''
        Cancel every IB history request.

        '''
        async def bars(self, **kwargs):
            '''
            Raise IB's historical-query cancellation response.

            '''
            raise RequestError(
                1,
                162,
                'API historical data query cancelled',
            )

    async def main() -> None:
        with (
            trio.fail_after(0.1),
            pytest.raises(DataUnavailable),
        ):
            await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
                max_failed_resets=2,
            )

    trio.run(main)


def test_query_error_cancels_stalled_reset(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Exceptional history exit must cancel its in-flight reset task.

    A timed-out request acquires the reset lock before launching
    IB's VNC reset task. If the query then raises while that task is
    blocked, stale work must not retain the reset lock. Force that
    ordering with explicit Trio checkpoints, then prove bounded query
    failure cancels the reset task before propagating.

    '''
    reset_cancelled: list[bool] = []

    class Proxy:
        '''
        Delay once, then return IB cancellation.

        '''
        async def bars(self, **kwargs):
            '''
            Let the parent acquire the reset lock before failing.

            '''
            await trio.sleep(0.02)
            raise RequestError(
                1,
                162,
                'API historical data query cancelled',
            )

    async def stalled_reset(
        proxy,
        reset_type='data',
        task_status=trio.TASK_STATUS_IGNORED,
    ) -> bool:
        '''
        Publish reset state, then block until sibling failure.

        '''
        done = trio.Event()
        result: list[bool] = []
        with trio.CancelScope() as cs:
            task_status.started((cs, done, result))
            try:
                await trio.sleep_forever()
            finally:
                reset_cancelled.append(True)
        return False

    monkeypatch.setattr(
        ib_feed,
        'wait_on_data_reset',
        stalled_reset,
    )

    async def main() -> None:
        with (
            trio.fail_after(0.1),
            pytest.raises(DataUnavailable),
        ):
            await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
                feed_reset_timeout=0.001,
                max_failed_resets=1,
            )

    trio.run(main)
    assert reset_cancelled == [True]


def test_failed_pacing_resets_reach_retry_limit(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Failed pacing resets must increment the request's retry count.

    The reset API returns an ``Event`` and result box. The old
    code tested the always-truthy event and reset the failure counter
    after each failure. Return immediate failures and prove two
    pacing responses terminate instead of looping forever.

    '''
    reset_calls: list[bool] = []

    class Proxy:
        '''
        Return a pacing violation for every request.

        '''
        _aio_ns = SimpleNamespace(
            ib=SimpleNamespace(
                client=SimpleNamespace(),
            ),
        )

        async def bars(self, **kwargs):
            '''
            Raise IB's pacing response.

            '''
            raise RequestError(
                1,
                162,
                ib_feed._pacing,
            )

    async def failed_reset(
        proxy,
        reset_type='data',
        task_status=trio.TASK_STATUS_IGNORED,
    ) -> bool:
        '''
        Publish one completed, unsuccessful reset.

        '''
        done = trio.Event()
        result = [False]
        reset_calls.append(True)
        with trio.CancelScope() as cs:
            task_status.started((cs, done, result))
            done.set()
        return False

    monkeypatch.setattr(
        ib_feed,
        'wait_on_data_reset',
        failed_reset,
    )

    async def main() -> None:
        with (
            trio.fail_after(0.1),
            pytest.raises(DataUnavailable),
        ):
            await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
                max_failed_resets=2,
            )

    trio.run(main)
    assert reset_calls == [True, True]


def test_pacing_resets_are_serialized_across_requests(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Concurrent history requests must never reset IB farms together.

    Pacing recovery bypassed reset ownership, allowing 1s and 60s
    requests to launch the VNC hack concurrently. Start two pacing
    failures in one nursery and hold each fake reset across a Trio
    checkpoint. Active-count assertions prove the actor-global lock
    serializes reset lifetimes without depending on scheduler timing.

    '''
    active: int = 0
    max_active: int = 0
    reset_calls: int = 0

    class Proxy:
        '''
        Return a pacing violation for every request.

        '''
        _aio_ns = SimpleNamespace(
            ib=SimpleNamespace(
                client=SimpleNamespace(),
            ),
        )

        async def bars(self, **kwargs):
            '''
            Raise IB's pacing response.

            '''
            raise RequestError(
                1,
                162,
                ib_feed._pacing,
            )

    async def failed_reset(
        proxy,
        reset_type='data',
        task_status=trio.TASK_STATUS_IGNORED,
    ) -> bool:
        '''
        Hold one failed reset long enough to expose overlap.

        '''
        nonlocal active, max_active, reset_calls
        done = trio.Event()
        result: list[bool] = []
        with trio.CancelScope() as cs:
            task_status.started((cs, done, result))
            active += 1
            reset_calls += 1
            max_active = max(max_active, active)
            await trio.sleep(0.01)
            active -= 1
            result.append(False)
            done.set()
        return False

    monkeypatch.setattr(
        ib_feed,
        'wait_on_data_reset',
        failed_reset,
    )

    async def request() -> None:
        '''
        Run one independently bounded history request.

        '''
        with pytest.raises(DataUnavailable):
            await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
                max_failed_resets=1,
            )

    async def main() -> None:
        with trio.fail_after(0.1):
            async with trio.open_nursery() as nursery:
                nursery.start_soon(request)
                nursery.start_soon(request)

    trio.run(main)
    assert reset_calls == 2
    assert max_active == 1


def test_pacing_supersedes_timeout_reset_once(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Pacing handoff must not count cancellation as another failure.

    A slow request can trigger timeout recovery immediately before IB
    returns a pacing violation. Query-side recovery then deliberately
    cancels that data reset and waits for the same actor-global lock.
    Reproduce this with blocked first and failed second resets. A
    one-attempt limit proves cancellation releases the lock
    without aborting before the intended connection reset runs.

    '''
    reset_types: list[str] = []

    class Proxy:
        '''
        Return pacing only after timeout recovery starts.

        '''
        _aio_ns = SimpleNamespace(
            ib=SimpleNamespace(
                client=SimpleNamespace(),
            ),
        )

        async def bars(self, **kwargs):
            '''
            Delay past the reset timeout, then report pacing.

            '''
            await trio.sleep(0.02)
            raise RequestError(
                1,
                162,
                ib_feed._pacing,
            )

    async def reset(
        proxy,
        reset_type='data',
        task_status=trio.TASK_STATUS_IGNORED,
    ) -> bool:
        '''
        Block data reset and fail connection reset immediately.

        '''
        done = trio.Event()
        result: list[bool] = []
        reset_types.append(reset_type)
        with trio.CancelScope() as cs:
            task_status.started((cs, done, result))
            if reset_type == 'data':
                await trio.sleep_forever()

        result.append(False)
        done.set()
        return False

    monkeypatch.setattr(
        ib_feed,
        'wait_on_data_reset',
        reset,
    )

    async def main() -> None:
        with (
            trio.fail_after(0.1),
            pytest.raises(DataUnavailable),
        ):
            await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
                feed_reset_timeout=0.001,
                max_failed_resets=1,
            )

    trio.run(main)
    assert reset_types == ['data', 'connection']


def test_completed_pacing_reset_restarts_timeout_wait(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    A queued timeout waiter must consume completed pacing recovery.

    While query-side pacing recovery holds the reset lock, the outer
    timeout task can already queue behind it. Without a completion
    handoff, that waiter launches a redundant data reset immediately.
    Return a blank frame just after a successful connection reset and
    prove no third reset runs before the restarted timeout observes
    completion.

    '''
    reset_types: list[str] = []

    class Proxy:
        '''
        Pace the first query and return blank data on retry.

        '''
        _aio_ns = SimpleNamespace(
            ib=SimpleNamespace(
                client=SimpleNamespace(),
            ),
        )

        def __init__(self) -> None:
            self.calls: int = 0

        async def bars(self, **kwargs):
            '''
            Control the pacing-to-success handoff ordering.

            '''
            self.calls += 1
            if self.calls == 1:
                await trio.sleep(0.02)
                raise RequestError(
                    1,
                    162,
                    ib_feed._pacing,
                )

            await trio.sleep(0.0005)
            return (
                [],
                np.empty(0),
                duration(seconds=2000),
            )

    async def reset(
        proxy,
        reset_type='data',
        task_status=trio.TASK_STATUS_IGNORED,
    ) -> bool:
        '''
        Cancel timeout recovery and complete pacing recovery.

        '''
        done = trio.Event()
        result: list[bool] = []
        reset_types.append(reset_type)
        with trio.CancelScope() as cs:
            task_status.started((cs, done, result))
            if reset_type == 'data':
                await trio.sleep_forever()

        result.append(reset_type == 'connection')
        done.set()
        return result[0]

    monkeypatch.setattr(
        ib_feed,
        'wait_on_data_reset',
        reset,
    )

    async def main() -> None:
        with trio.fail_after(0.1):
            result, _ = await get_bars(
                Proxy(),
                'qqq.nasdaq.ib',
                1,
                feed_reset_timeout=0.001,
                max_failed_resets=2,
            )
        assert result is None

    trio.run(main)
    assert reset_types == ['data', 'connection']
