"""
Unit tests for constraint builder.
Tests each constraint individually.
"""
import pytest
from ortools.sat.python import cp_model
import sys
from pathlib import Path
from zoneinfo import ZoneInfo

# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))

from constraint_builder import (
    availability_covers_shift,
    build_constraints,
    is_overnight_shift,
    parse_time,
    shift_interval,
)


def test_parse_time():
    """Test time parsing."""
    assert parse_time("00:00") == 0
    assert parse_time("09:00") == 540
    assert parse_time("12:30") == 750
    assert parse_time("23:59") == 1439


def test_is_overnight_shift():
    """Test overnight shift detection."""
    assert is_overnight_shift("22:00", "06:00") is True
    assert is_overnight_shift("09:00", "17:00") is False
    assert is_overnight_shift("23:00", "01:00") is True


def test_shift_interval_preserves_repeated_fall_back_hour_from_utc_instants():
    start, end = shift_interval(
        {
            "date": "2026-11-01",
            "endDate": "2026-11-01",
            "startTime": "01:30",
            "endTime": "01:30",
            "durationHours": 1.0,
            "startDateTimeUtc": "2026-11-01T06:30:00+00:00",
            "endDateTimeUtc": "2026-11-01T07:30:00+00:00",
            "storeTimezone": "America/Chicago",
        }
    )

    assert end.timestamp() - start.timestamp() == 3600
    local_start = start.astimezone(ZoneInfo("America/Chicago"))
    local_end = end.astimezone(ZoneInfo("America/Chicago"))
    assert local_start.strftime("%Y-%m-%d %H:%M") == "2026-11-01 01:30"
    assert local_end.strftime("%Y-%m-%d %H:%M") == "2026-11-01 01:30"


def test_c5_repeated_fall_back_hour_cannot_bypass_empty_availability():
    shift = {
        "date": "2026-11-01",
        "endDate": "2026-11-01",
        "startTime": "01:30",
        "endTime": "01:30",
        "durationHours": 1.0,
        "startDateTimeUtc": "2026-11-01T06:30:00+00:00",
        "endDateTimeUtc": "2026-11-01T07:30:00+00:00",
        "storeTimezone": "America/Chicago",
    }

    assert availability_covers_shift(shift, []) is False


def test_c5_repeated_fall_back_hour_requires_enough_positive_availability():
    shift = {
        "date": "2026-11-01",
        "endDate": "2026-11-01",
        "startTime": "01:30",
        "endTime": "01:30",
        "durationHours": 1.0,
        "startDateTimeUtc": "2026-11-01T06:30:00+00:00",
        "endDateTimeUtc": "2026-11-01T07:30:00+00:00",
        "storeTimezone": "America/Chicago",
    }
    availability = [
        {"dayOfWeek": 7, "startTime": "01:30", "endTime": "01:31"},
    ]

    assert availability_covers_shift(shift, availability) is False


def test_c5_full_day_availability_covers_repeated_fall_back_hour():
    shift = {
        "date": "2026-11-01",
        "endDate": "2026-11-01",
        "startTime": "01:30",
        "endTime": "01:30",
        "durationHours": 1.0,
        "startDateTimeUtc": "2026-11-01T06:30:00+00:00",
        "endDateTimeUtc": "2026-11-01T07:30:00+00:00",
        "storeTimezone": "America/Chicago",
    }
    availability = [
        {"dayOfWeek": 7, "startTime": "00:00", "endTime": "23:59"},
    ]

    assert availability_covers_shift(shift, availability) is True


def test_c1_one_employee_per_shift():
    """Test C1: Each shift assigned to at most 1 employee."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {"userId": 1, "role": 4, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [], "timeOff": [], "allowedPositionIds": []},
            {"userId": 2, "role": 4, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [], "timeOff": [], "allowedPositionIds": []}
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (2, 1): model.NewBoolVar("x_2_1")
    }

    metadata = build_constraints(model, problem, x)

    # Constraint should be added
    assert 1 in metadata["c1_one_per_shift"]

    # Try to solve - both employees assigned should be infeasible
    model.Add(x[(1, 1)] == 1)
    model.Add(x[(2, 1)] == 1)

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status == cp_model.INFEASIBLE


def test_c2_locked_shifts_immutable():
    """Test C2: Locked shifts stay with assigned employee."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": []
            },
            {
                "userId": 2,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": True}
        ],
        "lockedShifts": [
            {"shiftId": 1, "employeeId": 1, "date": "2026-02-16", "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0}
        ]
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (2, 1): model.NewBoolVar("x_2_1")
    }

    metadata = build_constraints(model, problem, x)

    assert (1, 1) in metadata["c2_locked_shifts"]

    # Add dummy objective to make model solvable
    model.Maximize(0)

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 1
    assert solver.Value(x[(2, 1)]) == 0


def test_c3_explicit_keyholder_qualification():
    """Test C3: an explicit keyholder shift requires a keyholder employee."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {"userId": 1, "role": 4, "isKeyholder": False, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [], "timeOff": [], "allowedPositionIds": []},
            {"userId": 2, "role": 4, "isKeyholder": True, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [], "timeOff": [], "allowedPositionIds": []}
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 5, "requiresKeyholder": True, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (2, 1): model.NewBoolVar("x_2_1")
    }

    metadata = build_constraints(model, problem, x)

    assert any(item[0] == 1 and item[1] == 1 for item in metadata["c3_keyholder_qualification"])
    assert not any(item[0] == 2 and item[1] == 1 for item in metadata["c3_keyholder_qualification"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0  # Not qualified
    # Employee 2 can be assigned (or not, but not blocked by role)


def test_c3_legacy_role_requirement_remains_keyholder_fallback():
    """Legacy minRoleId <= 3 continues to imply a keyholder requirement."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {"userId": 1, "role": 4, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [], "timeOff": [], "allowedPositionIds": []},  # Not qualified for role 3 shifts
            {"userId": 2, "role": 3, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [], "timeOff": [], "allowedPositionIds": []}   # Qualified
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 3, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "07:00", "endTime": "15:00", "durationHours": 8.0, "isLocked": False}  # Requires role <= 3
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (2, 1): model.NewBoolVar("x_2_1")
    }

    metadata = build_constraints(model, problem, x)

    assert any(item[0] == 1 and item[1] == 1 for item in metadata["c3_keyholder_qualification"])
    assert not any(item[0] == 2 and item[1] == 1 for item in metadata["c3_keyholder_qualification"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0  # Role 4 can't work shifts requiring role <= 3


def test_c4_opening_shift_requires_keyholder_even_without_legacy_role():
    """An opener flag must be enforced even when minRoleId is unrestricted."""
    model = cp_model.CpModel()
    problem = {
        "employees": [
            {"userId": 1, "role": 4, "isKeyholder": False, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [{"dayOfWeek": 1, "startTime": "00:00", "endTime": "23:59"}], "timeOff": [], "allowedPositionIds": []},
            {"userId": 2, "role": 4, "isKeyholder": True, "hoursMax": 40, "currentPeriodHours": 0, "shiftsThisWeek": 0, "availability": [{"dayOfWeek": 1, "startTime": "00:00", "endTime": "23:59"}], "timeOff": [], "allowedPositionIds": []},
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 5, "requiresKeyholder": False, "isOpener": True, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "07:00", "endTime": "15:00", "durationHours": 8.0, "isLocked": False}
        ],
        "lockedShifts": [],
    }
    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (2, 1): model.NewBoolVar("x_2_1"),
    }

    metadata = build_constraints(model, problem, x)
    model.Maximize(x[(1, 1)] + x[(2, 1)])
    solver = cp_model.CpSolver()
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0
    assert solver.Value(x[(2, 1)]) == 1
    assert (1, 1, "not_keyholder") in metadata["c4_opening_closing"]


def test_c5_availability_windows():
    """Test C5: Shifts must fall within availability windows."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [
                    {"dayOfWeek": 1, "startTime": "12:00", "endTime": "20:00"}
                ],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False}  # Before availability
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    # Should block assignment
    assert any(item[0] == 1 and item[1] == 1 for item in metadata["c5_availability"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0


def test_c6_time_off():
    """Test C6: Time-off days are respected."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [{"date": "2026-02-16", "reason": "Vacation"}],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    assert any(item[0] == 1 and item[1] == 1 for item in metadata["c6_time_off"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0


def test_c6_time_off_blocks_overnight_shift_on_every_touched_date():
    """Approved leave on the second calendar day blocks an overnight shift."""
    model = cp_model.CpModel()
    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [
                    {"dayOfWeek": 1, "startTime": "00:00", "endTime": "23:59"},
                    {"dayOfWeek": 2, "startTime": "00:00", "endTime": "23:59"},
                ],
                "timeOff": [{"date": "2026-02-17", "reason": "Vacation"}],
                "allowedPositionIds": [],
            }
        ],
        "shifts": [
            {
                "shiftId": 1,
                "minRoleId": 4,
                "date": "2026-02-16",
                "dayOfWeek": 1,
                "startTime": "22:00",
                "endTime": "02:00",
                "durationHours": 4.0,
                "isLocked": False,
            }
        ],
        "lockedShifts": [],
    }
    x = {(1, 1): model.NewBoolVar("x_1_1")}

    metadata = build_constraints(model, problem, x)

    assert (1, 1, "2026-02-17") in metadata["c6_time_off"]
    solver = cp_model.CpSolver()
    assert solver.Solve(model) in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0


def test_c7_hours_max_hard_cap():
    """Test C7: hoursMax is never exceeded."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 16.0,
                "currentPeriodHours": 8.0,
                "shiftsThisWeek": 1,
                "availability": [{"dayOfWeek": i, "startTime": "08:00", "endTime": "22:00"} for i in range(1, 8)],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-17", "dayOfWeek": 2, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False},
            {"shiftId": 2, "minRoleId": 4, "date": "2026-02-18", "dayOfWeek": 3, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (1, 2): model.NewBoolVar("x_1_2")
    }

    metadata = build_constraints(model, problem, x)

    assert (1, 16.0) in metadata["c7_hours_max"]

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]

    # Employee can take at most 1 shift (8h current + 8h = 16h max)
    assigned = solver.Value(x[(1, 1)]) + solver.Value(x[(1, 2)])
    assert assigned <= 1


def test_c8_one_shift_per_day():
    """Test C8: One shift per employee per day."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "13:00", "durationHours": 4.0, "isLocked": False},
            {"shiftId": 2, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "14:00", "endTime": "18:00", "durationHours": 4.0, "isLocked": False}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1"),
        (1, 2): model.NewBoolVar("x_1_2")
    }

    metadata = build_constraints(model, problem, x)

    assert any(item[0] == 1 and item[1] == "2026-02-16" for item in metadata["c8_one_per_day"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]

    # Employee can take at most 1 shift per day
    assigned = solver.Value(x[(1, 1)]) + solver.Value(x[(1, 2)])
    assert assigned <= 1


def test_c9_open_shift_cannot_overlap_locked_overnight_shift():
    """An open next-day shift cannot overlap an employee's locked overnight shift."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 8,
                "shiftsThisWeek": 1,
                "availability": [
                    {"dayOfWeek": 1, "startTime": "00:00", "endTime": "22:00"},
                    {"dayOfWeek": 7, "startTime": "20:00", "endTime": "23:59"},
                ],
                "timeOff": [],
                "allowedPositionIds": [],
            }
        ],
        "shifts": [
            {
                "shiftId": 2,
                "minRoleId": 4,
                "date": "2026-02-16",
                "dayOfWeek": 1,
                "startTime": "01:00",
                "endTime": "05:00",
                "durationHours": 4.0,
                "isLocked": False,
            }
        ],
        "lockedShifts": [
            {
                "shiftId": 1,
                "employeeId": 1,
                "date": "2026-02-15",
                "dayOfWeek": 7,
                "startTime": "22:00",
                "endTime": "02:00",
                "durationHours": 4.0,
            }
        ],
    }

    x = {(1, 2): model.NewBoolVar("x_1_2")}
    metadata = build_constraints(model, problem, x)
    model.Maximize(x[(1, 2)])

    solver = cp_model.CpSolver()
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 2)]) == 0
    assert (1, 2, 1) in metadata["c9_no_overlap"]


def test_c9_canonical_locked_dst_interval_never_mixes_aware_and_naive_datetimes():
    model = cp_model.CpModel()
    canonical = {
        "endDate": "2026-11-01",
        "startDateTimeUtc": "2026-11-01T06:30:00+00:00",
        "endDateTimeUtc": "2026-11-01T07:30:00+00:00",
        "storeTimezone": "America/Chicago",
    }
    problem = {
        "employees": [{"userId": 1, "role": 4, "hoursMax": 40, "currentPeriodHours": 1,
                       "shiftsThisWeek": 1, "availability": [], "timeOff": [], "allowedPositionIds": []}],
        "shifts": [{"shiftId": 2, "minRoleId": 4, "date": "2026-11-01", "dayOfWeek": 7,
                    "startTime": "01:45", "endTime": "02:15", "durationHours": 1.5,
                    "isLocked": False, **canonical}],
        "lockedShifts": [{"shiftId": 1, "employeeId": 1, "date": "2026-11-01", "dayOfWeek": 7,
                          "startTime": "01:30", "endTime": "01:30", "durationHours": 1.0,
                          **canonical}],
    }
    x = {(1, 2): model.NewBoolVar("x_1_2_dst")}

    metadata = build_constraints(model, problem, x)

    assert (1, 2, 1) in metadata["c9_no_overlap"]


def test_c10_max_five_shifts_per_week():
    """Test C10: Max 5 shifts per employee per week."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 50,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 2,  # Already has 2 shifts
                "availability": [{"dayOfWeek": i, "startTime": "08:00", "endTime": "22:00"} for i in range(1, 8)],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": i, "minRoleId": 4, "date": f"2026-02-{16+i}", "dayOfWeek": 1+i, "startTime": "09:00", "endTime": "15:00", "durationHours": 6.0, "isLocked": False}
            for i in range(5)  # 5 more shifts available
        ],
        "lockedShifts": []
    }

    x = {
        (1, i): model.NewBoolVar(f"x_1_{i}")
        for i in range(5)
    }

    metadata = build_constraints(model, problem, x)

    assert (1, 2) in metadata["c10_max_shifts_per_week"]

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]

    # Employee can take at most 3 more shifts (2 + 3 = 5 total)
    assigned = sum(solver.Value(x[(1, i)]) for i in range(5))
    assert assigned <= 3


def test_c12_position_eligible_allowed():
    """Test C12: Employee with positions [1,3] assigned to shift with positionId=1 -> ALLOWED."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": [1, 3]
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False, "positionId": 1}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    # Employee 1 should NOT be blocked (they have position 1)
    assert not any(item[0] == 1 and item[1] == 1 for item in metadata["c12_position_eligibility"])

    # Add dummy objective to make model solvable
    model.Maximize(0)

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    # Employee can be assigned (not blocked by position)


def test_c12_position_ineligible_blocked():
    """Test C12: Employee with positions [1,3] assigned to shift with positionId=2 -> BLOCKED."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": [1, 3]
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False, "positionId": 2}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    # Employee 1 should be blocked (they don't have position 2)
    assert any(item[0] == 1 and item[1] == 1 for item in metadata["c12_position_eligibility"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0  # Not qualified


def test_c12_no_positions_blocked():
    """Test C12: Employee with positions [] assigned to shift with positionId=1 -> BLOCKED."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False, "positionId": 1}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    # Employee 1 should be blocked (they have no positions)
    assert any(item[0] == 1 and item[1] == 1 for item in metadata["c12_position_eligibility"])

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    assert solver.Value(x[(1, 1)]) == 0  # Not qualified


def test_c12_null_position_any_employee():
    """Test C12: Employee with positions [1,3] assigned to shift with positionId=None -> ALLOWED."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": [1, 3]
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False, "positionId": None}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    # Employee 1 should NOT be blocked (shift has no position requirement)
    assert not any(item[0] == 1 and item[1] == 1 for item in metadata["c12_position_eligibility"])

    # Add dummy objective to make model solvable
    model.Maximize(0)

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    # Employee can be assigned (no position requirement)


def test_c12_null_position_no_positions_allowed():
    """Test C12: Employee with positions [] assigned to shift with positionId=None -> ALLOWED."""
    model = cp_model.CpModel()

    problem = {
        "employees": [
            {
                "userId": 1,
                "role": 4,
                "hoursMax": 40,
                "currentPeriodHours": 0,
                "shiftsThisWeek": 0,
                "availability": [{"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}],
                "timeOff": [],
                "allowedPositionIds": []
            }
        ],
        "shifts": [
            {"shiftId": 1, "minRoleId": 4, "date": "2026-02-16", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00", "durationHours": 8.0, "isLocked": False, "positionId": None}
        ],
        "lockedShifts": []
    }

    x = {
        (1, 1): model.NewBoolVar("x_1_1")
    }

    metadata = build_constraints(model, problem, x)

    # Employee 1 should NOT be blocked (shift has no position requirement)
    assert not any(item[0] == 1 and item[1] == 1 for item in metadata["c12_position_eligibility"])

    # Add dummy objective to make model solvable
    model.Maximize(0)

    solver = cp_model.CpSolver()
    solver.parameters.log_search_progress = False
    status = solver.Solve(model)

    assert status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    # Employee with no positions can still work shifts with no position requirement
