#!/usr/bin/env python3
"""Test ouroboros module for cyclical reset boundaries.

Tests cover:
- Day mode boundary calculation (sole supported mode)
- Segment iteration
- Exchange session detection
- Edge cases (DST transitions, leap years, year boundaries)
- Rejection of legacy modes (year, month, week)
"""

from datetime import UTC, date, datetime

import pytest
from opendeviationbar.ouroboros import (
    ExchangeSessionFlags,
    OrphanedBarMetadata,
    OuroborosBoundary,
    OuroborosMode,
    get_active_exchange_sessions,
    get_ouroboros_boundaries,
    iter_ouroboros_segments,
    validate_ouroboros_mode,
)


class TestOuroborosMode:
    """Test OuroborosMode enum."""

    def test_day_mode_value(self):
        """Test DAY enum value is correct."""
        assert OuroborosMode.DAY.value == "day"

    def test_day_mode_is_string_enum(self):
        """Test mode can be compared to strings."""
        assert OuroborosMode.DAY == "day"

    def test_only_day_mode_exists(self):
        """Test that only DAY mode exists in the enum."""
        assert list(OuroborosMode) == [OuroborosMode.DAY]

    def test_legacy_modes_removed(self):
        """Test that legacy modes (year, month, week) are removed."""
        assert not hasattr(OuroborosMode, "YEAR")
        assert not hasattr(OuroborosMode, "MONTH")
        assert not hasattr(OuroborosMode, "WEEK")


class TestOuroborosBoundary:
    """Test OuroborosBoundary dataclass."""

    def test_boundary_creation(self):
        """Test creating a boundary."""
        ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
        boundary = OuroborosBoundary(
            timestamp=ts,
            mode=OuroborosMode.DAY,
            reason="day_boundary",
        )
        assert boundary.timestamp == ts
        assert boundary.mode == OuroborosMode.DAY
        assert boundary.reason == "day_boundary"

    def test_timestamp_ms(self):
        """Test timestamp_ms property."""
        ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
        boundary = OuroborosBoundary(
            timestamp=ts,
            mode=OuroborosMode.DAY,
            reason="day_boundary",
        )
        # 2024-01-01 00:00:00 UTC in milliseconds
        expected_ms = 1704067200000
        assert boundary.timestamp_ms == expected_ms

    def test_timestamp_us(self):
        """Test timestamp_us property."""
        ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
        boundary = OuroborosBoundary(
            timestamp=ts,
            mode=OuroborosMode.DAY,
            reason="day_boundary",
        )
        expected_us = 1704067200000000
        assert boundary.timestamp_us == expected_us

    def test_boundary_is_frozen(self):
        """Test boundary is immutable."""
        ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
        boundary = OuroborosBoundary(
            timestamp=ts,
            mode=OuroborosMode.DAY,
            reason="day_boundary",
        )
        with pytest.raises(AttributeError):
            boundary.timestamp = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)


class TestOrphanedBarMetadata:
    """Test OrphanedBarMetadata dataclass."""

    def test_default_values(self):
        """Test default values."""
        metadata = OrphanedBarMetadata()
        assert metadata.is_orphan is True
        assert metadata.ouroboros_boundary is None
        assert metadata.reason is None
        assert metadata.expected_duration_us is None

    def test_full_metadata(self):
        """Test with all fields populated."""
        boundary_ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
        metadata = OrphanedBarMetadata(
            is_orphan=True,
            ouroboros_boundary=boundary_ts,
            reason="day_boundary",
            expected_duration_us=3600000000,  # 1 hour
        )
        assert metadata.ouroboros_boundary == boundary_ts
        assert metadata.reason == "day_boundary"
        assert metadata.expected_duration_us == 3600000000


class TestGetOuroborosBoundaries:
    """Test get_ouroboros_boundaries function."""

    def test_day_boundaries_single_day(self):
        """Test day boundaries for a single day."""
        boundaries = get_ouroboros_boundaries(date(2024, 3, 5), date(2024, 3, 5), "day")
        assert len(boundaries) == 1
        assert boundaries[0].timestamp == datetime(2024, 3, 5, 0, 0, 0, tzinfo=UTC)
        assert boundaries[0].mode == OuroborosMode.DAY
        assert boundaries[0].reason == "day_boundary"

    def test_day_boundaries_multi_day(self):
        """Test day boundaries across multiple days."""
        boundaries = get_ouroboros_boundaries(date(2024, 3, 1), date(2024, 3, 5), "day")
        assert len(boundaries) == 5
        for i, boundary in enumerate(boundaries):
            assert boundary.timestamp == datetime(2024, 3, i + 1, 0, 0, 0, tzinfo=UTC)
            assert boundary.mode == OuroborosMode.DAY
            assert boundary.reason == "day_boundary"

    def test_day_boundaries_cross_month(self):
        """Test day boundaries crossing month boundary."""
        boundaries = get_ouroboros_boundaries(
            date(2024, 1, 30), date(2024, 2, 2), "day"
        )
        assert len(boundaries) == 4
        assert boundaries[0].timestamp == datetime(2024, 1, 30, 0, 0, 0, tzinfo=UTC)
        assert boundaries[1].timestamp == datetime(2024, 1, 31, 0, 0, 0, tzinfo=UTC)
        assert boundaries[2].timestamp == datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC)
        assert boundaries[3].timestamp == datetime(2024, 2, 2, 0, 0, 0, tzinfo=UTC)

    def test_day_boundaries_full_year(self):
        """Test day boundaries for a full year (365 or 366 boundaries)."""
        boundaries = get_ouroboros_boundaries(
            date(2024, 1, 1), date(2024, 12, 31), "day"
        )
        assert len(boundaries) == 366  # 2024 is a leap year

    def test_day_boundaries_default_mode(self):
        """Test that default mode is 'day'."""
        boundaries = get_ouroboros_boundaries(date(2024, 3, 1), date(2024, 3, 3))
        assert len(boundaries) == 3

    def test_legacy_mode_year_rejected(self):
        """Test that 'year' mode raises ValueError."""
        with pytest.raises(ValueError, match="Only 'day' mode is supported"):
            get_ouroboros_boundaries(date(2024, 1, 1), date(2024, 12, 31), "year")

    def test_legacy_mode_month_rejected(self):
        """Test that 'month' mode raises ValueError."""
        with pytest.raises(ValueError, match="Only 'day' mode is supported"):
            get_ouroboros_boundaries(date(2024, 1, 1), date(2024, 3, 31), "month")

    def test_legacy_mode_week_rejected(self):
        """Test that 'week' mode raises ValueError."""
        with pytest.raises(ValueError, match="Only 'day' mode is supported"):
            get_ouroboros_boundaries(date(2024, 1, 1), date(2024, 1, 31), "week")


class TestIterOuroborosSegments:
    """Test iter_ouroboros_segments function."""

    def test_day_mode_segments(self):
        """Test day-mode produces one segment per day."""
        segments = list(
            iter_ouroboros_segments(date(2024, 3, 1), date(2024, 3, 3), "day")
        )
        # 3 segments: Mar 1, Mar 2, Mar 3
        assert len(segments) == 3

        # Each segment starts on a day boundary
        for i, (start, end, boundary) in enumerate(segments):
            assert boundary is not None
            assert boundary.mode == OuroborosMode.DAY
            assert boundary.reason == "day_boundary"
            assert start == datetime(2024, 3, i + 1, 0, 0, 0, tzinfo=UTC)

        # Last segment ends at end of Mar 3
        assert segments[-1][1] == datetime(2024, 3, 3, 23, 59, 59, 999999, tzinfo=UTC)

    def test_day_mode_single_day(self):
        """Test day-mode with single day produces one segment."""
        segments = list(
            iter_ouroboros_segments(date(2024, 3, 5), date(2024, 3, 5), "day")
        )
        assert len(segments) == 1
        start, end, boundary = segments[0]
        assert boundary is not None
        assert start == datetime(2024, 3, 5, 0, 0, 0, tzinfo=UTC)
        assert end == datetime(2024, 3, 5, 23, 59, 59, 999999, tzinfo=UTC)

    def test_segment_end_times(self):
        """Test segment end times are 23:59:59.999999."""
        segments = list(
            iter_ouroboros_segments(date(2024, 1, 1), date(2024, 1, 1), "day")
        )
        assert len(segments) == 1
        _start, end, _boundary = segments[0]
        assert end.hour == 23
        assert end.minute == 59
        assert end.second == 59
        assert end.microsecond == 999999

    def test_segment_boundary_transition(self):
        """Test segment transitions at boundary."""
        segments = list(
            iter_ouroboros_segments(date(2024, 1, 1), date(2024, 1, 2), "day")
        )
        assert len(segments) == 2

        # End of Jan 1 segment
        _start1, end1, _boundary1 = segments[0]
        # Start of Jan 2 segment
        start2, _end2, _boundary2 = segments[1]

        # 1 microsecond gap between segments
        assert end1 == datetime(2024, 1, 1, 23, 59, 59, 999999, tzinfo=UTC)
        assert start2 == datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)

    def test_segments_is_generator(self):
        """Test segment iteration returns generator."""
        segments_gen = iter_ouroboros_segments(
            date(2024, 1, 1), date(2024, 1, 3), "day"
        )
        assert hasattr(segments_gen, "__iter__")
        assert hasattr(segments_gen, "__next__")


class TestExchangeSessionFlags:
    """Test ExchangeSessionFlags dataclass."""

    def test_flags_creation(self):
        """Test creating session flags."""
        flags = ExchangeSessionFlags(
            sydney=True, tokyo=True, london=False, newyork=False
        )
        assert flags.sydney is True
        assert flags.tokyo is True
        assert flags.london is False
        assert flags.newyork is False

    def test_to_dict(self):
        """Test converting to dict with column names."""
        flags = ExchangeSessionFlags(
            sydney=True, tokyo=False, london=True, newyork=False
        )
        d = flags.to_dict()
        assert d == {
            "exchange_session_sydney": True,
            "exchange_session_tokyo": False,
            "exchange_session_london": True,
            "exchange_session_newyork": False,
        }

    def test_flags_is_frozen(self):
        """Test flags are immutable."""
        flags = ExchangeSessionFlags(
            sydney=True, tokyo=True, london=False, newyork=False
        )
        with pytest.raises(AttributeError):
            flags.sydney = False


class TestGetActiveExchangeSessions:
    """Test get_active_exchange_sessions function."""

    def test_tokyo_session_morning(self):
        """Test Tokyo session during Tokyo morning (9 AM JST = 0 AM UTC)."""
        ts = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC)  # Monday
        flags = get_active_exchange_sessions(ts)
        assert flags.tokyo is True

    def test_london_session_morning(self):
        """Test London session during London morning (9 AM GMT = 9 AM UTC)."""
        ts = datetime(2024, 1, 15, 9, 0, 0, tzinfo=UTC)  # Monday
        flags = get_active_exchange_sessions(ts)
        assert flags.london is True

    def test_newyork_session_afternoon(self):
        """Test New York session (2 PM EST = 7 PM UTC)."""
        ts = datetime(2024, 1, 15, 19, 0, 0, tzinfo=UTC)  # Monday
        flags = get_active_exchange_sessions(ts)
        assert flags.newyork is True

    def test_no_sessions_weekend(self):
        """Test no sessions active on weekend."""
        ts = datetime(2024, 1, 13, 12, 0, 0, tzinfo=UTC)  # Saturday
        flags = get_active_exchange_sessions(ts)
        assert flags.sydney is False
        assert flags.tokyo is False
        assert flags.london is False
        assert flags.newyork is False

    def test_session_overlap_london_newyork(self):
        """Test London/New York overlap (afternoon UTC)."""
        ts = datetime(2024, 1, 15, 15, 30, 0, tzinfo=UTC)  # Monday
        flags = get_active_exchange_sessions(ts)
        assert flags.london is True
        assert flags.newyork is True

    def test_sydney_session(self):
        """Test Sydney session (Sydney mid-morning = late UTC previous day)."""
        ts = datetime(2024, 1, 16, 1, 0, 0, tzinfo=UTC)  # Tuesday UTC
        flags = get_active_exchange_sessions(ts)
        assert flags.sydney is True


class TestValidateOuroborosMode:
    """Test validate_ouroboros_mode function."""

    def test_valid_day_mode(self):
        """Test 'day' is accepted."""
        assert validate_ouroboros_mode("day") == "day"

    def test_legacy_modes_rejected(self):
        """Test legacy modes raise ValueError."""
        for mode in ("year", "month", "week"):
            with pytest.raises(ValueError, match="Only 'day' mode is supported"):
                validate_ouroboros_mode(mode)

    def test_invalid_mode(self):
        """Test invalid mode raises ValueError."""
        with pytest.raises(ValueError, match="Only 'day' mode is supported"):
            validate_ouroboros_mode("hourly")

    def test_invalid_mode_none(self):
        """Test 'none' is not a valid mode."""
        with pytest.raises(ValueError, match="Only 'day' mode is supported"):
            validate_ouroboros_mode("none")


class TestEdgeCases:
    """Test edge cases and boundary conditions."""

    def test_leap_year_feb_boundaries(self):
        """Test February in leap year (2024) has 29 day boundaries."""
        boundaries = get_ouroboros_boundaries(
            date(2024, 2, 1), date(2024, 2, 29), "day"
        )
        assert len(boundaries) == 29

    def test_non_leap_year_feb_boundaries(self):
        """Test February in non-leap year (2023) has 28 day boundaries."""
        boundaries = get_ouroboros_boundaries(
            date(2023, 2, 1), date(2023, 2, 28), "day"
        )
        assert len(boundaries) == 28

    def test_day_boundary_midnight(self):
        """Test day boundary is exactly midnight UTC."""
        boundaries = get_ouroboros_boundaries(
            date(2024, 3, 15), date(2024, 3, 15), "day"
        )
        assert len(boundaries) == 1
        boundary = boundaries[0]
        assert boundary.timestamp.hour == 0
        assert boundary.timestamp.minute == 0
        assert boundary.timestamp.second == 0
        assert boundary.timestamp.microsecond == 0

    def test_same_day_start_end(self):
        """Test when start and end are the same day."""
        boundaries = get_ouroboros_boundaries(date(2024, 3, 1), date(2024, 3, 1), "day")
        assert len(boundaries) == 1

    def test_timestamp_ms_precision(self):
        """Test timestamp_ms has millisecond precision."""
        ts = datetime(2024, 1, 1, 12, 30, 45, 123456, tzinfo=UTC)
        boundary = OuroborosBoundary(
            timestamp=ts,
            mode=OuroborosMode.DAY,
            reason="test",
        )
        expected_ms = int(ts.timestamp() * 1000)
        assert boundary.timestamp_ms == expected_ms

    def test_timestamp_us_precision(self):
        """Test timestamp_us has microsecond precision."""
        ts = datetime(2024, 1, 1, 12, 30, 45, 123456, tzinfo=UTC)
        boundary = OuroborosBoundary(
            timestamp=ts,
            mode=OuroborosMode.DAY,
            reason="test",
        )
        expected_us = int(ts.timestamp() * 1_000_000)
        assert boundary.timestamp_us == expected_us


class TestOuroborosAPIContract:
    """Fast contract tests for ouroboros API signatures and validation."""

    def test_default_ouroboros_is_none(self):
        """Default ouroboros_mode parameter is None (resolved from config at runtime)."""
        import inspect

        from opendeviationbar import get_open_deviation_bars

        sig = inspect.signature(get_open_deviation_bars)
        ouroboros_param = sig.parameters["ouroboros_mode"]
        assert ouroboros_param.default is None

    def test_ouroboros_none_is_rejected(self):
        """ouroboros='none' (string) is rejected."""
        from opendeviationbar import get_open_deviation_bars

        with pytest.raises(ValueError, match="Invalid ouroboros mode"):
            get_open_deviation_bars(
                "BTCUSDT",
                "2024-01-01",
                "2024-01-07",
                threshold_decimal_bps=250,
                ouroboros_mode="none",
            )

    def test_ouroboros_invalid_value_rejected(self):
        """Invalid ouroboros values are rejected."""
        from opendeviationbar import get_open_deviation_bars

        with pytest.raises(ValueError, match="Invalid ouroboros mode"):
            get_open_deviation_bars(
                "BTCUSDT",
                "2024-01-01",
                "2024-01-07",
                threshold_decimal_bps=250,
                ouroboros_mode="daily",
            )
