"""
Constraint builder for CP-SAT scheduling model.
Implements the 12 hard constraints from Spec 038.
"""
from typing import Dict, List, Any, Tuple
from ortools.sat.python import cp_model
from datetime import datetime


def parse_time(time_str: str) -> int:
    """Convert HH:MM or HH:MM:SS time string to minutes since midnight."""
    if not time_str:
        return 0
    parts = time_str.split(':')
    h, m = int(parts[0]), int(parts[1])
    return h * 60 + m


def is_overnight_shift(start_time: str, end_time: str) -> bool:
    """Check if shift crosses midnight."""
    return parse_time(end_time) < parse_time(start_time)


def build_constraints(
    model: cp_model.CpModel,
    problem_data: Dict[str, Any],
    x: Dict[Tuple[int, int], cp_model.IntVar]
) -> Dict[str, List[Any]]:
    """
    Build all 12 hard constraints for the scheduling model.

    Args:
        model: CP-SAT model instance
        problem_data: Problem input data
        x: Decision variables x[employee_id, shift_id]

    Returns:
        Dict of constraint metadata for debugging/reporting
    """
    constraints_metadata = {
        "c1_one_per_shift": [],
        "c2_locked_shifts": [],
        "c3_role_qualification": [],
        "c4_opening_closing": [],
        "c5_availability": [],
        "c6_time_off": [],
        "c7_hours_max": [],
        "c8_one_per_day": [],
        "c9_no_overlap": [],
        "c10_max_shifts_per_week": [],
        "c11_hours_max_hard": []
    }

    employees = problem_data["employees"]
    shifts = problem_data["shifts"]
    locked_shifts = problem_data.get("lockedShifts", [])

    # Build lookups
    employees_by_id = {emp["userId"]: emp for emp in employees}
    shifts_by_id = {shift["shiftId"]: shift for shift in shifts}
    locked_assignments = {ls["shiftId"]: ls["employeeId"] for ls in locked_shifts}

    # C1: Each shift assigned to at most 1 employee
    for shift in shifts:
        shift_id = shift["shiftId"]
        if shift_id in locked_assignments:
            # Locked shifts handled separately
            continue

        employee_vars = [x.get((emp["userId"], shift_id)) for emp in employees]
        employee_vars = [v for v in employee_vars if v is not None]

        if employee_vars:
            model.Add(sum(employee_vars) <= 1)
            constraints_metadata["c1_one_per_shift"].append(shift_id)

    # C2: Locked shifts are immutable
    for locked_shift in locked_shifts:
        shift_id = locked_shift["shiftId"]
        employee_id = locked_shift["employeeId"]

        # Force this assignment
        if (employee_id, shift_id) in x:
            model.Add(x[(employee_id, shift_id)] == 1)
            constraints_metadata["c2_locked_shifts"].append((shift_id, employee_id))

        # Prevent other employees from taking this shift
        for emp in employees:
            if emp["userId"] != employee_id and (emp["userId"], shift_id) in x:
                model.Add(x[(emp["userId"], shift_id)] == 0)

    # C3: Role qualification
    for emp in employees:
        emp_id = emp["userId"]
        emp_role = emp["role"]

        for shift in shifts:
            shift_id = shift["shiftId"]
            min_role_id = shift["minRoleId"]

            # Lower role number = higher authority (Role 1 = Owner, Role 5 = Associate)
            # Employee must have role <= minRoleId
            if emp_role > min_role_id:
                # Not qualified - prevent assignment
                if (emp_id, shift_id) in x:
                    model.Add(x[(emp_id, shift_id)] == 0)
                    constraints_metadata["c3_role_qualification"].append((emp_id, shift_id, emp_role, min_role_id))

    # C4: Opening/closing shifts (minRoleId <= 3) require Role <= 3
    # Note: This is technically enforced by C3 already, but we add it
    # as explicit documentation of the business rule.
    for shift in shifts:
        if shift.get("isLocked"):
            continue
        shift_id = shift["shiftId"]
        min_role_id = shift.get("minRoleId", 5)

        if min_role_id <= 3:
            # This shift requires role <= 3 (opening/closing responsibility)
            for emp in employees:
                emp_id = emp["userId"]
                emp_role = emp["role"]

                if emp_role > 3:  # Role 4 or 5 cannot work shifts requiring role <= 3
                    if (emp_id, shift_id) in x:
                        model.Add(x[(emp_id, shift_id)] == 0)
                        constraints_metadata["c4_opening_closing"].append((emp_id, shift_id, emp_role))

    # C5: Availability windows
    for emp in employees:
        emp_id = emp["userId"]
        avail_list = emp.get("availability", [])

        for shift in shifts:
            shift_id = shift["shiftId"]
            dow = shift["dayOfWeek"]
            shift_start = parse_time(shift["startTime"])
            shift_end = parse_time(shift["endTime"])
            is_overnight = is_overnight_shift(shift["startTime"], shift["endTime"])

            if is_overnight:
                # Overnight shift - check both start day and next day availability
                # Employee must be available from startTime to end of day on start day
                # AND from start of day to endTime on next day
                start_day_avail = [a for a in avail_list if a["dayOfWeek"] == dow]
                next_day = (dow % 7) + 1
                next_day_avail = [a for a in avail_list if a["dayOfWeek"] == next_day]

                # Check if start day has coverage from shift start onwards
                start_ok = any(
                    parse_time(a["startTime"]) <= shift_start and parse_time(a["endTime"]) >= shift_start
                    for a in start_day_avail
                )

                # Check if next day has coverage from midnight to shift end
                end_ok = any(
                    parse_time(a["startTime"]) <= shift_end and parse_time(a["endTime"]) >= shift_end
                    for a in next_day_avail
                )

                if not start_ok or not end_ok:
                    if (emp_id, shift_id) in x:
                        model.Add(x[(emp_id, shift_id)] == 0)
                        constraints_metadata["c5_availability"].append((emp_id, shift_id, "overnight_mismatch"))
            else:
                # Normal shift - check same-day availability
                day_avail = [a for a in avail_list if a["dayOfWeek"] == dow]
                has_coverage = any(
                    parse_time(a["startTime"]) <= shift_start and
                    parse_time(a["endTime"]) >= shift_end
                    for a in day_avail
                )

                if not has_coverage:
                    if (emp_id, shift_id) in x:
                        model.Add(x[(emp_id, shift_id)] == 0)
                        constraints_metadata["c5_availability"].append((emp_id, shift_id, "outside_window"))

    # C6: Time-off respected
    for emp in employees:
        emp_id = emp["userId"]
        time_off_dates = {to["date"] for to in emp.get("timeOff", [])}

        for shift in shifts:
            shift_id = shift["shiftId"]
            shift_date = shift["date"]

            if shift_date in time_off_dates:
                if (emp_id, shift_id) in x:
                    model.Add(x[(emp_id, shift_id)] == 0)
                    constraints_metadata["c6_time_off"].append((emp_id, shift_id, shift_date))

    # C7 & C11: hoursMax never exceeded (hard cap)
    for emp in employees:
        emp_id = emp["userId"]
        hours_max = emp["hoursMax"]
        current_hours = emp["currentPeriodHours"]

        # Convert to centihours (integer)
        hours_max_centi = int(hours_max * 100)
        current_hours_centi = int(current_hours * 100)

        # Sum of assigned shift hours
        assigned_hours = []
        for shift in shifts:
            shift_id = shift["shiftId"]
            if (emp_id, shift_id) in x:
                duration_centi = int(shift["durationHours"] * 100)
                assigned_hours.append(x[(emp_id, shift_id)] * duration_centi)

        if assigned_hours:
            total_hours = current_hours_centi + sum(assigned_hours)
            model.Add(total_hours <= hours_max_centi)
            constraints_metadata["c7_hours_max"].append((emp_id, hours_max))

    # C8: One shift per employee per day
    # Build lookup of which employees already have locked shifts on which dates
    locked_dates_by_emp = {}  # emp_id -> set of dates with locked shifts
    for ls in locked_shifts:
        ls_emp_id = ls.get("employeeId")
        ls_date = ls.get("date")
        if ls_emp_id is not None and ls_date is not None:
            if ls_emp_id not in locked_dates_by_emp:
                locked_dates_by_emp[ls_emp_id] = set()
            locked_dates_by_emp[ls_emp_id].add(ls_date)

    shifts_by_date = {}
    for shift in shifts:
        date = shift["date"]
        if date not in shifts_by_date:
            shifts_by_date[date] = []
        shifts_by_date[date].append(shift)

    for emp in employees:
        emp_id = emp["userId"]
        emp_locked_dates = locked_dates_by_emp.get(emp_id, set())

        for date, date_shifts in shifts_by_date.items():
            shift_vars = [x.get((emp_id, s["shiftId"])) for s in date_shifts]
            shift_vars = [v for v in shift_vars if v is not None]

            if date in emp_locked_dates:
                # Employee already has a locked shift on this date —
                # prevent ANY open shift assignment on this day
                for var in shift_vars:
                    model.Add(var == 0)
                constraints_metadata["c8_one_per_day"].append((emp_id, date, "locked"))
            elif len(shift_vars) > 1:
                model.Add(sum(shift_vars) <= 1)
                constraints_metadata["c8_one_per_day"].append((emp_id, date, len(shift_vars)))

    # C9: No overlapping shifts
    for emp in employees:
        emp_id = emp["userId"]
        emp_shifts = [(s["shiftId"], s) for s in shifts]

        for i, (sid1, s1) in enumerate(emp_shifts):
            for sid2, s2 in emp_shifts[i+1:]:
                # Parse dates and times
                from datetime import datetime, timedelta

                date1 = datetime.strptime(s1["date"], "%Y-%m-%d")
                date2 = datetime.strptime(s2["date"], "%Y-%m-%d")

                start1 = parse_time(s1["startTime"])
                end1 = parse_time(s1["endTime"])
                start2 = parse_time(s2["startTime"])
                end2 = parse_time(s2["endTime"])

                # Check for overlap
                overlaps = False

                # For overnight shifts, check if they overlap with next-day shifts
                is_overnight1 = is_overnight_shift(s1["startTime"], s1["endTime"])
                is_overnight2 = is_overnight_shift(s2["startTime"], s2["endTime"])

                if s1["date"] == s2["date"]:
                    # Same day
                    if is_overnight1 or is_overnight2:
                        # Conservative: overnight shifts on same day conflict
                        overlaps = True
                    else:
                        # Normal overlap check: one starts before the other ends
                        overlaps = (start1 < end2 and start2 < end1)
                elif (date2 - date1).days == 1 and is_overnight1:
                    # s1 is overnight on day D, s2 is on day D+1
                    # s1 ends at end1 minutes after midnight on D+1
                    # s2 starts at start2 minutes after midnight on D+1
                    # They overlap if end1 > start2
                    overlaps = (end1 > start2)
                elif (date1 - date2).days == 1 and is_overnight2:
                    # s2 is overnight on day D, s1 is on day D+1
                    overlaps = (end2 > start1)

                if overlaps:
                    if (emp_id, sid1) in x and (emp_id, sid2) in x:
                        model.Add(x[(emp_id, sid1)] + x[(emp_id, sid2)] <= 1)
                        constraints_metadata["c9_no_overlap"].append((emp_id, sid1, sid2))

    # C10: Max 5 shifts per employee per week
    for emp in employees:
        emp_id = emp["userId"]
        shifts_this_week = emp.get("shiftsThisWeek", 0)

        # Count assigned shifts
        shift_vars = [x.get((emp_id, s["shiftId"])) for s in shifts]
        shift_vars = [v for v in shift_vars if v is not None]

        if shift_vars:
            model.Add(sum(shift_vars) + shifts_this_week <= 5)
            constraints_metadata["c10_max_shifts_per_week"].append((emp_id, shifts_this_week))

    # C12: Position eligibility
    # If a shift has a positionId, only employees with that position in
    # their allowedPositionIds can be assigned. Shifts with positionId=None
    # (no position) can be worked by any employee.
    constraints_metadata["c12_position_eligibility"] = []

    for emp in employees:
        emp_id = emp["userId"]
        allowed_pos_ids = set(emp.get("allowedPositionIds", []))

        for shift in shifts:
            shift_id = shift["shiftId"]
            shift_pos_id = shift.get("positionId")

            # Only enforce if the shift HAS a position assigned
            if shift_pos_id is not None and shift_pos_id not in allowed_pos_ids:
                if (emp_id, shift_id) in x:
                    model.Add(x[(emp_id, shift_id)] == 0)
                    constraints_metadata["c12_position_eligibility"].append(
                        (emp_id, shift_id, shift_pos_id, list(allowed_pos_ids))
                    )

    return constraints_metadata
