"""
MILP Solver for Travel Itinerary Optimization
"""

from typing import Any, Dict, List, Optional, Tuple, Union, Literal, Callable, get_args
from dataclasses import dataclass
import datetime
import pulp
import numpy as np
import math

from utils.poi_analyzer import POIAnalyzer

from .data_model import Itinerary, OptimizationObjective, TravelRequest, DayInfo, ScheduleDetail, DetailItem

class MILPSolver:
    """MILP solver for finding optimal travel itineraries"""

    def __init__(self, **kwargs) -> None:
        """Initialize MILP solver"""
        self.problem: pulp.LpProblem = None
        self.poi_dict: Dict[str, Any] = kwargs.get("poi_dict", {})
        self.poi_analyzer: POIAnalyzer = kwargs.get("poi_analyzer")
        self.request: TravelRequest = kwargs.get("request")
        self.solvers: Dict[OptimizationObjective, Callable[[Dict[str, Any]], Itinerary]] = {
            "user_preference": self._solve_user_preference
        }
        self.locale = kwargs.get("locale", "zh-CN")
        self.departure = kwargs.get("departure", "")
        self.arrive = kwargs.get("arrive", "")

    def is_hotel_in_range_of_poi(self, hotel_info: Dict, poi_info: Dict, locale: str = "zh-CN") -> bool:
        """
        Check if a hotel is in range of a POI based on location information overlap
        
        Args:
            hotel_info: Hotel information dictionary
            poi_info: POI information dictionary  
            locale: Language locale
            
        Returns:
            True if hotel is in range of POI, False otherwise
        """
        # Extract all location information from hotel
        hotel_locations = set()
        
        # Hotel city name
        hotel_city = hotel_info.get("cityName", "")
        if hotel_city:
            hotel_locations.add(hotel_city.lower().strip())
        
        # Hotel zone information
        hotel_zone_info = hotel_info.get("zoneInfo", [])
        for zone in hotel_zone_info:
            zone_name = zone.get("zoneName", "")
            zone_name_en = zone.get("zoneNameEn", "")
            if zone_name:
                hotel_locations.add(zone_name.lower().strip())
            if zone_name_en:
                hotel_locations.add(zone_name_en.lower().strip())
        
        # Hotel address information
        hotel_address = hotel_info.get("positionShowText", "")
        if hotel_address:
            hotel_locations.add(hotel_address.lower().strip())
        
        # Extract all location information from POI
        poi_locations = set()
        
        # POI district name
        poi_district = poi_info.get("districtName", "")
        if poi_district:
            poi_locations.add(poi_district.lower().strip())
        
        # POI address
        poi_address = poi_info.get("address", "")
        if poi_address:
            poi_locations.add(poi_address.lower().strip())
        
        # POI city name (if available)
        poi_city = poi_info.get("cityName", "")
        if poi_city:
            poi_locations.add(poi_city.lower().strip())
        
        # Check for any overlap between hotel and POI locations
        overlap = hotel_locations.intersection(poi_locations)
        

        
        return len(overlap) > 0

    def validate_hotel_assignment(self, hotel_info: Dict, scheduled_pois: List[Dict], locale: str = "zh-CN") -> bool:
        """
        Validate if a hotel assignment is valid for the scheduled POIs
        
        Args:
            hotel_info: Hotel information dictionary
            scheduled_pois: List of POI dictionaries that are scheduled
            locale: Language locale
            
        Returns:
            True if hotel is valid for the scheduled POIs, False otherwise
        """
        if not scheduled_pois:
            return True  # No POIs scheduled, any hotel is valid
        
        # Check if hotel is in range of at least one scheduled POI
        for poi in scheduled_pois:
            if self.is_hotel_in_range_of_poi(hotel_info, poi["info"], locale):
                return True
        
        return False

    def _calculate_poi_score(self, poi_info: Dict[str, Any]) -> float:
        """Calculate a quality score for a POI based on various factors"""
        score = 0.0
        
        # 1. comment score (0-5 points, weight 30%)
        comment_score = poi_info.get("commentScore", 0)
        if isinstance(comment_score, (int, float)) and comment_score > 0:
            score += (comment_score / 5.0) * 0.3
        
        # 2. tag richness (weight 20%)
        tags = poi_info.get("tag", [])
        if isinstance(tags, list) and len(tags) > 0:
            score += min(len(tags) / 10.0, 1.0) * 0.2
        
        # 3. description length (weight 15%)
        description = poi_info.get("description", "")
        if description and len(description) > 50:
            score += min(len(description) / 500.0, 1.0) * 0.15
        
        # 4. play time information (weight 15%)
        play_time = poi_info.get("playTime", "")
        if play_time and "小时" in play_time or "hour" in play_time.lower():
            score += 0.15
        
        # 5. opening time information (weight 10%)
        open_time_info = poi_info.get("openTimeInfo", {})
        if open_time_info and isinstance(open_time_info, list) and len(open_time_info) > 0:
            score += 0.1
        
        # 6. base score (weight 10%)
        score += 0.1
        
        return min(score, 1.0)  # ensure the score is not greater than 1.0

    def collect_travel_data(self, request: TravelRequest) -> Dict[str, Any]:
        """Collect all necessary information from travel request using POI analyzer"""
        # Get POI IDs for must-have POI names in destination cities
        # intelligent POI supplement logic, supplement all POIs
        must_have_poi_ids = []
        if len(request.must_have_pois) == 0 or len(request.must_have_pois) < 3*self.request.maximum_num_of_days:
            print(f"Adding POIs from destination cities. Current POIs: {len(request.must_have_pois)}")
            
            # analyze the distribution of existing POIs in the destination cities
            existing_poi_cities = {}
            if request.must_have_pois:
                for poi_name in request.must_have_pois:
                    for poi_id in self.poi_analyzer.poi_pool:
                        poi_info, _, _ = self.poi_analyzer.read_poi_api_info(poi_id, self.locale)
                        poi_display_name = poi_info.get("cname", "") if self.locale.startswith("zh") else poi_info.get("ename", "")
                        if (poi_name == poi_display_name 
                        or poi_name in poi_display_name 
                        or poi_display_name in poi_name 
                        or poi_name in str(poi_info.get('tag', [])) 
                        or poi_name == poi_info.get('id', '') 
                        or poi_name == poi_info.get('ename', '')) \
                        and (poi_info.get("districtEName") in request.destination_cities_ordered or poi_info.get("districtName") in request.destination_cities_ordered):
                            must_have_poi_ids.append({
                                "id": poi_id, 
                                "name": poi_info.get("cname", "") if self.locale.startswith("zh") else poi_info.get("ename", ""), 
                                "info": poi_info,
                                "opening_times": poi_info.get("openTimeInfo", {})
                                })
                            city = poi_info.get("districtName") if self.locale.startswith("zh") else poi_info.get("districtEName")
                            if city:
                                existing_poi_cities[city] = existing_poi_cities.get(city, 0) + 1
                            break
            
            print(f"Existing POI cities: {existing_poi_cities}")
            
            # first process cities without POIs, then process cities with existing POIs
            cities_to_process = []
            
            # first process cities without POIs
            for city in request.destination_cities_ordered:
                if city not in existing_poi_cities:
                    cities_to_process.append(city)
            
            # then process cities with existing POIs (add more POIs)
            for city in request.destination_cities_ordered:
                if city in existing_poi_cities:
                    cities_to_process.append(city)
            
            sigle_city_poi_need = math.floor(3*self.request.maximum_num_of_days//len(request.destination_cities_ordered))
            # add POIs to each city
            for city in cities_to_process:
                if len(request.must_have_pois) >= 3*self.request.maximum_num_of_days:
                    break
                    
                city_poi_count = 0
                max_pois_per_city = sigle_city_poi_need if city not in existing_poi_cities else sigle_city_poi_need-existing_poi_cities[city] 
                
                # collect all candidate POIs for this city
                city_candidates = []
                for poi_id in self.poi_analyzer.poi_pool:
                    poi_info, _, _ = self.poi_analyzer.read_poi_api_info(poi_id, self.locale)
                    if (poi_info.get("districtName") == city or poi_info.get("districtEName") == city) and poi_id not in [poi["id"] for poi in must_have_poi_ids]:
                        poi_name = poi_info.get("cname", "") if self.locale.startswith("zh") else poi_info.get("ename", "")
                        if poi_name and poi_name not in request.must_have_pois:
                            # calculate POI quality score
                            score = self._calculate_poi_score(poi_info)
                            city_candidates.append((poi_id,poi_name, score, poi_info))
                
                # sort by score, select the best POI
                city_candidates.sort(key=lambda x: x[2], reverse=True)
                
                for poi_id,poi_name, score, poi_info in city_candidates:
                    # if city_poi_count >= max_pois_per_city:
                    #     break
                    request.must_have_pois.add(poi_name)
                    # directly add to must_have_poi_ids, avoid duplicate lookup
                    must_have_poi_ids.append({
                        "id": poi_id,
                        "name": poi_name,
                        "info": poi_info,
                        "opening_times": poi_info.get("openTimeInfo", {})
                    })
                    city_poi_count += 1
                    # print(f"Added POI: {poi_name} from {city} (score: {score:.2f})")

        # process the POIs originally specified by the user (if not already processed)
        print(f"Processing {len(request.must_have_pois)} POIs: {list(request.must_have_pois)}")
        print(f"Total POIs found for scheduling: {len(must_have_poi_ids)}")
        
        # check if there are enough POIs for scheduling
        if len(must_have_poi_ids) == 0:
            raise ValueError(f"No POIs found in destination cities: {request.destination_cities_ordered}")
        
        # Get all POIs and hotels with opening times for destination cities
        pois_by_city = {}
        hotels_by_city = {}
        for city in request.destination_cities_ordered:
            pois_by_city[city] = []
            hotels_by_city[city] = []
            
            # Collect POIs in this city with opening times
            for poi_id in self.poi_analyzer.poi_pool:
                poi_info, _, _ = self.poi_analyzer.read_poi_api_info(poi_id, self.locale)
                if poi_info.get("districtName") == city or poi_info.get("districtEName") == city:
                    pois_by_city[city].append({
                        "id": poi_id, 
                        "name": poi_info.get("cname", "") if self.locale.startswith("zh") else poi_info.get("ename", ""),
                        "info": poi_info,
                        "opening_times": poi_info.get("openTimeInfo", {})
                    })
            
            # Collect hotels in this city with opening times
            
            # Find hotels for this district using the reusable function
            matching_hotels = []
            for hotel_id in self.poi_analyzer.hotel_pool:
                hotel_info = self.poi_analyzer.get_hotel_info(hotel_id, self.locale)
                
                # Check against all POIs in this city to find location overlaps
                for poi_id in self.poi_analyzer.poi_pool:
                    poi_info, _, _ = self.poi_analyzer.read_poi_api_info(poi_id, self.locale)
                    if poi_info.get("districtName") == city or poi_info.get("districtEName") == city:
                        if self.is_hotel_in_range_of_poi(hotel_info, poi_info, self.locale):
                            matching_hotels.append(hotel_info)
                            break  # Found a match, no need to check other POIs for this hotel
            
            for hotel_info in matching_hotels:
                hotels_by_city[city].append({
                    "id": hotel_info["id"], 
                    "name": hotel_info.get("cname", "") if self.locale.startswith("zh") else hotel_info.get("ename", ""),
                    "info": hotel_info,
                    "opening_times": hotel_info.get("openingTimes", [])
                })
        
        # debug: check if must_have_poi_ids is empty
        if not must_have_poi_ids:
            print("Warning: must_have_poi_ids is empty!")
            print(f"request.must_have_pois: {request.must_have_pois}")
            print(f"poi_analyzer.poi_pool size: {len(self.poi_analyzer.poi_pool)}")
            print(f"destination_cities: {request.destination_cities_ordered}")
        else:
            print(f"Successfully collected {len(must_have_poi_ids)} POIs")
        
        return {    
            "must_have_poi_ids": must_have_poi_ids,
            "pois_by_city": pois_by_city,
            "hotels_by_city": hotels_by_city,
            "duration_days": (request.end_date - request.start_date).days + 1,
            "destination_cities": request.destination_cities_ordered
        }

    def solve(self, objective: OptimizationObjective) -> Any:
        """Solve MILP problem using the appropriate solver"""
        if self.request is None:
            raise ValueError("Request must be provided in MILPSolver initialization")
        data = self.collect_travel_data(self.request)
        solver = self.solvers.get(objective)
        if solver is None:
            raise ValueError(f"Unknown objective: {objective}")
        return solver(data)
    
    def _solve_user_preference(self, data: Dict[str, Any]) -> Itinerary:
        """Solve for user preference optimization objective using MILP with fallback mechanisms"""
        try:
            return self._solve_user_preference_strict(data)
        except ValueError as e:
            if "No feasible solution" in str(e):
                print(f"Strict constraints failed, trying relaxed constraints...")
                print(f"Error details: {e}")
                return self._solve_user_preference_relaxed(data)
            else:
                raise e
    
    def _solve_user_preference_strict(self, data: Dict[str, Any]) -> Itinerary:
        """Solve for user preference optimization objective using MILP"""
        must_have_pois = data["must_have_poi_ids"]
        pois_by_city = data["pois_by_city"]
        hotels_by_city = data["hotels_by_city"]
        duration_days = data["duration_days"]
        destination_cities = data["destination_cities"]
        
        # debug: display the contents of must_have_pois
        print(f"=== start MILP solving ===")
        print(f"must_have_pois number: {len(must_have_pois)}")
        for i, poi in enumerate(must_have_pois[:5]):  # only display the first 5
            print(f"  POI {i+1}: {poi.get('name', 'Unknown')} (ID: {poi.get('id', 'Unknown')})")
        if len(must_have_pois) > 5:
            print(f"  ... there are {len(must_have_pois) - 5} POIs")
        print(f"=== end must_have_pois display ===")
        
        # create MILP problem
        prob = pulp.LpProblem("Travel_Itinerary_Optimization", pulp.LpMinimize)
        
        # Decision variables
        periods = ["Morning", "Afternoon", "Evening"]
        
        x = pulp.LpVariable.dicts("schedule_poi",
                                 [(poi["id"], day, period) 
                                  for poi in must_have_pois 
                                  for day in range(duration_days) 
                                  for period in periods],
                                 cat='Binary')
        
        y = pulp.LpVariable.dicts("use_hotel",
                                 [(hotel["id"], day) 
                                  for city_hotels in hotels_by_city.values() 
                                  for hotel in city_hotels 
                                  for day in range(duration_days)],
                                 cat='Binary')
        
        city_change_vars = pulp.LpVariable.dicts("city_change",
                                                [(day, city1, city2) 
                                                 for day in range(1, duration_days)
                                                 for city1 in destination_cities
                                                 for city2 in destination_cities
                                                 if city1 != city2],
                                                cat='Binary')
        
        city_change_penalty = pulp.lpSum([city_change_vars[day, city1, city2] * 100
                                        for day in range(1, duration_days)
                                        for city1 in destination_cities
                                        for city2 in destination_cities
                                        if city1 != city2])
        
        city_used_vars = pulp.LpVariable.dicts("city_used",
                                              [(day, city) 
                                               for day in range(duration_days)
                                               for city in destination_cities],
                                              cat='Binary')
        
        city_count_penalty = pulp.lpSum([city_used_vars[day, city] * 50
                                       for day in range(duration_days)
                                       for city in destination_cities])
        

        poi_scheduling_reward = pulp.lpSum([x[poi["id"], day, period] 
                                          for poi in must_have_pois 
                                          for day in range(duration_days) 
                                          for period in periods]) * (-10)  # Reward for each scheduled POI
        
        prob += city_change_penalty + city_count_penalty + poi_scheduling_reward
        
        # Constraints
        
        # 1. All must-have POIs should be scheduled at most once (allow skipping if necessary)
        for poi in must_have_pois:
            prob += pulp.lpSum([x[poi["id"], day, period] 
                               for day in range(duration_days) 
                               for period in periods]) <= 1
        
        # 2. Only one POI per time slot (day, period), but allow empty periods for travel
        for day in range(duration_days):
            for period in periods:
                # calculate the number of POIs scheduled in the current time slot
                poi_count = pulp.lpSum([x[poi["id"], day, period] for poi in must_have_pois])
                # modify to: each time slot should at least schedule one POI, at most schedule two POIs
                prob += poi_count >= 1, f"Min_POI_{day}_{period}"
                prob += poi_count <= 2, f"Max_POI_{day}_{period}"
        
        # 2.5. Only one city per day (to prevent bouncing between cities)
        # But allow flexibility if we have activities in multiple cities
        for day in range(duration_days):
            # Count how many cities have activities on this day
            city_activity_count = pulp.lpSum([city_used_vars[day, city] 
                                             for city in destination_cities])
            # Allow up to 2 cities per day if we have activities in multiple cities
            prob += city_activity_count <= 2
        

        
        # 3. POI can only be scheduled when it's open
        for poi in must_have_pois:
            for day in range(duration_days):
                for period in periods:
                    # Only add opening time constraint if we have valid opening time data
                    if poi["opening_times"] and isinstance(poi["opening_times"], list) and len(poi["opening_times"]) > 0:
                        if not self._is_poi_open_during_period(poi["opening_times"], period):
                            prob += x[poi["id"], day, period] == 0
        
        # 4. Hotel constraints
        # Check if we have any hotels available
        total_hotels = sum(len(hotels) for hotels in hotels_by_city.values())
        

        
        if total_hotels > 0:
            for day in range(duration_days):
                # Must have exactly one hotel per day
                prob += pulp.lpSum([y[hotel["id"], day] 
                                   for city_hotels in hotels_by_city.values() 
                                   for hotel in city_hotels]) == 1
                
                # Hotel constraints - relaxed approach
                # Only constrain hotel location when we have activities that require proximity
                for city in destination_cities:
                    city_pois = [poi for poi in must_have_pois 
                                if poi["info"].get("districtName") == city or poi["info"].get("districtEName") == city]
                    city_hotels = hotels_by_city.get(city, [])
                    
                    if city_pois and city_hotels:  # Only add constraint if we have both POIs and hotels in this city
                        # Check for evening activities on current day
                        evening_activity = pulp.lpSum([x[poi["id"], day, "Evening"] 
                                                     for poi in city_pois])
                        
                        # Check for morning activities on next day (if not last day)
                        morning_activity_next = 0
                        if day < duration_days - 1:
                            morning_activity_next = pulp.lpSum([x[poi["id"], day + 1, "Morning"] 
                                                              for poi in city_pois])
                        
                        # If we have evening activity OR morning activity next day, prefer hotel in this city
                        # Use a binary variable to track if we have any activity requiring proximity
                        activity_requires_hotel = pulp.LpVariable(f"activity_requires_hotel_{day}_{city}", cat='Binary')
                        
                        # activity_requires_hotel = 1 if we have evening activity OR morning activity next day
                        prob += activity_requires_hotel >= evening_activity
                        prob += activity_requires_hotel >= morning_activity_next
                        prob += activity_requires_hotel <= evening_activity + morning_activity_next
                        
                        # If activity requires hotel proximity, stay in this city's hotel
                        prob += pulp.lpSum([y[hotel["id"], day] for hotel in city_hotels]) >= activity_requires_hotel
        else:
            # No hotels available, skip hotel constraints
            pass
        
        # 5. Link city_used_vars to actual scheduling
        for day in range(duration_days):
            for city in destination_cities:
                # Get POIs in this city
                city_pois = [poi for poi in must_have_pois 
                            if poi["info"].get("districtName") == city or poi["info"].get("districtEName") == city]
                
                # city_used_vars[day, city] = 1 if any POI in this city is scheduled on this day
                if city_pois:
                    city_activity = pulp.lpSum([x[poi["id"], day, period] 
                                              for poi in city_pois 
                                              for period in periods])
                    # If any POI in this city is scheduled, city_used_vars should be 1
                    prob += city_used_vars[day, city] >= city_activity
                    # If no POI in this city is scheduled, city_used_vars should be 0
                    prob += city_used_vars[day, city] <= city_activity
                else:
                    prob += city_used_vars[day, city] == 0
        
        # 6. Link city_change_vars to actual scheduling
        for day in range(1, duration_days):
            for city1 in destination_cities:
                for city2 in destination_cities:
                    if city1 != city2:
                        # city_change_vars[day, city1, city2] = 1 if we have activities in city1 on day-1 
                        # and city2 on day
                        city1_pois = [poi for poi in must_have_pois 
                                     if poi["info"].get("districtName") == city1 or poi["info"].get("districtEName") == city1]
                        city2_pois = [poi for poi in must_have_pois 
                                     if poi["info"].get("districtName") == city2 or poi["info"].get("districtEName") == city2]
                        
                        if city1_pois and city2_pois:
                            # If we have activities in city1 on day-1 and city2 on day, then city_change = 1
                            city1_activity = pulp.lpSum([x[poi["id"], day - 1, period] 
                                                       for poi in city1_pois 
                                                       for period in periods])
                            city2_activity = pulp.lpSum([x[poi["id"], day, period] 
                                                       for poi in city2_pois 
                                                       for period in periods])
                            
                            prob += city_change_vars[day, city1, city2] >= city1_activity + city2_activity - 1
                            prob += city_change_vars[day, city1, city2] <= city1_activity
                            prob += city_change_vars[day, city1, city2] <= city2_activity
        

        
        # Solve the problem
        prob.solve()
        print(f"MILP problem status: {prob.status}")
        
        # debug: display the solution of x variables
        if prob.status == pulp.LpStatusOptimal:
            print("=== X variables solution ===")
            scheduled_count = 0
            for poi in must_have_pois:
                for day in range(duration_days):
                    for period in periods:
                        value = pulp.value(x[poi["id"], day, period])
                        if value == 1:
                            print(f"POI {poi['name']} (ID: {poi['id']}) is scheduled on day {day+1} {period}")
                            scheduled_count += 1
            print(f"Total scheduled POIs: {scheduled_count}")
            print("=== end X variables solution ===")
        
        # Check if solution was found
        if prob.status != pulp.LpStatusOptimal:
            # Add detailed diagnostic information
            error_msg = f"No feasible solution found. Status: {prob.status}"
            
            if prob.status == pulp.LpStatusInfeasible:
                error_msg += "\nPossible causes:"
                error_msg += "\n- POI opening hours conflict with available time slots"
                error_msg += "\n- Insufficient time to visit all required POIs"
                error_msg += "\n- City movement constraints too restrictive"
                error_msg += f"\n- Total POIs to visit: {len(must_have_pois)}"
                error_msg += f"\n- Available time slots: {duration_days * len(periods)}"
                
                # Check if we have enough time slots for all POIs
                if len(must_have_pois) > duration_days * len(periods):
                    error_msg += f"\n- CRITICAL: More POIs ({len(must_have_pois)}) than available time slots ({duration_days * len(periods)})"
                
                # Check POI opening time constraints
                constrained_pois = []
                for poi in must_have_pois:
                    if poi.get("opening_times"):
                        constrained_pois.append(poi["name"])
                if constrained_pois:
                    error_msg += f"\n- POIs with opening time constraints: {', '.join(constrained_pois[:5])}"
                    if len(constrained_pois) > 5:
                        error_msg += f" (and {len(constrained_pois) - 5} more)"
                
                # Check city distribution
                city_poi_count = {}
                for poi in must_have_pois:
                    city = poi["info"].get("districtName", "")
                    city_poi_count[city] = city_poi_count.get(city, 0) + 1
                error_msg += f"\n- POI distribution by city: {dict(city_poi_count)}"
                
                # Check hotel availability
                total_hotels = sum(len(hotels) for hotels in hotels_by_city.values())
                error_msg += f"\n- Available hotels: {total_hotels}"
                
                if total_hotels == 0:
                    error_msg += "\n- WARNING: No hotels available, using generic hotels"
            
            raise ValueError(error_msg)
        
        # Extract solution
        day_infos = []
        prev_city = self.departure  # start city
        for day in range(duration_days):
            schedule_details = []
            curr_cities = []  # cities visited on this day
            # Add scheduled POIs
            scheduled_pois = set()  # track scheduled POIs, avoid duplicate
            for poi in must_have_pois:
                for period in periods:
                    if pulp.value(x[poi["id"], day, period]) == 1:
                        if poi["id"] not in scheduled_pois:  # avoid adding the same POI twice
                            poi_city = poi["info"].get("districtName") if self.locale.split('-')[0] == 'zh' else poi["info"].get("districtEName")
                            if poi_city:
                                curr_cities.append(poi_city)    
                            schedule_details.append({
                                "period": period,
                                "description": f"Visit {poi['name']}",
                                "detailList": [{
                                    "type": "poi",
                                    "id": poi["id"],
                                    "name": poi["name"]
                                }]
                            })
                            scheduled_pois.add(poi["id"])
                        break  # stop after finding the first time slot, avoid duplicate
            
            # Add hotels if available
            if total_hotels > 0:
                for city in destination_cities:
                    if city in hotels_by_city:
                        for hotel in hotels_by_city[city]:
                            if pulp.value(y[hotel["id"], day]) == 1:
                                schedule_details.append({
                                    "period": "Evening",
                                    "description": f"Stay at {hotel['info'].get('cname', 'Hotel')}",
                                    "detailList": [{
                                        "type": "hotel",
                                        "id": hotel["id"],
                                        "name": hotel["info"].get("cname", "Hotel")
                                    }]
                                })
            else:
                # Add a generic hotel entry if no hotels are available
                schedule_details.append({
                    "period": "Evening",
                    "description": "Stay at local hotel",
                    "detailList": [{
                        "type": "hotel",
                        "id": f"generic_hotel_{day}",
                        "name": "Local Hotel"
                    }]
                })
            
            # Add transportation between cities if needed
            add_transportation = False
            add_return_transportation = False
            if curr_cities:
                # check if we need to add city-to-city transportation
                curr_city = list(curr_cities)[-1]  # take the last city as the main city of this day
                for transportation in self.poi_analyzer.transportation_info:
                    if transportation["key"].startswith(prev_city) and transportation["key"].endswith(curr_city):
                        schedule_details.append({
                            "period": "Morning",
                            "description": f"Travel from {prev_city} to {curr_city}",
                            "detailList": [{
                                "type": "transportation",
                                "id": transportation['planid'],
                                "name": transportation['name']
                            }]
                        })
                        add_transportation = True
                        break
                if prev_city and curr_city and prev_city != curr_city and not add_transportation:
                    schedule_details.append({
                        "period": "Morning",
                        "description": f"Travel from {prev_city} to {curr_city}",
                        "detailList": [{
                            "type": "transportation",
                            "id": f"",
                            "name": f"Travel from {prev_city} to {curr_city}"
                        }]
                    })
                    add_transportation = True
                elif day == duration_days-1 and not add_return_transportation:
                    for transportation in self.poi_analyzer.transportation_info:
                        if transportation["key"].startswith(prev_city) and transportation["key"].endswith(self.departure):
                            schedule_details.append({
                                "period": "Evening",
                                "description": f"Travel from {prev_city} to {self.departure}",
                                "detailList": [{
                                    "type": "transportation",
                                    "id": transportation['planid'],
                                    "name": transportation['name']
                                }]
                            })
                            add_return_transportation = True
                            break
                    if not add_return_transportation:
                        schedule_details.append({
                            "period": "Evening",
                            "description": f"Travel from {prev_city} to {self.departure}",
                            "detailList": [{
                                "type": "transportation",
                                "id": f"",
                                "name": f"Travel from {prev_city} to {self.departure}"
                            }]
                        })
                        add_return_transportation = True
                # update the previous city to the current city
                prev_city = curr_city
            elif curr_cities:
                # first day, update the start city
                prev_city = list(curr_cities)[-1]

            # Sort schedule details by period order
            period_order = {"Morning": 0, "Afternoon": 1, "Evening": 2}
            schedule_details.sort(key=lambda x: period_order.get(x["period"], 3))
            
            # Convert schedule_details to dataclass objects
            schedule_detail_objects = []
            for detail in schedule_details:
                detail_items = [
                    DetailItem(
                        type=item["type"],
                        id=item["id"],
                        name=item["name"]
                    ) for item in detail["detailList"]
                ]
                schedule_detail_objects.append(
                    ScheduleDetail(
                        period=detail["period"],
                        description=detail["description"],
                        detailList=detail_items
                    )
                )
            
            # Determine the main city for this day
            day_cities = set()
            for poi in must_have_pois:
                city = poi["info"].get("districtName")  
                if city:
                    activity = pulp.value(pulp.lpSum([x[poi["id"], day, p] for p in periods]))
                    if activity == 1:
                        day_cities.add(city)
            
            # If no activities in this day, check which city we're staying in (for hotels)
            if not day_cities and total_hotels > 0:
                for city in destination_cities:
                    if city in hotels_by_city:
                        for hotel in hotels_by_city[city]:
                            if pulp.value(y[hotel["id"], day]) == 1:
                                day_cities.add(city)
                                break
                        if day_cities:
                            break
            
            main_city = list(day_cities)[0] if day_cities else destination_cities[day % len(destination_cities)]
            
            day_info = DayInfo(
                day=day + 1,
                scheduleTitle=f"Day {day + 1} in {main_city}",
                tips="Enjoy your visit!",
                scheduleDetail=schedule_detail_objects
            )
            day_infos.append(day_info)
        
        return Itinerary(
            itineraryName=f"User Preference Itinerary - {duration_days} Days",
            dayInfos=day_infos
        )
    
    def _solve_user_preference_relaxed(self, data: Dict[str, Any]) -> Itinerary:
        """Solve with relaxed constraints when strict constraints fail"""
        must_have_pois = data["must_have_poi_ids"]
        pois_by_city = data["pois_by_city"]
        hotels_by_city = data["hotels_by_city"]
        duration_days = data["duration_days"]
        destination_cities = data["destination_cities"]
        
        print("Using relaxed constraints...")
        
        # Create MILP problem
        prob = pulp.LpProblem("Travel_Itinerary_Optimization_Relaxed", pulp.LpMinimize)
        
        # Decision variables
        periods = ["Morning", "Afternoon", "Evening"]
        
        # x[poi_id][day][period] = 1 if POI is scheduled on day/period, 0 otherwise
        x = pulp.LpVariable.dicts("schedule_poi",
                                 [(poi["id"], day, period) 
                                  for poi in must_have_pois 
                                  for day in range(duration_days) 
                                  for period in periods],
                                 cat='Binary')
        
        # y[hotel_id][day] = 1 if hotel is used on day, 0 otherwise
        y = pulp.LpVariable.dicts("use_hotel",
                                 [(hotel["id"], day) 
                                  for city_hotels in hotels_by_city.values() 
                                  for hotel in city_hotels 
                                  for day in range(duration_days)],
                                 cat='Binary')
        
        # City usage variables
        city_used_vars = pulp.LpVariable.dicts("city_used",
                                              [(day, city) 
                                               for day in range(duration_days) 
                                               for city in destination_cities],
                                              cat='Binary')
        
        # City change variables
        city_change_vars = pulp.LpVariable.dicts("city_change",
                                                [(day, city1, city2) 
                                                 for day in range(1, duration_days)
                                                 for city1 in destination_cities 
                                                 for city2 in destination_cities 
                                                 if city1 != city2],
                                                cat='Binary')
        
        # Objective: Minimize city changes and city count (same as strict version)
        city_change_penalty = pulp.lpSum([city_change_vars[day, city1, city2] 
                                         for day in range(1, duration_days)
                                         for city1 in destination_cities 
                                         for city2 in destination_cities 
                                         if city1 != city2])
        
        city_count_penalty = pulp.lpSum([city_used_vars[day, city] 
                                        for day in range(duration_days) 
                                        for city in destination_cities])
        
        # Add POI scheduling reward to relaxed version too
        poi_scheduling_reward = pulp.lpSum([x[poi["id"], day, period] 
                                          for poi in must_have_pois 
                                          for day in range(duration_days) 
                                          for period in periods]) * (-10)  # Reward for each scheduled POI
        
        prob += city_change_penalty + city_count_penalty + poi_scheduling_reward
        
        # RELAXED CONSTRAINTS
        
        # 1. Try to schedule all POIs, but allow some to be skipped if necessary
        # Instead of == 1, use <= 1 to allow skipping
        for poi in must_have_pois:
            prob += pulp.lpSum([x[poi["id"], day, period] 
                               for day in range(duration_days) 
                               for period in periods]) <= 1
        
        # 2. Only one POI per time slot (day, period)
        for day in range(duration_days):
            for period in periods:
                prob += pulp.lpSum([x[poi["id"], day, period] 
                                   for poi in must_have_pois]) <= 1
        
        # 3. RELAXED: Allow up to 3 cities per day instead of 2
        for day in range(duration_days):
            city_activity_count = pulp.lpSum([city_used_vars[day, city] 
                                             for city in destination_cities])
            prob += city_activity_count <= 3
        
        # 4. RELAXED: Skip POI opening time constraints entirely
        # This allows POIs to be scheduled regardless of opening hours
        
        # 5. Hotel constraints (same as strict version)
        total_hotels = sum(len(hotels) for hotels in hotels_by_city.values())
        
        if total_hotels > 0:
            for day in range(duration_days):
                prob += pulp.lpSum([y[hotel["id"], day] 
                                   for city_hotels in hotels_by_city.values() 
                                   for hotel in city_hotels]) == 1
        else:
            # No hotels available, skip hotel constraints
            pass
        
        # 6. Link city_used_vars to actual scheduling (same as strict version)
        for day in range(duration_days):
            for city in destination_cities:
                city_pois = [poi for poi in must_have_pois 
                            if poi["info"].get("districtName") == city or poi["info"].get("districtEName") == city]
                
                if city_pois:
                    city_activity = pulp.lpSum([x[poi["id"], day, period] 
                                              for poi in city_pois 
                                              for period in periods])
                    prob += city_used_vars[day, city] >= city_activity
                    prob += city_used_vars[day, city] <= city_activity
                else:
                    prob += city_used_vars[day, city] == 0
        
        # 7. Link city_change_vars to actual scheduling (same as strict version)
        for day in range(1, duration_days):
            for city1 in destination_cities:
                for city2 in destination_cities:
                    if city1 != city2:
                        city1_pois = [poi for poi in must_have_pois 
                                     if poi["info"].get("districtName") == city1 or poi["info"].get("districtEName") == city1]
                        city2_pois = [poi for poi in must_have_pois 
                                     if poi["info"].get("districtName") == city2 or poi["info"].get("districtEName") == city2]
                        
                        if city1_pois and city2_pois:
                            city1_activity = pulp.lpSum([x[poi["id"], day - 1, period] 
                                                       for poi in city1_pois 
                                                       for period in periods])
                            city2_activity = pulp.lpSum([x[poi["id"], day, period] 
                                                       for poi in city2_pois 
                                                       for period in periods])
                            
                            prob += city_change_vars[day, city1, city2] >= city1_activity + city2_activity - 1
                            prob += city_change_vars[day, city1, city2] <= city1_activity
                            prob += city_change_vars[day, city1, city2] <= city2_activity
        
        # Solve the problem
        prob.solve()
        print(f"Relaxed MILP problem status: {prob.status}")
        
        # debug: display the solution of x variables (relaxed mode)
        if prob.status == pulp.LpStatusOptimal:
            print("=== relaxed mode X variables solution ===")
            scheduled_count = 0
            for poi in must_have_pois:
                for day in range(duration_days):
                    for period in periods:
                        value = pulp.value(x[poi["id"], day, period])
                        if value == 1:
                            print(f"POI {poi['name']} (ID: {poi['id']}) is scheduled on day {day+1} {period}")
                            scheduled_count += 1
            print(f"Relaxed mode total scheduled POIs: {scheduled_count}")
            print("=== end relaxed mode X variables solution ===")
        
        # Check if solution was found
        if prob.status != pulp.LpStatusOptimal:
            # Even relaxed constraints failed
            error_msg = f"Even relaxed constraints failed. Status: {prob.status}"
            error_msg += f"\n- Total POIs: {len(must_have_pois)}"
            error_msg += f"\n- Available time slots: {duration_days * len(periods)}"
            raise ValueError(error_msg)
        
        # Extract solution (same as strict version)
        day_infos = []
        prev_city = self.departure  # start city
        # Add scheduled POIs
        scheduled_pois = set()  # track scheduled POIs, avoid duplicate
        for day in range(duration_days):
            schedule_details = []
            curr_cities = []  # cities visited on this day
            
            for poi in must_have_pois: 
                for period in periods:
                    if pulp.value(x[poi["id"], day, period]) == 1:  # only add the POIs actually scheduled
                        if poi["id"] not in scheduled_pois:  # avoid adding the same POI twice
                            # get the city of the POI
                            poi_city = poi["info"].get("districtName") if self.locale.split('-')[0] == 'zh' else poi["info"].get("districtEName")
                            if poi_city:
                                curr_cities.append(poi_city)
                            
                            schedule_details.append({
                                "period": period,
                                "description": f"Visit {poi['name']}",
                                "detailList": [{
                                    "type": "poi",
                                    "id": poi["id"],
                                    "name": poi["name"]
                                }]
                            })
                            scheduled_pois.add(poi["id"])
                        break  # stop after finding the first time slot, avoid duplicate
            
            # Add hotel if available
            if total_hotels > 0:
                for city_hotels in hotels_by_city.values():
                    for hotel in city_hotels:
                        if pulp.value(y[hotel["id"], day]) == 1:
                            schedule_details.append({
                                "period": "Evening",
                                "description": f"Stay at {hotel['name']}",
                                "detailList": [{
                                    "type": "hotel",
                                    "id": hotel["id"],
                                    "name": hotel["name"]
                                }]
                            })
                            break
            else:
                # No hotels available, add generic hotel
                schedule_details.append({
                    "period": "Evening",
                    "description": "Stay at local hotel",
                    "detailList": [{
                        "type": "hotel",
                        "id": f"generic_hotel_{day}",
                        "name": "Local Hotel"
                    }]
                })
            
            # Add transportation between cities if needed
            add_transportation = False
            add_return_transportation = False
            if curr_cities:
                # check if we need to add city-to-city transportation
                curr_city = list(curr_cities)[-1]  # take the last city as the main city of this day
                for transportation in self.poi_analyzer.transportation_info:
                    if transportation["key"].startswith(prev_city) and transportation["key"].endswith(curr_city):
                        schedule_details.append({
                            "period": "Morning",
                            "description": f"Travel from {prev_city} to {curr_city}",
                            "detailList": [{
                                "type": "transportation",
                                "id": transportation['planid'],
                                "name": transportation['name']
                            }]
                        })
                        add_transportation = True
                        break
                if prev_city and curr_city and prev_city != curr_city and not add_transportation:
                    schedule_details.append({
                        "period": "Morning",
                        "description": f"Travel from {prev_city} to {curr_city}",
                        "detailList": [{
                            "type": "transportation",
                            "id": f"",
                            "name": f"Travel from {prev_city} to {curr_city}"
                        }]
                    })
                    add_transportation = True
                elif day == duration_days-1 and not add_return_transportation:
                    for transportation in self.poi_analyzer.transportation_info:
                        if transportation["key"].startswith(prev_city) and transportation["key"].endswith(self.departure):
                            schedule_details.append({
                                "period": "Evening",
                                "description": f"Travel from {prev_city} to {self.departure}",
                                "detailList": [{
                                    "type": "transportation",
                                    "id": transportation['planid'],
                                    "name": transportation['name']
                                }]
                            })
                            add_return_transportation = True
                            break
                    if not add_return_transportation:
                        schedule_details.append({
                            "period": "Evening",
                            "description": f"Travel from {prev_city} to {self.departure}",
                            "detailList": [{
                                "type": "transportation",
                                "id": f"",
                                "name": f"Travel from {prev_city} to {self.departure}"
                            }]
                        })
                        add_return_transportation = True
                # update the previous city to the current city
                prev_city = curr_city
            elif curr_cities:
                # first day, update the start city
                prev_city = list(curr_cities)[-1]


            # Sort schedule details by period order
            period_order = {"Morning": 0, "Afternoon": 1, "Evening": 2}
            schedule_details.sort(key=lambda x: period_order.get(x["period"], 3))
            
            # Convert schedule_details to dataclass objects
            schedule_detail_objects = []
            for detail in schedule_details:
                detail_items = [
                    DetailItem(
                        type=item["type"],
                        id=item["id"],
                        name=item["name"]
                    ) for item in detail["detailList"]
                ]
                schedule_detail_objects.append(
                    ScheduleDetail(
                        period=detail["period"],
                        description=detail["description"],
                        detailList=detail_items
                    )
                )
            
            # Determine the main city for this day
            day_cities = set()
            for poi in must_have_pois:
                city = poi["info"].get("districtName")  
                if city:
                    activity = pulp.value(pulp.lpSum([x[poi["id"], day, p] for p in periods]))
                    if activity == 1:
                        day_cities.add(city)
            
            # If no activities in this day, check which city we're staying in (for hotels)
            if not day_cities and total_hotels > 0:
                for city in destination_cities:
                    if city in hotels_by_city:
                        for hotel in hotels_by_city[city]:
                            if pulp.value(y[hotel["id"], day]) == 1:
                                day_cities.add(city)
                                break
                        if day_cities:
                            break
            
            main_city = list(day_cities)[0] if day_cities else destination_cities[day % len(destination_cities)]
            
            day_info = DayInfo(
                day=day + 1,
                scheduleTitle=f"Day {day + 1} in {main_city}",
                tips="Enjoy your visit!",
                scheduleDetail=schedule_detail_objects
            )
            day_infos.append(day_info)
        
        return Itinerary(
            itineraryName=f"User Preference Itinerary - {duration_days} Days",
            dayInfos=day_infos
        )

    def _is_poi_open_during_period(self, opening_times: List[Dict[str, Any]], period: str) -> bool:
        """Check if POI is open during the specified period"""
        if not opening_times or not isinstance(opening_times, list):
            return True  # Assume open if no opening time info
        
        # Extract time information from opening_times (it's a list) 
        if not opening_times:
            return True  # Assume open if no time info
        
        # Get the first time info (most common case)
        time_info = opening_times[0] if opening_times else {}
        time_desc_list = time_info.get("openTimeRuleInfoList", [])
        
        if not time_desc_list:
            return True  # Assume open if no time descriptions
        
        # Get start and end times
        time_desc = time_desc_list[0]
        
        # safely parse the time description
        try:
            description = time_desc.get('description', '')
            if '-' in description:
                start_time = description.split('-')[0].strip()  # 8:30 AM
                end_time = description.split('-')[1].strip()
            else:
                # if there is no '-' separator, try other formats
                start_time = time_desc.get("startTime", "")
                end_time = time_desc.get("endTime", "")
        except (KeyError, IndexError, AttributeError):
            # if parsing fails, use default values
            start_time = time_desc.get("startTime", "")
            end_time = time_desc.get("endTime", "")
        
        if not start_time or not end_time:
            return True  # Assume open if no specific times
        
        # Convert times to 24-hour format for comparison
        start_hour = self._parse_time_to_hour(start_time)
        end_hour = self._parse_time_to_hour(end_time)
        
        # Define period time ranges
        period_ranges = {
            "Morning": (6, 12),    # 6 AM - 12 PM
            "Afternoon": (12, 18), # 12 PM - 6 PM
            "Evening": (18, 24)    # 6 PM - 12 AM
        }
        
        period_start, period_end = period_ranges.get(period, (0, 24))
        
        # Check if POI opening hours overlap with the period
        # POI is open during period if:
        # 1. POI opens before period ends AND
        # 2. POI closes after period starts
        return start_hour < period_end and end_hour > period_start

    def _parse_time_to_hour(self, time_str: str) -> int:
        """Parse time string to hour (24-hour format)"""
        time_str = time_str.strip().upper()
        
        # Handle formats like "8:00 AM", "4:30 PM", etc.
        if "AM" in time_str or "PM" in time_str:
            time_part = time_str.replace("AM", "").replace("PM", "").strip()
            if ":" in time_part:
                try:
                    hour, minute = map(int, time_part.split(":"))
                except ValueError:
                    return 0
            else:
                hour, minute = int(time_part), 0
            
            if "PM" in time_str and hour != 12:
                hour += 12
            elif "AM" in time_str and hour == 12:
                hour = 0
                
            return hour
        
        # Handle 24-hour format
        if ":" in time_str:
            hour, minute = map(int, time_str.split(":"))
            return hour
        else:
            return int(time_str) 