"""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. Stathera gap classification in health_heartbeat.py — all tid_delta > 0 are real gaps.
"""

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 backfill_gap
    from opendeviationbar.config.backfill import BackfillConfig
    trailing_edge_min_gap_s = BackfillConfig().trailing_edge_min_gap_s

    # 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
    # CH returns microseconds after Phase 3-4 column rename (_ms → _us)
    now_us = int(datetime.now(UTC).timestamp() * 1_000_000)
    fresh_ts = now_us - (10 * 60 * 1_000_000)  # 10 minutes ago in microseconds

    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 backfill_gap
    from opendeviationbar.config.backfill import BackfillConfig
    trailing_edge_min_gap_s = BackfillConfig().trailing_edge_min_gap_s

    # latest_ts is 2 hours ago — well beyond the 30-min threshold
    # CH returns microseconds after Phase 3-4 column rename (_ms → _us)
    now_us = int(datetime.now(UTC).timestamp() * 1_000_000)
    stale_ts = now_us - (2 * 60 * 60 * 1_000_000)  # 2 hours ago in microseconds

    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,
        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,
        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._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._repair", return_value=-1)
@patch("opendeviationbar.kintsugi.orchestrator._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,
        had_anchor_tid=True,
        ouroboros_splits=1,
        error="deferred_today_guard",
    )

    # Call the real _maybe_log_repair (not the mock)
    mock_log.stop()
    with patch("opendeviationbar.kintsugi.telemetry._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._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,
        had_anchor_tid=True,
        ouroboros_splits=1,
        error="deferred_today_guard",
    )

    # _maybe_log_repair should return early without logging
    with patch("opendeviationbar.kintsugi.telemetry._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,
            had_anchor_tid=True,
            ouroboros_splits=1,
            error="deferred_today_guard",
        ),
        # Healed (successful repair)
        KintsugiResult(
            shard=yesterday_shard,
            healed=True,
            bars_written=42,
            duration_seconds=5.0,
            had_anchor_tid=True,
            ouroboros_splits=1,
            error=None,
        ),
        # Failed (real error)
        KintsugiResult(
            shard=yesterday_shard,
            healed=False,
            bars_written=0,
            duration_seconds=1.0,
            had_anchor_tid=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. Stathera gap classification (health_heartbeat.py)
# =============================================================================
#
# Stathera invariant: tid_delta > 0 = gap, tid_delta < 0 = overlap.
# All gaps are real and must be repaired.


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

    Simple classification: tid_delta > 0 = gap, tid_delta < 0 = overlap.
    """
    gap_count = 1 if tid_delta > 0 else 0
    overlap_count = 1 if tid_delta < 0 else 0

    return {
        "gap_count": str(gap_count),
        "overlap_count": str(overlap_count),
    }


def test_positive_tid_delta_is_gap():
    """Any tid_delta > 0 is a gap regardless of day boundaries."""
    result = _simulate_heartbeat_gap_count(tid_delta=150000)
    assert result["gap_count"] == "1"
    assert result["overlap_count"] == "0"


def test_negative_tid_delta_is_overlap():
    """tid_delta < 0 is an overlap."""
    result = _simulate_heartbeat_gap_count(tid_delta=-500)
    assert result["gap_count"] == "0"
    assert result["overlap_count"] == "1"


def test_zero_tid_delta_is_clean():
    """tid_delta == 0 means no anomaly."""
    result = _simulate_heartbeat_gap_count(tid_delta=0)
    assert result["gap_count"] == "0"
    assert result["overlap_count"] == "0"
