"""Tests for configabc.TimeWindow and TimeWindowSequence.

Timezone contract under test:

* ``start_time`` is always **naive** (no ``tzinfo``).
* ``date`` is inherently timezone-free (a calendar date).
* ``date_time`` / ``reference_date`` passed to ``contains()``,
  ``earliest_start_time()``, and ``latest_start_time()`` may be
  timezone-aware or naive.
* When a timezone-aware datetime is supplied, ``start_time`` is
  interpreted as wall-clock time **in that timezone** — no tz
  conversion is applied to ``start_time`` itself.
* Constructing a ``TimeWindow`` with a naive ``start_time`` raises
  ``ValidationError``.
"""

import datetime
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from typing import cast

import numpy as np
import pandas as pd
import pendulum
import pytest
from pydantic import ValidationError

from akkudoktoreos.config.configabc import TimeWindow
from akkudoktoreos.config.configabc import TimeWindow as _TW_check
from akkudoktoreos.config.configabc import (  # noqa — ensure Time is importable
    TimeWindowSequence,
    ValueTimeWindow,
    ValueTimeWindowSequence,
)
from akkudoktoreos.utils.datetimeutil import Time

# ===========================================================================
# Helpers
# ===========================================================================

def naive_dt(year, month, day, hour=0, minute=0, second=0):
    """Return a truly naive DateTime (no timezone)."""
    return pendulum.instance(
        datetime.datetime(year, month, day, hour, minute, second)
    ).naive()


def aware_dt(year, month, day, hour=0, minute=0, second=0, tz="Europe/Berlin"):
    """Return a timezone-aware pendulum DateTime."""
    return pendulum.datetime(year, month, day, hour, minute, second, tz=tz)


def make_window(start_h, duration_h, **kwargs):
    """Build a TimeWindow with a naive start_time at ``start_h:00``."""
    return TimeWindow(
        start_time=f"{start_h:02d}:00:00",
        duration=f"{duration_h} hours",
        **kwargs,
    )


# ===========================================================================
# Construction / validation
# ===========================================================================

class TestTimeWindowConstruction:
    def test_naive_start_time_accepted(self):
        w = make_window(8, 2)
        assert w.start_time.tzinfo is None

    def test_aware_start_time_stripped_to_naive(self):
        """An aware start_time is silently stripped to naive (to_time may add a tz)."""
        w = TimeWindow(
            start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("Europe/Berlin")),
            duration="2 hours",
        )
        assert w.start_time.tzinfo is None
        assert w.start_time.hour == 8

    def test_duration_string_parsed(self):
        w = make_window(8, 3)
        assert w.duration.total_seconds() == 3 * 3600

    def test_day_of_week_integer_valid(self):
        w = make_window(8, 2, day_of_week=0)
        assert w.day_of_week == 0

    def test_day_of_week_integer_out_of_range(self):
        with pytest.raises(ValidationError):
            make_window(8, 2, day_of_week=7)

    def test_day_of_week_english_string(self):
        w = make_window(8, 2, day_of_week="Monday")
        assert w.day_of_week == 0

    def test_day_of_week_english_string_case_insensitive(self):
        w = make_window(8, 2, day_of_week="friday")
        assert w.day_of_week == 4

    def test_day_of_week_invalid_string(self):
        with pytest.raises(ValidationError, match="Invalid weekday"):
            make_window(8, 2, day_of_week="notaday")

    def test_day_of_week_localized_german(self):
        w = make_window(8, 2, day_of_week="Montag", locale="de")
        assert w.day_of_week == 0


# ===========================================================================
# _window_start_end
# ===========================================================================

class TestWindowStartEnd:
    def test_naive_reference_date(self):
        w = make_window(8, 2)
        ref = naive_dt(2024, 6, 15, 10, 0, 0)
        start, end = w._window_start_end(ref)
        assert start.hour == 8
        assert start.minute == 0
        assert end.hour == 10
        assert end.minute == 0
        assert start.timezone is None

    def test_aware_reference_date_berlin(self):
        w = make_window(8, 2)
        ref = aware_dt(2024, 6, 15, 10, 0, 0, tz="Europe/Berlin")
        start, end = w._window_start_end(ref)
        assert start.hour == 8
        assert end.hour == 10
        assert str(start.timezone) == "Europe/Berlin"

    def test_aware_reference_date_utc(self):
        w = make_window(6, 4)
        ref = aware_dt(2024, 1, 10, 9, 0, 0, tz="UTC")
        start, end = w._window_start_end(ref)
        assert start.hour == 6
        assert end.hour == 10
        assert str(start.timezone) == "UTC"

    def test_aware_reference_date_eastern(self):
        w = make_window(20, 4)
        ref = aware_dt(2024, 6, 15, 21, 0, 0, tz="US/Eastern")
        start, end = w._window_start_end(ref)
        assert start.hour == 20
        assert end.hour == 0   # midnight next day
        assert str(start.timezone) == "US/Eastern"


# ===========================================================================
# contains() — naive datetime
# ===========================================================================

class TestContainsNaive:
    def setup_method(self, method):
        self.w = make_window(8, 2)

    def test_inside_window(self):
        assert self.w.contains(naive_dt(2024, 6, 15, 9, 0, 0))

    def test_at_start(self):
        assert self.w.contains(naive_dt(2024, 6, 15, 8, 0, 0))

    def test_at_end_exclusive(self):
        assert not self.w.contains(naive_dt(2024, 6, 15, 10, 0, 0))

    def test_before_window(self):
        assert not self.w.contains(naive_dt(2024, 6, 15, 7, 59, 59))

    def test_after_window(self):
        assert not self.w.contains(naive_dt(2024, 6, 15, 10, 0, 1))

    def test_with_fitting_duration(self):
        dt = naive_dt(2024, 6, 15, 8, 0, 0)
        assert self.w.contains(dt, duration=pendulum.duration(hours=2))

    def test_with_duration_too_long(self):
        dt = naive_dt(2024, 6, 15, 8, 0, 0)
        assert not self.w.contains(dt, duration=pendulum.duration(hours=3))

    def test_with_duration_starting_late(self):
        dt = naive_dt(2024, 6, 15, 9, 30, 0)
        assert not self.w.contains(dt, duration=pendulum.duration(hours=1))

    def test_with_duration_exactly_fitting_late(self):
        dt = naive_dt(2024, 6, 15, 9, 0, 0)
        assert self.w.contains(dt, duration=pendulum.duration(hours=1))


# ===========================================================================
# contains() — aware datetime
# ===========================================================================

class TestContainsAware:
    def setup_method(self, method):
        self.w = make_window(8, 2)

    def test_inside_window_berlin(self):
        dt = aware_dt(2024, 6, 15, 9, 0, 0, tz="Europe/Berlin")
        assert self.w.contains(dt)

    def test_before_window_berlin(self):
        dt = aware_dt(2024, 6, 15, 7, 30, 0, tz="Europe/Berlin")
        assert not self.w.contains(dt)

    def test_after_window_berlin(self):
        dt = aware_dt(2024, 6, 15, 10, 30, 0, tz="Europe/Berlin")
        assert not self.w.contains(dt)

    def test_start_time_is_local_not_utc(self):
        # 06:00 UTC is before the 08:00 UTC window → outside
        dt_utc = aware_dt(2024, 6, 15, 6, 0, 0, tz="UTC")
        assert not self.w.contains(dt_utc)

        # 08:30 UTC is inside the 08:00–10:00 UTC window
        dt_utc_inside = aware_dt(2024, 6, 15, 8, 30, 0, tz="UTC")
        assert self.w.contains(dt_utc_inside)

    def test_crossing_midnight(self):
        w = make_window(23, 2)
        dt_inside = aware_dt(2024, 6, 15, 23, 30, 0, tz="Europe/Berlin")
        dt_outside = aware_dt(2024, 6, 15, 22, 59, 0, tz="Europe/Berlin")
        assert w.contains(dt_inside)
        assert not w.contains(dt_outside)

    def test_same_wall_clock_different_tz(self):
        """Naive start_time means 12:00 wall clock in *whatever* tz is passed."""
        w = make_window(12, 2)
        dt_berlin = aware_dt(2024, 6, 15, 13, 0, 0, tz="Europe/Berlin")
        dt_ny = aware_dt(2024, 6, 15, 13, 0, 0, tz="US/Eastern")
        assert w.contains(dt_berlin)
        assert w.contains(dt_ny)


# ===========================================================================
# contains() — day_of_week and date constraints
# ===========================================================================

class TestContainsConstraints:
    def test_day_of_week_match_naive(self):
        # 2024-06-17 is a Monday (day_of_week == 0)
        w = make_window(8, 4, day_of_week=0)
        assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))

    def test_day_of_week_no_match_naive(self):
        w = make_window(8, 4, day_of_week=0)
        # 2024-06-18 is a Tuesday
        assert not w.contains(naive_dt(2024, 6, 18, 9, 0, 0))

    def test_day_of_week_match_aware(self):
        w = make_window(8, 4, day_of_week=0)
        dt = aware_dt(2024, 6, 17, 9, 0, 0, tz="Europe/Berlin")
        assert w.contains(dt)

    def test_date_constraint_match(self):
        w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
        assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))

    def test_date_constraint_no_match(self):
        w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
        assert not w.contains(naive_dt(2024, 6, 18, 9, 0, 0))

    def test_date_constraint_aware_datetime(self):
        w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
        dt = aware_dt(2024, 6, 17, 9, 0, 0, tz="US/Eastern")
        assert w.contains(dt)

    def test_date_and_day_of_week_both_must_hold(self):
        # 2024-06-18 is Tuesday; day_of_week=0 (Monday) → False even on matching date
        w = make_window(8, 4, date=pendulum.date(2024, 6, 18), day_of_week=0)
        assert not w.contains(naive_dt(2024, 6, 18, 9, 0, 0))


# ===========================================================================
# earliest_start_time / latest_start_time
# ===========================================================================

class TestStartTimes:
    def setup_method(self, method):
        self.w = make_window(8, 4)  # 08:00–12:00

    def test_earliest_naive(self):
        ref = naive_dt(2024, 6, 15)
        result = self.w.earliest_start_time(pendulum.duration(hours=2), reference_date=ref)
        assert result is not None
        assert result.hour == 8

    def test_latest_naive(self):
        ref = naive_dt(2024, 6, 15)
        result = self.w.latest_start_time(pendulum.duration(hours=2), reference_date=ref)
        assert result is not None
        assert result.hour == 10  # 12:00 - 2h = 10:00

    def test_earliest_aware_berlin(self):
        ref = aware_dt(2024, 6, 15, tz="Europe/Berlin")
        result = self.w.earliest_start_time(pendulum.duration(hours=1), reference_date=ref)
        assert result is not None
        assert result.hour == 8
        assert str(result.timezone) == "Europe/Berlin"

    def test_latest_aware_berlin(self):
        ref = aware_dt(2024, 6, 15, tz="Europe/Berlin")
        result = self.w.latest_start_time(pendulum.duration(hours=1), reference_date=ref)
        assert result is not None
        assert result.hour == 11
        assert str(result.timezone) == "Europe/Berlin"

    def test_duration_too_long_returns_none(self):
        ref = naive_dt(2024, 6, 15)
        result = self.w.earliest_start_time(pendulum.duration(hours=5), reference_date=ref)
        assert result is None

    def test_wrong_day_of_week_returns_none(self):
        w = make_window(8, 4, day_of_week=0)  # Monday only
        ref = naive_dt(2024, 6, 18)  # Tuesday
        assert w.earliest_start_time(pendulum.duration(hours=1), reference_date=ref) is None

    def test_wrong_date_returns_none(self):
        w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
        ref = naive_dt(2024, 6, 18)
        assert w.earliest_start_time(pendulum.duration(hours=1), reference_date=ref) is None

    def test_earliest_aware_utc(self):
        ref = aware_dt(2024, 6, 15, tz="UTC")
        result = self.w.earliest_start_time(pendulum.duration(hours=2), reference_date=ref)
        assert result is not None
        assert result.hour == 8
        assert str(result.timezone) == "UTC"

    def test_latest_equals_window_end_minus_duration(self):
        ref = naive_dt(2024, 6, 15)
        result = self.w.latest_start_time(pendulum.duration(hours=4), reference_date=ref)
        assert result is not None
        assert result.hour == 8  # exactly at window start when duration == window size

    def test_latest_duration_leaves_no_room(self):
        # duration > window → None
        ref = naive_dt(2024, 6, 15)
        result = self.w.latest_start_time(pendulum.duration(hours=5), reference_date=ref)
        assert result is None


# ===========================================================================
# can_fit_duration / available_duration
# ===========================================================================

class TestFitAndAvailable:
    def setup_method(self, method):
        self.w = make_window(8, 3)  # 08:00–11:00

    def test_can_fit_exact(self):
        assert self.w.can_fit_duration(pendulum.duration(hours=3), naive_dt(2024, 6, 15))

    def test_can_fit_shorter(self):
        assert self.w.can_fit_duration(pendulum.duration(hours=1), naive_dt(2024, 6, 15))

    def test_cannot_fit_longer(self):
        assert not self.w.can_fit_duration(pendulum.duration(hours=4), naive_dt(2024, 6, 15))

    def test_available_duration_no_constraint(self):
        result = self.w.available_duration(naive_dt(2024, 6, 15))
        assert result == pendulum.duration(hours=3)

    def test_available_duration_wrong_date(self):
        w = make_window(8, 3, date=pendulum.date(2024, 6, 17))
        result = w.available_duration(naive_dt(2024, 6, 15))
        assert result is None


# ===========================================================================
# TimeWindowSequence
# ===========================================================================

class TestTimeWindowSequence:
    def setup_method(self, method):
        self.seq = TimeWindowSequence(
            windows=[
                make_window(8, 2),   # 08:00–10:00
                make_window(14, 3),  # 14:00–17:00
            ]
        )

    def test_contains_first_window(self):
        assert self.seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))

    def test_contains_second_window(self):
        assert self.seq.contains(naive_dt(2024, 6, 15, 15, 0, 0))

    def test_contains_gap_between_windows(self):
        assert not self.seq.contains(naive_dt(2024, 6, 15, 12, 0, 0))

    def test_contains_with_duration_fits_second(self):
        dt = naive_dt(2024, 6, 15, 14, 0, 0)
        assert self.seq.contains(dt, duration=pendulum.duration(hours=2))

    def test_contains_aware(self):
        dt = aware_dt(2024, 6, 15, 9, 0, 0, tz="Europe/Berlin")
        assert self.seq.contains(dt)

    def test_earliest_start_time(self):
        ref = naive_dt(2024, 6, 15)
        result = self.seq.earliest_start_time(pendulum.duration(hours=1), ref)
        assert result is not None
        assert result.hour == 8

    def test_latest_start_time(self):
        ref = naive_dt(2024, 6, 15)
        result = self.seq.latest_start_time(pendulum.duration(hours=1), ref)
        assert result is not None
        assert result.hour == 16  # 17:00 - 1h

    def test_available_duration_sum(self):
        ref = naive_dt(2024, 6, 15)
        result = self.seq.available_duration(ref)
        assert result == pendulum.duration(hours=5)

    def test_empty_sequence_contains_false(self):
        seq = TimeWindowSequence()
        assert not seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))

    def test_empty_sequence_earliest_none(self):
        seq = TimeWindowSequence()
        assert seq.earliest_start_time(pendulum.duration(hours=1), naive_dt(2024, 6, 15)) is None

    def test_empty_sequence_available_none(self):
        seq = TimeWindowSequence()
        assert seq.available_duration(naive_dt(2024, 6, 15)) is None

    def test_get_applicable_windows(self):
        ref = naive_dt(2024, 6, 15)
        applicable = self.seq.get_applicable_windows(ref)
        assert len(applicable) == 2

    def test_find_windows_for_duration_fits_both(self):
        ref = naive_dt(2024, 6, 15)
        fits = self.seq.find_windows_for_duration(pendulum.duration(hours=1), ref)
        assert len(fits) == 2

    def test_find_windows_for_duration_fits_only_second(self):
        ref = naive_dt(2024, 6, 15)
        fits = self.seq.find_windows_for_duration(pendulum.duration(hours=3), ref)
        assert len(fits) == 1
        assert fits[0].start_time.hour == 14

    def test_sort_windows_by_start_time(self):
        seq = TimeWindowSequence(
            windows=[make_window(14, 1), make_window(8, 1)]
        )
        ref = naive_dt(2024, 6, 15)
        seq.sort_windows_by_start_time(ref)
        assert seq.windows[0].start_time.hour == 8
        assert seq.windows[1].start_time.hour == 14

    def test_add_and_remove_window(self):
        seq = TimeWindowSequence()
        w = make_window(10, 1)
        seq.add_window(w)
        assert len(seq) == 1
        removed = seq.remove_window(0)
        assert removed == w
        assert len(seq) == 0

    def test_remove_from_empty_raises(self):
        seq = TimeWindowSequence()
        with pytest.raises(IndexError):
            seq.remove_window(0)

    def test_get_all_possible_start_times(self):
        ref = naive_dt(2024, 6, 15)
        result = self.seq.get_all_possible_start_times(pendulum.duration(hours=1), ref)
        assert len(result) == 2
        earliest_hours = sorted(e.hour for e, _, _ in result)
        assert earliest_hours == [8, 14]

    def test_iter_and_len_and_getitem(self):
        assert len(self.seq) == 2
        windows = list(self.seq)
        assert len(windows) == 2
        assert self.seq[0].start_time.hour == 8


# ===========================================================================
# ValueTimeWindow / ValueTimeWindowSequence
# ===========================================================================

class TestValueTimeWindow:
    def test_value_stored(self):
        w = ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.288)
        assert w.value == pytest.approx(0.288)

    def test_value_default_none(self):
        w = ValueTimeWindow(start_time="08:00:00", duration="2 hours")
        assert w.value is None

    def test_inherits_aware_start_time_stripped(self):
        """ValueTimeWindow inherits the strip-to-naive behaviour from TimeWindow."""
        w = ValueTimeWindow(
            start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("UTC")),
            duration="2 hours",
            value=0.1,
        )
        assert w.start_time.tzinfo is None
        assert w.start_time.hour == 8


class TestValueTimeWindowSequence:
    def setup_method(self, method):
        self.seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
                ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
            ]
        )

    def test_get_value_morning(self):
        dt = naive_dt(2024, 6, 15, 9, 0, 0)
        assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.25)

    def test_get_value_evening(self):
        dt = naive_dt(2024, 6, 15, 19, 0, 0)
        assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.35)

    def test_get_value_outside_all_windows(self):
        dt = naive_dt(2024, 6, 15, 13, 0, 0)
        assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.0)

    def test_get_value_none_value_returns_zero(self):
        seq = ValueTimeWindowSequence(
            windows=[ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=None)]
        )
        assert seq.get_value_for_datetime(naive_dt(2024, 6, 15, 9, 0, 0)) == pytest.approx(0.0)

    def test_get_value_aware_datetime(self):
        dt = aware_dt(2024, 6, 15, 9, 0, 0, tz="Europe/Berlin")
        assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.25)


# ===========================================================================
# TimeWindowSequence.to_array
# ===========================================================================

class TestTimeWindowSequenceToArray:
    """Tests for TimeWindowSequence.to_array.

    Window layout used throughout:
        win1: 08:00–10:00  (2 h)
        win2: 14:00–17:00  (3 h)

    Grid step = 1 hour unless stated otherwise.
    """

    def setup_method(self, method):
        self.seq = TimeWindowSequence(
            windows=[
                make_window(8, 2),   # 08:00–10:00
                make_window(14, 3),  # 14:00–17:00
            ]
        )

    # ------------------------------------------------------------------
    # basic correctness
    # ------------------------------------------------------------------

    def test_basic_1h_steps_naive(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 0).add(hours=24)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (24,)
        # Window 1: hours 8, 9
        assert arr[8] == pytest.approx(1.0)
        assert arr[9] == pytest.approx(1.0)
        assert arr[10] == pytest.approx(0.0)
        # Window 2: hours 14, 15, 16
        assert arr[14] == pytest.approx(1.0)
        assert arr[15] == pytest.approx(1.0)
        assert arr[16] == pytest.approx(1.0)
        assert arr[17] == pytest.approx(0.0)
        # Gap between windows
        assert arr[12] == pytest.approx(0.0)

    def test_basic_1h_steps_aware_berlin(self):
        start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
        end   = aware_dt(2024, 6, 16, 0, tz="Europe/Berlin")
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (24,)
        assert arr[8] == pytest.approx(1.0)
        assert arr[9] == pytest.approx(1.0)
        assert arr[10] == pytest.approx(0.0)
        assert arr[14] == pytest.approx(1.0)
        assert arr[16] == pytest.approx(1.0)
        assert arr[17] == pytest.approx(0.0)

    def test_outside_all_windows_all_zeros(self):
        # Only 2 hours at midnight — no overlap with any window
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 2)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert np.all(arr == 0.0)

    def test_inside_one_window_all_ones(self):
        # Entirely inside window 1 (08:00–10:00)
        start = naive_dt(2024, 6, 15, 8)
        end   = naive_dt(2024, 6, 15, 10)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert np.all(arr == 1.0)

    def test_dtype_is_float64(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.dtype == np.float64

    def test_end_is_exclusive(self):
        # end == window start → 0 steps inside
        start = naive_dt(2024, 6, 15, 6)
        end   = naive_dt(2024, 6, 15, 8)   # exclusive — 08:00 itself not emitted
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (2,)
        assert np.all(arr == 0.0)

    # ------------------------------------------------------------------
    # sub-hour steps
    # ------------------------------------------------------------------

    def test_30min_steps(self):
        start = naive_dt(2024, 6, 15, 8)
        end   = naive_dt(2024, 6, 15, 10)
        arr = self.seq.to_array(start, end, pendulum.duration(minutes=30))
        # Steps: 08:00, 08:30 → both inside [08:00, 10:00)
        assert arr.shape == (4,)
        assert np.all(arr == 1.0)

    def test_15min_steps_boundary(self):
        # Steps at 09:45, 10:00, 10:15; only 09:45 inside window
        start = naive_dt(2024, 6, 15, 9, 45)
        end   = naive_dt(2024, 6, 15, 10, 30)
        arr = self.seq.to_array(start, end, pendulum.duration(minutes=15))
        # align_to_interval=True floors to interval boundary
        # interval=15 min; 09:45 is already on a 15-min boundary
        assert arr[0] == pytest.approx(1.0)   # 09:45 inside win1
        assert arr[1] == pytest.approx(0.0)   # 10:00 outside (exclusive end)

    # ------------------------------------------------------------------
    # align_to_interval
    # ------------------------------------------------------------------

    def test_align_to_interval_false_preserves_start(self):
        # Start at 08:10 — not on a whole-hour boundary
        start = naive_dt(2024, 6, 15, 8, 10)
        end   = naive_dt(2024, 6, 15, 10, 10)
        arr = self.seq.to_array(
            start, end, pendulum.duration(hours=1), align_to_interval=False
        )
        # Steps: 08:10 (inside win1), 09:10 (inside win1), 10:10 (outside)
        assert arr.shape == (2,)
        assert arr[0] == pytest.approx(1.0)
        assert arr[1] == pytest.approx(1.0)

    def test_align_to_interval_true_floors_start(self):
        # Start at 08:10; floored to 08:00 with 1h interval
        start = naive_dt(2024, 6, 15, 8, 10)
        end   = naive_dt(2024, 6, 15, 10, 10)
        arr = self.seq.to_array(
            start, end, pendulum.duration(hours=1), align_to_interval=True
        )
        # After flooring: steps 08:00, 09:00, 10:00 → 3 steps
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(1.0)   # 08:00
        assert arr[1] == pytest.approx(1.0)   # 09:00
        assert arr[2] == pytest.approx(0.0)   # 10:00

    # ------------------------------------------------------------------
    # boundary validation
    # ------------------------------------------------------------------

    def test_unsupported_boundary_raises(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        with pytest.raises(ValueError, match="boundary"):
            self.seq.to_array(start, end, pendulum.duration(hours=1), boundary="strict")

    # ------------------------------------------------------------------
    # dropna (no effect for binary windows — accepted for compat)
    # ------------------------------------------------------------------

    def test_dropna_true_no_effect(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        arr_t = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=True)
        arr_f = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=False)
        np.testing.assert_array_equal(arr_t, arr_f)

    # ------------------------------------------------------------------
    # empty sequence
    # ------------------------------------------------------------------

    def test_empty_sequence_all_zeros(self):
        seq = TimeWindowSequence()
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        arr = seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (4,)
        assert np.all(arr == 0.0)

    # ------------------------------------------------------------------
    # day_of_week and date constraints propagate
    # ------------------------------------------------------------------

    def test_day_of_week_constraint_respected(self):
        # Monday-only window; 2024-06-17 is Monday, 2024-06-18 is Tuesday
        seq = TimeWindowSequence(windows=[make_window(8, 2, day_of_week=0)])
        monday_start = naive_dt(2024, 6, 17, 7)
        tuesday_start = naive_dt(2024, 6, 18, 7)
        end_offset = pendulum.duration(hours=4)

        arr_mon = seq.to_array(monday_start, monday_start.add(hours=4),
                               pendulum.duration(hours=1))
        arr_tue = seq.to_array(tuesday_start, tuesday_start.add(hours=4),
                               pendulum.duration(hours=1))

        assert arr_mon[1] == pytest.approx(1.0)   # 08:00 Monday — inside
        assert np.all(arr_tue == 0.0)              # Tuesday — all outside


# ===========================================================================
# TimeWindowSequence.to_series
# ===========================================================================

class TestTimeWindowSequenceToSeries:
    """Tests for TimeWindowSequence.to_series.

    Window layout:
        win1: 08:00–10:00
        win2: 14:00–17:00
    """

    def setup_method(self, method):
        self.seq = TimeWindowSequence(
            windows=[
                make_window(8, 2),
                make_window(14, 3),
            ]
        )

    def test_basic_1h_steps_naive(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 16, 0)

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert isinstance(series, pd.Series)
        assert series.shape == (24,)
        assert isinstance(series.index, pd.DatetimeIndex)

        assert series.iloc[8] == pytest.approx(1.0)
        assert series.iloc[9] == pytest.approx(1.0)
        assert series.iloc[10] == pytest.approx(0.0)

        assert series.iloc[14] == pytest.approx(1.0)
        assert series.iloc[15] == pytest.approx(1.0)
        assert series.iloc[16] == pytest.approx(1.0)
        assert series.iloc[17] == pytest.approx(0.0)

    def test_values_match_to_array(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 16, 0)
        interval = pendulum.duration(hours=1)

        arr = self.seq.to_array(start, end, interval)
        series = self.seq.to_series(start, end, interval)

        np.testing.assert_array_equal(series.to_numpy(), arr)

    def test_dtype_is_float64(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 4)

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert series.dtype == np.float64

    def test_end_is_exclusive(self):
        start = naive_dt(2024, 6, 15, 6)
        end = naive_dt(2024, 6, 15, 8)

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (2,)
        assert list(index.hour) == [6, 7]
        assert np.all(series.to_numpy() == 0.0)

    def test_align_to_interval_false_preserves_start(self):
        start = naive_dt(2024, 6, 15, 8, 10)
        end = naive_dt(2024, 6, 15, 10, 10)

        series = self.seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            align_to_interval=False,
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (2,)
        assert index[0] == pd.Timestamp(start)
        assert index[1] == pd.Timestamp(start.add(hours=1))
        assert np.all(series.to_numpy() == 1.0)

    def test_align_to_interval_true_floors_start(self):
        start = naive_dt(2024, 6, 15, 8, 10)
        end = naive_dt(2024, 6, 15, 10, 10)

        series = self.seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            align_to_interval=True,
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (3,)
        assert list(index.hour) == [8, 9, 10]
        assert series.iloc[0] == pytest.approx(1.0)
        assert series.iloc[1] == pytest.approx(1.0)
        assert series.iloc[2] == pytest.approx(0.0)

    def test_aware_datetime_preserves_timezone(self):
        start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
        end = aware_dt(2024, 6, 15, 4, tz="Europe/Berlin")

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (4,)
        assert str(index.tz) == "Europe/Berlin"

    def test_unsupported_boundary_raises(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 4)

        with pytest.raises(ValueError, match="boundary"):
            self.seq.to_series(
                start,
                end,
                pendulum.duration(hours=1),
                boundary="strict",
            )

    def test_empty_sequence_all_zeros(self):
        seq = TimeWindowSequence()
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 4)

        series = seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert series.shape == (4,)
        assert np.all(series.to_numpy() == 0.0)


# ===========================================================================
# ValueTimeWindowSequence.to_array
# ===========================================================================

class TestValueTimeWindowSequenceToArray:
    """Tests for ValueTimeWindowSequence.to_array.

    Window layout:
        win1: 08:00–12:00  value=0.25
        win2: 18:00–22:00  value=0.35
    """

    def setup_method(self, method):
        self.seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
                ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
            ]
        )

    # ------------------------------------------------------------------
    # basic correctness
    # ------------------------------------------------------------------

    def test_basic_1h_steps_values(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 16, 0)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (24,)
        # win1: hours 8–11
        assert arr[8]  == pytest.approx(0.25)
        assert arr[11] == pytest.approx(0.25)
        assert arr[12] == pytest.approx(0.0)
        # win2: hours 18–21
        assert arr[18] == pytest.approx(0.35)
        assert arr[21] == pytest.approx(0.35)
        assert arr[22] == pytest.approx(0.0)
        # Gap
        assert arr[0]  == pytest.approx(0.0)
        assert arr[14] == pytest.approx(0.0)

    def test_dtype_is_float64(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.dtype == np.float64

    def test_zero_outside_all_windows(self):
        start = naive_dt(2024, 6, 15, 12)
        end   = naive_dt(2024, 6, 15, 18)
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert np.all(arr == 0.0)

    def test_aware_datetime_berlin(self):
        start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
        end   = aware_dt(2024, 6, 16, 0, tz="Europe/Berlin")
        arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (24,)
        assert arr[8]  == pytest.approx(0.25)
        assert arr[18] == pytest.approx(0.35)
        assert arr[7]  == pytest.approx(0.0)

    # ------------------------------------------------------------------
    # dropna semantics
    # ------------------------------------------------------------------

    def test_dropna_false_none_value_emits_nan(self):
        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
                ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
            ]
        )
        start = naive_dt(2024, 6, 15, 8)
        end   = naive_dt(2024, 6, 15, 15)
        arr = seq.to_array(start, end, pendulum.duration(hours=1), dropna=False)
        # Steps: 08, 09 (nan), 10 (0), 11 (0), 12 (0.5), 13 (0.5), 14 (0)
        assert arr.shape == (7,)
        assert np.isnan(arr[0])
        assert np.isnan(arr[1])
        assert arr[2] == pytest.approx(0.0)
        assert arr[4] == pytest.approx(0.5)

    def test_dropna_true_none_value_step_omitted(self):
        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
                ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
            ]
        )
        start = naive_dt(2024, 6, 15, 8)
        end   = naive_dt(2024, 6, 15, 15)
        arr = seq.to_array(start, end, pendulum.duration(hours=1), dropna=True)
        # 08 and 09 dropped (None value), remaining 5 steps: 10,11,12,13,14
        assert arr.shape == (5,)
        assert arr[0] == pytest.approx(0.0)  # 10:00
        assert arr[1] == pytest.approx(0.0)  # 11:00
        assert arr[2] == pytest.approx(0.5)  # 12:00
        assert arr[3] == pytest.approx(0.5)  # 13:00
        assert arr[4] == pytest.approx(0.0)  # 14:00

    def test_dropna_no_none_values_same_result(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 6)
        arr_t = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=True)
        arr_f = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=False)
        np.testing.assert_array_equal(arr_t, arr_f)

    # ------------------------------------------------------------------
    # align_to_interval and boundary
    # ------------------------------------------------------------------

    def test_align_to_interval_false(self):
        # Start at 08:30 — between steps
        start = naive_dt(2024, 6, 15, 8, 30)
        end   = naive_dt(2024, 6, 15, 12, 30)
        arr = self.seq.to_array(
            start, end, pendulum.duration(hours=1), align_to_interval=False
        )
        # Steps: 08:30, 09:30, 10:30, 11:30 → all inside win1 [08:00–12:00)
        assert arr.shape == (4,)
        assert np.all(arr == pytest.approx(0.25))

    def test_unsupported_boundary_raises(self):
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        with pytest.raises(ValueError, match="boundary"):
            self.seq.to_array(start, end, pendulum.duration(hours=1), boundary="inner")

    # ------------------------------------------------------------------
    # empty sequence
    # ------------------------------------------------------------------

    def test_empty_sequence_all_zeros(self):
        seq = ValueTimeWindowSequence()
        start = naive_dt(2024, 6, 15, 0)
        end   = naive_dt(2024, 6, 15, 4)
        arr = seq.to_array(start, end, pendulum.duration(hours=1))
        assert arr.shape == (4,)
        assert np.all(arr == 0.0)

    # ------------------------------------------------------------------
    # overlapping windows — first match wins
    # ------------------------------------------------------------------

    def test_overlapping_windows_first_wins(self):
        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.10),
                ValueTimeWindow(start_time="09:00:00", duration="4 hours", value=0.99),
            ]
        )
        start = naive_dt(2024, 6, 15, 9)
        end   = naive_dt(2024, 6, 15, 11)
        arr = seq.to_array(start, end, pendulum.duration(hours=1))
        # 09:00 and 10:00 are in both windows; first (0.10) must win
        assert arr[0] == pytest.approx(0.10)
        assert arr[1] == pytest.approx(0.10)


# ===========================================================================
# ValueTimeWindowSequence.to_series
# ===========================================================================

class TestValueTimeWindowSequenceToSeries:
    """Tests for ValueTimeWindowSequence.to_series."""

    def setup_method(self, method):
        self.seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(
                    start_time="08:00:00",
                    duration="4 hours",
                    value=0.25,
                ),
                ValueTimeWindow(
                    start_time="18:00:00",
                    duration="4 hours",
                    value=0.35,
                ),
            ]
        )

    def test_basic_1h_steps_values(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 16, 0)

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert isinstance(series, pd.Series)
        assert isinstance(series.index, pd.DatetimeIndex)
        assert series.shape == (24,)

        assert series.iloc[8] == pytest.approx(0.25)
        assert series.iloc[11] == pytest.approx(0.25)
        assert series.iloc[12] == pytest.approx(0.0)

        assert series.iloc[18] == pytest.approx(0.35)
        assert series.iloc[21] == pytest.approx(0.35)
        assert series.iloc[22] == pytest.approx(0.0)

    def test_values_match_to_array(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 16, 0)
        interval = pendulum.duration(hours=1)

        arr = self.seq.to_array(start, end, interval)
        series = self.seq.to_series(start, end, interval)

        np.testing.assert_array_equal(series.to_numpy(), arr)

    def test_dtype_is_float64(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 4)

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert series.dtype == np.float64

    def test_dropna_false_none_value_emits_nan(self):
        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(
                    start_time="08:00:00",
                    duration="2 hours",
                    value=None,
                ),
                ValueTimeWindow(
                    start_time="12:00:00",
                    duration="2 hours",
                    value=0.5,
                ),
            ]
        )

        start = naive_dt(2024, 6, 15, 8)
        end = naive_dt(2024, 6, 15, 15)

        series = seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            dropna=False,
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (7,)
        assert list(index.hour) == [8, 9, 10, 11, 12, 13, 14]

        assert np.isnan(series.iloc[0])
        assert np.isnan(series.iloc[1])
        assert series.iloc[2] == pytest.approx(0.0)
        assert series.iloc[3] == pytest.approx(0.0)
        assert series.iloc[4] == pytest.approx(0.5)
        assert series.iloc[5] == pytest.approx(0.5)
        assert series.iloc[6] == pytest.approx(0.0)

    def test_dropna_true_none_value_omits_timestamp(self):
        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(
                    start_time="08:00:00",
                    duration="2 hours",
                    value=None,
                ),
                ValueTimeWindow(
                    start_time="12:00:00",
                    duration="2 hours",
                    value=0.5,
                ),
            ]
        )

        start = naive_dt(2024, 6, 15, 8)
        end = naive_dt(2024, 6, 15, 15)

        series = seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            dropna=True,
        )
        index = cast(pd.DatetimeIndex, series.index)

        # 08:00 and 09:00 are omitted completely.
        assert series.shape == (5,)
        assert list(index.hour) == [10, 11, 12, 13, 14]

        np.testing.assert_allclose(
            series.to_numpy(),
            [0.0, 0.0, 0.5, 0.5, 0.0],
        )

    def test_dropna_no_none_values_same_result(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 6)
        interval = pendulum.duration(hours=1)

        series_true = self.seq.to_series(
            start, end, interval, dropna=True
        )
        series_false = self.seq.to_series(
            start, end, interval, dropna=False
        )

        pd.testing.assert_series_equal(series_true, series_false)

    def test_aware_datetime_preserves_timezone(self):
        start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
        end = aware_dt(2024, 6, 15, 4, tz="Europe/Berlin")

        series = self.seq.to_series(
            start, end, pendulum.duration(hours=1)
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (4,)
        assert str(index.tz) == "Europe/Berlin"

    def test_align_to_interval_true_floors_start(self):
        start = naive_dt(2024, 6, 15, 8, 10)
        end = naive_dt(2024, 6, 15, 10, 10)

        series = self.seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            align_to_interval=True,
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (3,)
        assert list(index.hour) == [8, 9, 10]
        assert series.iloc[0] == pytest.approx(0.25)
        assert series.iloc[1] == pytest.approx(0.25)
        assert series.iloc[2] == pytest.approx(0.25)

    def test_align_to_interval_false_preserves_start(self):
        start = naive_dt(2024, 6, 15, 8, 30)
        end = naive_dt(2024, 6, 15, 12, 30)

        series = self.seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            align_to_interval=False,
        )

        assert series.shape == (4,)
        assert series.index[0] == pd.Timestamp(start)
        assert series.iloc[0] == pytest.approx(0.25)
        assert np.all(series.to_numpy() == pytest.approx(0.25))

    def test_unsupported_boundary_raises(self):
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 4)

        with pytest.raises(ValueError, match="boundary"):
            self.seq.to_series(
                start,
                end,
                pendulum.duration(hours=1),
                boundary="inner",
            )

    def test_empty_sequence_all_zeros(self):
        seq = ValueTimeWindowSequence()
        start = naive_dt(2024, 6, 15, 0)
        end = naive_dt(2024, 6, 15, 4)

        series = seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert series.shape == (4,)
        assert np.all(series.to_numpy() == 0.0)

    def test_overlapping_windows_first_wins(self):
        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(
                    start_time="08:00:00",
                    duration="4 hours",
                    value=0.10,
                ),
                ValueTimeWindow(
                    start_time="09:00:00",
                    duration="4 hours",
                    value=0.99,
                ),
            ]
        )

        start = naive_dt(2024, 6, 15, 9)
        end = naive_dt(2024, 6, 15, 11)

        series = seq.to_series(
            start, end, pendulum.duration(hours=1)
        )

        assert series.iloc[0] == pytest.approx(0.10)
        assert series.iloc[1] == pytest.approx(0.10)


# ===========================================================================
# align_to_interval — timezone-invariance
#
# These tests reproduce the bug that existed before the wall-clock floor fix.
# The old epoch-arithmetic implementation gave wrong results when the machine's
# local timezone was non-UTC:
#   - For naive datetimes, pendulum.instance() attached the *local* timezone,
#     so subtracting a UTC epoch shifted the floored start.
#   - For aware datetimes, subtracting a UTC epoch converted to UTC first,
#     then epoch.add() returned a UTC datetime instead of preserving the
#     original timezone.
#
# The `set_other_timezone` fixture (from conftest.py) temporarily changes
# pendulum's local timezone via pendulum.set_local_timezone() and restores it
# after the test. Calling it with no argument picks a non-UTC default
# ("Atlantic/Canary" or "Asia/Singapore"); calling it with "UTC" sets UTC.
#
# Each scenario runs two tests:
#   _utc   — local tz = UTC  (passes even with the old code)
#   _nonUTC — local tz = non-UTC  (would have FAILED with the old code)
# ===========================================================================


class TestAlignToIntervalTimezoneInvariance:
    """Verify align_to_interval produces identical results regardless of
    the machine's local timezone.

    Tests are paired: ``_utc`` sets local tz to UTC, ``_non_utc`` sets it to
    a non-UTC zone via ``set_other_timezone()``.  The pair must produce the
    same array — any divergence indicates a timezone-dependent bug.
    """

    # ------------------------------------------------------------------
    # helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _tws_naive():
        """TimeWindowSequence with one window 08:00–10:00."""
        return TimeWindowSequence(windows=[make_window(8, 2)])

    @staticmethod
    def _tws_naive_start():
        return naive_dt(2024, 6, 15, 8, 10)

    @staticmethod
    def _tws_naive_end():
        return naive_dt(2024, 6, 15, 10, 10)

    # ------------------------------------------------------------------
    # TimeWindowSequence — naive datetime, 1-hour steps
    # floor 08:10 → 08:00; expect steps 08:00(1), 09:00(1), 10:00(0)
    # ------------------------------------------------------------------

    def test_tws_naive_floor_utc(self, set_other_timezone):
        set_other_timezone("UTC")
        arr = self._tws_naive().to_array(
            self._tws_naive_start(), self._tws_naive_end(),
            pendulum.duration(hours=1), align_to_interval=True,
        )
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(1.0)
        assert arr[1] == pytest.approx(1.0)
        assert arr[2] == pytest.approx(0.0)

    def test_tws_naive_floor_non_utc(self, set_other_timezone):
        set_other_timezone()
        arr = self._tws_naive().to_array(
            self._tws_naive_start(), self._tws_naive_end(),
            pendulum.duration(hours=1), align_to_interval=True,
        )
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(1.0)
        assert arr[1] == pytest.approx(1.0)
        assert arr[2] == pytest.approx(0.0)

    def test_tws_series_naive_floor_non_utc(self, set_other_timezone):
        set_other_timezone()

        series = self._tws_naive().to_series(
            self._tws_naive_start(),
            self._tws_naive_end(),
            pendulum.duration(hours=1),
            align_to_interval=True,
        )

        assert series.shape == (3,)
        assert list(series.index.hour) == [8, 9, 10]
        assert series.iloc[0] == pytest.approx(1.0)
        assert series.iloc[1] == pytest.approx(1.0)
        assert series.iloc[2] == pytest.approx(0.0)

    # ------------------------------------------------------------------
    # TimeWindowSequence — naive datetime, 30-min steps
    # floor 08:10 → 08:00; expect steps 08:00(1), 08:30(1), 09:00(1), 09:30(1), 10:00(0)
    # ------------------------------------------------------------------

    def test_tws_naive_30min_floor_utc(self, set_other_timezone):
        set_other_timezone("UTC")
        arr = self._tws_naive().to_array(
            self._tws_naive_start(), self._tws_naive_end(),
            pendulum.duration(minutes=30), align_to_interval=True,
        )
        assert arr.shape == (5,)
        assert np.all(arr[:4] == pytest.approx(1.0))
        assert arr[4] == pytest.approx(0.0)

    def test_tws_naive_30min_floor_non_utc(self, set_other_timezone):
        set_other_timezone()
        arr = self._tws_naive().to_array(
            self._tws_naive_start(), self._tws_naive_end(),
            pendulum.duration(minutes=30), align_to_interval=True,
        )
        assert arr.shape == (5,)
        assert np.all(arr[:4] == pytest.approx(1.0))
        assert arr[4] == pytest.approx(0.0)

    # ------------------------------------------------------------------
    # TimeWindowSequence — aware datetime (Europe/Berlin), 1-hour steps
    # floor 08:10 Berlin → 08:00 Berlin; timezone must be preserved
    # ------------------------------------------------------------------

    def test_tws_aware_floor_utc(self, set_other_timezone):
        set_other_timezone("UTC")
        seq = self._tws_naive()
        start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
        end   = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")
        arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(1.0)
        assert arr[1] == pytest.approx(1.0)
        assert arr[2] == pytest.approx(0.0)

    def test_tws_aware_floor_non_utc(self, set_other_timezone):
        set_other_timezone()
        seq = self._tws_naive()
        start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
        end   = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")
        arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(1.0)
        assert arr[1] == pytest.approx(1.0)
        assert arr[2] == pytest.approx(0.0)


    def test_tws_series_aware_floor_non_utc(self, set_other_timezone):
        set_other_timezone()

        seq = self._tws_naive()
        start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
        end = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")

        series = seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            align_to_interval=True,
        )

        assert series.shape == (3,)
        assert list(series.index.hour) == [8, 9, 10]
        assert str(series.index.tz) == "Europe/Berlin"
        assert series.iloc[0] == pytest.approx(1.0)
        assert series.iloc[1] == pytest.approx(1.0)
        assert series.iloc[2] == pytest.approx(0.0)

    # ------------------------------------------------------------------
    # ValueTimeWindowSequence — naive datetime, 1-hour steps
    # floor 08:10 → 08:00; values 0.25 at 08:00, 09:00; 0.0 at 10:00
    # ------------------------------------------------------------------

    def test_vtws_naive_floor_utc(self, set_other_timezone):
        set_other_timezone("UTC")
        seq = ValueTimeWindowSequence(windows=[
            ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
        ])
        start = naive_dt(2024, 6, 15, 8, 10)
        end   = naive_dt(2024, 6, 15, 10, 10)
        arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(0.25)
        assert arr[1] == pytest.approx(0.25)
        assert arr[2] == pytest.approx(0.0)

    def test_vtws_naive_floor_non_utc(self, set_other_timezone):
        set_other_timezone()
        seq = ValueTimeWindowSequence(windows=[
            ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
        ])
        start = naive_dt(2024, 6, 15, 8, 10)
        end   = naive_dt(2024, 6, 15, 10, 10)
        arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
        assert arr.shape == (3,)
        assert arr[0] == pytest.approx(0.25)
        assert arr[1] == pytest.approx(0.25)
        assert arr[2] == pytest.approx(0.0)

    def test_vtws_series_naive_floor_non_utc(self, set_other_timezone):
        set_other_timezone()

        seq = ValueTimeWindowSequence(
            windows=[
                ValueTimeWindow(
                    start_time="08:00:00",
                    duration="2 hours",
                    value=0.25,
                )
            ]
        )

        start = naive_dt(2024, 6, 15, 8, 10)
        end = naive_dt(2024, 6, 15, 10, 10)

        series = seq.to_series(
            start,
            end,
            pendulum.duration(hours=1),
            align_to_interval=True,
        )
        index = cast(pd.DatetimeIndex, series.index)

        assert series.shape == (3,)
        assert list(index.hour) == [8, 9, 10]
        assert series.iloc[0] == pytest.approx(0.25)
        assert series.iloc[1] == pytest.approx(0.25)
        assert series.iloc[2] == pytest.approx(0.0)
