'''
NativeDB durability and history-preservation regressions.

'''
import os
from pathlib import Path

import numpy as np
import polars as pl
import pytest
import trio

from piker import tsp
from piker.data._source import def_iohlcv_fields
from piker.storage.nativedb import (
    iter_native_series,
    NativeStorageClient,
    parse_ohlcv_parquet_path,
)


def mk_ohlcv(
    times: tuple[float, ...],
    closes: tuple[float, ...] | None = None,

) -> np.ndarray:
    '''
    Build a minimal structured OHLCV frame.

    '''
    array = np.zeros(
        len(times),
        dtype=np.dtype(def_iohlcv_fields),
    )
    array['index'] = np.arange(len(times))
    array['time'] = times
    array['close'] = closes or times
    return array


def run(coro) -> None:
    '''
    Run a NativeDB operation with a bounded Trio clock.

    '''
    async def main() -> None:
        with trio.fail_after(1):
            await coro

    trio.run(main)


@pytest.mark.parametrize('use_polars', [False, True])
def test_numpy_and_polars_round_trip(
    tmp_path: Path,
    use_polars: bool,
) -> None:
    '''
    Both supported frame types survive durable serialization.

    '''
    client = NativeStorageClient(tmp_path)
    array = mk_ohlcv(
        (60, 120, 180),
        (1, 2, 3),
    )
    payload = tsp.np2pl(array) if use_polars else array

    run(client.write_ohlcv('x.test', payload, 60))
    loaded = trio.run(client.read_ohlcv, 'x.test', 60)

    assert loaded['time'].tolist() == [60, 120, 180]
    assert loaded['close'].tolist() == [1, 2, 3]


def test_pl2np_maps_fields_by_name() -> None:
    '''
    Polars conversion must not depend on DataFrame column positions.

    Provider and legacy Parquet frames can include extra columns or
    present canonical fields in a different order. The old ``zip()``
    conversion paired NumPy field names with DataFrame positions,
    silently assigning unrelated values. Build distinct canonical
    values, prepend provider-only ``count``, reverse canonical order,
    and prove every structured-array field is selected by its name.

    '''
    expected = mk_ohlcv(
        (60, 120),
        (1.15, 2.15),
    )
    expected['open'] = [1.1, 2.1]
    expected['high'] = [1.2, 2.2]
    expected['low'] = [1.0, 2.0]
    expected['volume'] = [10, 20]
    canonical = [name for name, _ in def_iohlcv_fields]
    reordered = (
        tsp.np2pl(expected)
        .with_columns(pl.Series('count', [3, 4]))
        .select(['count', *reversed(canonical)])
    )

    actual = tsp.pl2np(
        reordered,
        dtype=expected.dtype,
    )

    for field in canonical:
        assert actual[field].tolist() == expected[field].tolist()


def test_update_preserves_history_and_resolves_conflicts(
    tmp_path: Path,
) -> None:
    '''
    Incremental writes retain old rows and prefer incoming bars.

    '''
    client = NativeStorageClient(tmp_path)
    old = mk_ohlcv(
        (60, 120, 180),
        (1, 2, 3),
    )
    incoming = mk_ohlcv(
        (180, 240),
        (30, 4),
    )

    run(client.write_ohlcv('x.test', old, 60))
    run(client.update_ohlcv('x.test', incoming, 60))
    stored = pl.read_parquet(client.mk_path('x.test', 60))

    assert stored['time'].to_list() == [60, 120, 180, 240]
    assert stored['close'].to_list() == [1, 2, 30, 4]
    assert stored['index'].to_list() == [0, 1, 2, 3]


def test_update_repairs_invalid_persisted_sentinel_rows(
    tmp_path: Path,
) -> None:
    '''
    Valid incoming history must repair legacy sentinel rows.

    The known MNQ baseline contained epoch-zero and all-zero rows,
    plus extra columns and absolute indexes. ``update_ohlcv()`` first
    retained both, then removed only epoch zero and left chart range
    anchored at zero. Write both sentinels, append a valid provider
    frame, and prove only valid bars survive with canonical
    indexes.

    '''
    client = NativeStorageClient(tmp_path)
    legacy = (
        tsp.np2pl(mk_ohlcv(
            (0, 60, 120, 180),
            (0, 0, 2, 3),
        ))
        .with_columns(
            pl.Series(
                'index',
                [3137800, 3137801, 3137802, 3137803],
            ),
            pl.Series('time_prev', [None, 0, 60, 120]),
        )
    )
    path: Path = client.mk_path('x.test', 60)
    legacy.write_parquet(path)

    loaded = trio.run(client.read_ohlcv, 'x.test', 60)
    assert loaded['time'].tolist() == [120, 180]
    assert loaded['index'].tolist() == [0, 1]
    assert pl.read_parquet(path).height == 4

    run(client.update_ohlcv(
        'x.test',
        mk_ohlcv((240,), (4,)),
        60,
    ))
    stored: pl.DataFrame = pl.read_parquet(path)

    assert stored['time'].to_list() == [120, 180, 240]
    assert stored['close'].to_list() == [2, 3, 4]
    assert stored['index'].to_list() == [0, 1, 2]


def test_write_preserves_modern_all_zero_prices(
    tmp_path: Path,
) -> None:
    '''
    Zero prices outside the legacy sentinel range remain valid.

    Some spreads or synthetic instruments may legitimately trade at
    zero. Repair targets the observed first-slot sentinel, not every
    all-zero OHLC bar. Write a modern all-zero row and
    prove it survives normal replacement unchanged.

    '''
    client = NativeStorageClient(tmp_path)
    zero_bar = mk_ohlcv((1_800_000_000,), (0,))

    run(client.write_ohlcv('x.test', zero_bar, 60))
    stored = pl.read_parquet(client.mk_path('x.test', 60))

    assert stored['time'].to_list() == [1_800_000_000]
    assert stored['close'].to_list() == [0]


def test_write_rejects_first_slot_zero_sentinel(
    tmp_path: Path,
) -> None:
    '''
    New writes must not recreate the repaired legacy sentinel.

    Read repair removes all-zero OHLC first-slot rows. Accepting one from
    new input would recreate a file whose audit fails and whose read view
    hides a row. Submit the exact 60-second
    sentinel and prove no file publishes.

    '''
    client = NativeStorageClient(tmp_path)
    sentinel = mk_ohlcv((60,), (0,))

    with pytest.raises(ValueError, match='epoch sentinel'):
        run(client.write_ohlcv('x.test', sentinel, 60))

    assert not client.mk_path('x.test', 60).exists()


def test_sentinel_only_storage_loads_as_no_history(
    tmp_path: Path,
) -> None:
    '''
    A fully repairable legacy file must permit fresh startup.

    Filtering a file containing only sentinels yields an empty array.
    ``load()`` previously indexed its endpoint timestamps and crashed
    before valid data could replace it. Persist only sentinel rows,
    prove load reports no history, then append a valid bar and
    verify durable storage becomes canonical.

    '''
    client = NativeStorageClient(tmp_path)
    path: Path = client.mk_path('x.test', 60)
    tsp.np2pl(mk_ohlcv(
        (0, 60),
        (0, 0),
    )).write_parquet(path)

    assert trio.run(client.load, 'x.test', 60) is None
    run(client.update_ohlcv(
        'x.test',
        mk_ohlcv((120,), (1,)),
        60,
    ))
    stored: pl.DataFrame = pl.read_parquet(path)

    assert stored['time'].to_list() == [120]
    assert stored['index'].to_list() == [0]


def test_merge_does_not_hide_null_price_rows(
    tmp_path: Path,
) -> None:
    '''
    Sentinel repair must not silently discard other malformed rows.

    Polars boolean filters drop null predicates. Without an explicit
    false fill, one null plus three zeros looked neither valid nor
    all-zero but disappeared before validation. Persist that shape,
    append valid data, and prove merge still rejects the null
    while preserving the original evidence file.

    '''
    client = NativeStorageClient(tmp_path)
    path: Path = client.mk_path('x.test', 60)
    malformed = tsp.np2pl(
        mk_ohlcv((1_800_000_000,), (0,))
    ).with_columns(
        pl.Series('open', [None], dtype=pl.Float64)
    )
    malformed.write_parquet(path)
    before: bytes = path.read_bytes()

    with pytest.raises(ValueError, match='finite numeric'):
        run(client.update_ohlcv(
            'x.test',
            mk_ohlcv((1_800_000_060,), (1,)),
            60,
        ))

    assert path.read_bytes() == before


def test_write_ohlcv_remains_an_explicit_replacement(
    tmp_path: Path,
) -> None:
    '''
    Full repair writes can intentionally remove persisted rows.

    '''
    client = NativeStorageClient(tmp_path)
    old = mk_ohlcv((60, 120, 180))
    replacement = mk_ohlcv((120, 180))

    run(client.write_ohlcv('x.test', old, 60))
    run(client.write_ohlcv('x.test', replacement, 60))
    stored = pl.read_parquet(client.mk_path('x.test', 60))

    assert stored['time'].to_list() == [120, 180]


def test_failed_write_preserves_file_and_cache(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    A failed parquet write leaves durable and cached data intact.

    '''
    client = NativeStorageClient(tmp_path)
    old = mk_ohlcv((60, 120))
    incoming = mk_ohlcv((180,))
    run(client.write_ohlcv('x.test', old, 60))

    def fail_write(
        df: pl.DataFrame,
        file: Path,
        *args,
        **kwargs,
    ) -> None:
        Path(file).write_bytes(b'partial parquet')
        raise OSError('simulated write failure')

    monkeypatch.setattr(
        pl.DataFrame,
        'write_parquet',
        fail_write,
    )
    with pytest.raises(OSError, match='simulated write failure'):
        run(client.update_ohlcv('x.test', incoming, 60))

    stored = pl.read_parquet(client.mk_path('x.test', 60))
    cached = trio.run(client.as_df, 'x.test', 60, False)
    assert stored['time'].to_list() == [60, 120]
    assert cached['time'].to_list() == [60, 120]
    assert not list(tmp_path.glob('*.tmp'))


def test_invalid_temporary_parquet_is_not_committed(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    The reopened candidate must validate before atomic replacement.

    '''
    client = NativeStorageClient(tmp_path)
    old = mk_ohlcv((60, 120))
    run(client.write_ohlcv('x.test', old, 60))
    real_read = pl.read_parquet

    def corrupt_candidate(source, *args, **kwargs):
        if Path(source).suffix == '.tmp':
            return pl.DataFrame({'time': [180]})
        return real_read(source, *args, **kwargs)

    monkeypatch.setattr(pl, 'read_parquet', corrupt_candidate)
    with pytest.raises(ValueError, match='missing columns'):
        run(client.update_ohlcv(
            'x.test',
            mk_ohlcv((180,)),
            60,
        ))

    path = client.mk_path('x.test', 60)
    stored = real_read(path)
    cached = trio.run(client.as_df, 'x.test', 60, False)
    assert stored['time'].to_list() == [60, 120]
    assert cached['time'].to_list() == [60, 120]
    assert not list(tmp_path.glob('*.tmp'))


def test_directory_sync_failure_keeps_visible_cache_consistent(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    A post-replace durability error leaves cache matching the file.

    '''
    client = NativeStorageClient(tmp_path)
    run(client.write_ohlcv('x.test', mk_ohlcv((60,)), 60))
    real_fsync = os.fsync
    calls: int = 0

    def fail_directory_sync(fd: int) -> None:
        nonlocal calls
        calls += 1
        if calls == 2:
            raise OSError('simulated directory sync failure')
        real_fsync(fd)

    monkeypatch.setattr(os, 'fsync', fail_directory_sync)
    with pytest.raises(OSError, match='directory sync failure'):
        run(client.update_ohlcv(
            'x.test',
            mk_ohlcv((120,)),
            60,
        ))

    stored = pl.read_parquet(client.mk_path('x.test', 60))
    cached = trio.run(client.as_df, 'x.test', 60, False)
    assert stored['time'].to_list() == [60, 120]
    assert cached['time'].to_list() == [60, 120]


@pytest.mark.parametrize(
    'times',
    [
        (0, 60),
        (60, 60),
        (120, 60),
    ],
)
def test_update_rejects_invalid_timestamps(
    tmp_path: Path,
    times: tuple[float, ...],
) -> None:
    '''
    Incremental input must have positive, increasing timestamps.

    '''
    client = NativeStorageClient(tmp_path)
    incoming = mk_ohlcv(times)

    with pytest.raises(ValueError):
        run(client.update_ohlcv('x.test', incoming, 60))

    assert not client.mk_path('x.test', 60).exists()


def test_replacement_rejects_invalid_timestamps(
    tmp_path: Path,
) -> None:
    '''
    Explicit replacement enforces the same durable invariants.

    '''
    client = NativeStorageClient(tmp_path)

    with pytest.raises(ValueError):
        run(client.write_ohlcv(
            'x.test',
            mk_ohlcv((60, 60)),
            60,
        ))

    assert not client.mk_path('x.test', 60).exists()


def test_fractional_timestamps_are_not_truncated(
    tmp_path: Path,
) -> None:
    '''
    Durable timestamp coercion must not silently alter provider data.

    NativeDB declares integer epoch seconds, while IB delivers its
    timestamps in a floating dtype. Casting before validation truncated
    fractional values and could collapse distinct rows at one second.
    Supply otherwise-valid half-second values and prove the write rejects
    them before publishing any Parquet path.

    '''
    client = NativeStorageClient(tmp_path)
    frame = tsp.np2pl(mk_ohlcv((60, 120))).with_columns(
        pl.Series('time', [60.5, 120.5])
    )

    with pytest.raises(ValueError, match='whole-second'):
        run(client.write_ohlcv('x.test', frame, 60))

    assert not client.mk_path('x.test', 60).exists()


def test_write_rejects_invalid_schema_and_values(
    tmp_path: Path,
) -> None:
    '''
    Durable OHLCV columns must exist and contain finite numbers.

    '''
    client = NativeStorageClient(tmp_path)
    with pytest.raises(ValueError, match='missing columns'):
        run(client.write_ohlcv(
            'x.test',
            pl.DataFrame({'time': [60]}),
            60,
        ))

    invalid = mk_ohlcv((60,))
    invalid['close'] = np.nan
    with pytest.raises(ValueError, match='finite numeric'):
        run(client.write_ohlcv('x.test', invalid, 60))


def test_series_do_not_interfere(
    tmp_path: Path,
) -> None:
    '''
    FQME and timeframe keys isolate merge and cache state.

    '''
    client = NativeStorageClient(tmp_path)
    run(client.write_ohlcv('x.test', mk_ohlcv((60,)), 60))
    run(client.write_ohlcv('x.test', mk_ohlcv((1, 2)), 1))
    run(client.write_ohlcv('y.test', mk_ohlcv((60, 120)), 60))
    run(client.update_ohlcv('x.test', mk_ohlcv((120,)), 60))

    x_60 = pl.read_parquet(client.mk_path('x.test', 60))
    x_1 = pl.read_parquet(client.mk_path('x.test', 1))
    y_60 = pl.read_parquet(client.mk_path('y.test', 60))
    assert x_60['time'].to_list() == [60, 120]
    assert x_1['time'].to_list() == [1, 2]
    assert y_60['time'].to_list() == [60, 120]


def test_index_files_ignores_sidecar_files(
    tmp_path: Path,
) -> None:
    '''
    Legacy writer locks and crash leftovers are not series entries.

    Earlier deep-fix revisions created persistent
    ``.parquet.lock`` files beside each series. The upstream indexer
    mistakes those sidecars for Parquet data and crashes while parsing
    their period. Arrange both a legacy lock and stale temporary file,
    then prove exact-suffix indexing exposes only the durable series.

    '''
    client = NativeStorageClient(tmp_path)
    run(client.write_ohlcv('x.test', mk_ohlcv((60,)), 60))
    (tmp_path / '.x.test.ohlcv60s.parquet.lock').touch()
    (tmp_path / 'x.test.ohlcv60s.parquet.crash.tmp').touch()

    index = client.index_files()

    assert list(index) == [('x.test', 60)]
    ref = index[('x.test', 60)]
    assert ref.fqme == 'x.test'
    assert ref.period_s == 60


def test_index_files_preserves_every_series_timeframe(
    tmp_path: Path,
) -> None:
    '''
    NativeDB discovery must identify FQME and timeframe together.

    The old index keyed only by FQME, so filesystem iteration silently
    discarded either the 1s or 60s file. Write both periods plus another
    instrument, rebuild the index, and prove exact series remain stable
    while compatibility key listing still returns unique FQMEs.

    '''
    client = NativeStorageClient(tmp_path)
    run(client.write_ohlcv('x.test', mk_ohlcv((1, 2)), 1))
    run(client.write_ohlcv('x.test', mk_ohlcv((60,)), 60))
    run(client.write_ohlcv('y.test', mk_ohlcv((60,)), 60))

    index = client.index_files()
    keys = trio.run(client.list_keys)
    series = trio.run(client.list_series)

    assert list(index) == [
        ('x.test', 1),
        ('x.test', 60),
        ('y.test', 60),
    ]
    assert keys == ['x.test', 'y.test']
    assert [
        (ref.fqme, ref.period_s)
        for ref in series
    ] == list(index)


@pytest.mark.parametrize(
    'name',
    [
        'x.test.ohlcv0s.parquet',
        'x.test.ohlcv01s.parquet',
        'x.test.ohlcv²s.parquet',
        'x.test.ohlcv1.parquet',
        'x.test.ohlcv1s.parquet.tmp',
        'x.test.parquet',
        '.ohlcv1s.parquet',
    ],
)
def test_native_series_parser_rejects_noncanonical_names(
    tmp_path: Path,
    name: str,
) -> None:
    '''
    Malformed files must not become selectable storage identities.

    NativeDB directories can contain crash files, legacy sidecars, and
    unrelated Parquet. Exercise ambiguous period and suffix forms and
    prove parser and directory discovery ignore them deterministically.

    '''
    path: Path = tmp_path / name
    path.touch()

    assert parse_ohlcv_parquet_path(path) is None
    assert list(iter_native_series(tmp_path)) == []


def test_native_series_parser_round_trips_dotted_fqme(
    tmp_path: Path,
) -> None:
    '''
    Dotted market identities must survive canonical filename parsing.

    The legacy parser split on every dot and failed for ordinary FQMEs.
    Parse a futures-style identity and prove every selector field is
    preserved exactly.

    '''
    path = tmp_path / 'mnq.cme.20260918.ib.ohlcv1s.parquet'
    path.touch()

    ref = parse_ohlcv_parquet_path(path)

    assert ref is not None
    assert ref.fqme == 'mnq.cme.20260918.ib'
    assert ref.period_s == 1
    assert ref.path == path


def test_writes_create_no_lock_sidecars(
    tmp_path: Path,
) -> None:
    '''
    Actor-owned NativeDB writes must not create lock sidecars.

    ``datad`` already gives each persistent feed one parent history
    writer, with its child tasks writing distinct timeframe files. A
    redundant filesystem lock previously leaked ``.parquet.lock``
    files into NativeDB and made upstream ``flake_update`` crash during
    startup. Exercise replacement and incremental writes, then prove
    no lock artifact exists and the merged history remains intact.

    '''
    client = NativeStorageClient(tmp_path)
    run(client.write_ohlcv('x.test', mk_ohlcv((60,)), 60))
    run(client.update_ohlcv('x.test', mk_ohlcv((120,)), 60))
    path = client.mk_path('x.test', 60)
    stored = pl.read_parquet(path)

    assert not list(tmp_path.glob('*.lock'))
    assert stored['time'].to_list() == [60, 120]
