'''
Deterministic time-series analysis regressions.

'''
import numpy as np
import polars as pl
import pytest

from piker.data._source import def_iohlcv_fields
from piker.tsp import _anal
from piker.tsp._anal import (
    dedupe,
    detect_time_gaps,
    detect_time_ordering_errors,
    get_null_segs,
    iter_null_segs,
    np2pl,
    with_dts,
)
from piker.tsp._dedupe_smart import dedupe_ohlcv_smart


def mk_ohlcv(
    times: list[int],
    indexes: list[int]|None = None,
) -> np.ndarray:
    '''
    Build a synthetic OHLCV frame from timestamps.

    '''
    size: int = len(times)
    frame = np.zeros(
        size,
        dtype=np.dtype(def_iohlcv_fields),
    )
    frame['index'] = (
        np.arange(size)
        if indexes is None
        else indexes
    )
    frame['time'] = times
    frame['open'] = np.arange(size) + 10
    frame['high'] = frame['open'] + 2
    frame['low'] = frame['open'] - 2
    frame['close'] = frame['open'] + 1
    frame['volume'] = np.arange(size) + 100
    return frame


def expected_null_segments(
    null_positions: list[int],
    size: int,
    offset: int,
    margin: int,

) -> list[list[int]]:
    '''
    Group relative null positions with a simple state machine.

    '''
    runs: list[list[int]] = []
    for pos in null_positions:
        if (
            not runs
            or
            pos != runs[-1][1] + 1
        ):
            runs.append([pos, pos])
        else:
            runs[-1][1] = pos

    frame_end: int = offset + size - 1
    return [
        [
            max(offset, offset + start - margin),
            min(frame_end, offset + end + margin),
        ]
        for start, end in runs
    ]


def test_dedupe_recomputes_dts_after_sort() -> None:
    '''
    Derived columns describe final sorted, unique rows.

    '''
    src: pl.DataFrame = np2pl(mk_ohlcv([
        60,
        180,
        120,
        180,
    ]))

    _, deduped, diff = dedupe(src)

    assert diff == 1
    assert deduped['time'].to_list() == [60, 120, 180]
    assert deduped['time_prev'].to_list() == [None, 60, 120]
    assert deduped['s_diff'].to_list() == [None, 60, 60]
    assert detect_time_gaps(
        deduped,
        expect_period=60,
    ).is_empty()


def test_negative_delta_is_an_ordering_error(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Reversed timestamps are reported as ordering faults, not gaps.

    '''
    src = np2pl(mk_ohlcv([120, 60]))
    wdts: pl.DataFrame = with_dts(src)
    errors = detect_time_ordering_errors(wdts)

    assert detect_time_gaps(
        wdts,
        expect_period=30,
    ).is_empty()
    assert errors['time_prev'].to_list() == [120]
    assert errors['time'].to_list() == [60]
    assert errors['s_diff'].to_list() == [-60]

    warnings: list[str] = []
    monkeypatch.setattr(_anal.log, 'warning', warnings.append)
    dedupe(src)
    assert len(warnings) == 1
    assert 'non-positive timestamp step' in warnings[0]


def test_single_null_row_is_a_segment() -> None:
    '''
    A one-row null segment remains repairable.

    '''
    frame: np.ndarray = mk_ohlcv([
        60,
        120,
        0,
        240,
        300,
    ])

    nulls = get_null_segs(frame, period=60)

    assert nulls is not None
    segments, zero_indexes, zero_rows = nulls
    assert segments == [[1, 3]]
    assert zero_indexes.tolist() == [2]
    assert zero_rows['index'].tolist() == [2]


@pytest.mark.parametrize(
    ('times', 'expected'),
    [
        ([0, 0, 180, 240, 300, 360], [[10, 12]]),
        ([60, 0, 0, 240, 0, 360], [[10, 13], [13, 15]]),
        ([60, 120, 180, 240, 0, 0], [[13, 15]]),
    ],
)
def test_null_segment_boundaries_match_numpy_and_polars(
    times: list[int],
    expected: list[list[int]],
) -> None:
    '''
    Segments have exact, inclusive, margin-expanded endpoints.

    '''
    frame: np.ndarray = mk_ohlcv(
        times,
        indexes=list(range(10, 16)),
    )

    np_nulls = get_null_segs(frame, period=60)
    pl_nulls = get_null_segs(np2pl(frame), period=60)

    assert np_nulls is not None
    assert pl_nulls is not None
    assert np_nulls[0] == expected
    assert pl_nulls[0] == expected
    assert np_nulls[1].tolist() == pl_nulls[1].to_list()


def test_null_segment_grouping_exhaustive() -> None:
    '''
    Every null layout through six rows matches an independent oracle.

    '''
    offset: int = 10
    for size in range(1, 7):
        for mask in range(1, 1 << size):
            null_positions: list[int] = [
                pos
                for pos in range(size)
                if mask & (1 << pos)
            ]
            frame = mk_ohlcv(
                [
                    0
                    if pos in null_positions
                    else (pos + 1) * 60
                    for pos in range(size)
                ],
                indexes=list(range(offset, offset + size)),
            )

            for margin in range(3):
                expected = expected_null_segments(
                    null_positions,
                    size,
                    offset,
                    margin,
                )
                for candidate in (frame, np2pl(frame)):
                    nulls = get_null_segs(
                        candidate,
                        period=60,
                        imargin=margin,
                    )
                    assert nulls is not None
                    assert nulls[0] == expected


def test_null_segment_margin_and_column() -> None:
    '''
    The requested column and index margin define each segment.

    '''
    frame: np.ndarray = mk_ohlcv(
        [60, 120, 180, 240, 300],
        indexes=[10, 11, 12, 13, 14],
    )
    frame['volume'][1:3] = 0

    for candidate in (frame, np2pl(frame)):
        no_margin = get_null_segs(
            candidate,
            period=60,
            imargin=0,
            col='volume',
        )
        wide_margin = get_null_segs(
            candidate,
            period=60,
            imargin=2,
            col='volume',
        )

        assert no_margin is not None
        assert wide_margin is not None
        assert no_margin[0] == [[11, 12]]
        assert wide_margin[0] == [[10, 14]]
        assert list(no_margin[1]) == [11, 12]


def test_null_segment_input_invariants() -> None:
    '''
    Empty results and invalid index/margin inputs are explicit.

    '''
    frame = mk_ohlcv([60, 120, 180])
    assert get_null_segs(frame, period=60) is None

    with pytest.raises(ValueError, match='imargin'):
        get_null_segs(
            mk_ohlcv([60, 0, 180]),
            period=60,
            imargin=-1,
        )

    with pytest.raises(ValueError, match='contiguous'):
        get_null_segs(
            mk_ohlcv(
                [60, 0, 180],
                indexes=[10, 12, 13],
            ),
            period=60,
        )


def test_iter_null_segs_uses_precomputed_segments(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Iteration preserves inclusive boundary rows without rescanning.

    '''
    frame = mk_ohlcv([60, 120, 0, 240, 300])
    nulls = get_null_segs(frame, period=60)
    assert nulls is not None

    def fail_rescan(*args, **kwargs):
        raise AssertionError('precomputed segments were ignored')

    monkeypatch.setattr(_anal, 'get_null_segs', fail_rescan)
    segments = list(iter_null_segs(
        timeframe=60,
        frame=frame,
        null_segs=nulls,
    ))

    assert len(segments) == 1
    assert segments[0][:6] == (
        1,
        3,
        1,
        3,
        120,
        240,
    )


def test_smart_dedupe_honors_sort_without_dupes() -> None:
    '''
    The sort option applies even when no duplicates exist.

    '''
    src: pl.DataFrame = np2pl(mk_ohlcv([
        180,
        60,
        120,
    ]))

    _, deduped, diff, _, issues = dedupe_ohlcv_smart(src)

    assert diff == 0
    assert issues is None
    assert deduped['time'].to_list() == [60, 120, 180]
    assert deduped['s_diff'].to_list() == [None, 60, 60]
