'''
Read-only NativeDB audit regressions.

'''
from hashlib import sha256
import json
from pathlib import Path

import numpy as np
import polars as pl
import pytest
from typer.testing import CliRunner

from piker import config
from piker.data import def_iohlcv_fields
from piker.storage import _audit as audit_mod
from piker.storage._audit import (
    audit_ohlcv_frame,
    audit_ohlcv_parquet,
)
from piker.storage.cli import store
from piker.storage.nativedb import (
    mk_ohlcv_shm_keyed_filepath,
)


def mk_frame(times: tuple[int, ...]) -> pl.DataFrame:
    '''
    Build one canonical persisted frame with finite values.

    '''
    size: int = len(times)
    return pl.DataFrame({
        'index': pl.Series(np.arange(size), dtype=pl.Int64),
        'time': pl.Series(times, dtype=pl.Int64),
        'open': pl.Series(np.arange(size) + 1, dtype=pl.Float64),
        'high': pl.Series(np.arange(size) + 2, dtype=pl.Float64),
        'low': pl.Series(np.arange(size), dtype=pl.Float64),
        'close': pl.Series(np.arange(size) + 1, dtype=pl.Float64),
        'volume': pl.Series(np.arange(size) + 10, dtype=pl.Float64),
    })


def test_audit_separates_structure_from_positive_gaps() -> None:
    '''
    Expected closures must not masquerade as structural corruption.

    Persisted CME and session-market history can be canonical while
    containing positive timestamp gaps. Build a structurally valid
    frame with one aligned interval and prove validity remains green
    while exact endpoints and unresolved coverage remain visible.

    '''
    report = audit_ohlcv_frame(
        mk_frame((60, 120, 300)),
        fqme='mnq.cme.20260918.ib',
        period_s=60,
    )

    assert report['result'] == {
        'structural_ok': True,
        'gap_free': False,
        'qualification_ok': False,
        'violations': [],
        'warnings': ['positive_time_gaps_unclassified'],
    }
    assert report['gaps']['count'] == 1
    assert report['gaps']['missing_samples_total'] == 2
    assert report['gaps']['intervals'][0] == {
        'left_timestamp': 120,
        'right_timestamp': 300,
        'left_utc': '1970-01-01T00:02:00Z',
        'right_utc': '1970-01-01T00:05:00Z',
        'delta_s': 180,
        'period_multiple': True,
        'missing_samples': 2,
        'classification': 'unclassified',
    }


def test_audit_preserves_raw_defect_evidence() -> None:
    '''
    Audit must count malformed rows without repairing or sorting.

    A baseline can contain extra provider columns, duplicated and
    reversed timestamps, a zero epoch, broken indexes, and non-finite
    values simultaneously. Construct all defects in physical file
    order and prove stable violations report each layer instead of
    hiding them through NativeDB canonicalization or dedupe.

    '''
    frame = mk_frame((60, 60, 30, 0)).with_columns(
        pl.Series('index', [0, 2, 2, 4]),
        pl.Series('open', [1, 2, 3, 4], dtype=pl.Int64),
        pl.Series(
            'close',
            [1, float('nan'), 3, 4],
            dtype=pl.Float64,
        ),
        pl.Series(
            'volume',
            [1, 2, float('inf'), 4],
            dtype=pl.Float64,
        ),
        pl.Series('count', [1, 1, 1, 1]),
    )
    report = audit_ohlcv_frame(
        frame,
        fqme='x.test',
        period_s=60,
    )

    assert set(report['result']['violations']) == {
        'extra_columns',
        'column_order',
        'canonical_dtypes',
        'nonfinite_ohlcv',
        'nonpositive_timestamps',
        'duplicate_timestamps',
        'timestamp_order',
        'index_not_canonical',
        'subperiod_time_step',
    }
    assert report['timestamps']['duplicate_excess'] == 1
    assert report['timestamps']['adjacent_zero_delta'] == 1
    assert report['timestamps']['negative_delta'] == 2
    assert report['index']['row_position_mismatch'] == 2
    assert report['values']['nan_by_column']['close'] == 1
    assert report['values']['infinity_by_column']['volume'] == 1


def test_audit_rejects_epoch_zero_price_sentinel() -> None:
    '''
    The legacy first-slot sentinel is structural corruption.

    The repaired MNQ Parquet retained a row at epoch 60 whose OHLCV
    payload was zero. Numeric and finite checks marked it valid even
    though chart auto-ranging then included zero. Audit a canonical
    frame with that sentinel and prove the report counts it and fails
    structural qualification.

    '''
    frame = mk_frame((60, 120)).with_columns(
        pl.Series('open', [0, 1], dtype=pl.Float64),
        pl.Series('high', [0, 2], dtype=pl.Float64),
        pl.Series('low', [0, 1], dtype=pl.Float64),
        pl.Series('close', [0, 2], dtype=pl.Float64),
    )

    report: dict = audit_ohlcv_frame(
        frame,
        fqme='x.test',
        period_s=60,
    )

    assert report['values']['all_zero_price_rows'] == 1
    assert report['values']['epoch_sentinel_rows'] == 1
    assert 'epoch_ohlc_sentinel' in report['result']['violations']
    assert report['result']['structural_ok'] is False


def test_audit_leaves_modern_zero_prices_unclassified() -> None:
    '''
    Audit must not call every all-zero price bar corrupt.

    A spread or synthetic instrument may legitimately trade at zero.
    The known corruption is tied to the first slot. Place an all-zero
    bar later and prove it remains visible as a warning without
    failing structural qualification.

    '''
    frame = mk_frame((120,)).with_columns(
        pl.Series('open', [0], dtype=pl.Float64),
        pl.Series('high', [0], dtype=pl.Float64),
        pl.Series('low', [0], dtype=pl.Float64),
        pl.Series('close', [0], dtype=pl.Float64),
    )

    report: dict = audit_ohlcv_frame(
        frame,
        fqme='x.test',
        period_s=60,
    )

    assert report['values']['all_zero_price_rows'] == 1
    assert report['values']['epoch_sentinel_rows'] == 0
    assert report['values']['unclassified_zero_price_rows'] == 1
    assert report['result']['structural_ok'] is True
    assert (
        'all_zero_ohlc_prices_unclassified'
        in report['result']['warnings']
    )


def test_gap_aggregates_are_not_truncated_with_details() -> None:
    '''
    Limiting JSON detail must not undercount total missing coverage.

    Long-lived session markets can contain thousands of expected
    closure intervals. Arrange two gaps but request one detail,
    record, then prove aggregate counts still describe the complete
    frame while the bounded detail list is marked truncated.

    '''
    report = audit_ohlcv_frame(
        mk_frame((60, 180, 300)),
        fqme='x.test',
        period_s=60,
        max_gaps=1,
    )
    gaps: dict = report['gaps']

    assert gaps['count'] == 2
    assert gaps['aligned_count'] == 2
    assert gaps['missing_samples_total'] == 2
    assert gaps['details_count'] == 1
    assert gaps['details_truncated'] is True


def test_subperiod_steps_fail_cadence_qualification() -> None:
    '''
    Short positive deltas must not bypass expected-period validation.

    Gap detection alone considers deltas larger than the expected
    period. That let a 30-second step in 60-second data qualify.
    Arrange that defect and prove it remains separate from positive
    gaps while making structural qualification fail.

    '''
    report = audit_ohlcv_frame(
        mk_frame((60, 90)),
        fqme='x.test',
        period_s=60,
    )

    assert report['gaps']['count'] == 0
    assert report['gaps']['subperiod_count'] == 1
    assert report['gaps']['subperiod_intervals'][0]['delta_s'] == 30
    assert 'subperiod_time_step' in report['result']['violations']
    assert report['result']['qualification_ok'] is False


def test_integer_evidence_preserves_values_above_float_precision(
) -> None:
    '''
    Int64 timestamp and index evidence must not round through float.

    Adjacent integers above ``2**53`` collapse when coerced to
    Float64, inventing duplicate times and index mismatches. Audit a
    canonical two-row frame at that boundary and prove exact
    endpoints, uniqueness, and indexes remain JSON-native integers.

    '''
    start: int = 2**53
    frame = mk_frame((start, start + 1)).with_columns(
        pl.Series('index', [start, start + 1], dtype=pl.Int64)
    )
    report = audit_ohlcv_frame(
        frame,
        fqme='x.test',
        period_s=1,
    )

    assert report['timestamps']['minimum'] == start
    assert report['timestamps']['maximum'] == start + 1
    assert report['timestamps']['duplicate_excess'] == 0
    assert report['timestamps']['strictly_increasing'] is True
    assert report['index']['first'] == start
    assert report['index']['last'] == start + 1
    assert report['index']['duplicate_excess'] == 0
    assert report['index']['contiguous'] is True
    assert report['index']['canonical_from_zero'] is False


def test_large_integer_gap_counts_remain_exact() -> None:
    '''
    Missing-sample totals must not divide Int64 deltas through float.

    A near-Int64 interval exceeds Float64's exact range. Audit it at
    one-second cadence and prove all counts agree with exact integer
    division rather than rounded evidence.

    '''
    right: int = 2**63 - 1
    report = audit_ohlcv_frame(
        mk_frame((1, right)),
        fqme='x.test',
        period_s=1,
    )
    expected: int = right - 2

    assert report['gaps']['missing_samples_total'] == expected
    assert (
        report['gaps']['intervals'][0]['missing_samples']
        ==
        expected
    )


def test_decimal_columns_remain_auditable_defect_evidence() -> None:
    '''
    Numeric but noncanonical Polars dtypes must produce a report.

    Decimal Parquet columns are readable numeric evidence but do not
    support Polars ``is_nan()``. Cast timestamps and an OHLC field to
    Decimal and prove audit reports the dtype violation instead of
    crashing before malformed baseline evidence can be saved.

    '''
    frame = mk_frame((60, 120)).with_columns(
        pl.col('time').cast(pl.Decimal(scale=0)),
        pl.col('close').cast(pl.Decimal(scale=2)),
    )
    report = audit_ohlcv_frame(
        frame,
        fqme='x.test',
        period_s=60,
    )

    assert report['timestamps']['numeric'] is True
    assert report['timestamps']['strictly_increasing'] is True
    assert report['values']['all_finite'] is True
    assert 'canonical_dtypes' in report['result']['violations']


def test_empty_frame_has_unverifiable_gap_coverage() -> None:
    '''
    Empty storage must not claim valid cadence or index results.

    An empty canonical schema is structurally invalid and has no pair
    of timestamps from which gap coverage can be inferred. Prove the
    report retains its stable gap shape while gap-free status remains
    unknown and canonical index qualification is rejected.

    '''
    report = audit_ohlcv_frame(
        mk_frame(()),
        fqme='x.test',
        period_s=60,
    )

    assert report['gaps']['verifiable'] is False
    assert report['gaps']['aligned_count'] == 0
    assert report['result']['gap_free'] is None
    assert report['index']['canonical_from_zero'] is False
    assert report['result']['qualification_ok'] is False


def test_detail_limit_bounds_all_cadence_deviations() -> None:
    '''
    One detail budget must cover gaps and short steps together.

    Human output combines both anomaly types. Slicing each list can
    exceed ``--max-gaps`` and misreport the detail count. Arrange one
    short step and one long gap with a budget of one and prove the
    combined detail output remains bounded and marked truncated.

    '''
    report = audit_ohlcv_frame(
        mk_frame((60, 90, 210)),
        fqme='x.test',
        period_s=60,
        max_gaps=1,
    )
    gaps: dict = report['gaps']

    assert gaps['count'] == 1
    assert gaps['subperiod_count'] == 1
    assert gaps['details_count'] == 1
    assert (
        len(gaps['intervals'])
        + len(gaps['subperiod_intervals'])
    ) == 1
    assert gaps['details_truncated'] is True


def test_store_audit_json_is_read_only(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    The CLI must inspect exact bytes without storage side effects.

    Operational storage openers can create config directories and
    mutate caches. Point config at an existing disposable NativeDB,
    invoke the real Typer command, and prove its JSON references the
    exact file while bytes and modification time stay unchanged.

    '''
    monkeypatch.setattr(config, '_config_dir', tmp_path)
    datadir: Path = tmp_path / 'nativedb'
    datadir.mkdir()
    fqme: str = 'x.test'
    path: Path = mk_ohlcv_shm_keyed_filepath(
        fqme,
        60,
        datadir,
    )
    mk_frame((60, 120)).write_parquet(path)
    before_bytes: bytes = path.read_bytes()
    before_mtime: int = path.stat().st_mtime_ns
    output: Path = tmp_path / 'audit.json'
    snapshot: Path = tmp_path / 'audit.parquet'

    result = CliRunner().invoke(
        store,
        [
            'audit',
            fqme,
            '--period',
            '60',
            '--output',
            str(output),
            '--snapshot',
            str(snapshot),
            '--json',
        ],
    )
    report: dict = json.loads(result.stdout)

    assert result.exit_code == 0
    assert report['source']['path'] == str(path)
    assert report['source']['sha256'] == sha256(
        before_bytes
    ).hexdigest()
    assert report['result']['qualification_ok'] is True
    assert path.read_bytes() == before_bytes
    assert path.stat().st_mtime_ns == before_mtime
    assert snapshot.read_bytes() == before_bytes
    assert json.loads(output.read_text()) == report
    assert report['schema']['columns'] == [
        name
        for name, _ in def_iohlcv_fields
    ]


def test_store_audit_refuses_output_collisions(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Evidence output must not replace source or existing files.

    The first CLI accepted any ``--output`` path and could overwrite
    audited Parquet with JSON. Point output at the source, a report,
    and a directory; prove each exits with original bytes unchanged.

    '''
    monkeypatch.setattr(config, '_config_dir', tmp_path)
    datadir: Path = tmp_path / 'nativedb'
    datadir.mkdir()
    fqme: str = 'x.test'
    path: Path = mk_ohlcv_shm_keyed_filepath(
        fqme,
        60,
        datadir,
    )
    mk_frame((60, 120)).write_parquet(path)
    before: bytes = path.read_bytes()
    runner = CliRunner()

    for output in (path, tmp_path / 'existing.json', tmp_path):
        if output.name == 'existing.json':
            output.write_text('keep\n')
        result = runner.invoke(
            store,
            ['audit', fqme, '--output', str(output)],
        )
        assert result.exit_code == 2

    assert path.read_bytes() == before
    assert (tmp_path / 'existing.json').read_text() == 'keep\n'


def test_direct_snapshot_collision_preserves_existing_file(
    tmp_path: Path,
) -> None:
    '''
    Exclusive snapshot races must never unlink another writer's file.

    The helper opens snapshots with ``xb``. Its first cleanup path
    unlinked the destination when exclusive open failed. Pre-create a
    destination and prove the collision preserves its existing bytes.

    '''
    source: Path = tmp_path / 'source.parquet'
    snapshot: Path = tmp_path / 'snapshot.parquet'
    mk_frame((60, 120)).write_parquet(source)
    snapshot.write_bytes(b'existing evidence')

    with pytest.raises(FileExistsError):
        audit_ohlcv_parquet(
            source,
            fqme='x.test',
            period_s=60,
            snapshot=snapshot,
        )

    assert snapshot.read_bytes() == b'existing evidence'


def test_invalid_parquet_is_snapshotted_before_parse(
    tmp_path: Path,
) -> None:
    '''
    Unreadable phase bytes must survive a failed structural audit.

    A truncated or non-Parquet file previously failed during parsing
    before ``--snapshot`` captured anything, discarding exact failure
    evidence from repair and restart. Supply invalid bytes to the
    low-level audit and prove it raises only after creating an exact,
    read-only snapshot for later diagnosis.

    '''
    source: Path = tmp_path / 'broken.parquet'
    snapshot: Path = tmp_path / 'snapshot.parquet'
    evidence: bytes = b'not a parquet file\n'
    source.write_bytes(evidence)

    with pytest.raises(pl.exceptions.PolarsError):
        audit_ohlcv_parquet(
            source,
            fqme='x.test',
            period_s=60,
            snapshot=snapshot,
        )

    assert snapshot.read_bytes() == evidence
    assert snapshot.stat().st_mode & 0o222 == 0


def test_snapshot_copy_failure_removes_partial_output(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    Copy failure must not leave partial authoritative evidence.

    Snapshot bytes are captured before parsing so malformed evidence
    survives. Failed copies leave partial output that blocks retry.
    Raise after a prefix. Prove partial output is removed while the
    source stays whole.

    '''
    source: Path = tmp_path / 'source.parquet'
    snapshot: Path = tmp_path / 'snapshot.parquet'
    mk_frame((60, 120)).write_parquet(source)
    before: bytes = source.read_bytes()

    def fail_copy(source_file, snapshot_file) -> None:
        snapshot_file.write(source_file.read(10))
        raise OSError('simulated full filesystem')

    monkeypatch.setattr(audit_mod.shutil, 'copyfileobj', fail_copy)
    with pytest.raises(OSError, match='full filesystem'):
        audit_ohlcv_parquet(
            source,
            fqme='x.test',
            period_s=60,
            snapshot=snapshot,
        )

    assert not snapshot.exists()
    assert source.read_bytes() == before


def test_store_audit_rejects_unsafe_or_symlinked_sources(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    '''
    User-controlled FQME text must stay inside NativeDB storage.

    Absolute and parent-relative market names previously flowed into
    path construction, while a normal market could be a symlink to
    external bytes. Point config at a disposable NativeDB and prove
    traversal and source links fail before external bytes change.

    '''
    monkeypatch.setattr(config, '_config_dir', tmp_path)
    datadir: Path = tmp_path / 'nativedb'
    datadir.mkdir()
    outside: Path = tmp_path / 'outside.parquet'
    mk_frame((60, 120)).write_parquet(outside)
    before: bytes = outside.read_bytes()
    source: Path = mk_ohlcv_shm_keyed_filepath(
        'x.test',
        60,
        datadir,
    )
    source.symlink_to(outside)
    runner = CliRunner()

    for fqme in ('../../outside', str(outside), 'x.test'):
        result = runner.invoke(store, ['audit', fqme])
        assert result.exit_code == 2

    assert outside.read_bytes() == before


def test_direct_audit_rejects_nonregular_source(
    tmp_path: Path,
) -> None:
    '''
    Audit must not read devices, directories, or blocking pipes.

    ``O_NOFOLLOW`` rejects links but opens other node types. Pass a
    a directory to the reader and prove descriptor metadata
    rejects it before hashing or handing it to the Parquet parser.

    '''
    with pytest.raises(ValueError, match='regular file'):
        audit_ohlcv_parquet(
            tmp_path,
            fqme='x.test',
            period_s=60,
        )
