"""
Unit Tests for Routine, RoutineLayer, and RoutineRotation

Covered Methods:
----------------
- Routine.create_routine()
- Routine.get_on_call()
- Routine.is_on_call()
- Routine.is_assignee_going_on_call()
- Routine.is_assignee_coming_off_on_call()
- Routine.has_hand_off()
- Routine.get_assignee_on_call_period()
- Routine.prepare_schedule()
- Routine.get_gap_adjusted_schedule() [static]
- Routine.find_gaps_between_schedules() [static]
- Routine.to_dict()

- RoutineLayer.create_layer()
- RoutineLayer.get_on_call()
- RoutineLayer.prepare_schedule()
- RoutineLayer.remove_rotations()
- RoutineLayer.to_dict()

- RoutineRotation.create_rotation()
- RoutineRotation.equivalent_to()
- RoutineRotation.to_dict()

Assumptions:
------------
- All input is considered type-safe and pre-validated (e.g., DB or cache layer).
- RoutineLayer.create_layer() is patched where needed to isolate Routine logic.
- Dynamic values are generated via FactoryBoy and Faker to ensure variation.

-------------------------------------------------------------------------------
🧪 Unit Test Index (Table of Contents)
-------------------------------------------------------------------------------
Test Name                                              | Purpose / Scenario                                 | Precondition                     | Expected Outcome
-----------------------------------------------------------------------------------------------------------------------------------------------------------
✅ test_all_valid_nested_fields_provided               | Valid input dict with all fields                   | routine_layers present            | Returns populated Routine
✅ test_missing_optional_fields_defaults_to_none       | Omits reference_id and associated_policies         | routine_layers present            | Fields default to None
✅ test_for_display_true_uses_reference_ids            | for_display=True → routine_ref_id used             | routine_layers present            | routine_id = routine_ref_id
✅ test_missing_required_fields_raises_keyerror        | Missing routine_id or routine_layers               | routine_layers missing            | Raises KeyError

✅ test_get_on_call_scenarios                          | Various on-call resolution paths                   | routine_layers mocked             | Returns expected users or exception

✅ test_is_on_call_scenarios                           | Check user ID presence in on-call list             | get_on_call is mocked             | Boolean match

✅ test_hand_off_logic                                 | Check on→off, off→on, and no-change cases          | is_on_call mocked via side_effect | Transitions correctly

✅ test_schedule_matching                              | Validate current + next on-call window             | routine.prepare_schedule mocked   | Correct tuple returned
✅ test_invalid_data_triggers_keyerror                 | Malformed schedule missing keys                    | Invalid dict                      | Raises KeyError

✅ test_valid_routine_layers                           | Valid base layers return composed schedule         | prepare_schedule mocked           | Full schedule returned
✅ test_with_exception_layers_adjusting_gaps           | Base + Exception layers merged                     | Layers mocked                     | Combined schedule returned
✅ test_layer_prepare_schedule_failure                 | Internal layer throws error                        | Side effect raises RuntimeError   | Exception propagated

✅ test_get_gap_adjusted_schedule                      | Covers overlap, full inclusion, outside, trimming  | Static test set                   | Schedule adjusted or skipped
✅ test_dynamic_boundary_gaps                          | Tests edge boundaries for gap adjustment           | Dynamic param                     | Matches expected result

✅ test_gap_detection_matrix                           | Empty list, gaps, and contiguous schedules         | Various schedules                 | Correct gap list returned

✅ test_to_dict_with_empty_layers                      | Empty routine_layers handled gracefully            | Routine with no layers            | rotations=[]

✅ test_all_fields_valid                               | RoutineLayer.create_layer() with valid input       | All required fields present       | Rotation list populated
✅ test_optional_timestamps                            | Omits rotation_start and rotation_end              | Optional fields skipped           | Defaults applied
✅ test_invalid_date_format_fails                      | Invalid date string passed                         | invalid date format               | Raises ValueError
✅ test_for_display_uses_reference_id                  | Uses rotation_ref_id if for_display=True           | Reference fields present          | Rotation contains reference ID

✅ test_get_on_call_matrix                             | Matrix-driven tests for get_on_call logic          | Layer and time inputs             | Expected match or empty list

✅ test_get_schedule_matrix                            | Matrix-driven schedule test: gaps, skips, loops    | RoutineLayer fixture matrix       | Schedule valid or empty

✅ test_remove_rotation_cases                          | Rotations removed, rebalanced if needed            | Known list sizes                  | Correct removals and order
✅ test_remove_rotation_randomized_many                | Fuzz-style rotation removal                        | 10 random iterations              | Validity and rotation integrity

✅ test_to_dict_basic_info_true                        | to_dict(basic_info=True) → grouped tuples          | RoutineLayer with rotations       | Grouped rotation tuples
✅ test_to_dict_basic_info_false                       | to_dict(basic_info=False) → full rotation dicts    | RoutineLayer with rotations       | Full rotation dictionaries
✅ test_to_dict_empty_rotations                        | No rotations returns empty list in both modes      | rotations=[]                      | Empty list returned

✅ test_create_rotation_for_display_true               | Uses preferred_username and policy_ref_id          | for_display=True                  | Reference IDs used
✅ test_create_rotation_for_display_false              | Uses raw assignee_name and policy ID               | for_display=False                 | Raw fields used
✅ test_create_rotation_missing_key_raises_keyerror    | Missing required keys                             | Missing fields                    | Raises KeyError

✅ test_equivalent_to_identical_data                   | identical → True                                   | deepcopy used                     | True
✅ test_equivalent_to_different_data                   | start/end period changed                           | start_period modified             | False
✅ test_equivalent_to_wrong_type                       | Non-RoutineRotation passed                         | Invalid type                      | Raises AssertionError

✅ test_to_dict_returns_expected_format                | Validates output keys and values from to_dict      | RoutineRotationFactory used       | All keys match internal state

-------------------------------------------------------------------------------
Notes:
- Subtests are used throughout for clear reporting per scenario.
- Some methods (like get_gap_adjusted_schedule) are static and tested separately.
- FactoryBoy is used for randomized fixtures; Faker ensures dynamic data variation.
- Test matrix strategies are applied for coverage across combinations.
"""

# ──────────────────────────────────────────────────────────────
# Imports
# ──────────────────────────────────────────────────────────────

from datetime import datetime, timedelta
from objects.routine import Routine
from objects.routine_layer import RoutineLayer
from objects.routine_rotation import RoutineRotation
from tests.fixtures.routine_fixtures import (
    OnCallEntryFactory,
    RoutineLayerFactory,
    RoutineRotationFactory,
    UserFactory,
    fake,
    gap_adjustment_scenarios,
    gaps_between_schedule_scenarios,
    routine_layer_schedule_matrix_cases,
    sample_layer_data,
)
from unittest.mock import MagicMock, patch
from utils import var_names
from utils.helpers import get_rotation_start_day  # Used to compute expected start_period

import copy
import datetime as dtmod
import pytest
import random
import utils.helpers as _helpers  # Required for monkeypatching helpers


# ──────────────────────────────────────────────────────────────
# Test Routine.create_routine
# ──────────────────────────────────────────────────────────────

class TestRoutineCreateRoutine:
    """
    Unit tests for the `Routine.create_routine()` static method.

    This suite ensures that Routine objects are instantiated correctly
    from various input dictionary configurations, covering:
    - Fully populated inputs
    - Optional fields omitted
    - for_display logic (uses reference IDs)
    - Error handling for missing required keys

    Test isolation is achieved by mocking `RoutineLayer.create_layer`,
    as it is invoked internally to construct each routine layer.

    Precondition:
        - Input data must be type-correct and schema-valid
        - `routine_layers` is always a list of dictionaries (not validated here)
    """

    @pytest.fixture(autouse=True)
    def _mock_routine_layer_create_layer(self):
        """
        Auto-applied fixture to patch `RoutineLayer.create_layer()`.

        This patch ensures:
            - Tests remain isolated from actual layer parsing logic
            - Performance is optimized by skipping deep nested creation
            - All routine_layers returned are MagicMock instances

        Applied to every test in this class automatically via `autouse=True`.
        """
        with patch("objects.routine.RoutineLayer.create_layer", return_value=MagicMock()):
            yield

    # ───────────────────────────────────────────────
    # ✅ All valid nested fields
    # ───────────────────────────────────────────────
    def test_all_valid_nested_fields_provided(self, sample_routine_data, mock_var_names, subtests):
        """
        ✅ Test Case: All valid fields provided (happy path)

        Verifies that `Routine.create_routine()` correctly instantiates a Routine
        object when the input dictionary includes all expected fields, including:
        - routine_id, organization_id, routine_name, timezone
        - routine_layers (mocked), reference_id, and associated_policies

        Args:
            sample_routine_data (dict): Fixture with all required and optional keys.
            mock_var_names (Mock): Fixture simulating var_names constant mappings.
            subtests (SubTests): Pytest subtest context for isolating assertions.

        Expected:
            - The returned object is a `Routine` instance.
            - All attributes match the input dict.
            - `routine_layers` is a list of MagicMock (due to patching).
        """
        with subtests.test("Valid data: all fields present"):
            routine = Routine.create_routine(sample_routine_data)

            # Confirm the object type
            assert isinstance(routine, Routine), "Returned object must be a Routine instance"

            # Check each top-level field against the input
            assert routine.routine_id == sample_routine_data[mock_var_names.routine_id]
            assert routine.organization_id == sample_routine_data[mock_var_names.organization_id]
            assert routine.routine_name == sample_routine_data[mock_var_names.routine_name]
            assert routine.routine_timezone == sample_routine_data[mock_var_names.timezone]
            assert routine.reference_id == sample_routine_data[mock_var_names.routine_ref_id]
            assert routine.associated_policies == sample_routine_data[mock_var_names.associated_policies]

            # Ensure routine_layers is a list of mocks (patched via fixture)
            assert isinstance(routine.routine_layers, list)
            assert all(isinstance(l, MagicMock) for l in routine.routine_layers)

    # ───────────────────────────────────────────────
    # ✅ Missing optional fields defaults to None
    # ───────────────────────────────────────────────
    def test_missing_optional_fields_defaults_to_none(self, sample_routine_data_missing_optional, mock_var_names, subtests):
        """
        ✅ Test Case: Missing optional fields (`reference_id`, `associated_policies`)

        Verifies that when optional keys are omitted from the input dictionary,
        `Routine.create_routine()` correctly sets their values to `None`.

        Args:
            sample_routine_data_missing_optional (dict): Input missing optional fields.
            mock_var_names (Mock): Fixture representing string constant mappings.
            subtests (SubTests): Pytest subtest object for granular test feedback.

        Expected:
            - Routine object is created successfully.
            - `reference_id` and `associated_policies` are set to None.
        """
        with subtests.test("Missing optionals -> None"):
            routine = Routine.create_routine(sample_routine_data_missing_optional)

            # Confirm the object type
            assert isinstance(routine, Routine)

            # Optional fields should default to None
            assert routine.reference_id is None, "Expected reference_id to be None"
            assert routine.associated_policies is None, "Expected associated_policies to be None"

    # ───────────────────────────────────────────────
    # ✅ for_display=True uses routine_ref_id
    # ───────────────────────────────────────────────
    def test_for_display_true_uses_reference_ids(self, sample_routine_data_for_display, mock_var_names, subtests):
        """
        ✅ Test Case: `for_display=True` → routine_ref_id is used as routine_id

        This test verifies that when the `for_display` flag is set to True,
        `Routine.create_routine()` assigns the `routine_id` field from the
        input’s `routine_ref_id` instead of the raw `routine_id`.

        Args:
            sample_routine_data_for_display (dict): Input with both ID fields populated.
            mock_var_names (Mock): Simulated string constants for field access.
            subtests (SubTests): Pytest subtest handler for isolated verification.

        Expected:
            - A valid Routine object is created.
            - routine.routine_id equals routine_ref_id from input.
        """
        with subtests.test("for_display=True uses routine_ref_id"):
            routine = Routine.create_routine(sample_routine_data_for_display, for_display=True)

            # Ensure instance was created correctly
            assert isinstance(routine, Routine)

            # Confirm routine_id is derived from reference ID
            assert routine.routine_id == sample_routine_data_for_display[mock_var_names.routine_ref_id]

    # ───────────────────────────────────────────────
    # ✅ Missing required fields raises KeyError
    # ───────────────────────────────────────────────
    def test_missing_required_fields_raises_keyerror(self, sample_routine_data_missing_required, subtests):
        """
        ✅ Test Case: Missing required fields (`routine_id`, `routine_layers`)

        Ensures that `Routine.create_routine()` raises a KeyError when essential
        fields are omitted from the input dictionary. This protects the contract
        of required input structure and enforces minimum valid schema.

        Args:
            sample_routine_data_missing_required (dict): Input with required fields removed.
            subtests (SubTests): Pytest subtest handler for scoped failure reporting.

        Raises:
            KeyError: If `routine_id` or `routine_layers` is missing from input.
        """
        with subtests.test("Missing required field -> KeyError"):
            # Expected to raise KeyError due to missing mandatory keys
            with pytest.raises(KeyError):
                Routine.create_routine(sample_routine_data_missing_required)

# ──────────────────────────────────────────────────────────────
# Test Routine.get_on_call
# ──────────────────────────────────────────────────────────────

class TestRoutineGetOnCall:
    """
    Unit tests for the `Routine.get_on_call()` method.

    This method aggregates on-call users by delegating to each layer's `get_on_call()`.
    If exception layers are present, they take precedence over base layers.
    These tests validate the behavior under a variety of real-world and edge-case inputs:
        - Valid base layer logic
        - Exception layer override
        - No active layers
        - Null timestamp fallback
        - Invalid layer objects triggering error
    """

    def test_get_on_call_scenarios(self, subtests):
        """
        ✅ Test Case Matrix: `Routine.get_on_call()` behavior

        Tests different combinations of routine layers and datetime inputs to ensure
        the correct users are returned or exceptions are raised where applicable.

        Args:
            subtests (SubTests): Provides per-scenario isolation for easier debugging.

        Validates:
            - Exception layers override base layers
            - Correct fallback when check_datetime is None
            - Returns empty list when no valid layers
            - Raises error on malformed layer input
        """
        now = datetime.now()

        # Simulated users
        user = UserFactory()
        user_base = UserFactory()
        user_override = UserFactory()

        # Scenario matrix
        test_cases = [
            {
                "scenario": "No exceptions, valid routine layers",
                "routine_layers": [
                    MagicMock(is_exception=False, get_on_call=MagicMock(return_value=[user]))
                ],
                "expected": [user],
                "check_datetime": now,
                "why": "Base layer returns on-call user"
            },
            {
                "scenario": "Valid exceptions override base layers",
                "routine_layers": [
                    MagicMock(is_exception=False, get_on_call=MagicMock(return_value=[user_base])),
                    MagicMock(is_exception=True, get_on_call=MagicMock(return_value=[user_override]))
                ],
                "expected": [user_override],
                "check_datetime": now,
                "why": "Exception layer overrides base layer"
            },
            {
                "scenario": "No on-call rotations found",
                "routine_layers": [],
                "expected": [],
                "check_datetime": now,
                "why": "Empty layer list returns empty result"
            },
            {
                "scenario": "check_datetime=None",
                "routine_layers": [
                    MagicMock(is_exception=False, get_on_call=MagicMock(return_value=[user]))
                ],
                "expected": [user],
                "check_datetime": None,
                "why": "Should default to current datetime"
            },
            {
                "scenario": "Invalid layer objects (e.g., corrupted input)",
                "routine_layers": [None, "not_a_layer", 123],
                "expected_exception": AttributeError,
                "check_datetime": now,
                "why": "Invalid objects should raise exception when get_on_call is called"
            }
        ]

        for case in test_cases:
            with subtests.test(msg=case["scenario"]):
                routine = Routine(
                    routine_id="r1",
                    organization_id="org1",
                    routine_name="Routine A",
                    routine_timezone="UTC",
                    routine_layers=case["routine_layers"]
                )

                if "expected_exception" in case:
                    # Expect an exception due to malformed input (e.g., None or int instead of layer)
                    with pytest.raises(case["expected_exception"]):
                        routine.get_on_call(check_datetime=case["check_datetime"])
                else:
                    # Expected result from normal operation
                    result = routine.get_on_call(check_datetime=case["check_datetime"])
                    assert result == case["expected"], f"Scenario '{case['scenario']}' failed: {case['why']}"

# ──────────────────────────────────────────────────────────────
# Test Routine.is_on_call
# ──────────────────────────────────────────────────────────────

class TestRoutineIsOnCall:
    """
    Unit tests for the `Routine.is_on_call()` method.

    This method checks if a given user ID is currently on call by scanning the list
    returned from `Routine.get_on_call()`. It is expected to match against the third
    element of each tuple in the on-call list: (rotation_start, rotation_end, assignee_id).
    """

    def test_is_on_call_scenarios(self, subtests):
        """
        ✅ Verifies `Routine.is_on_call()` behavior across match and mismatch cases.

        Args:
            subtests (SubTests): Used to isolate each scenario for independent reporting.

        Test Cases:
            - Assignee is found in the list → returns True
            - Assignee is not found         → returns False
        """
        # Create one user who is on-call and one who is not
        user_on_call = UserFactory()
        user_not_on_call = UserFactory()

        test_cases = [
            {
                "scenario": "Assignee found in on-call list",
                "on_call_users": [(None, None, user_on_call["id"])],  # Format: (start, end, user_id)
                "check_user_id": user_on_call["id"],
                "expected": True,
                "why": "user_on_call's ID is included as the 3rd element of the on_call tuple"
            },
            {
                "scenario": "Assignee not found",
                "on_call_users": [(None, None, user_on_call["id"])],
                "check_user_id": user_not_on_call["id"],
                "expected": False,
                "why": "user_not_on_call's ID is not present in the on_call list"
            }
        ]

        for case in test_cases:
            with subtests.test(msg=case["scenario"]):
                routine = Routine(
                    routine_id="r2",
                    organization_id="org2",
                    routine_name="Check On-Call",
                    routine_timezone="UTC",
                    routine_layers=[]
                )

                # Patch `get_on_call` to return predefined on-call list
                routine.get_on_call = MagicMock(return_value=case["on_call_users"])

                # Run check and validate against expectation
                result = routine.is_on_call(case["check_user_id"])
                assert result == case["expected"], f"Scenario '{case['scenario']}' failed: {case['why']}"

# ──────────────────────────────────────────────────────────────
# Test Routine Hand-Off Logic
# is_assignee_going_on_call(), is_assignee_coming_off_on_call(), has_hand_off()
# ──────────────────────────────────────────────────────────────

class TestRoutineHandOffMethods:
    """
    Unit tests for Routine hand-off detection methods:

    - `is_assignee_going_on_call()`: True if user was off-call before, now on-call.
    - `is_assignee_coming_off_on_call()`: True if user was on-call before, now off-call.
    - `has_hand_off()`: True if there is any transition (on→off or off→on).

    These methods are thin wrappers around two consecutive calls to `is_on_call()`,
    using mocked return values to simulate different transitions.
    """

    @pytest.mark.parametrize("before, after, expected_going_on, expected_coming_off, expected_hand_off", [
        (True, False, False, True, True),   # Assignee is coming off
        (False, True, True, False, True),   # Assignee is going on
        (True, True, False, False, False),  # No transition (still on-call)
        (False, False, False, False, False) # No transition (still off-call)
    ])
    def test_hand_off_logic(self, before, after, expected_going_on, expected_coming_off, expected_hand_off, subtests):
        """
        ✅ Parametrized test for all Routine hand-off transitions

        Simulates combinations of `is_on_call()` returning different values for two timestamps
        (e.g., current and previous), then validates each of the following behaviors:

        - going_on: was not on-call, now is
        - coming_off: was on-call, now is not
        - hand_off: any transition between the two states

        Args:
            before (bool): Return value of is_on_call at T1 (previous)
            after (bool): Return value of is_on_call at T2 (current)
            expected_going_on (bool): Expected result from `is_assignee_going_on_call()`
            expected_coming_off (bool): Expected result from `is_assignee_coming_off_on_call()`
            expected_hand_off (bool): Expected result from `has_hand_off()`
            subtests (SubTests): For isolated feedback on each assertion
        """
        assignee_id = "test-assignee"
        now = datetime.now()

        # Create dummy routine with no real layers
        routine = Routine(
            routine_id="r1",
            organization_id="org1",
            routine_name="Routine A",
            routine_timezone="UTC",
            routine_layers=[]
        )

        # Each check below calls is_on_call twice internally.
        # We patch is_on_call with a fixed side_effect list for each test call.

        # Test: is_assignee_going_on_call()
        routine.is_on_call = MagicMock(side_effect=[before, after])
        with subtests.test("is_assignee_going_on_call()"):
            assert routine.is_assignee_going_on_call(assignee_id, now) == expected_going_on

        # Test: is_assignee_coming_off_on_call()
        routine.is_on_call = MagicMock(side_effect=[before, after])
        with subtests.test("is_assignee_coming_off_on_call()"):
            assert routine.is_assignee_coming_off_on_call(assignee_id, now) == expected_coming_off

        # Test: has_hand_off()
        routine.is_on_call = MagicMock(side_effect=[before, after])
        with subtests.test("has_hand_off()"):
            assert routine.has_hand_off(assignee_id, now) == expected_hand_off

# ──────────────────────────────────────────────────────────────
# Test Routine.get_assignee_on_call_period
# ──────────────────────────────────────────────────────────────

class TestRoutineGetAssigneeOnCallPeriod:
    """
    Unit tests for the `Routine.get_assignee_on_call_period()` method.

    This method returns the current and next on-call periods (if any) for a given assignee,
    based on schedule entries returned by `Routine.prepare_schedule()`.

    Each period is a tuple of (start_datetime, end_datetime), or None if no match is found.
    """

    class MockRotation:
        """Minimal mock rotation object to simulate assignee identity."""
        def __init__(self, assignee_policy_id):
            self.assignee_policy_id = assignee_policy_id

    @staticmethod
    def make_schedule(start, end, assignee_id):
        """
        Constructs a schedule dictionary with a start/end time and an on-call assignee.

        Args:
            start (datetime): Start of the rotation window.
            end (datetime): End of the rotation window.
            assignee_id (str): The assignee's policy ID.

        Returns:
            dict: A mock schedule item.
        """
        return {
            "rotation_start": start,
            "rotation_end": end,
            "on_call": [TestRoutineGetAssigneeOnCallPeriod.MockRotation(assignee_id)]
        }

    @pytest.mark.parametrize("scenario, schedules, assignee_id, expected", [
        (
            "Both current and next period exist",
            # Two valid periods: one currently active, one in future
            lambda now: [
                TestRoutineGetAssigneeOnCallPeriod.make_schedule(
                    now - timedelta(minutes=30), now + timedelta(hours=1), "user-123"
                ),
                TestRoutineGetAssigneeOnCallPeriod.make_schedule(
                    now + timedelta(hours=1), now + timedelta(hours=5), "user-123"
                )
            ],
            "user-123",
            lambda now: (
                (now - timedelta(minutes=30), now + timedelta(hours=1)),  # current period
                (now + timedelta(hours=1), now + timedelta(hours=5))       # next period
            )
        ),
        (
            "Only next period exists",
            # No current period matches; only one starts in the future
            lambda now: [
                TestRoutineGetAssigneeOnCallPeriod.make_schedule(
                    now + timedelta(minutes=5), now + timedelta(hours=1), "user-456"
                )
            ],
            "user-456",
            lambda now: (
                None,  # no current match
                (now + timedelta(minutes=5), now + timedelta(hours=1))
            )
        ),
        (
            "No matching assignee",
            # Assignee ID does not appear in any on_call entry
            lambda now: [
                TestRoutineGetAssigneeOnCallPeriod.make_schedule(
                    now - timedelta(hours=1), now + timedelta(hours=1), "someone-else"
                )
            ],
            "non-existent-id",
            lambda now: (None, None)
        ),
    ])

    def test_schedule_matching(self, scenario, schedules, assignee_id, expected, subtests):
        """
        ✅ Validates current and next on-call period detection under different scenarios.

        Args:
            scenario (str): Human-readable name of the test case.
            schedules (Callable): Function returning schedule entries.
            assignee_id (str): The user ID to check in on-call rotations.
            expected (Callable): Function returning the expected (current, next) result.
            subtests (SubTests): Pytest subtest context for per-case isolation.
        """
        now = datetime.now()
        routine = Routine("rid", "oid", "Test Routine", "UTC", [])

        # Patch prepare_schedule() to return custom test data for this scenario
        routine.prepare_schedule = MagicMock(return_value=schedules(now))

        with subtests.test(scenario):
            # Execute and compare against expected (current, next) period tuple
            result = routine.get_assignee_on_call_period(assignee_id, now)
            assert result == expected(now), f"Failed scenario: {scenario}"

    def test_invalid_data_triggers_keyerror(self, subtests):
        """
        ❌ Should raise KeyError when schedule entry is missing required keys.

        Validates defensive behavior when schedule data structure is malformed
        (e.g., missing 'rotation_start', 'rotation_end', or 'on_call').
        """
        now = datetime.now()
        routine = Routine("rid", "oid", "Test Routine", "UTC", [])

        # Prepare a broken schedule with a missing key (intentionally malformed)
        routine.prepare_schedule = MagicMock(return_value=[
            {"bad_start_key": now}
        ])

        with subtests.test("Invalid schedule data"):
            # Expect KeyError due to missing 'rotation_start' or 'on_call'
            with pytest.raises(KeyError):
                routine.get_assignee_on_call_period("user-x", now)

# ──────────────────────────────────────────────────────────────
# Test Routine.prepare_schedule
# ──────────────────────────────────────────────────────────────

class TestRoutinePrepareSchedule:
    """
    Test suite for Routine.prepare_schedule().

    This method collects the schedules from each RoutineLayer by calling
    their own `prepare_schedule()` methods and combines the results.

    The logic must:
    - Merge base and exception layers
    - Handle empty and missing data correctly
    - Surface exceptions raised by any layer
    """

    def make_layer(self, is_exception: bool, return_value):
        """
        Constructs a MagicMock representing a RoutineLayer with controlled behavior.

        Args:
            is_exception (bool): Whether the mocked layer is an exception.
            return_value (Any): The return value when prepare_schedule() is called.

        Returns:
            MagicMock: Simulated RoutineLayer instance.
        """
        layer = MagicMock()
        layer.is_exception = is_exception
        layer.prepare_schedule = MagicMock(return_value=return_value)
        return layer

    def test_valid_routine_layers(self, subtests):
        """
        ✅ Test Case: Valid base layers return combined schedule.

        Ensures the final output is a concatenation of individual layer schedules.
        """
        # Simulated schedule returned by each layer
        base_schedule = [{"rotation_start": "s1", "rotation_end": "e1"}]

        # Routine with two non-exception layers
        routine = Routine("rid", "orgid", "RoutineX", "UTC", [
            self.make_layer(False, base_schedule),
            self.make_layer(False, base_schedule)
        ])

        with subtests.test("Composes schedule from two base layers"):
            result = routine.prepare_schedule(datetime.now(), period=5)

            # The full schedule should be a flat merge of both base schedules
            assert result == base_schedule + base_schedule

    def test_with_exception_layers_adjusting_gaps(self, subtests):
        """
        ✅ Test Case: Base + Exception layers produce merged schedule.

        Verifies that exception layers are added *in addition to* base layers.
        """
        fixed_now = datetime(2025, 6, 3, 6, 0, 0)  # fixed timestamp for consistency

        # Base schedule: single block of coverage
        base_schedule = [{
            "rotation_start": fixed_now,
            "rotation_end": fixed_now + timedelta(hours=4),
            "on_call": "dummy_user1"
        }]

        # Exception schedule: another block that starts later
        exception_schedule = [{
            "rotation_start": fixed_now + timedelta(days=1),
            "rotation_end": fixed_now + timedelta(days=1, hours=4),
            "on_call": "dummy_user2"
        }]

        # Routine with one base layer and one exception layer
        routine = Routine("rid", "orgid", "RoutineX", "UTC", [
            self.make_layer(False, base_schedule),
            self.make_layer(True, exception_schedule)
        ])

        with subtests.test("Base + Exception merged"):
            result = routine.prepare_schedule(start_date=fixed_now, period=7)

            # Assert that both base and exception entries are present
            assert base_schedule[0] in result, "Base schedule not found"
            assert exception_schedule[0] in result, "Exception schedule not found"

    def test_layer_prepare_schedule_failure(self, subtests):
        """
        ❌ Test Case: Layer raises error → Routine should propagate it.

        Validates that if any layer raises an exception, the Routine also fails.
        """
        # Faulty mock layer configured to throw a RuntimeError
        broken_layer = MagicMock()
        broken_layer.is_exception = False
        broken_layer.prepare_schedule.side_effect = RuntimeError("Layer logic broke")

        # Routine with one broken layer
        routine = Routine("rid", "orgid", "RoutineX", "UTC", [broken_layer])

        with subtests.test("Layer error bubbles up"):
            # Error should propagate out of Routine.prepare_schedule
            with pytest.raises(RuntimeError, match="Layer logic broke"):
                routine.prepare_schedule(datetime.now(), period=7)



# ──────────────────────────────────────────────────────────────
# Test Routine.get_gap_adjusted_schedule
# ──────────────────────────────────────────────────────────────

def test_get_gap_adjusted_schedule(gap_adjustment_scenarios, subtests):
    """
    ✅ Parametrized test for Routine.get_gap_adjusted_schedule()

    This function validates how a schedule is adjusted when a gap period is applied.
    Each test case defines:
        - A sample schedule (`rotation_start`, `rotation_end`, `on_call`)
        - A gap window (`gap_start`, `gap_end`)
        - The expected result after applying the adjustment

    Returns:
        None (assertions validate correctness)

    Scenarios covered:
        - Schedule fully within gap → should remain
        - Partial overlap → trim accordingly
        - No overlap → return None
    """
    for case in gap_adjustment_scenarios:
        # Reconstruct the input schedule dictionary from the case
        sch = {
            var_names.rotation_start: case["item"][var_names.rotation_start],
            var_names.rotation_end: case["item"][var_names.rotation_end],
            var_names.on_call: case["item"][var_names.on_call],
        }

        with subtests.test(msg=case["desc"]):
            # Apply the gap adjustment logic
            result = Routine.get_gap_adjusted_schedule(sch, case["gap"])

            if case["expected"] is None:
                # Expect schedule to be removed due to being completely outside the gap
                assert result is None, case["desc"]
            else:
                # Expect a correctly adjusted schedule (or untouched if fully within)
                assert result == case["expected"], case["desc"]


# ──────────────────────────────────────────────────────────────
# Test Routine.find_gaps_between_schedules
# ──────────────────────────────────────────────────────────────

"""
Tests for Routine.find_gaps_between_schedules()

We probe three failure-prone scenarios:

1. Empty list                        ➜ should yield `[]`
2. Gaps at start / middle / end     ➜ explicit list of gap tuples
3. Overlapping / contiguous entries ➜ no gaps expected

The schedule dictionaries use the mock var-name keys so the test suite
remains independent of the real utils.var_names module.
"""

class TestRoutineFindGapsBetweenSchedules:
    """
    Behaviour-driven tests for the static `find_gaps_between_schedules()` method.

    This method identifies periods not covered by any rotation schedule
    between a specified start and end window. It is often used to support
    exception-based overrides or alerting for schedule voids.
    """

    def test_gap_detection_matrix(self, gaps_between_schedule_scenarios, subtests):
        """
        ✅ Parametric test cases from fixture-driven matrix

        Each scenario includes:
            - A list of schedule blocks (start/end)
            - A global period window to compare against
            - The expected list of uncovered gap intervals

        This test ensures that the method:
            - Handles edge cases like no input
            - Properly finds gaps before, between, or after schedules
            - Ignores overlaps or continuous coverage
        """
        for case in gaps_between_schedule_scenarios:
            with subtests.test(msg=case["desc"]):
                # Call the method under test
                result = Routine.find_gaps_between_schedules(
                    case["schedules"],        # list of dicts with rotation_start/end
                    case["period_start"],     # full range start
                    case["period_end"]        # full range end
                )

                # Assert exact match to expected gap tuples
                assert result == case["expected"], case["desc"]

# ──────────────────────────────────────────────────────────────
# Test Routine.get_gap_adjusted_schedule – Edge Boundary Checks
# ──────────────────────────────────────────────────────────────

class TestRoutineGapAdjustmentBoundaries:
    """
    ✅ Test Suite: Edge-case coverage for `Routine.get_gap_adjusted_schedule`.

    Focus:
    - Handles cases where schedule items touch gap boundaries exactly.
    - Ensures correct trimming logic when schedules partially align with gap edges.
    - Verifies that schedules entirely within gaps are preserved (per business rule).
    """

    def test_dynamic_boundary_gaps(self, boundary_gap_cases, mock_var_names, subtests):
        """
        ✅ Parametrized boundary case tests.

        Each `boundary_gap_case` provides:
            - `desc`: scenario description for subtest labeling
            - `item`: rotation dictionary with mock var_name keys
            - `gap`: (gap_start, gap_end) tuple
            - `expected`: adjusted schedule dictionary or None

        Test Logic:
            - If schedule is fully within gap → should remain as-is.
            - If schedule is outside gap → should return None.
            - If overlapping at edges → should be trimmed accordingly.
        """
        for case in boundary_gap_cases:
            with subtests.test(msg=case["desc"]):
                # Apply adjustment to schedule item based on gap
                result = Routine.get_gap_adjusted_schedule(
                    case["item"],
                    case["gap"]
                )

                # Validate against the expected output
                assert result == case["expected"], f"Boundary test failed: {case['desc']}"


# ──────────────────────────────────────────────────────────────
# Test Routine.to_dict
# ──────────────────────────────────────────────────────────────
class TestRoutineToDict:
    """
    ✅ Test Suite: Routine.to_dict()

    This suite verifies the correctness and structure of the serialized
    dictionary output of a Routine instance. Ensures compatibility with
    downstream consumers that expect a consistent dictionary format.

    Focus:
    - Handles empty routine_layers correctly
    - Verifies inclusion of key fields
    """

    def test_to_dict_with_empty_layers(self, empty_layer_routine):
        """
        ✅ Test Case: Routine with no layers should serialize correctly.

        Given:
            A Routine object with routine_layers = []

        When:
            Calling `to_dict()`

        Then:
            - Result should contain a "routine_layers" key
            - Value should be an empty list

        This validates that the method doesn't raise or omit the key when
        the layers list is empty.
        """
        result = empty_layer_routine.to_dict()
        assert "routine_layers" in result, "Missing 'routine_layers' key"
        assert result["routine_layers"] == [], "'routine_layers' should be an empty list"


# ──────────────────────────────────────────────────────────────
# Test RoutineLayer.create_layer
# ──────────────────────────────────────────────────────────────
class TestRoutineLayerCreateLayer:
    """
    ✅ Unit Test Suite: RoutineLayer.create_layer()

    This suite validates the instantiation logic for RoutineLayer, which
    also calls `RoutineRotation.create_rotation()` for each rotation entry.

    Test Matrix:
    ┌──────────────────────────────────────────────┬──────────┬────────────────────────────────────────┐
    │ Scenario                                     │ Expected │ Notes                                  │
    ├──────────────────────────────────────────────┼──────────┼────────────────────────────────────────┤
    │ All fields valid                             │ ✅ Pass  │ Proper mapping + rotation delegation   │
    │ Omits optional timestamps                    │ ✅ Pass  │ Should still succeed                   │
    │ Invalid timestamp format in valid_start      │ ❌ Fail  │ ValueError via strptime                │
    │ for_display=True → uses rotation_ref_id      │ ✅ Pass  │ Proper ref_id substitution             │
    └──────────────────────────────────────────────┴──────────┴────────────────────────────────────────┘
    """

    def test_all_fields_valid(self, sample_layer_data):
        """
        ✅ Test Case: All fields are present and valid.

        Asserts:
        - RoutineLayer fields match input
        - create_rotation is invoked correctly
        """
        with patch("objects.routine_layer.RoutineRotation.create_rotation") as mock_create_rotation:
            mock_create_rotation.return_value = "rotation-ok"

            layer = RoutineLayer.create_layer(sample_layer_data)

            assert layer.layer_name == sample_layer_data[var_names.layer_name]
            assert layer.rotations == ["rotation-ok"] * len(sample_layer_data[var_names.rotations])
            assert layer.is_exception == sample_layer_data[var_names.is_exception]

    def test_optional_timestamps(self, sample_layer_data):
        """
        ✅ Test Case: Optional keys `rotation_start` and `rotation_end` are omitted.

        Verifies:
        - No error is raised
        - Rotations are still created with defaults
        """
        sample_layer_data[var_names.rotations][0].pop(var_names.rotation_start, None)
        sample_layer_data[var_names.rotations][0].pop(var_names.rotation_end, None)

        with patch("objects.routine_layer.RoutineRotation.create_rotation") as mock_create_rotation:
            mock_create_rotation.return_value = "rotation-default"

            layer = RoutineLayer.create_layer(sample_layer_data)

            assert layer.rotations == ["rotation-default"] * len(sample_layer_data[var_names.rotations])

    def test_invalid_date_format_fails(self, sample_layer_data):
        """
        ❌ Test Case: Invalid date string in `valid_start`.

        Expects:
        - ValueError raised due to datetime.strptime() failure
        """
        sample_layer_data[var_names.valid_start] = "invalid-date"

        with pytest.raises(ValueError):
            RoutineLayer.create_layer(sample_layer_data)

    def test_for_display_uses_reference_id(self, sample_layer_data, faker):
        """
        ✅ Test Case: for_display=True uses reference keys.

        Setup:
        - Insert a `rotation_ref_id` in the rotation entry

        Expectation:
        - The returned rotation object uses ref_id, not raw ID
        """
        ref_id = "ref-" + faker.pystr(min_chars=5, max_chars=10)
        sample_layer_data[var_names.rotations][0][var_names.rotation_ref_id] = ref_id

        with patch("objects.routine_layer.RoutineRotation.create_rotation") as mock_create_rotation:
            mock_create_rotation.return_value = {"id": "should-not-use", "ref_id": ref_id}

            layer = RoutineLayer.create_layer(sample_layer_data, for_display=True)

            assert layer.rotations[0]["ref_id"] == ref_id, "Expected reference ID to be used"


# ──────────────────────────────────────────────────────────────
# Test RoutineLayer.get_on_call (Matrix Test)
# ──────────────────────────────────────────────────────────────
class TestRoutineLayerGetOnCallMatrix:
    """
    ✅ Matrix-Driven Unit Tests: RoutineLayer.get_on_call()

    This test suite validates whether a rotation assignee is correctly identified
    as on-call at a given moment based on the layer’s configuration.

    The test cases are dynamically provided via the fixture `routine_layer_get_on_call_cases`.

    Test Matrix Covers:
    ┌──────────────────────────────────────────────┬────────────┬────────────────────────────────────────────┐
    │ Scenario                                     │ Expect     │ Why                                         │
    ├──────────────────────────────────────────────┼────────────┼────────────────────────────────────────────┤
    │ Valid overlap with shift                     │ ✅ True    │ Within rotation and shift hours             │
    │ Outside valid_start and valid_end            │ ❌ False   │ Outside layer bounds                        │
    │ Assignee rotation not yet started            │ ❌ False   │ start > check_time                          │
    │ Rotation ongoing but skipped day             │ ❌ False   │ skip_days = includes current day            │
    │ Rotation matches across multiple windows     │ ✅ True    │ Still within repeating schedule             │
    └──────────────────────────────────────────────┴────────────┴────────────────────────────────────────────┘
    """

    def make_layer(self, config):
        """
        Constructs a RoutineLayer instance based on test config dictionary.

        Args:
            config (dict): Contains all necessary keys to build a valid layer.

        Returns:
            RoutineLayer: Initialized object with test configuration.
        """
        return RoutineLayer(
            layer=1,
            valid_start=config["valid_start"],
            valid_end=config["valid_end"],
            is_exception=False,
            rotation_start=config["rotation_start"],
            shift_length=config["shift_length"],
            rotation_period=7,
            rotation_frequency=1,
            skip_days=config.get("skip_days", []),
            rotations=config["rotations"]
        )

    def test_get_on_call_matrix(self, routine_layer_get_on_call_cases, subtests):
        """
        ✅ Test Case: Parametrized matrix of get_on_call() logic.

        Each test case provides:
            - layer_config: Rotation + layer timing
            - check_time: Datetime to test
            - expected_found: True if assignee should be on call at that time

        Assertion:
            - True if on-call assignee found
            - False if none found
        """
        for case in routine_layer_get_on_call_cases:
            with subtests.test(msg=case["desc"]):
                layer = self.make_layer(case["layer_config"])
                result = layer.get_on_call(case["check_time"], "UTC")

                # Validate whether someone is on-call at that moment
                assert (len(result) > 0) == case["expected_found"], (
                    f"{case['desc']} → result: {result}"
                )


# ──────────────────────────────────────────────────────────────
# Test RoutineLayer.prepare_schedule (Matrix Test)
# ──────────────────────────────────────────────────────────────
class TestRoutineLayerPrepareScheduleMatrix:
    """
    ✅ Matrix-Driven Tests: RoutineLayer.prepare_schedule()

    This suite tests whether the `prepare_schedule()` method of a RoutineLayer
    correctly generates a rotation schedule for a variety of edge-case and
    representative scenarios.

    The cases are supplied via `routine_layer_schedule_matrix_cases`.

    Test Matrix Covers:
    ┌─────────────────────────────────────────────┬────────────────────────────┬──────────────┐
    │ Scenario Description                        │ Setup                       │ Expectation  │
    ├─────────────────────────────────────────────┼────────────────────────────┼──────────────┤
    │ Valid standard config                       │ Normal bounds and shifts    │ Non-empty    │
    │ All rotations skipped due to skip_days      │ skip_days covers all days   │ Empty        │
    │ Empty rotations list                        │ No rotations provided       │ Empty        │
    │ Infinite loop safeguard hit                 │ Bad config → loop breaker   │ Empty        │
    │ Cross-day rotation span                     │ Shift overflows midnight    │ Non-empty    │
    │ Short shift, long rotation_period           │ Sparse rotation logic       │ Non-empty    │
    └─────────────────────────────────────────────┴────────────────────────────┴──────────────┘
    """

    def test_get_schedule_matrix(self, routine_layer_schedule_matrix_cases, subtests):
        """
        ✅ Test Case: Scenario matrix for RoutineLayer.prepare_schedule()

        Validates the output schedule based on provided configuration.

        Each test case includes:
        - `layer`: A fully prepared RoutineLayer instance
        - `expected_non_empty`: Boolean, whether the schedule should be populated
        - `desc`: Subtest description for clarity in test output
        """
        for case in routine_layer_schedule_matrix_cases:
            with subtests.test(msg=case["desc"]):
                result = case["layer"].prepare_schedule(
                    start_date=dtmod.datetime(2025, 6, 1),
                    period=5
                )

                if case["expected_non_empty"]:
                    assert isinstance(result, list) and len(result) > 0, "Expected non-empty schedule"
                else:
                    assert result == [], "Expected empty schedule"

# ──────────────────────────────────────────────────────────────
# Test RoutineLayer.remove_rotations (Matrix + Fuzz Tests)
# ──────────────────────────────────────────────────────────────

class TestRoutineLayerRemoveRotations:
    """
    ✅ Matrix and Fuzz Tests: RoutineLayer.remove_rotations()

    This suite ensures that `remove_rotations()` correctly removes specified
    assignees from a layer and rebalances rotation periods accordingly.

    A helper patch is applied to `helpers.get_rotation_end_from_start` to
    bypass complex frequency/end logic and simplify validation.

    Test Matrix Covers:
    ┌──────────────────────────────┬────────────────────────────────────┐
    │ Scenario                     │ Expectation                        │
    ├──────────────────────────────┼────────────────────────────────────┤
    │ Single match removed         │ One user removed, others intact    │
    │ All removed                  │ `rotations` becomes empty          │
    │ Partial removal              │ Remove N, keep and rebalance M     │
    │ Remove only one              │ Edge case, one rotation only       │
    │ Remove none                  │ No change in rotation list         │
    └──────────────────────────────┴────────────────────────────────────┘

    Also includes 10 randomized fuzz cases for broader coverage.
    """

    @pytest.fixture(autouse=True)
    def _patch_get_rotation_end(self, monkeypatch):
        """
        ⛑ Patch: Simplifies rotation end computation.

        Mocks `helpers.get_rotation_end_from_start()` to return:
            end = start + frequency (or start + 1 if frequency is None)
        """
        def fake_get_rotation_end_from_start(start_period, frequency=None):
            return start_period + (frequency or 1)

        monkeypatch.setattr(_helpers, "get_rotation_end_from_start", fake_get_rotation_end_from_start)
        yield

    def _clone_rotations(self, rotations):
        """
        🧪 Utility: Deep-copy list of RoutineRotation objects.

        Args:
            rotations (list): Original rotation list

        Returns:
            list: Deep copied rotations for validation
        """
        return [copy.deepcopy(r) for r in rotations]

    @pytest.mark.parametrize("num_rotations, num_remove", [
        (5, 1),    # One out of five removed
        (3, 3),    # Remove all → empty list
        (7, 3),    # Remove some → partial rebalance
        (1, 1),    # Edge: remove single
        (6, 0),    # Nothing to remove
    ])
    def test_remove_rotation_cases(self, num_rotations, num_remove, subtests):
        """
        ✅ Matrix Test: remove_rotations() under fixed-size setups.

        Ensures expected names are removed and valid start/end bounds remain.
        """
        rotations = [RoutineRotationFactory() for _ in range(num_rotations)]
        all_usernames = [r.assignee_name for r in rotations]
        remove_usernames = random.sample(all_usernames, num_remove) if num_remove > 0 else []

        orig_rotations = self._clone_rotations(rotations)
        orig_start_map = {r.assignee_name: r.start_period for r in orig_rotations}

        layer = RoutineLayerFactory(rotations=rotations)

        layer.remove_rotations(remove_usernames)

        with subtests.test(f"{num_rotations} rotations, remove {num_remove}"):
            final_usernames = [r.assignee_name for r in layer.rotations]

            if num_remove == 0:
                assert set(final_usernames) == set(all_usernames), "No rotations should have been removed"
                return

            if num_remove == num_rotations:
                assert layer.rotations == [], "All rotations removed → layer.rotations must be empty"
                return

            # Partial removal: check correctness
            for name in remove_usernames:
                assert name not in final_usernames, f"Removed username {name} still present"

            for name in all_usernames:
                if name not in remove_usernames:
                    assert final_usernames.count(name) == 1, f"Kept username {name} missing or duplicated"

            # Valid time bounds
            for rot in layer.rotations:
                assert rot.start_period < rot.end_period, f"Invalid range: {rot.start_period} → {rot.end_period}"

    def test_remove_rotation_randomized_many(self, subtests):
        """
        🔁 Fuzz Test: 10 randomized scenarios.

        Randomly constructs and removes users, validating:
        - Correct removals
        - Valid range (start < end)
        - Emptiness when all are removed
        """
        for _ in range(10):
            num_rotations = random.randint(3, 10)
            rotations = [RoutineRotationFactory() for _ in range(num_rotations)]

            layer = RoutineLayerFactory(rotations=rotations)
            orig_rotations = self._clone_rotations(rotations)
            orig_start_map = {r.assignee_name: r.start_period for r in orig_rotations}

            all_usernames = [r.assignee_name for r in rotations]
            num_remove = random.randint(1, num_rotations)
            to_remove = random.sample(all_usernames, num_remove)

            with subtests.test(f"Fuzz: {num_rotations} total, remove {num_remove} ({to_remove})"):
                layer.remove_rotations(to_remove)
                final_usernames = [r.assignee_name for r in layer.rotations]

                for name in to_remove:
                    assert name not in final_usernames, f"[Fuzz] Removed {name} still present"

                if num_remove == num_rotations:
                    assert layer.rotations == [], "[Fuzz] All removed → rotations must be empty"
                    continue

                for rot in layer.rotations:
                    assert rot.start_period < rot.end_period, (
                        f"[Fuzz] Invalid period: {rot.start_period} → {rot.end_period}"
                    )


# ──────────────────────────────────────────────────────────────
# Test RoutineLayer.to_dict
# ──────────────────────────────────────────────────────────────

class TestRoutineLayerToDict:
    """
    ✅ Unit Tests for RoutineLayer.to_dict()

    Method Signature:
        to_dict(basic_info: bool) → Dict[str, Any]

    Behavior:
    - When basic_info=True → returns grouped tuples of (display_name, assignee_name)
    - When basic_info=False → returns full rotation data as list of dicts
    - If no rotations are defined → returns empty list either way

    Test Matrix:
    ┌──────────────────────────────┬────────────────────────────────────┐
    │ Scenario                     │ Expectation                        │
    ├──────────────────────────────┼────────────────────────────────────┤
    │ basic_info = True            │ List of grouped (display, assignee)│
    │ basic_info = False           │ List of full rotation dicts        │
    │ Empty rotation list          │ rotations = []                     │
    └──────────────────────────────┴────────────────────────────────────┘
    """

    def test_to_dict_basic_info_true(self):
        """
        ✅ basic_info=True → group rotations by start_period and return tuples.
        """
        r1 = RoutineRotationFactory(start_period=1)
        r2 = RoutineRotationFactory(start_period=1)
        r3 = RoutineRotationFactory(start_period=2)

        layer = RoutineLayerFactory(rotations=[r1, r2, r3])
        result = layer.to_dict(basic_info=True)

        # Ensure result contains a list of groups
        assert isinstance(result["rotations"], list)
        assert all(isinstance(group, list) for group in result["rotations"])

        # Flatten groups and validate (display_name, assignee_name) tuple structure
        flat = [entry for group in result["rotations"] for entry in group]
        assert all(isinstance(entry, tuple) and len(entry) == 2 for entry in flat)

        # Ensure correct values are present
        expected_names = {(r.display_name, r.assignee_name) for r in [r1, r2, r3]}
        actual_names = set(flat)
        assert actual_names == expected_names

    def test_to_dict_basic_info_false(self):
        """
        ✅ basic_info=False → return detailed dicts for each rotation.
        """
        r1 = RoutineRotationFactory()
        r2 = RoutineRotationFactory()

        layer = RoutineLayerFactory(rotations=[r1, r2])
        result = layer.to_dict(basic_info=False)

        # Validate each rotation is represented as a dictionary
        rotations = result["rotations"]
        assert isinstance(rotations, list)
        assert all(isinstance(r, dict) for r in rotations)

        expected_keys = {
            "layer", "start_period", "end_period",
            "assignee_name", "display_name", "assignee_policy_id"
        }

        for r in rotations:
            assert set(r.keys()) == expected_keys

    def test_to_dict_empty_rotations(self):
        """
        ✅ No rotations → to_dict() should return an empty list for both modes.
        """
        layer = RoutineLayerFactory(rotations=[])

        result_true = layer.to_dict(basic_info=True)
        result_false = layer.to_dict(basic_info=False)

        assert result_true["rotations"] == []
        assert result_false["rotations"] == []

# ──────────────────────────────────────────────────────────────
# Test RoutineRotation.create_rotation
# ──────────────────────────────────────────────────────────────

class TestRoutineRotationCreateRotation:
    """
    ✅ Unit Tests for RoutineRotation.create_rotation()

    Method Signature:
        create_rotation(layer: int, data: dict, for_display: bool) → RoutineRotation

    Behavior:
    - If for_display=True: uses reference fields (preferred_username, policy_ref_id)
    - If for_display=False: uses raw fields (assignee_name, assignee_policy_id)
    - Raises KeyError if any required field is missing
    """

    def test_create_rotation_for_display_true(self):
        """
        ✅ for_display=True → should use reference keys from data.
        """
        layer = fake.random_int(min=0, max=5)

        # Provide both raw and reference keys. Only reference keys should be used.
        data = {
            var_names.start_period: fake.random_int(min=1, max=10),
            var_names.end_period: fake.random_int(min=11, max=20),
            var_names.display_name: fake.name(),
            var_names.assignee_name: fake.user_name(),            # Should be ignored
            var_names.assignee_policy_id: fake.uuid4(),           # Should be ignored
            var_names.preferred_username: fake.user_name(),       # Used instead
            var_names.policy_ref_id: fake.uuid4(),                # Used instead
        }

        # Call the factory method with for_display=True
        result = RoutineRotation.create_rotation(layer, data, for_display=True)

        # Verify reference keys were picked
        assert isinstance(result, RoutineRotation)
        assert result.assignee_name == data[var_names.preferred_username]
        assert result.assignee_policy_id == data[var_names.policy_ref_id]

        # Other fields should remain consistent
        assert result.layer == layer
        assert result.start_period == data[var_names.start_period]
        assert result.end_period == data[var_names.end_period]
        assert result.display_name == data[var_names.display_name]

    def test_create_rotation_for_display_false(self):
        """
        ✅ for_display=False → should use raw keys from data.
        """
        layer = fake.random_int(min=0, max=5)

        # Provide only raw keys (no reference keys present)
        data = {
            var_names.start_period: fake.random_int(min=1, max=5),
            var_names.end_period: fake.random_int(min=6, max=10),
            var_names.display_name: fake.name(),
            var_names.assignee_name: fake.user_name(),
            var_names.assignee_policy_id: fake.uuid4()
        }

        # Call the factory method with for_display=False
        result = RoutineRotation.create_rotation(layer, data, for_display=False)

        # Verify raw keys were used
        assert isinstance(result, RoutineRotation)
        assert result.assignee_name == data[var_names.assignee_name]
        assert result.assignee_policy_id == data[var_names.assignee_policy_id]

        # Confirm all fields mapped correctly
        assert result.display_name == data[var_names.display_name]
        assert result.start_period == data[var_names.start_period]
        assert result.end_period == data[var_names.end_period]
        assert result.layer == layer

    def test_create_rotation_missing_key_raises_keyerror(self):
        """
        ❌ Missing required keys should raise KeyError.
        """
        layer = fake.random_int(min=0, max=3)

        # Deliberately omit assignee_name and assignee_policy_id
        incomplete_data = {
            var_names.start_period: fake.random_int(min=1, max=3),
            var_names.end_period: fake.random_int(min=4, max=7),
            var_names.display_name: fake.name()
        }

        # This should raise KeyError due to missing required fields
        with pytest.raises(KeyError):
            RoutineRotation.create_rotation(layer, incomplete_data, for_display=False)

# ──────────────────────────────────────────────────────────────
# Test RoutineRotation.equivalent_to
# ──────────────────────────────────────────────────────────────

class TestRoutineRotationEquivalentTo:
    """
    ✅ Unit tests for RoutineRotation.equivalent_to()

    Method behavior:
    - Returns True for identical rotation instances
    - Returns False if any field differs
    - Raises AssertionError if compared with a non-RoutineRotation
    """

    def test_equivalent_to_identical_data(self):
        """
        ✅ Two cloned RoutineRotation objects → should be equivalent
        """
        original = RoutineRotationFactory()
        clone = copy.deepcopy(original)  # Make a deep copy of the same object

        # Should return True since all attributes are the same
        assert original.equivalent_to(clone) is True

    def test_equivalent_to_different_data(self):
        """
        ✅ Slightly modified object → should return False
        """
        base = RoutineRotationFactory()

        # Modify periods to simulate a data change
        modified = RoutineRotation(
            layer=base.layer,
            start_period=base.start_period + 1,  # One period ahead
            end_period=base.end_period + 1,
            assignee_name=base.assignee_name,
            display_name=base.display_name,
            assignee_policy_id=base.assignee_policy_id,
        )

        # Should detect difference in start/end period
        assert base.equivalent_to(modified) is False

    def test_equivalent_to_wrong_type(self):
        """
        ❌ Non-RoutineRotation input → should raise AssertionError
        """
        rotation = RoutineRotationFactory()

        # Passing a string instead of a RoutineRotation should fail
        with pytest.raises(AssertionError):
            rotation.equivalent_to("invalid_type")

# ──────────────────────────────────────────────────────────────
# Test RoutineRotation.to_dict
# ──────────────────────────────────────────────────────────────

class TestRoutineRotationToDict:
    """
    ✅ Unit test for RoutineRotation.to_dict()

    This test ensures:
    - The output is a dictionary
    - All expected fields are present
    - Field values match the object's internal state
    """

    def test_to_dict_returns_expected_format(self):
        """
        ✅ Ensures all expected keys are present and match original object fields.
        """
        # Create a mock rotation using FactoryBoy
        rotation = RoutineRotationFactory()

        # Convert to dictionary using the method under test
        result = rotation.to_dict()

        # Must return a dict
        assert isinstance(result, dict), "Returned value must be a dictionary"

        # Define expected keys that the output must contain
        expected_keys = {
            var_names.layer,
            var_names.start_period,
            var_names.end_period,
            var_names.assignee_name,
            var_names.display_name,
            var_names.assignee_policy_id
        }

        # Check that the dictionary keys exactly match the expected set
        assert set(result.keys()) == expected_keys, "Missing or unexpected keys in output"

        # Check that each field value matches the object's corresponding attribute
        assert result[var_names.layer] == rotation.layer
        assert result[var_names.start_period] == rotation.start_period
        assert result[var_names.end_period] == rotation.end_period
        assert result[var_names.assignee_name] == rotation.assignee_name
        assert result[var_names.display_name] == rotation.display_name
        assert result[var_names.assignee_policy_id] == rotation.assignee_policy_id
