"""Tests for Parquet tick bar processing (SEED-FIX-01, SEED-FIX-02).

Validates that _process_parquet_ticks() reads today's Parquet ticks via
TickStorage.read_ticks_streaming(), processes them through Rust processor per
(symbol, threshold), writes bars to ClickHouse, and returns
{symbol: last_completed_bar_tid} for seeding fill_from_rest.
"""

from __future__ import annotations

import os
import time
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock, patch

import polars as pl
import pyarrow as pa
import pytest


@pytest.fixture
def today_str() -> str:
    """Return today's date string in YYYY-MM-DD format."""
    return datetime.now(UTC).strftime("%Y-%m-%d")


@pytest.fixture
def ticks_dir(tmp_path: Path) -> Path:
    """Return a temporary ticks directory."""
    d = tmp_path / "ticks"
    d.mkdir()
    return d


def _write_parquet(
    ticks_dir: Path,
    symbol: str,
    day_key: str,
    trade_ids: list[int],
    prices: list[float] | None = None,
    quantities: list[float] | None = None,
) -> Path:
    """Helper: write a Parquet file with all required columns."""
    n = len(trade_ids)
    sym_dir = ticks_dir / symbol
    sym_dir.mkdir(parents=True, exist_ok=True)
    path = sym_dir / f"{day_key}.parquet"

    if prices is None:
        prices = [100.0] * n
    if quantities is None:
        quantities = [1.0] * n

    df = pl.DataFrame(
        {
            "agg_trade_id": trade_ids,
            "price": prices,
            "quantity": quantities,
            "first_trade_id": trade_ids,
            "last_trade_id": trade_ids,
            "timestamp": [1700000000000000 + i * 1000 for i in range(n)],
            "is_buyer_maker": [False] * n,
        },
        schema={
            "agg_trade_id": pl.Int64,
            "price": pl.Float64,
            "quantity": pl.Float64,
            "first_trade_id": pl.Int64,
            "last_trade_id": pl.Int64,
            "timestamp": pl.Int64,
            "is_buyer_maker": pl.Boolean,
        },
    )
    df.write_parquet(path)
    return path


def _make_ticks_df(trade_ids: list[int]) -> pl.DataFrame:
    """Create a Polars DataFrame matching TickStorage.read_ticks_streaming() chunk output."""
    n = len(trade_ids)
    return pl.DataFrame(
        {
            "agg_trade_id": trade_ids,
            "price": [100.0 + i * 0.01 for i in range(n)],
            "quantity": [1.0] * n,
            "first_trade_id": trade_ids,
            "last_trade_id": trade_ids,
            "timestamp": [1700000000000000 + i * 1000 for i in range(n)],
            "is_buyer_maker": [False] * n,
        },
        schema={
            "agg_trade_id": pl.Int64,
            "price": pl.Float64,
            "quantity": pl.Float64,
            "first_trade_id": pl.Int64,
            "last_trade_id": pl.Int64,
            "timestamp": pl.Int64,
            "is_buyer_maker": pl.Boolean,
        },
    )


class TestProcessParquetTicks:
    """Tests for _process_parquet_ticks() function (SEED-FIX-01, SEED-FIX-02)."""

    @patch("opendeviationbar.clickhouse.cache.OpenDeviationBarCache")
    @patch("opendeviationbar.processors.core.OpenDeviationBarProcessor")
    @patch("opendeviationbar.storage.parquet.TickStorage")
    def test_fresh_parquet_processes_bars(
        self,
        mock_storage_cls: MagicMock,
        mock_processor_cls: MagicMock,
        mock_cache_cls: MagicMock,
        ticks_dir: Path,
        today_str: str,
    ) -> None:
        """SEED-FIX-01: Fresh Parquet ticks are processed into bars per (symbol, threshold)."""
        # Write real Parquet files so freshness check passes
        _write_parquet(ticks_dir, "BTCUSDT", today_str, list(range(1, 101)))
        _write_parquet(ticks_dir, "ETHUSDT", today_str, list(range(1, 51)))

        # Mock TickStorage.read_ticks to return test DataFrames
        ticks_btc = _make_ticks_df(list(range(1, 101)))
        ticks_eth = _make_ticks_df(list(range(1, 51)))
        mock_storage = MagicMock()
        mock_storage.ticks_dir = ticks_dir
        mock_storage.read_ticks_streaming.side_effect = lambda symbol, **_kw: iter(
            [ticks_btc if symbol == "BTCUSDT" else ticks_eth]
        )
        mock_storage_cls.return_value = mock_storage

        # Mock processor: returns non-empty Arrow batch and last_completed_bar_tid
        mock_proc = MagicMock()
        bars_arrow = pa.record_batch(
            {"open": [100.0], "close": [101.0]},
        )
        mock_proc.process_trades_arrow_native.return_value = bars_arrow
        mock_proc.last_completed_bar_tid.return_value = 5000
        mock_processor_cls.return_value = mock_proc

        # Mock cache context manager
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT", "ETHUSDT"], [250], ticks_dir=ticks_dir
        )

        # Both symbols should have results
        assert "BTCUSDT" in result
        assert "ETHUSDT" in result
        # Processor and store_bars_batch called for each (symbol, threshold)
        assert mock_proc.process_trades_arrow_native.call_count == 2  # 2 symbols x 1 threshold
        assert mock_cache.store_bars_batch.call_count == 2

    @patch("opendeviationbar.clickhouse.cache.OpenDeviationBarCache")
    @patch("opendeviationbar.processors.core.OpenDeviationBarProcessor")
    @patch("opendeviationbar.storage.parquet.TickStorage")
    def test_seed_uses_completed_bar_tid(
        self,
        mock_storage_cls: MagicMock,
        mock_processor_cls: MagicMock,
        mock_cache_cls: MagicMock,
        ticks_dir: Path,
        today_str: str,
    ) -> None:
        """SEED-FIX-02: Returned TIDs come from last_completed_bar_tid(), NOT max(agg_trade_id)."""
        # Parquet max agg_trade_id is 5500, but last_completed_bar_tid is 5000
        _write_parquet(ticks_dir, "BTCUSDT", today_str, list(range(5000, 5501)))

        mock_storage = MagicMock()
        mock_storage.ticks_dir = ticks_dir
        mock_storage.read_ticks_streaming.return_value = iter([_make_ticks_df(list(range(5000, 5501)))])
        mock_storage_cls.return_value = mock_storage

        mock_proc = MagicMock()
        bars_arrow = pa.record_batch({"open": [100.0]})
        mock_proc.process_trades_arrow_native.return_value = bars_arrow
        # Critical: last_completed_bar_tid = 5000, NOT max agg_trade_id (5500)
        mock_proc.last_completed_bar_tid.return_value = 5000
        mock_processor_cls.return_value = mock_proc

        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT"], [250], ticks_dir=ticks_dir
        )

        assert result["BTCUSDT"] == 5000  # NOT 5500

    def test_stale_parquet_skipped(
        self, ticks_dir: Path, today_str: str
    ) -> None:
        """Parquet file mtime older than 600s is skipped."""
        path = _write_parquet(ticks_dir, "BTCUSDT", today_str, [100, 200, 300])
        # Backdate mtime to 15 minutes ago
        old_time = time.time() - 900
        os.utime(path, (old_time, old_time))

        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT"], [250], ticks_dir=ticks_dir
        )

        assert result == {}

    def test_missing_parquet_returns_empty(self, ticks_dir: Path) -> None:
        """No Parquet file exists, returns empty dict."""
        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT", "ETHUSDT"], [250], ticks_dir=ticks_dir
        )

        assert result == {}

    @patch("opendeviationbar.clickhouse.cache.OpenDeviationBarCache")
    @patch("opendeviationbar.processors.core.OpenDeviationBarProcessor")
    @patch("opendeviationbar.storage.parquet.TickStorage")
    def test_processing_failure_skips_symbol(
        self,
        mock_storage_cls: MagicMock,
        mock_processor_cls: MagicMock,
        mock_cache_cls: MagicMock,
        ticks_dir: Path,
        today_str: str,
    ) -> None:
        """If process_trades_arrow_native raises, that symbol is skipped."""
        _write_parquet(ticks_dir, "BTCUSDT", today_str, list(range(1, 101)))
        _write_parquet(ticks_dir, "ETHUSDT", today_str, list(range(1, 51)))

        mock_storage = MagicMock()
        mock_storage.ticks_dir = ticks_dir
        # Each call to read_ticks_streaming returns a fresh iterator
        mock_storage.read_ticks_streaming.side_effect = lambda _symbol, **_kw: iter(
            [_make_ticks_df(list(range(1, 51)))]
        )
        mock_storage_cls.return_value = mock_storage

        # First symbol's processor raises during processing, second succeeds
        mock_proc_fail = MagicMock()
        mock_proc_fail.process_trades_arrow_native.side_effect = RuntimeError("boom")

        mock_proc_ok = MagicMock()
        bars_arrow = pa.record_batch({"open": [100.0]})
        mock_proc_ok.process_trades_arrow_native.return_value = bars_arrow
        mock_proc_ok.last_completed_bar_tid.return_value = 3000

        # Processor constructor: fail for BTCUSDT, succeed for ETHUSDT
        mock_processor_cls.side_effect = [mock_proc_fail, mock_proc_ok]

        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT", "ETHUSDT"], [250], ticks_dir=ticks_dir
        )

        # BTCUSDT failed, ETHUSDT succeeded
        assert "BTCUSDT" not in result
        assert result["ETHUSDT"] == 3000

    @patch("opendeviationbar.clickhouse.cache.OpenDeviationBarCache")
    @patch("opendeviationbar.processors.core.OpenDeviationBarProcessor")
    @patch("opendeviationbar.storage.parquet.TickStorage")
    def test_no_completed_bars_omits_symbol(
        self,
        mock_storage_cls: MagicMock,
        mock_processor_cls: MagicMock,
        mock_cache_cls: MagicMock,
        ticks_dir: Path,
        today_str: str,
    ) -> None:
        """If last_completed_bar_tid() returns None, symbol omitted from result."""
        _write_parquet(ticks_dir, "BTCUSDT", today_str, list(range(1, 11)))

        mock_storage = MagicMock()
        mock_storage.ticks_dir = ticks_dir
        mock_storage.read_ticks_streaming.return_value = iter([_make_ticks_df(list(range(1, 11)))])
        mock_storage_cls.return_value = mock_storage

        mock_proc = MagicMock()
        # Empty result -- no bars produced
        bars_arrow = pa.record_batch({"open": pa.array([], type=pa.float64())})
        mock_proc.process_trades_arrow_native.return_value = bars_arrow
        mock_proc.last_completed_bar_tid.return_value = None
        mock_processor_cls.return_value = mock_proc

        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT"], [250], ticks_dir=ticks_dir
        )

        assert result == {}

    @patch("opendeviationbar.clickhouse.cache.OpenDeviationBarCache")
    @patch("opendeviationbar.processors.core.OpenDeviationBarProcessor")
    @patch("opendeviationbar.storage.parquet.TickStorage")
    def test_all_thresholds_processed(
        self,
        mock_storage_cls: MagicMock,
        mock_processor_cls: MagicMock,
        mock_cache_cls: MagicMock,
        ticks_dir: Path,
        today_str: str,
    ) -> None:
        """Given 2 thresholds, processor created and called for each."""
        _write_parquet(ticks_dir, "BTCUSDT", today_str, list(range(1, 101)))

        mock_storage = MagicMock()
        mock_storage.ticks_dir = ticks_dir
        mock_storage.read_ticks_streaming.return_value = iter([_make_ticks_df(list(range(1, 101)))])
        mock_storage_cls.return_value = mock_storage

        mock_proc = MagicMock()
        bars_arrow = pa.record_batch({"open": [100.0]})
        mock_proc.process_trades_arrow_native.return_value = bars_arrow
        mock_proc.last_completed_bar_tid.return_value = 8000
        mock_processor_cls.return_value = mock_proc

        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

        from opendeviationbar.sidecar.midnight import _process_parquet_ticks

        result = _process_parquet_ticks(
            ["BTCUSDT"], [250, 500], ticks_dir=ticks_dir
        )

        assert result["BTCUSDT"] == 8000
        # Processor created for each threshold
        assert mock_processor_cls.call_count == 2
        # process_trades_arrow_native called for each threshold
        assert mock_proc.process_trades_arrow_native.call_count == 2
        # store_bars_batch called for each threshold
        assert mock_cache.store_bars_batch.call_count == 2
