"""Test suite for Artip core functionality."""

import pytest
import polars as pl
from datetime import date, datetime

from artip import (
    Artip,
    InvalidGranularityError,
    InvalidIntervalError,
    PeriodTooLargeError,
)


class TestArtipInitialization:
    """Tests for Artip initialization."""

    def test_basic_initialization(self):
        """Test basic Artip creation."""
        artip = Artip(
            min_time=date(2024, 1, 1),
            max_time=date(2024, 12, 31),
            granularity="day"
        )
        assert artip.granularity == "day"
        assert artip.period_max_size is None

    def test_initialization_with_period_max_size(self):
        """Test Artip with period_max_size constraint."""
        artip = Artip(
            min_time=date(2024, 1, 1),
            max_time=date(2024, 12, 31),
            granularity="day",
            period_max_size=100
        )
        assert artip.period_max_size == 100

    def test_initialization_with_datetime(self):
        """Test Artip with datetime objects."""
        artip = Artip(
            min_time=datetime(2024, 1, 1, 0, 0, 0),
            max_time=datetime(2024, 12, 31, 23, 59, 59)
        )
        assert isinstance(artip.min_time, datetime)

    def test_initialization_truncates_bounds_to_granularity(self):
        """Bounds are normalized to the configured granularity at initialization."""
        artip = Artip(
            min_time=datetime(2024, 1, 1, 12, 30, 45),
            max_time=datetime(2024, 1, 3, 23, 59, 59),
            granularity="day",
        )

        assert artip.min_time == datetime(2024, 1, 1, 0, 0, 0)
        assert artip.max_time == datetime(2024, 1, 3, 0, 0, 0)

        pid = artip.make_period_id(
            datetime(2024, 1, 1, 8, 0, 0),
            datetime(2024, 1, 2, 18, 0, 0),
        )
        assert pid.value == 11

    def test_invalid_granularity(self):
        """Test that invalid granularity raises error."""
        with pytest.raises(InvalidGranularityError):
            Artip(
                min_time=date(2024, 1, 1),
                max_time=date(2024, 12, 31),
                granularity="invalid"
            )

    def test_invalid_time_interval(self):
        """Test that invalid time interval raises error."""
        with pytest.raises(InvalidIntervalError):
            Artip(
                min_time=date(2024, 12, 31),
                max_time=date(2024, 1, 1)
            )

    def test_same_min_max_raises_error(self):
        """Test that min_time equal to max_time raises error."""
        with pytest.raises(InvalidIntervalError):
            Artip(
                min_time=date(2024, 1, 1),
                max_time=date(2024, 1, 1)
            )

    @pytest.mark.parametrize("period_max_size", [0, -1, True, 1.5])
    def test_invalid_period_max_size_raises_error(self, period_max_size):
        """period_max_size must be None or a strictly positive integer."""
        with pytest.raises(ValueError, match="period_max_size must be a positive integer or None"):
            Artip(
                min_time=date(2024, 1, 1),
                max_time=date(2024, 12, 31),
                period_max_size=period_max_size,
            )


class TestMakePeriodId:
    """
    Tests for make_period_id — encoding correctness.

    Reference encoding (min=Jan1, max=Jan10, granularity='day'):
      total_units=10, time_id_size=2, period_size=2
      period_value = start_id * 10^2 + (end_id - start_id)
    """

    def test_encodes_start_and_duration(self):
        """Exact encoded value: Jan1..Jan5 → start_id=1, length=4 → 104."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))
        assert pid.value == 104

    def test_different_start_produces_different_id(self):
        """Same duration, shifted start: Jan2..Jan6 → start_id=2, length=4 → 204."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid1 = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))  # 104
        pid2 = artip.make_period_id(date(2024, 1, 2), date(2024, 1, 6))  # 204
        assert pid1 != pid2
        assert pid2.value == 204

    def test_different_duration_produces_different_id(self):
        """Same start, longer end: Jan1..Jan6 → start_id=1, length=5 → 105."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid1 = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))  # 104
        pid2 = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 6))  # 105
        assert pid1 != pid2
        assert pid2.value == 105

    def test_single_unit_period_has_zero_length(self):
        """One-day period: start_id=3, length=0 → 300."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid = artip.make_period_id(date(2024, 1, 3), date(2024, 1, 3))
        assert pid.value == 300

    def test_encoded_value_is_decodable(self):
        """start_id and length are recoverable from the period_value."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid = artip.make_period_id(date(2024, 1, 3), date(2024, 1, 8))  # start_id=3, length=5 → 305
        multiplier = 10 ** artip.period_size
        assert pid.value // multiplier == 3   # start_id
        assert pid.value % multiplier == 5    # period_length

    def test_start_after_end_raises_error(self):
        """start > end must raise InvalidIntervalError."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31))
        with pytest.raises(InvalidIntervalError):
            artip.make_period_id(date(2024, 6, 30), date(2024, 6, 1))

    def test_period_entirely_before_min_returns_zero(self):
        """Period entirely before min_time must return PeriodId(0)."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31))
        pid = artip.make_period_id(date(2023, 1, 1), date(2023, 6, 30))
        assert pid.value == 0

    def test_period_entirely_after_max_returns_zero(self):
        """Period entirely after max_time must return PeriodId(0)."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31))
        pid = artip.make_period_id(date(2025, 1, 1), date(2025, 6, 30))
        assert pid.value == 0

    def test_start_before_min_uses_reserved_start_id_zero(self):
        """If start < min_time, start_id=0 must be used in encoded value."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        # start_id=0, end_id=3, length=3 -> period_value=3
        pid = artip.make_period_id(date(2023, 12, 30), date(2024, 1, 3))
        assert pid.value == 3

        # Decoding must preserve the originally provided bounds.
        start, end = artip.get_period_range(pid)
        assert start.date() == date(2023, 12, 30)
        assert end.date() == date(2024, 1, 3)

    def test_end_after_max_uses_reserved_end_id(self):
        """If end > max_time, end_id=max_id+1 must be used in encoded value."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        # start_id=8, end_id=11, length=3 -> period_value=803
        pid = artip.make_period_id(date(2024, 1, 8), date(2024, 1, 20))
        assert pid.value == 803

        # Decoding must preserve the originally provided bounds.
        start, end = artip.get_period_range(pid)
        assert start.date() == date(2024, 1, 8)
        assert end.date() == date(2024, 1, 20)

    def test_both_bounds_outside_encode_and_decode_correctly(self):
        """Periods crossing both bounds must be encoded and decoded consistently."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        # start_id=0, end_id=11, length=11 -> period_value=11
        pid = artip.make_period_id(date(2023, 12, 20), date(2024, 1, 20))
        assert pid.value == 11

        start, end = artip.get_period_range(pid)
        assert start.date() == date(2023, 12, 20)
        assert end.date() == date(2024, 1, 20)

    def test_period_too_large_raises_error(self):
        """Period exceeding period_max_size must raise PeriodTooLargeError."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31), period_max_size=10)
        with pytest.raises(PeriodTooLargeError):
            artip.make_period_id(date(2024, 1, 1), date(2024, 2, 15))

    def test_period_exactly_at_max_size_succeeds(self):
        """Period length == period_max_size must not raise."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31), granularity="day", period_max_size=10)
        # start_id=1, end_id=11, length=10 — exactly at the limit
        pid = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 11))
        assert pid.value > 0

    def test_day_granularity_truncates_hms_before_encoding(self):
        """For day granularity, hour/minute/second are normalized to 00:00:00."""
        artip = Artip(
            datetime(2024, 1, 1, 0, 0, 0),
            datetime(2024, 1, 10, 23, 59, 59),
            granularity="day",
        )
        pid = artip.make_period_id(
            datetime(2024, 1, 3, 12, 34, 56),
            datetime(2024, 1, 5, 23, 59, 59),
        )
        start, end = artip.get_period_range(pid)

        assert start == datetime(2024, 1, 3, 0, 0, 0)
        assert end == datetime(2024, 1, 5, 0, 0, 0)

    def test_month_granularity_truncates_day_and_time(self):
        """For month granularity, day and time are normalized to first day at midnight."""
        artip = Artip(
            datetime(2024, 1, 1, 0, 0, 0),
            datetime(2024, 12, 31, 23, 59, 59),
            granularity="month",
        )
        pid = artip.make_period_id(
            datetime(2024, 3, 18, 9, 30, 45),
            datetime(2024, 5, 27, 22, 10, 5),
        )
        start, end = artip.get_period_range(pid)

        assert start == datetime(2024, 3, 1, 0, 0, 0)
        assert end == datetime(2024, 5, 1, 0, 0, 0)

    def test_duplicate_period_id_generation_reuses_existing_entry(self):
        """Generating the same period twice must keep a single stored entry."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")

        pid1 = artip.make_period_id(date(2024, 1, 3), date(2024, 1, 5))
        pid2 = artip.make_period_id(date(2024, 1, 3), date(2024, 1, 5))

        assert pid1 == pid2
        assert len(artip._periods) == 1


class TestGetPeriodRange:
    """Tests for get_period_range — round-trip fidelity."""

    def test_round_trip_returns_original_start_and_end(self):
        """get_period_range returns the exact start/end passed to make_period_id."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31))
        pid = artip.make_period_id(date(2024, 3, 15), date(2024, 3, 20))
        start, end = artip.get_period_range(pid)
        assert start.date() == date(2024, 3, 15)
        assert end.date() == date(2024, 3, 20)

    def test_multiple_periods_are_retrieved_independently(self):
        """Each period_id maps to its own range without cross-contamination."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31))
        pid1 = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 31))
        pid2 = artip.make_period_id(date(2024, 6, 1), date(2024, 6, 30))
        start1, end1 = artip.get_period_range(pid1)
        start2, end2 = artip.get_period_range(pid2)
        assert start1.date() == date(2024, 1, 1) and end1.date() == date(2024, 1, 31)
        assert start2.date() == date(2024, 6, 1) and end2.date() == date(2024, 6, 30)

    def test_period_from_other_instance_raises_error(self):
        """A period_id produced by artip1 must not be valid for artip2."""
        artip1 = Artip(date(2024, 1, 1), date(2024, 12, 31))
        artip2 = Artip(date(2024, 1, 1), date(2024, 12, 31))
        pid = artip1.make_period_id(date(2024, 3, 1), date(2024, 3, 15))
        with pytest.raises(InvalidIntervalError):
            artip2.get_period_range(pid)


class TestMakeCalendarTable:
    """
    Tests for make_calendar_table — data correctness.

    Per spec, for each period three sets of rows are created:
      - one "starts" row for the start date
      - one "ends"   row for the end date
      - one "in"     row for every date from start to end INCLUSIVE
    A 5-day period (Jan1..Jan5) → 1+1+5 = 7 rows.
    A 1-day period              → 1+1+1 = 3 rows.
    """

    def _build_three_periods_artip(self):
        """Helper to build an Artip instance with three distinct periods."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        # Create out of natural order to ensure filtering logic is robust.
        last_pid = artip.make_period_id(date(2024, 1, 6), date(2024, 1, 10)).value
        first_pid = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 3)).value
        middle_pid = artip.make_period_id(date(2024, 1, 4), date(2024, 1, 5)).value
        return artip, first_pid, middle_pid, last_pid

    def test_schema_has_three_exact_columns(self):
        """Calendar must have exactly the columns: date, period_id, label."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        artip.make_period_id(date(2024, 1, 1), date(2024, 1, 3))
        cal = artip.make_calendar_table()
        assert set(cal.collect_schema().names()) == {"date", "period_id", "label"}

    def test_empty_when_no_periods_generated(self):
        """Calendar must be empty when make_period_id has never been called."""
        artip = Artip(date(2024, 1, 1), date(2024, 12, 31))
        assert len(artip.make_calendar_table().collect()) == 0

    def test_row_count_5_day_period(self):
        """A 5-day period produces exactly 7 rows (5 'in' + 1 'starts' + 1 'ends')."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))
        assert len(artip.make_calendar_table().collect()) == 7

    def test_row_count_1_day_period(self):
        """A 1-day period produces exactly 3 rows (1 'in' + 1 'starts' + 1 'ends')."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        artip.make_period_id(date(2024, 1, 3), date(2024, 1, 3))
        assert len(artip.make_calendar_table().collect()) == 3

    def test_one_day_period_has_all_three_labels_on_same_date(self):
        """A one-day period must contain labels starts/ends/in on the same date."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid = artip.make_period_id(date(2024, 1, 3), date(2024, 1, 3))
        cal = artip.make_calendar_table().filter(pl.col("period_id") == pid.value).collect()

        assert len(cal) == 3
        assert set(cal["label"].to_list()) == {"starts", "ends", "in"}
        assert set(cal["date"].to_list()) == {datetime(2024, 1, 3)}

    def test_start_date_has_starts_label(self):
        """The period start date must appear exactly once with label 'starts'."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))
        cal = artip.make_calendar_table().collect()
        starts_rows = cal.filter(pl.col("label") == "starts")
        assert len(starts_rows) == 1
        assert starts_rows["date"][0] == datetime(2024, 1, 1)

    def test_end_date_has_ends_label(self):
        """The period end date must appear exactly once with label 'ends'."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))
        cal = artip.make_calendar_table().collect()
        ends_rows = cal.filter(pl.col("label") == "ends")
        assert len(ends_rows) == 1
        assert ends_rows["date"][0] == datetime(2024, 1, 5)

    def test_all_dates_including_boundaries_appear_as_in(self):
        """Every date from start to end inclusive must appear with label 'in'."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))
        cal = artip.make_calendar_table().collect()
        in_dates = set(
            cal.filter(pl.col("label") == "in")["date"].to_list()
        )
        expected = {datetime(2024, 1, d) for d in range(1, 6)}
        assert in_dates == expected

    def test_period_id_column_matches_generated_pid(self):
        """All rows must carry the period_id value returned by make_period_id."""
        artip = Artip(date(2024, 1, 1), date(2024, 1, 10), granularity="day")
        pid = artip.make_period_id(date(2024, 1, 1), date(2024, 1, 5))  # 104
        cal = artip.make_calendar_table().collect()
        assert set(cal["period_id"].to_list()) == {pid.value}
        assert pid.value == 104

    def test_first_middle_last_inserted_periods_have_expected_rows(self):
        """Validate first, intermediate and last inserted periods in the calendar table."""
        artip, first_pid, middle_pid, last_pid = self._build_three_periods_artip()
        cal = artip.make_calendar_table().collect()

        first_rows = cal.filter(pl.col("period_id") == first_pid)
        middle_rows = cal.filter(pl.col("period_id") == middle_pid)
        last_rows = cal.filter(pl.col("period_id") == last_pid)

        # first period: Jan1..Jan3 => 3 "in" + starts + ends = 5 rows
        assert len(first_rows) == 5
        assert first_rows.filter(pl.col("label") == "starts")["date"][0] == datetime(2024, 1, 1)
        assert first_rows.filter(pl.col("label") == "ends")["date"][0] == datetime(2024, 1, 3)

        # intermediate period: Jan4..Jan5 => 2 "in" + starts + ends = 4 rows
        assert len(middle_rows) == 4
        assert middle_rows.filter(pl.col("label") == "starts")["date"][0] == datetime(2024, 1, 4)
        assert middle_rows.filter(pl.col("label") == "ends")["date"][0] == datetime(2024, 1, 5)

        # last period: Jan6..Jan10 => 5 "in" + starts + ends = 7 rows
        assert len(last_rows) == 7
        assert last_rows.filter(pl.col("label") == "starts")["date"][0] == datetime(2024, 1, 6)
        assert last_rows.filter(pl.col("label") == "ends")["date"][0] == datetime(2024, 1, 10)


class TestGranularities:
    """Tests for different granularities — encoding values verified."""

    @pytest.mark.parametrize("granularity", ["year", "month", "day", "hour", "minute", "second"])
    def test_all_granularities_are_accepted(self, granularity: str):
        """All valid granularities must initialize without error."""
        artip = Artip(
            min_time=date(2024, 1, 1),
            max_time=date(2025, 12, 31),
            granularity=granularity,
        )
        assert artip.granularity == granularity

    def test_hour_granularity_encoded_value(self):
        """
        min=Jan1 00:00, max=Jan1 23:00 → total_units=24, period_size=2.
        Jan1 08:00..Jan1 12:00: start_id=9, length=4 → 9*100+4 = 904.
        """
        artip = Artip(
            min_time=datetime(2024, 1, 1, 0, 0, 0),
            max_time=datetime(2024, 1, 1, 23, 0, 0),
            granularity="hour",
        )
        pid = artip.make_period_id(
            datetime(2024, 1, 1, 8, 0, 0),
            datetime(2024, 1, 1, 12, 0, 0),
        )
        assert pid.value == 904

    def test_month_granularity_encoded_value(self):
        """
        min=Jan2024, max=Dec2024 → total_units=12, period_size=2.
        Jan..Mar: start_id=1, length=2 → 1*100+2 = 102.
        """
        artip = Artip(
            min_time=date(2024, 1, 1),
            max_time=date(2024, 12, 1),
            granularity="month",
        )
        pid = artip.make_period_id(date(2024, 1, 1), date(2024, 3, 1))
        assert pid.value == 102
