"""Tests for seeder REST fallback, day guards, batch rotation, and kintsugi guard.

Validates:
  - REST fallback only for yesterday (D-01, D-04, SEEDER-04)
  - Two-step fetch pattern (time->ID, then parallel bulk)
  - .meta sidecar creation and Vision overwrite (D-08)
  - Batch rotation with BTCUSDT/ETHUSDT priority (D-14)
  - Kintsugi-processed guard (SEEDER-04)
"""

from __future__ import annotations

import json
import sys
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from unittest.mock import patch

# Add scripts dir to path so we can import tick_cache_seeder
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

from tick_cache_seeder import (
    _find_missing_days,
    _load_batch_offset,
    _rest_fallback_day,
    _save_batch_offset,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_MOCK_REST = "opendeviationbar._core.fetch_aggtrades_rest"
_MOCK_PARALLEL = "opendeviationbar._core.fetch_aggtrades_parallel"


def _make_mock_trades(n: int = 100, start_id: int = 1000) -> list[dict]:
    """Build mock trade dicts matching Rust binding output (full key names)."""
    return [
        {
            "agg_trade_id": start_id + i,
            "price": 50000.0 + i,
            "quantity": 1.5,
            "first_trade_id": start_id + i,
            "last_trade_id": start_id + i,
            "timestamp": 1700000000000 + i * 1000,
            "is_buyer_maker": i % 2 == 0,
        }
        for i in range(n)
    ]


# ---------------------------------------------------------------------------
# REST fallback day guards
# ---------------------------------------------------------------------------


class TestRestFallbackDayGuards:
    """Test that REST fallback respects day guards (D-04, SEEDER-04)."""

    def test_never_seed_today(self):
        """REST fallback age guard rejects today (age=0, SEEDER-04).

        Today belongs to sidecar (writer ownership model).
        The guard in _seed_symbol checks: 1 <= age_days <= max_age.
        Today has age_days=0, so the condition fails.
        """
        today = datetime.now(UTC).date()
        age_days = (today - today).days
        assert age_days == 0
        assert not (1 <= age_days <= 2)  # Guard condition fails for today

    @patch(_MOCK_PARALLEL)
    @patch(_MOCK_REST)
    def test_rest_fallback_yesterday(self, mock_rest, mock_parallel):
        """REST fallback should attempt fetch for yesterday (age=1 day)."""
        yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
        trades = _make_mock_trades(50)

        mock_rest.return_value = trades[:1]  # First/last trade lookup
        mock_parallel.return_value = trades

        df = _rest_fallback_day("BTCUSDT", yesterday, "spot")
        assert df is not None
        assert df.height == 50
        assert "agg_trade_id" in df.columns
        mock_rest.assert_called()
        mock_parallel.assert_called_once()

    def test_no_rest_for_old_days(self):
        """REST fallback age guard rejects days older than max_age.

        The guard in _seed_symbol checks: 1 <= age_days <= max_age.
        age=3 with max_age=2 fails the condition.
        """
        today = datetime.now(UTC).date()
        old_date = today - timedelta(days=3)
        age_days = (today - old_date).days
        assert age_days == 3
        assert not (1 <= age_days <= 2)  # Guard condition fails for age > max


# ---------------------------------------------------------------------------
# REST fallback config
# ---------------------------------------------------------------------------


class TestRestFallbackConfig:
    """Test REST fallback enable/disable behavior."""

    def test_rest_fallback_disabled(self):
        """When rest_fallback_enabled=False, the config flag is False.

        The guard in _seed_symbol: `if cfg.rest_fallback_enabled` will
        prevent any REST call when this is False.
        """
        from opendeviationbar.config.seeder import SeederConfig

        cfg = SeederConfig(rest_fallback_enabled=False)
        assert cfg.rest_fallback_enabled is False

    def test_rest_fallback_non_spot_returns_none(self):
        """REST fallback returns None for non-spot markets."""
        yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
        result = _rest_fallback_day("BTCUSDT", yesterday, "um_futures")
        assert result is None

    @patch(_MOCK_PARALLEL)
    @patch(_MOCK_REST)
    def test_rest_fallback_no_trades_at_start(self, mock_rest, mock_parallel):
        """REST fallback returns None when no trades found at day start."""
        yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
        mock_rest.return_value = []  # No trades
        result = _rest_fallback_day("BTCUSDT", yesterday, "spot")
        assert result is None
        mock_parallel.assert_not_called()

    @patch(_MOCK_PARALLEL)
    @patch(_MOCK_REST)
    def test_rest_fallback_partial_response_logs_warning(
        self, mock_rest, mock_parallel, caplog
    ):
        """Partial REST response (fewer trades than range) returns DataFrame + logs warning."""
        import logging

        yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
        # First trade at id=1000, last trade at id=1099
        mock_rest.side_effect = [
            [{"agg_trade_id": 1000, "price": 50000.0, "quantity": 1.0,
              "first_trade_id": 1000, "last_trade_id": 1000,
              "timestamp": 1700000000000, "is_buyer_maker": False}],
            [{"agg_trade_id": 1099, "price": 50099.0, "quantity": 1.0,
              "first_trade_id": 1099, "last_trade_id": 1099,
              "timestamp": 1700000099000, "is_buyer_maker": False}],
        ]
        # Parallel fetch returns only 50 of the 100 requested trades
        partial_trades = _make_mock_trades(50, start_id=1000)
        mock_parallel.return_value = partial_trades

        with caplog.at_level(logging.WARNING):
            df = _rest_fallback_day("BTCUSDT", yesterday, "spot")

        # Should still return a DataFrame (not discard valid data)
        assert df is not None
        assert df.height == 50
        # Should log a warning about partial response
        assert any("REST partial" in msg for msg in caplog.messages)


# ---------------------------------------------------------------------------
# .meta sidecar
# ---------------------------------------------------------------------------


class TestMetaSidecar:
    """Test .meta sidecar file creation and handling."""

    @patch(_MOCK_PARALLEL)
    @patch(_MOCK_REST)
    def test_meta_file_created(self, mock_rest, mock_parallel, tmp_path):
        """After REST fallback, .meta JSON file should have source=REST.

        Simulates the meta file creation from _seed_symbol.
        """
        trades = _make_mock_trades(10)
        mock_rest.return_value = trades[:1]
        mock_parallel.return_value = trades

        yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
        df = _rest_fallback_day("BTCUSDT", yesterday, "spot")
        assert df is not None

        # Simulate meta file creation that _seed_symbol does
        meta_path = tmp_path / "BTCUSDT" / f"{yesterday}.parquet.meta"
        meta_path.parent.mkdir(parents=True, exist_ok=True)
        meta_path.write_text(json.dumps({
            "source": "REST",
            "complete": False,
            "fetched_at": datetime.now(UTC).isoformat(),
            "trade_count": df.height,
        }))

        assert meta_path.exists()
        meta = json.loads(meta_path.read_text())
        assert meta["source"] == "REST"
        assert meta["complete"] is False
        assert meta["trade_count"] == 10

    def test_vision_overwrite_rest(self, tmp_path):
        """When Vision succeeds for a REST-sourced day, .meta is removed (D-08)."""
        parquet_path = tmp_path / "BTCUSDT" / "2026-03-20.parquet"
        parquet_path.parent.mkdir(parents=True, exist_ok=True)
        parquet_path.touch()
        meta_path = parquet_path.with_suffix(".parquet.meta")
        meta_path.write_text(json.dumps({"source": "REST"}))
        assert meta_path.exists()

        # Simulate Vision overwrite logic from _seed_symbol
        rest_fallback_used = False
        if not rest_fallback_used and meta_path.exists():
            meta_path.unlink(missing_ok=True)

        assert not meta_path.exists()


# ---------------------------------------------------------------------------
# Batch rotation
# ---------------------------------------------------------------------------


class TestBatchRotation:
    """Test batch offset persistence and symbol rotation."""

    def test_batch_rotation_offset(self, tmp_path):
        """_load_batch_offset / _save_batch_offset round-trip correctly."""
        state_file = tmp_path / "batch-state.json"
        _save_batch_offset(state_file, 6)
        offset = _load_batch_offset(state_file, 18)
        assert offset == 6

    def test_batch_rotation_wraps(self, tmp_path):
        """Batch offset wraps to 0 when exceeding total symbols."""
        state_file = tmp_path / "batch-state.json"
        _save_batch_offset(state_file, 20)
        offset = _load_batch_offset(state_file, 18)
        assert offset == 0  # 20 >= 18, wraps to 0

    def test_batch_rotation_missing_file(self, tmp_path):
        """Missing state file returns offset 0."""
        state_file = tmp_path / "nonexistent.json"
        offset = _load_batch_offset(state_file, 18)
        assert offset == 0

    def test_batch_priority_order(self):
        """BTCUSDT and ETHUSDT always appear in the first batch (D-14)."""
        symbols = [
            "ADAUSDT", "AVAXUSDT", "BNBUSDT", "BTCUSDT", "DOGEUSDT",
            "DOTUSDT", "ETHUSDT", "LINKUSDT", "LTCUSDT", "MATICUSDT",
            "NEARUSDT", "SOLUSDT", "SHIBUSDT", "TRXUSDT", "UNIUSDT",
            "XLMUSDT", "XRPUSDT", "ATOMUSDT",
        ]

        priority = ["BTCUSDT", "ETHUSDT"]
        ordered = [s for s in priority if s in symbols] + [s for s in symbols if s not in priority]

        batch_size = 6
        first_batch = ordered[:batch_size]

        assert first_batch[0] == "BTCUSDT"
        assert first_batch[1] == "ETHUSDT"
        assert len(first_batch) == 6


# ---------------------------------------------------------------------------
# Kintsugi-processed guard (SEEDER-04)
# ---------------------------------------------------------------------------


class TestKintsugiGuard:
    """Test _find_missing_days guards for kintsugi-processed files."""

    def _make_storage(self, tmp_path):
        """Create a TickStorage with tmp_path as cache dir."""
        from opendeviationbar.storage.parquet import TickStorage

        return TickStorage(cache_dir=tmp_path)

    def test_skip_kintsugi_processed(self, tmp_path):
        """Parquet without .meta = kintsugi/Vision processed, skip (SEEDER-04)."""
        storage = self._make_storage(tmp_path)

        symbol = "BTCUSDT"
        date_str = "2026-03-20"
        day_path = storage._get_parquet_path(symbol, date_str)
        day_path.parent.mkdir(parents=True, exist_ok=True)
        day_path.touch()

        target_date = date.fromisoformat(date_str)
        missing = _find_missing_days(symbol, target_date, target_date, storage)
        assert date_str not in missing

    def test_include_rest_sourced(self, tmp_path):
        """Parquet WITH .meta = REST-sourced, include for Vision overwrite (D-08)."""
        storage = self._make_storage(tmp_path)

        symbol = "BTCUSDT"
        date_str = "2026-03-20"
        day_path = storage._get_parquet_path(symbol, date_str)
        day_path.parent.mkdir(parents=True, exist_ok=True)
        day_path.touch()

        meta_path = day_path.with_suffix(".parquet.meta")
        meta_path.write_text(json.dumps({"source": "REST"}))

        target_date = date.fromisoformat(date_str)
        missing = _find_missing_days(symbol, target_date, target_date, storage)
        assert date_str in missing

    def test_missing_day_included(self, tmp_path):
        """Day with no Parquet file at all should be included."""
        storage = self._make_storage(tmp_path)

        symbol = "BTCUSDT"
        date_str = "2026-03-20"
        target_date = date.fromisoformat(date_str)
        missing = _find_missing_days(symbol, target_date, target_date, storage)
        assert date_str in missing
