"""Tests for production fixes committed in 1294131 and c9972a5 (v13.5.2+).

Covers three categories of fixes:
1. Trailing-edge skip in backfill.py — prevents sidecar/backfill overlap races.
2. Today-guard deferral in kintsugi.py — defers today's shards, avoids premature dismissal.
3. Day-boundary structural gap classification in health_heartbeat.py — separates
   structural day-boundary gaps from real data-loss gaps in Stathera audit.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import MagicMock, patch

# =============================================================================
# 1. Trailing-edge skip (backfill.py)
# =============================================================================


def _make_backfill_result(**overrides: Any) -> Any:
    """Helper to import and construct a BackfillResult."""
    from opendeviationbar.backfill import BackfillResult

    defaults = {
        "symbol": "BTCUSDT",
        "threshold_decimal_bps": 250,
        "bars_written": 0,
        "gap_seconds": 0.0,
        "latest_ts_before": None,
        "latest_ts_after": None,
        "duration_seconds": 0.0,
        "error": None,
    }
    defaults.update(overrides)
    return BackfillResult(**defaults)


@patch("opendeviationbar.symbol_registry.validate_symbol_registered")
@patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
def test_trailing_edge_skip_fresh_gap(mock_min_thr, mock_validate):
    """When gap is < 30 min old (fresh), backfill should skip (bars_written=0).

    The sidecar is likely actively processing these trades via WebSocket.
    Backfilling via REST would race with the sidecar, producing overlapping
    bars from different starting trade IDs.
    """
    from opendeviationbar.backfill import _TRAILING_EDGE_MIN_GAP_S, backfill_gap

    # Verify the constant is 1800 seconds (30 minutes)
    assert _TRAILING_EDGE_MIN_GAP_S == 1800

    # latest_ts is 10 minutes ago — well within the 30-min threshold
    now_ms = int(datetime.now(UTC).timestamp() * 1000)
    fresh_ts = now_ms - (10 * 60 * 1000)  # 10 minutes ago

    mock_cache = MagicMock()
    mock_cache.get_latest_bar_with_trade_id.return_value = (fresh_ts, 12345)
    mock_cache.__enter__ = MagicMock(return_value=mock_cache)
    mock_cache.__exit__ = MagicMock(return_value=False)

    with patch("opendeviationbar.clickhouse.OpenDeviationBarCache", return_value=mock_cache):
        result = backfill_gap("BTCUSDT", threshold_decimal_bps=250)

    assert result.bars_written == 0
    assert result.error is None  # Not an error — intentional skip
    assert result.gap_seconds < _TRAILING_EDGE_MIN_GAP_S


@patch("opendeviationbar.symbol_registry.validate_symbol_registered")
@patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
def test_trailing_edge_skip_stale_gap(mock_min_thr, mock_validate):
    """When gap is > 30 min old (stale), backfill should proceed with REST fetch.

    A stale gap indicates the sidecar missed this pair — it may be down,
    reconnecting, or the pair is not actively streaming.
    """
    from opendeviationbar.backfill import _TRAILING_EDGE_MIN_GAP_S, backfill_gap

    # latest_ts is 2 hours ago — well beyond the 30-min threshold
    now_ms = int(datetime.now(UTC).timestamp() * 1000)
    stale_ts = now_ms - (2 * 60 * 60 * 1000)  # 2 hours ago

    mock_cache = MagicMock()
    mock_cache.get_latest_bar_with_trade_id.return_value = (stale_ts, 12345)
    mock_cache.__enter__ = MagicMock(return_value=mock_cache)
    mock_cache.__exit__ = MagicMock(return_value=False)

    # Mock _fetch_and_process_gap to avoid actual REST calls
    with (
        patch("opendeviationbar.clickhouse.OpenDeviationBarCache", return_value=mock_cache),
        patch("opendeviationbar.backfill._fetch_and_process_gap", return_value=5) as mock_fetch,
    ):
        result = backfill_gap("BTCUSDT", threshold_decimal_bps=250)

    # Should have proceeded to fetch (not skipped)
    mock_fetch.assert_called_once()
    assert result.gap_seconds >= _TRAILING_EDGE_MIN_GAP_S


# =============================================================================
# 2. Today-guard deferral (kintsugi.py)
# =============================================================================


def _make_today_shard():
    """Create a shard whose gap is entirely within today (UTC)."""
    from opendeviationbar.kintsugi import Shard

    now = datetime.now(tz=UTC)
    # Gap spans from early today to now
    gap_start_ms = int(now.replace(hour=0, minute=30).timestamp() * 1000)
    gap_end_ms = int(now.timestamp() * 1000)
    return Shard(
        symbol="BTCUSDT",
        threshold_decimal_bps=250,
        gap_start_ms=gap_start_ms,
        gap_end_ms=gap_end_ms,
        gap_hours=(gap_end_ms - gap_start_ms) / 3_600_000,
        last_agg_trade_id_before=100000,
        anchor_last_tid=100000,
        resume_first_tid=100500,
        missing_trade_count=500,
    )


def _make_yesterday_shard():
    """Create a shard whose gap is entirely within yesterday (UTC)."""
    from opendeviationbar.kintsugi import Shard

    yesterday = datetime.now(tz=UTC) - timedelta(days=1)
    gap_start_ms = int(yesterday.replace(hour=2).timestamp() * 1000)
    gap_end_ms = int(yesterday.replace(hour=14).timestamp() * 1000)
    return Shard(
        symbol="BTCUSDT",
        threshold_decimal_bps=250,
        gap_start_ms=gap_start_ms,
        gap_end_ms=gap_end_ms,
        gap_hours=12.0,
        last_agg_trade_id_before=100000,
        anchor_last_tid=100000,
        resume_first_tid=100500,
        missing_trade_count=500,
    )


@patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
@patch("opendeviationbar.kintsugi._repair", return_value=-1)
def test_today_guard_deferral_returns_sentinel(mock_repair, mock_min_thr):
    """_repair() returns -1 for today's shards, producing 'deferred_today_guard' error."""
    from opendeviationbar.kintsugi import repair_shard

    shard = _make_today_shard()
    result = repair_shard(shard)

    assert result.error == "deferred_today_guard"
    assert result.healed is False
    assert result.bars_written == 0


@patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
@patch("opendeviationbar.kintsugi._repair", return_value=-1)
@patch("opendeviationbar.kintsugi._maybe_log_repair")
def test_deferral_skips_kintsugi_log(mock_log, mock_repair, mock_min_thr):
    """Deferred shards are NOT logged to kintsugi_log.

    If they were logged, they'd be dismissed for 30 days by _get_dismissed_set(),
    preventing future repair attempts even after the sidecar stops writing.
    """
    from opendeviationbar.kintsugi import KintsugiResult, _maybe_log_repair

    shard = _make_today_shard()
    result = KintsugiResult(
        shard=shard,
        healed=False,
        bars_written=0,
        duration_seconds=0.1,
        ariadne_used=True,
        ouroboros_splits=1,
        error="deferred_today_guard",
    )

    # Call the real _maybe_log_repair (not the mock)
    mock_log.stop()
    with patch("opendeviationbar.kintsugi._log_repair") as mock_ch_log:
        _maybe_log_repair(result)
        # Should NOT have called _log_repair (writes to kintsugi_log table)
        mock_ch_log.assert_not_called()


@patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
@patch("opendeviationbar.kintsugi._repair", return_value=-1)
def test_deferral_not_counted_as_attempt(mock_repair, mock_min_thr):
    """Deferred shards don't count toward the 3-attempt-per-24h rate limit.

    The error is 'deferred_today_guard' which _maybe_log_repair() skips entirely,
    so it never reaches the kintsugi_log table that _get_dismissed_set() reads.
    """
    from opendeviationbar.kintsugi import KintsugiResult, _maybe_log_repair

    shard = _make_today_shard()
    result = KintsugiResult(
        shard=shard,
        healed=False,
        bars_written=0,
        duration_seconds=0.1,
        ariadne_used=True,
        ouroboros_splits=1,
        error="deferred_today_guard",
    )

    # _maybe_log_repair should return early without logging
    with patch("opendeviationbar.kintsugi._log_repair") as mock_ch_log:
        _maybe_log_repair(result)
        mock_ch_log.assert_not_called()


def test_pass_summary_counts_deferral():
    """Deferred count appears separately in pass summary (four-state classification).

    Pass summary classifies results into: healed / clean / deferred / failed.
    Deferred results are those with error == 'deferred_today_guard'.
    """
    from opendeviationbar.kintsugi import KintsugiResult

    today_shard = _make_today_shard()
    yesterday_shard = _make_yesterday_shard()

    results = [
        # Deferred (today-guard)
        KintsugiResult(
            shard=today_shard,
            healed=False,
            bars_written=0,
            duration_seconds=0.1,
            ariadne_used=True,
            ouroboros_splits=1,
            error="deferred_today_guard",
        ),
        # Healed (successful repair)
        KintsugiResult(
            shard=yesterday_shard,
            healed=True,
            bars_written=42,
            duration_seconds=5.0,
            ariadne_used=True,
            ouroboros_splits=1,
            error=None,
        ),
        # Failed (real error)
        KintsugiResult(
            shard=yesterday_shard,
            healed=False,
            bars_written=0,
            duration_seconds=1.0,
            ariadne_used=True,
            ouroboros_splits=1,
            error="connection timeout",
        ),
    ]

    # Replicate the four-state summary logic from kintsugi_pass()
    healed = sum(1 for r in results if r.healed)
    clean = sum(1 for r in results if not r.healed and not r.error)
    deferred = sum(1 for r in results if r.error == "deferred_today_guard")
    failed = sum(1 for r in results if r.error and r.error != "deferred_today_guard")

    assert healed == 1
    assert clean == 0
    assert deferred == 1
    assert failed == 1
    # Total should account for all results
    assert healed + clean + deferred + failed == len(results)


# =============================================================================
# 3. Day-boundary structural gap (health_heartbeat.py)
# =============================================================================
#
# The heartbeat classifies Stathera gaps by day_diff:
#   - day_diff == 1: structural (day-boundary, normal in day-mode Ouroboros)
#   - day_diff == 0: real gap (intra-day data loss)
#   - day_diff > 1: real gap (multi-day missing data)
#
# The SQL uses:
#   countIf(tid_delta > 0 AND day_diff = 1) AS structural_gap_count
#   countIf(tid_delta > 0 AND day_diff != 1) AS real_gap_count
#
# We test the classification logic by simulating query results.


def _simulate_heartbeat_gap_classification(
    tid_delta: int, day_diff: int
) -> dict[str, str]:
    """Simulate the heartbeat's Stathera gap classification.

    Replicates the SQL classification logic:
      structural: tid_delta > 0 AND day_diff == 1
      real:       tid_delta > 0 AND day_diff != 1
      overlap:    tid_delta < 0
    """
    structural_gap_count = 1 if (tid_delta > 0 and day_diff == 1) else 0
    real_gap_count = 1 if (tid_delta > 0 and day_diff != 1) else 0
    overlap_count = 1 if tid_delta < 0 else 0

    return {
        "structural_gap_count": str(structural_gap_count),
        "real_gap_count": str(real_gap_count),
        "overlap_count": str(overlap_count),
    }


def test_day_boundary_structural_gap():
    """When day_diff == 1, classified as structural (not real).

    In day-mode Ouroboros, the processor resets at midnight UTC. The gap between
    the last bar of day D and the first bar of day D+1 is structural — overnight
    trades that don't breach the threshold. For high thresholds (750/1000 dbps),
    these can have tid_delta of 100K-300K.
    """
    result = _simulate_heartbeat_gap_classification(tid_delta=150000, day_diff=1)
    assert result["structural_gap_count"] == "1"
    assert result["real_gap_count"] == "0"
    assert result["overlap_count"] == "0"


def test_intra_day_real_gap():
    """When day_diff == 0, classified as real gap (intra-day data loss).

    Both the previous bar's close and the current bar's open are on the same
    UTC day, so this is a genuine gap within a single trading day.
    """
    result = _simulate_heartbeat_gap_classification(tid_delta=500, day_diff=0)
    assert result["structural_gap_count"] == "0"
    assert result["real_gap_count"] == "1"
    assert result["overlap_count"] == "0"


def test_multi_day_real_gap():
    """When day_diff > 1, classified as real gap (multi-day missing data).

    A gap spanning more than one day boundary indicates genuine data loss —
    not just an overnight Ouroboros reset.
    """
    result = _simulate_heartbeat_gap_classification(tid_delta=500000, day_diff=3)
    assert result["structural_gap_count"] == "0"
    assert result["real_gap_count"] == "1"
    assert result["overlap_count"] == "0"
