"""Unit tests for analysis.data_audit."""

from __future__ import annotations

import sqlite3
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path

from cerbero_bite.analysis.data_audit import (
    ChainAuditReport,
    MarketAuditReport,
    audit_market_snapshots,
    audit_option_chain,
)
from cerbero_bite.analysis.data_audit import (  # noqa: PLC2701
    _detect_gaps,
    _expected_ticks,
    _max_zero_streak,
    _pct,
)
from cerbero_bite.state import connect, run_migrations, transaction


def test_expected_ticks_basic() -> None:
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
    # 12:00, 12:15, 12:30, 12:45 → 4 ticks before 13:00
    assert _expected_ticks(since, until) == 4


def test_expected_ticks_inclusive_left_exclusive_right() -> None:
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 12, 15, tzinfo=UTC)
    # Only 12:00
    assert _expected_ticks(since, until) == 1


def test_expected_ticks_unaligned_since_rounds_up() -> None:
    since = datetime(2026, 5, 12, 12, 7, tzinfo=UTC)
    until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC)
    # First aligned tick after 12:07 is 12:15, then 12:30 is excluded
    assert _expected_ticks(since, until) == 1


def test_expected_ticks_non_multiple_span_uses_ceiling() -> None:
    # Span = 16 min (12:00 → 12:16): ticks 12:00 AND 12:15 are < 12:16.
    # floor(16/15) = 1 would under-count; the correct answer is 2.
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 12, 16, tzinfo=UTC)
    assert _expected_ticks(since, until) == 2


def test_expected_ticks_exact_quarter_boundary_is_excluded() -> None:
    # Until landing exactly on a */15 tick: that tick is NOT counted
    # because the window is half-open on the right.
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 12, 45, tzinfo=UTC)
    # Ticks at 12:00, 12:15, 12:30 → 3 (12:45 excluded)
    assert _expected_ticks(since, until) == 3


def test_detect_gaps_returns_empty_when_no_gap() -> None:
    ts = [
        datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
        datetime(2026, 5, 12, 12, 15, tzinfo=UTC),
        datetime(2026, 5, 12, 12, 30, tzinfo=UTC),
    ]
    assert _detect_gaps(ts) == ()


def test_detect_gaps_flags_above_threshold() -> None:
    ts = [
        datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
        datetime(2026, 5, 12, 12, 45, tzinfo=UTC),  # 45-min gap
        datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
    ]
    gaps = _detect_gaps(ts)
    assert len(gaps) == 1
    assert gaps[0].gap_minutes == 45
    assert gaps[0].prev_timestamp == ts[0]
    assert gaps[0].next_timestamp == ts[1]


def test_detect_gaps_ignores_threshold_boundary() -> None:
    # 20-min gap is exactly the threshold → NOT flagged (strict >)
    ts = [
        datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
        datetime(2026, 5, 12, 12, 20, tzinfo=UTC),
    ]
    assert _detect_gaps(ts) == ()


def test_max_zero_streak_empty() -> None:
    assert _max_zero_streak([]) == 0


def test_max_zero_streak_no_zeros() -> None:
    assert _max_zero_streak([1, 1, 1, 1]) == 0


def test_max_zero_streak_single_zero_block() -> None:
    assert _max_zero_streak([1, 0, 0, 0, 1, 0, 1]) == 3


def test_max_zero_streak_all_zeros() -> None:
    assert _max_zero_streak([0, 0, 0]) == 3


def _make_conn(tmp_path: Path) -> sqlite3.Connection:
    conn = connect(tmp_path / "state.sqlite")
    run_migrations(conn)
    return conn


def _seed_market(
    conn: sqlite3.Connection,
    *,
    asset: str,
    ts: datetime,
    spot: Decimal | None = Decimal("3000"),
    dvol: Decimal | None = Decimal("55"),
    dealer_net_gamma: Decimal | None = Decimal("-50000000"),
    fetch_ok: int = 1,
) -> None:
    conn.execute(
        "INSERT INTO market_snapshots(timestamp, asset, spot, dvol, "
        "dealer_net_gamma, fetch_ok) VALUES (?,?,?,?,?,?)",
        (
            ts.isoformat(),
            asset,
            str(spot) if spot is not None else None,
            str(dvol) if dvol is not None else None,
            str(dealer_net_gamma) if dealer_net_gamma is not None else None,
            fetch_ok,
        ),
    )


def test_audit_market_full_coverage(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
    try:
        with transaction(conn):
            for minute in (0, 15, 30, 45):
                _seed_market(
                    conn,
                    asset="ETH",
                    ts=since.replace(minute=minute),
                )
        report = audit_market_snapshots(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.asset == "ETH"
    assert report.expected_ticks == 4
    assert report.actual_ticks == 4
    assert report.coverage_pct == Decimal("100")
    assert report.gaps == ()
    assert report.fetch_ok_zero_count == 0
    assert report.max_fetch_ok_zero_streak == 0


def test_audit_market_detects_gap_and_streak(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 30, tzinfo=UTC)
    try:
        with transaction(conn):
            # 12:00 OK, 12:15 OK, gap (12:30, 12:45 missing), 13:00 fail,
            # 13:15 fail, 13:30 outside window.
            _seed_market(conn, asset="ETH", ts=since.replace(minute=0))
            _seed_market(conn, asset="ETH", ts=since.replace(minute=15))
            _seed_market(
                conn, asset="ETH", ts=since.replace(hour=13, minute=0),
                fetch_ok=0,
            )
            _seed_market(
                conn, asset="ETH", ts=since.replace(hour=13, minute=15),
                fetch_ok=0,
            )
        report = audit_market_snapshots(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.expected_ticks == 6
    assert report.actual_ticks == 4
    # 12:15 → 13:00 is a 45-min gap → flagged
    assert len(report.gaps) == 1
    assert report.gaps[0].gap_minutes == 45
    assert report.fetch_ok_zero_count == 2
    assert report.max_fetch_ok_zero_streak == 2


def test_audit_market_null_rate_per_column(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
    try:
        with transaction(conn):
            # 4 ticks, 1 with dealer_net_gamma=NULL → 25% null
            for minute in (0, 15, 30, 45):
                _seed_market(
                    conn,
                    asset="ETH",
                    ts=since.replace(minute=minute),
                    dealer_net_gamma=None if minute == 30 else Decimal("-50000000"),
                )
        report = audit_market_snapshots(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.null_rate_by_column["dealer_net_gamma"] == Decimal("0.25")
    assert report.null_rate_by_column["spot"] == Decimal("0")


def test_pct_empty_returns_zero() -> None:
    assert _pct([], 50) == 0


def test_pct_single_value_returns_that_value() -> None:
    assert _pct([42], 50) == 42
    assert _pct([42], 10) == 42
    assert _pct([42], 90) == 42


def test_pct_median_odd_count() -> None:
    assert _pct([10, 20, 30, 40, 50], 50) == 30


def test_pct_p10_p90_on_100_values() -> None:
    # inclusive method on [1..100]:
    #   p10 interpolates to 10.9 → 11; p90 interpolates to 90.1 → 90.
    values = list(range(1, 101))
    assert _pct(values, 10) == 11
    assert _pct(values, 90) == 90


def _seed_chain_row(
    conn: sqlite3.Connection,
    *,
    asset: str,
    ts: datetime,
    instrument: str,
    strike: str = "3000",
    expiry: datetime | None = None,
    option_type: str = "C",
    bid: str | None = "0.01",
    ask: str | None = "0.02",
    iv: str | None = "0.7",
) -> None:
    if expiry is None:
        expiry = datetime(2026, 6, 12, 8, 0, tzinfo=UTC)
    conn.execute(
        "INSERT INTO option_chain_snapshots(timestamp, asset, instrument_name, "
        "strike, expiry, option_type, bid, ask, iv) "
        "VALUES (?,?,?,?,?,?,?,?,?)",
        (
            ts.isoformat(),
            asset,
            instrument,
            strike,
            expiry.isoformat(),
            option_type,
            bid,
            ask,
            iv,
        ),
    )


def test_audit_chain_full_coverage(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
    try:
        with transaction(conn):
            for minute in (0, 15, 30, 45):
                ts = since.replace(minute=minute)
                for i in range(10):
                    _seed_chain_row(
                        conn,
                        asset="ETH",
                        ts=ts,
                        instrument=f"ETH-EXP-{i}-C",
                        strike=str(3000 + i * 50),
                    )
        report = audit_option_chain(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert isinstance(report, ChainAuditReport)
    assert report.asset == "ETH"
    assert report.expected_snapshots == 4
    assert report.actual_snapshots == 4
    assert report.coverage_pct == Decimal("100")
    assert report.quotes_per_snap_median == 10
    assert report.quotes_per_snap_p10 == 10
    assert report.quotes_per_snap_p90 == 10
    assert report.bid_gt_ask_count == 0
    assert report.ask_zero_count == 0
    assert report.iv_null_count == 0
    assert report.iv_null_pct == Decimal("0")


def test_audit_chain_detects_bid_gt_ask_and_iv_null(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC)
    try:
        with transaction(conn):
            ts = since.replace(minute=0)
            # Crossed BBO: bid > ask
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-A",
                bid="0.10",
                ask="0.05",
            )
            # Missing IV
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-B",
                strike="3050",
                iv=None,
            )
            # Normal row (control)
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-C",
                strike="3100",
            )
        report = audit_option_chain(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.bid_gt_ask_count == 1
    assert report.ask_zero_count == 0
    assert report.iv_null_count == 1
    # 1 of 3 quotes → 33.33%
    assert report.iv_null_pct == Decimal("33.33")


def test_audit_chain_ask_zero_counted_separately(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC)
    try:
        with transaction(conn):
            ts = since.replace(minute=0)
            # ask=0 → conta in ask_zero, NON in bid_gt_ask
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-A",
                bid="0.05",
                ask="0",
            )
            # ask negativo (impossibile economicamente) → ask_zero
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-B",
                strike="3050",
                bid="0.05",
                ask="-0.01",
            )
            # Crossed BBO reale (ask>0) → bid_gt_ask
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-C",
                strike="3100",
                bid="0.10",
                ask="0.05",
            )
            # Normal control
            _seed_chain_row(
                conn,
                asset="ETH",
                ts=ts,
                instrument="ETH-D",
                strike="3150",
            )
        report = audit_option_chain(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.ask_zero_count == 2
    assert report.bid_gt_ask_count == 1


def test_audit_chain_missing_snapshots(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
    try:
        with transaction(conn):
            # Only 2 of the expected 4 ticks have data
            for minute in (0, 15):
                _seed_chain_row(
                    conn,
                    asset="ETH",
                    ts=since.replace(minute=minute),
                    instrument=f"ETH-{minute}",
                )
        report = audit_option_chain(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.expected_snapshots == 4
    assert report.actual_snapshots == 2
    assert report.coverage_pct == Decimal("50")


def test_audit_chain_empty_window_returns_zero_coverage(tmp_path: Path) -> None:
    conn = _make_conn(tmp_path)
    since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)
    until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC)
    try:
        report = audit_option_chain(
            conn, asset="ETH", since=since, until=until
        )
    finally:
        conn.close()
    assert report.expected_snapshots == 4
    assert report.actual_snapshots == 0
    assert report.coverage_pct == Decimal("0")
    assert report.quotes_per_snap_median == 0
    assert report.bid_gt_ask_count == 0
    assert report.iv_null_count == 0
