#!/usr/bin/env python3
"""Etera Mini — lightweight proxy server.

Reads GOOGLE_PLACES_API_KEY and ANTHROPIC_API_KEY from .env and exposes:
  /api/search?q=<query>          → Google Places Text Search (New)
  /api/photo?name=<photo_name>   → Google Places Photo Media
  /api/chat  (POST)              → AI Concierge via Claude + tool use
Everything else serves static files from the current directory.
"""

import base64
import concurrent.futures
import gzip
import hashlib
import http.server
import json
import json as json_mod
import math
import os
import queue
import random
import re
import tempfile
import threading
import time
import urllib.parse
import urllib.request
import uuid

PORT = 3000

# ---------------------------------------------------------------------------
# Load .env
# ---------------------------------------------------------------------------
def load_env(path=".env"):
    if not os.path.exists(path):
        return
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            key, _, value = line.partition("=")
            os.environ.setdefault(key.strip(), value.strip())

load_env()

# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
USERS = {}
TOKENS = {}

def hash_password(password, salt=None):
    if salt is None:
        salt = uuid.uuid4().hex
    hashed = hashlib.sha256((salt + password).encode()).hexdigest()
    return hashed, salt

def generate_token(user_id, email):
    payload = json_mod.dumps({"user_id": user_id, "email": email, "ts": time.time()})
    token = base64.b64encode(payload.encode()).decode()
    TOKENS[token] = user_id
    return token

def verify_token(token):
    return TOKENS.get(token)

# Seed default user
default_salt = uuid.uuid4().hex
default_hash, default_salt = hash_password("Etera2026!", default_salt)
default_user_id = str(uuid.uuid4())
USERS["reyis@etera.app"] = {
    "id": default_user_id,
    "email": "reyis@etera.app",
    "name": "Reyis",
    "password_hash": default_hash,
    "salt": default_salt
}
print(f"\033[92m✓ Default user seeded: reyis@etera.app\033[0m")

# ---------------------------------------------------------------------------
# OTP helpers
# ---------------------------------------------------------------------------
OTP_STORE = {}  # identifier -> {"code": str, "expires": float, "attempts": int}

def generate_otp():
    return str(random.randint(100000, 999999))

def store_otp(identifier, code):
    OTP_STORE[identifier] = {"code": code, "expires": time.time() + 300, "attempts": 0}

def verify_otp_code(identifier, code):
    entry = OTP_STORE.get(identifier)
    if not entry:
        return False, "No OTP found — please request a new code"
    if time.time() > entry["expires"]:
        del OTP_STORE[identifier]
        return False, "OTP expired — please request a new code"
    entry["attempts"] += 1
    if entry["attempts"] > 5:
        del OTP_STORE[identifier]
        return False, "Too many attempts — please request a new code"
    if entry["code"] != code:
        return False, "Invalid code"
    del OTP_STORE[identifier]
    return True, None

def find_user_by_identifier(identifier):
    """Look up user by email or phone."""
    identifier = identifier.strip().lower()
    for u in USERS.values():
        if u.get("email", "").lower() == identifier or u.get("phone", "") == identifier:
            return u
    return None

def find_or_create_user(identifier, name=None, auth_method="otp"):
    """Return (user_dict, is_new)."""
    existing = find_user_by_identifier(identifier)
    if existing:
        return existing, False
    user_id = str(uuid.uuid4())
    is_email = "@" in identifier
    user = {
        "id": user_id,
        "email": identifier if is_email else "",
        "phone": identifier if not is_email else "",
        "name": name or "",
        "password_hash": "",
        "salt": "",
        "auth_method": auth_method,
        "city": "",
        "profile_complete": False,
    }
    key = identifier.lower()
    USERS[key] = user
    return user, True

# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
RATE_LIMITS = {}  # ip -> {endpoint -> [timestamps]}

def check_rate_limit(ip, endpoint, max_requests=100, window=60):
    key = f"{ip}:{endpoint}"
    now = time.time()
    if key not in RATE_LIMITS:
        RATE_LIMITS[key] = []
    RATE_LIMITS[key] = [t for t in RATE_LIMITS[key] if now - t < window]
    if len(RATE_LIMITS[key]) >= max_requests:
        return False
    RATE_LIMITS[key].append(now)
    return True

def get_rate_limit(path):
    """Return (max_requests, window) for a given path."""
    if path.startswith("/api/auth/"):
        return 5, 60
    if path == "/api/chat" or path == "/api/chat/stream" or path == "/api/chat/action":
        return 20, 60
    if path == "/api/flights" or path == "/api/flights/search":
        return 10, 60
    return 100, 60

API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY", "")
if not API_KEY or API_KEY == "your_api_key_here":
    print("\033[91m✗ GOOGLE_PLACES_API_KEY not set in .env — searches will fail.\033[0m")
else:
    print(f"\033[92m✓ Google Places API key loaded ({API_KEY[:8]}…)\033[0m")

ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
if not ANTHROPIC_KEY:
    print("\033[91m✗ ANTHROPIC_API_KEY not set in .env — concierge chat will fail.\033[0m")
else:
    print(f"\033[92m✓ Anthropic API key loaded ({ANTHROPIC_KEY[:12]}…)\033[0m")

AMADEUS_KEY = os.environ.get("AMADEUS_API_KEY", "")
AMADEUS_SECRET = os.environ.get("AMADEUS_API_SECRET", "")
_amadeus_token = {"access_token": "", "expires_at": 0}

if not AMADEUS_KEY or not AMADEUS_SECRET:
    print("\033[93m⚠ AMADEUS_API_KEY/SECRET not set — flights will use generated data.\033[0m")
else:
    print(f"\033[92m✓ Amadeus API keys loaded ({AMADEUS_KEY[:8]}…)\033[0m")

# ---------------------------------------------------------------------------
# In-memory cache with TTL
# ---------------------------------------------------------------------------
_cache = {}
_cache_lock = threading.Lock()

SEARCH_CACHE_TTL = 3600    # 1 hour for search results
PHOTO_CACHE_TTL = 86400    # 24 hours for photos
FLIGHT_CACHE_TTL = 1800    # 30 minutes for flights
TOP10_CACHE_TTL = 7200     # 2 hours for top 10 / explore init data
CHAT_STREAM_CACHE_TTL = 300  # 5 min for identical chat queries
EXPERIENCE_DETAIL_CACHE_TTL = 86400  # 24hr cache for AI-generated experience content

RESTAURANT_KEYWORDS = {
    "restaurant", "restaurants", "eat", "eating", "food", "dinner", "lunch", "breakfast", "brunch",
    "table", "dine", "dining", "cuisine", "italian", "japanese", "chinese", "french",
    "indian", "mexican", "thai", "mediterranean", "korean", "greek",
    "spanish", "american", "lebanese", "turkish", "sushi", "pizza",
    "burger", "steak", "seafood", "bbq", "vegan", "vegetarian", "ramen",
    "tapas", "dim sum", "kebab",
}
HOTEL_KEYWORDS = {"hotel", "hotels", "stay", "room", "rooms", "accommodation", "resort", "resorts", "hostel", "lodge", "lodging"}
EXPERIENCE_KEYWORDS = {"activity", "activities", "experience", "experiences", "things to do", "tickets", "tour", "tours", "safari", "museum", "museums", "adventure", "visit", "attraction", "attractions", "sightseeing"}
FLIGHT_KEYWORDS = {"flight", "flights", "fly", "flying", "airline", "airlines", "airport", "travel to", "trip to", "airfare"}
LISTS_KEYWORDS = {"list", "lists", "curated", "recommend", "recommendations", "best of", "top", "creator", "creators", "sophie", "james", "maria", "akiko", "david", "elena", "omar", "chloe", "leila", "mason", "noah", "madison"}

SUPPORTED_CITIES = ["dubai", "abu dhabi", "riyadh", "doha", "kuwait city", "muscat", "bahrain", "paris", "london", "barcelona", "rome", "milan", "amsterdam", "berlin", "munich", "vienna", "zurich", "istanbul", "new york", "los angeles", "miami", "san francisco", "las vegas", "chicago", "tokyo", "singapore", "bangkok", "bali", "hong kong", "seoul", "sydney", "melbourne", "marrakech", "cape town", "maldives", "mauritius"]

INTENT_SUGGESTIONS = {
    "restaurants": ["Find hotels nearby", "Show curated lists", "More restaurant options"],
    "hotels": ["Find restaurants nearby", "Search for flights", "Show curated lists"],
    "experiences": ["Find restaurants nearby", "Search for hotels", "Show curated lists"],
    "flights": ["Find hotels at destination", "Things to do there", "Show other dates"],
    "lists": ["Show more lists", "Find restaurants", "Search for hotels"],
}

def cache_get(key):
    with _cache_lock:
        entry = _cache.get(key)
        if entry and time.time() < entry["expires"]:
            return entry["data"]
        if entry:
            del _cache[key]
    return None

def cache_set(key, data, ttl):
    with _cache_lock:
        _cache[key] = {"data": data, "expires": time.time() + ttl}
        # Evict old entries if cache grows too large (>500 entries)
        if len(_cache) > 500:
            now = time.time()
            expired = [k for k, v in _cache.items() if now >= v["expires"]]
            for k in expired:
                del _cache[k]

print(f"\033[92m✓ Cache enabled (search={SEARCH_CACHE_TTL}s, photo={PHOTO_CACHE_TTL}s, flights={FLIGHT_CACHE_TTL}s)\033[0m")

# ---------------------------------------------------------------------------
# Trip planner session storage (in-memory, keyed by chat ID)
# ---------------------------------------------------------------------------
_trip_sessions = {}
_trip_sessions_lock = threading.Lock()

def trip_session_get(session_id):
    with _trip_sessions_lock:
        return _trip_sessions.get(session_id)

def trip_session_set(session_id, state):
    with _trip_sessions_lock:
        _trip_sessions[session_id] = state
        # Evict oldest if >100 sessions
        if len(_trip_sessions) > 100:
            oldest = sorted(_trip_sessions.items(), key=lambda x: x[1].get("updated_at", 0))
            for k, _ in oldest[:len(_trip_sessions) - 100]:
                del _trip_sessions[k]

DAY_PLANNER_PHRASES = [
    "plan my day", "plan a day", "day plan", "one day in",
    "what to do today", "today in", "day itinerary",
    "plan today", "single day",
]

DAY_PLANNER_PATTERNS = [
    re.compile(r'\bplan\b.*\bday\b.*\bin\b'),
    re.compile(r'\bone\s+day\b.*\bin\b'),
    re.compile(r'\bday\s+(?:trip|plan)\b.*\bin\b'),
    re.compile(r'\bwhat\s+to\s+do\b.*\btoday\b'),
]

TRIP_PLANNER_PHRASES = [
    "plan a trip", "plan my trip", "plan a vacation", "plan my vacation",
    "plan a holiday", "plan my holiday", "itinerary", "weekend in",
    "honeymoon", "romantic trip", "business trip", "family trip",
    "solo trip", "friends trip", "getaway", "days in", "day trip",
    "plan me a", "trip for", "vacation to", "holiday to",
]

# Regex patterns that detect trip intent even with words in between
# e.g. "plan a romantic 5-day trip" matches "plan.*trip"
TRIP_PLANNER_PATTERNS = [
    re.compile(r'\bplan\b.*\btrip\b'),
    re.compile(r'\bplan\b.*\bvacation\b'),
    re.compile(r'\bplan\b.*\bholiday\b'),
    re.compile(r'\bplan\b.*\bgetaway\b'),
    re.compile(r'\bplan\b.*\bitinerary\b'),
    re.compile(r'\b\d+[\s-]*days?\b.*\btrip\b'),
    re.compile(r'\btrip\b.*\b\d+[\s-]*days?\b'),
    re.compile(r'\b(?:romantic|business|family|solo|friends)\b.*\b(?:trip|vacation|holiday|getaway)\b'),
]

def get_amadeus_token():
    """Get or refresh Amadeus OAuth2 access token."""
    if _amadeus_token["access_token"] and time.time() < _amadeus_token["expires_at"] - 30:
        return _amadeus_token["access_token"]
    body = urllib.parse.urlencode({
        "grant_type": "client_credentials",
        "client_id": AMADEUS_KEY,
        "client_secret": AMADEUS_SECRET,
    }).encode()
    req = urllib.request.Request(
        "https://test.api.amadeus.com/v1/security/oauth2/token",
        data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())
        _amadeus_token["access_token"] = data["access_token"]
        _amadeus_token["expires_at"] = time.time() + data.get("expires_in", 1799)
        return data["access_token"]
    except Exception as e:
        print(f"  Amadeus auth error: {e}")
        return None

# Airline name lookup
AIRLINE_NAMES = {
    "EK": "Emirates", "BA": "British Airways", "AF": "Air France",
    "LH": "Lufthansa", "AA": "American Airlines", "DL": "Delta Air Lines",
    "UA": "United Airlines", "QR": "Qatar Airways", "SQ": "Singapore Airlines",
    "NH": "ANA", "TK": "Turkish Airlines", "KL": "KLM", "LX": "Swiss",
    "OS": "Austrian", "AZ": "ITA Airways", "IB": "Iberia", "FR": "Ryanair",
    "U2": "easyJet", "W6": "Wizz Air", "VS": "Virgin Atlantic",
}

# Airport city mapping for common IATA codes
CITY_AIRPORTS = {
    "new york": "JFK", "nyc": "JFK", "paris": "CDG", "london": "LHR",
    "tokyo": "NRT", "dubai": "DXB", "rome": "FCO", "barcelona": "BCN",
    "berlin": "BER", "istanbul": "IST", "bangkok": "BKK", "singapore": "SIN",
    "sydney": "SYD", "los angeles": "LAX", "miami": "MIA", "san francisco": "SFO",
    "chicago": "ORD", "toronto": "YYZ", "hong kong": "HKG", "seoul": "ICN",
    "amsterdam": "AMS", "madrid": "MAD", "lisbon": "LIS", "milan": "MXP",
    "zurich": "ZRH", "vienna": "VIE", "mumbai": "BOM", "delhi": "DEL",
}

# ---------------------------------------------------------------------------
# Claude tool definition & system prompt
# ---------------------------------------------------------------------------
CLAUDE_TOOLS = [
    {
        "name": "search_places",
        "description": (
            "Search for restaurants, hotels, or experiences/attractions. "
            "Use this when the user asks about places to eat, stay, or things to do."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Natural language search query, e.g. 'Italian restaurants near Dubai Marina'",
                },
                "type": {
                    "type": "string",
                    "enum": ["restaurant", "lodging", "tourist_attraction"],
                    "description": "Optional place type filter",
                },
            },
            "required": ["query"],
        },
    },
    {
        "name": "search_flights",
        "description": (
            "Search for flights between two cities. "
            "Use this when the user asks about flights, airfare, or flying between cities."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "origin": {
                    "type": "string",
                    "description": "Origin city name or IATA airport code (e.g. 'New York', 'JFK')",
                },
                "destination": {
                    "type": "string",
                    "description": "Destination city name or IATA airport code (e.g. 'Paris', 'CDG')",
                },
                "date": {
                    "type": "string",
                    "description": "Departure date in YYYY-MM-DD format (optional, defaults to 7 days from now)",
                },
                "passengers": {
                    "type": "integer",
                    "description": "Number of adult passengers (default 1)",
                },
            },
            "required": ["origin", "destination"],
        },
    },
    {
        "name": "search_lists",
        "description": (
            "Search curated travel lists made by our community creators. "
            "Use this when the user asks about lists, recommendations from people, "
            "curated guides, or mentions wanting to see what others recommend. "
            "Also use when the user asks about a specific creator by name."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "City to filter lists (e.g. 'Paris', 'London', 'New York')",
                },
                "keywords": {
                    "type": "string",
                    "description": "Keywords to match against list titles and tags (e.g. 'romantic hotel foodie')",
                },
                "creator": {
                    "type": "string",
                    "description": "Creator name to filter by (e.g. 'Sophie', 'James')",
                },
            },
            "required": [],
        },
    },
]

SYSTEM_PROMPT = (
    "You are Etera, a friendly and knowledgeable AI travel concierge. "
    "You help users discover restaurants, hotels, experiences, and curated travel lists. "
    "When a user asks about places to eat, stay, or things to do, use the search_places tool. "
    "When a user asks about lists, curated recommendations, what others suggest, or mentions "
    "a creator name (Sophie, James, Maria, Akiko, David, Elena, Omar, Chloe), use the search_lists tool. "
    "When a user asks about flights, airfare, or flying between cities, use the search_flights tool. "
    "For broad queries about a city (e.g. 'plan my trip to Paris'), use BOTH tools. "
    "If a user asks about multiple categories, make multiple tool calls. "
    "Never make up place names or list details. "
    "Respond conversationally and helpfully. Keep responses concise but warm. "
    "When presenting results, write a brief natural-language overview per category. "
    "The actual cards will be shown in the UI automatically. "
    "FORMATTING RULES: Never use bold (**), italic (*), or bullet points. "
    "Use short section headers with ## (e.g. ## Top Picks, ## Curated Lists). "
    "Under each header write 1-2 plain sentences. Keep it brief and scannable. "
    "After your response, suggest 2-3 follow-up actions on a final line starting with "
    "SUGGESTIONS: separated by | (e.g. 'SUGGESTIONS: Find hotels nearby|Show Sophie's Paris lists|Save to a list'). "
    "Always include this suggestions line."
)

# ---------------------------------------------------------------------------
# Mock data for curated lists
# ---------------------------------------------------------------------------
USERS_DATA = {
    "sophie": {"name": "Sophie Laurent", "city": "Paris"},
    "james":  {"name": "James Mitchell", "city": "London"},
    "maria":  {"name": "Maria Santos", "city": "New York"},
    "akiko":  {"name": "Akiko Tanaka", "city": "Tokyo"},
    "david":  {"name": "David Chen", "city": "New York"},
    "elena":  {"name": "Elena Rossi", "city": "Milan"},
    "omar":   {"name": "Omar Hassan", "city": "Dubai"},
    "chloe":  {"name": "Chloe Williams", "city": "London"},
    "leila":  {"name": "Leila Al-Rashid", "city": "Dubai"},
    "mason":  {"name": "Mason Reed", "city": "Dubai"},
    "noah":   {"name": "Noah Park", "city": "Dubai"},
    "madison": {"name": "Madison Taylor", "city": "Dubai"},
}

LISTS_DATA = [
    {"id": "sophie-1", "userId": "sophie", "title": "Hidden Gems of Paris", "city": "Paris", "keywords": ["hidden gems","local","authentic","bistro"], "likes": 47, "commentCount": 2},
    {"id": "sophie-2", "userId": "sophie", "title": "Romantic Hotels in Paris", "city": "Paris", "keywords": ["romantic","luxury","hotel","couples"], "likes": 34, "commentCount": 1},
    {"id": "sophie-3", "userId": "sophie", "title": "Best Art Experiences in Paris", "city": "Paris", "keywords": ["art","museum","gallery","culture"], "likes": 62, "commentCount": 0},
    {"id": "sophie-4", "userId": "sophie", "title": "Perfect Weekend in London", "city": "London", "keywords": ["weekend","london","sightseeing","fun"], "likes": 89, "commentCount": 1},
    {"id": "james-1", "userId": "james", "title": "London's Best Pubs & Restaurants", "city": "London", "keywords": ["pub","restaurant","British","gastropub"], "likes": 73, "commentCount": 1},
    {"id": "james-2", "userId": "james", "title": "Luxury Stays in London", "city": "London", "keywords": ["luxury","hotel","five star","boutique"], "likes": 41, "commentCount": 0},
    {"id": "james-3", "userId": "james", "title": "New York Like a Local", "city": "New York", "keywords": ["local","NYC","off the beaten path","experience"], "likes": 55, "commentCount": 1},
    {"id": "maria-1", "userId": "maria", "title": "NYC Foodie Guide", "city": "New York", "keywords": ["foodie","NYC","restaurant","cuisine"], "likes": 95, "commentCount": 2},
    {"id": "maria-2", "userId": "maria", "title": "Manhattan's Best Hotels", "city": "New York", "keywords": ["hotel","manhattan","NYC","luxury","boutique"], "likes": 68, "commentCount": 1},
    {"id": "maria-3", "userId": "maria", "title": "Must-Do Experiences in New York", "city": "New York", "keywords": ["experience","attraction","NYC","must-do","sightseeing"], "likes": 52, "commentCount": 0},
    {"id": "akiko-1", "userId": "akiko", "title": "Hidden Ramen Spots in Tokyo", "city": "Tokyo", "keywords": ["ramen","hidden","tokyo","noodles","japanese"], "likes": 78, "commentCount": 1},
    {"id": "akiko-2", "userId": "akiko", "title": "London Through a Lens", "city": "London", "keywords": ["photography","london","photogenic","views","landmarks"], "likes": 64, "commentCount": 1},
    {"id": "david-1", "userId": "david", "title": "Chef's Picks: NYC Fine Dining", "city": "New York", "keywords": ["fine dining","chef","NYC","tasting menu","michelin"], "likes": 112, "commentCount": 2},
    {"id": "david-2", "userId": "david", "title": "London Breakfast Guide", "city": "London", "keywords": ["breakfast","brunch","london","morning","eggs"], "likes": 58, "commentCount": 1},
    {"id": "elena-1", "userId": "elena", "title": "Architectural Wonders of Paris", "city": "Paris", "keywords": ["architecture","paris","buildings","design","art nouveau"], "likes": 71, "commentCount": 1},
    {"id": "elena-2", "userId": "elena", "title": "Design Hotels of New York", "city": "New York", "keywords": ["design","boutique","hotel","NYC","interior design"], "likes": 49, "commentCount": 1},
    {"id": "omar-1", "userId": "omar", "title": "Spice Route: London Edition", "city": "London", "keywords": ["spice","curry","london","indian","middle eastern"], "likes": 66, "commentCount": 1},
    {"id": "omar-2", "userId": "omar", "title": "Best of Both Worlds: NYC", "city": "New York", "keywords": ["fusion","NYC","multicultural","global","diverse"], "likes": 43, "commentCount": 1},
    {"id": "chloe-1", "userId": "chloe", "title": "Wine Bars of Paris", "city": "Paris", "keywords": ["wine","paris","wine bar","natural wine","drinks"], "likes": 57, "commentCount": 1},
    {"id": "chloe-2", "userId": "chloe", "title": "Cozy Cafes in London", "city": "London", "keywords": ["cafe","coffee","cozy","london","tea","pastry"], "likes": 51, "commentCount": 1},
    {"id": "leila-1", "userId": "leila", "title": "Best Arabic Restaurants in Dubai", "city": "Dubai", "keywords": ["arabic","dubai","restaurant","local","emirati"], "likes": 84, "commentCount": 1},
    {"id": "leila-2", "userId": "leila", "title": "Luxury Hotels in Dubai", "city": "Dubai", "keywords": ["luxury","hotel","dubai","five star","resort"], "likes": 72, "commentCount": 1},
    {"id": "mason-1", "userId": "mason", "title": "Dubai's Best Rooftop Bars", "city": "Dubai", "keywords": ["rooftop","bar","dubai","cocktail","nightlife","drinks"], "likes": 67, "commentCount": 1},
    {"id": "mason-2", "userId": "mason", "title": "Adventure Activities in Dubai", "city": "Dubai", "keywords": ["adventure","activity","dubai","thrill","outdoor","extreme"], "likes": 53, "commentCount": 0},
    {"id": "madison-1", "userId": "madison", "title": "Dubai Brunch Spots", "city": "Dubai", "keywords": ["brunch","dubai","breakfast","weekend","restaurant"], "likes": 61, "commentCount": 1},
    {"id": "noah-1", "userId": "noah", "title": "Beach Hotels in Dubai", "city": "Dubai", "keywords": ["beach","hotel","dubai","resort","pool","ocean"], "likes": 48, "commentCount": 1},
]

# ---------------------------------------------------------------------------
# Request handler
# ---------------------------------------------------------------------------
class Handler(http.server.SimpleHTTPRequestHandler):

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)

        # Rate limiting
        if parsed.path.startswith("/api/"):
            ip = self.client_address[0]
            max_req, window = get_rate_limit(parsed.path)
            if not check_rate_limit(ip, parsed.path, max_req, window):
                self._json_response(429, {"error": "Rate limit exceeded"})
                return

        if parsed.path == "/api/auth/me":
            auth_header = self.headers.get("Authorization", "")
            if not auth_header.startswith("Bearer "):
                self._json_response(401, {"error": "Missing or invalid token"})
                return
            token = auth_header[7:]
            user_id = verify_token(token)
            if not user_id:
                self._json_response(401, {"error": "Invalid token"})
                return
            user = None
            for u in USERS.values():
                if u["id"] == user_id:
                    user = u
                    break
            if not user:
                self._json_response(401, {"error": "User not found"})
                return
            self._json_response(200, {"user": {
                "id": user["id"],
                "email": user.get("email", ""),
                "name": user.get("name", ""),
                "phone": user.get("phone", ""),
                "city": user.get("city", ""),
                "profile_complete": user.get("profile_complete", True),
            }})
            return

        if parsed.path == "/api/config":
            self._json_response(200, {"mapsKey": API_KEY})
        elif parsed.path == "/api/search":
            self._handle_search(parsed)
        elif parsed.path == "/api/photo":
            self._handle_photo(parsed)
        elif parsed.path == "/api/flights":
            self._handle_flights(parsed)
        elif parsed.path == "/api/explore/init":
            self._handle_explore_init(parsed)
        elif parsed.path.startswith("/api/experience/") and parsed.path.endswith("/details"):
            self._handle_experience_details(parsed)
        elif parsed.path.startswith("/api/restaurant/") and parsed.path.endswith("/details"):
            self._handle_restaurant_details(parsed)
        elif parsed.path in ("/api/restaurants", "/api/hotels", "/api/experiences"):
            self._handle_single_category(parsed)
        elif parsed.path == "/api/places/related":
            self._handle_places_related(parsed)
        elif parsed.path.startswith("/api/trip/") and parsed.path.endswith("/itinerary"):
            self._handle_trip_itinerary(parsed)
        else:
            super().do_GET()

    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)

        # Rate limiting
        ip = self.client_address[0]
        max_req, window = get_rate_limit(parsed.path)
        if not check_rate_limit(ip, parsed.path, max_req, window):
            # Drain request body to avoid connection issues
            length = int(self.headers.get("Content-Length", 0))
            if length:
                self.rfile.read(length)
            self._json_response(429, {"error": "Rate limit exceeded"})
            return

        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length)

        if parsed.path == "/api/transcribe":
            self._handle_transcribe(raw)
            return

        try:
            body = json.loads(raw) if raw else {}
        except json.JSONDecodeError:
            self._json_response(400, {"error": "Invalid JSON"})
            return

        if parsed.path == "/api/auth/register":
            email = body.get("email", "").strip()
            password = body.get("password", "")
            name = body.get("name", "").strip()
            if not email or "@" not in email:
                self._json_response(400, {"error": "Valid email is required"})
                return
            if len(password) < 8:
                self._json_response(400, {"error": "Password must be at least 8 characters"})
                return
            if not name:
                self._json_response(400, {"error": "Name is required"})
                return
            if email in USERS:
                self._json_response(409, {"error": "Email already registered"})
                return
            pw_hash, salt = hash_password(password)
            user_id = str(uuid.uuid4())
            USERS[email] = {
                "id": user_id,
                "email": email,
                "name": name,
                "password_hash": pw_hash,
                "salt": salt
            }
            token = generate_token(user_id, email)
            self._json_response(200, {
                "token": token,
                "user": {"id": user_id, "email": email, "name": name}
            })
            return

        if parsed.path == "/api/auth/login":
            email = body.get("email", "").strip()
            password = body.get("password", "")
            user = USERS.get(email)
            if not user:
                self._json_response(401, {"error": "Invalid email or password"})
                return
            pw_hash, _ = hash_password(password, user["salt"])
            if pw_hash != user["password_hash"]:
                self._json_response(401, {"error": "Invalid email or password"})
                return
            token = generate_token(user["id"], user["email"])
            self._json_response(200, {
                "token": token,
                "user": {"id": user["id"], "email": user["email"], "name": user["name"]}
            })
            return

        # --- OTP send ---
        if parsed.path == "/api/auth/otp/send":
            identifier = body.get("identifier", "").strip()
            if not identifier:
                self._json_response(400, {"error": "Phone or email is required"})
                return
            code = generate_otp()
            store_otp(identifier.lower(), code)
            print(f"\033[93m📲 OTP for {identifier}: {code}\033[0m")
            self._json_response(200, {"message": "OTP sent", "identifier": identifier})
            return

        # --- OTP verify ---
        if parsed.path == "/api/auth/otp/verify":
            identifier = body.get("identifier", "").strip().lower()
            code = body.get("code", "").strip()
            if not identifier or not code:
                self._json_response(400, {"error": "identifier and code are required"})
                return
            ok, err = verify_otp_code(identifier, code)
            if not ok:
                self._json_response(401, {"error": err})
                return
            user, is_new = find_or_create_user(identifier)
            token = generate_token(user["id"], user.get("email", identifier))
            self._json_response(200, {
                "token": token,
                "isNewUser": is_new,
                "user": {
                    "id": user["id"],
                    "email": user.get("email", ""),
                    "name": user.get("name", ""),
                    "phone": user.get("phone", ""),
                    "city": user.get("city", ""),
                    "profile_complete": user.get("profile_complete", True),
                },
            })
            return

        # --- Google Sign-In ---
        if parsed.path == "/api/auth/google":
            id_token = body.get("idToken", "")
            if not id_token:
                self._json_response(400, {"error": "idToken is required"})
                return
            # Decode JWT payload (no verification — dev mode)
            try:
                payload_b64 = id_token.split(".")[1]
                # Fix padding
                payload_b64 += "=" * (4 - len(payload_b64) % 4)
                payload = json.loads(base64.b64decode(payload_b64))
            except Exception:
                self._json_response(400, {"error": "Invalid idToken"})
                return
            email = payload.get("email", "")
            name = payload.get("name", "")
            if not email:
                self._json_response(400, {"error": "No email in token"})
                return
            user, is_new = find_or_create_user(email, name=name, auth_method="google")
            token = generate_token(user["id"], email)
            self._json_response(200, {
                "token": token,
                "isNewUser": is_new,
                "user": {
                    "id": user["id"],
                    "email": user.get("email", ""),
                    "name": user.get("name", ""),
                    "phone": user.get("phone", ""),
                    "city": user.get("city", ""),
                    "profile_complete": user.get("profile_complete", True),
                },
            })
            return

        # --- Apple Sign-In ---
        if parsed.path == "/api/auth/apple":
            identity_token = body.get("identityToken", "")
            if not identity_token:
                self._json_response(400, {"error": "identityToken is required"})
                return
            try:
                payload_b64 = identity_token.split(".")[1]
                payload_b64 += "=" * (4 - len(payload_b64) % 4)
                payload = json.loads(base64.b64decode(payload_b64))
            except Exception:
                self._json_response(400, {"error": "Invalid identityToken"})
                return
            email = payload.get("email", "")
            full_name = body.get("fullName", "")
            if not email:
                self._json_response(400, {"error": "No email in token"})
                return
            user, is_new = find_or_create_user(email, name=full_name, auth_method="apple")
            token = generate_token(user["id"], email)
            self._json_response(200, {
                "token": token,
                "isNewUser": is_new,
                "user": {
                    "id": user["id"],
                    "email": user.get("email", ""),
                    "name": user.get("name", ""),
                    "phone": user.get("phone", ""),
                    "city": user.get("city", ""),
                    "profile_complete": user.get("profile_complete", True),
                },
            })
            return

        # --- Complete profile ---
        if parsed.path == "/api/auth/profile":
            auth_header = self.headers.get("Authorization", "")
            if not auth_header.startswith("Bearer "):
                self._json_response(401, {"error": "Missing or invalid token"})
                return
            token = auth_header[7:]
            user_id = verify_token(token)
            if not user_id:
                self._json_response(401, {"error": "Invalid token"})
                return
            # Find the user
            target = None
            for u in USERS.values():
                if u["id"] == user_id:
                    target = u
                    break
            if not target:
                self._json_response(401, {"error": "User not found"})
                return
            name = body.get("name", "").strip()
            city = body.get("city", "").strip()
            if name:
                target["name"] = name
            if city:
                target["city"] = city
            target["profile_complete"] = True
            self._json_response(200, {
                "user": {
                    "id": target["id"],
                    "email": target.get("email", ""),
                    "name": target.get("name", ""),
                    "phone": target.get("phone", ""),
                    "city": target.get("city", ""),
                    "profile_complete": True,
                },
            })
            return

        if parsed.path == "/api/chat":
            self._handle_chat(body)
        elif parsed.path == "/api/chat/stream":
            self._handle_chat_stream(body)
        elif parsed.path == "/api/chat/action":
            self._handle_chat_action(body)
        elif parsed.path == "/api/flights/search":
            origin = body.get("origin", "")
            destination = body.get("destination", "")
            date = body.get("date", "")
            passengers = body.get("passengers", 1)
            if not origin or not destination:
                self._json_response(400, {"error": "origin and destination required"})
                return
            flights = self._search_flights(origin, destination, date, passengers)
            self._json_response(200, {"flights": flights})
        else:
            self._json_response(404, {"error": "Not found"})

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
        self.send_header("Content-Length", "0")
        self.end_headers()

    # ---- /api/explore/init?city=... — batch endpoint -------------------------
    # Returns restaurants, hotels, and experiences in ONE response using parallel fetches
    def _handle_explore_init(self, parsed):
        t0 = time.time()
        params = urllib.parse.parse_qs(parsed.query)
        city = params.get("city", ["Dubai"])[0]

        cache_key = f"explore-init:{city}"
        cached = cache_get(cache_key)
        if cached:
            dt = int((time.time() - t0) * 1000)
            print(f"  [explore/init] {city} → cache hit ({dt}ms)")
            self._gzip_json_response(200, cached)
            return

        def fetch_places(query, place_type=""):
            url = "https://places.googleapis.com/v1/places:searchText"
            headers = {
                "Content-Type": "application/json",
                "X-Goog-Api-Key": API_KEY,
                "X-Goog-FieldMask": (
                    "places.id,places.displayName,places.formattedAddress,"
                    "places.rating,places.userRatingCount,places.priceLevel,"
                    "places.currentOpeningHours,places.photos,places.primaryType,"
                    "places.location"
                ),
            }
            req_body = {"textQuery": query, "maxResultCount": 10}
            if place_type:
                req_body["includedType"] = place_type
            body = json.dumps(req_body).encode()
            try:
                req = urllib.request.Request(url, data=body, headers=headers, method="POST")
                with urllib.request.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                # Trim photos to first 1 per place to reduce payload
                for p in data.get("places", []):
                    if p.get("photos"):
                        p["photos"] = p["photos"][:1]
                return data.get("places", [])
            except Exception as e:
                print(f"  [explore/init] fetch error: {e}")
                return []

        # Fetch all 3 sections in PARALLEL
        with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
            f_rest = pool.submit(fetch_places, f"best restaurants in {city}")
            f_hotel = pool.submit(fetch_places, f"best hotels in {city}", "lodging")
            f_exp = pool.submit(fetch_places, f"best experiences in {city}", "tourist_attraction")
            restaurants = f_rest.result()
            hotels = f_hotel.result()
            experiences = f_exp.result()

        result = {
            "restaurants": {"places": restaurants},
            "hotels": {"places": hotels},
            "experiences": {"places": experiences},
        }
        cache_set(cache_key, result, TOP10_CACHE_TTL)
        dt = int((time.time() - t0) * 1000)
        print(f"  [explore/init] {city} → {len(restaurants)}r {len(hotels)}h {len(experiences)}e ({dt}ms)")
        self._gzip_json_response(200, result)

    # ---- /api/restaurants, /api/hotels, /api/experiences — single-category endpoints
    def _handle_single_category(self, parsed):
        t0 = time.time()
        params = urllib.parse.parse_qs(parsed.query)
        city = params.get("city", ["Dubai"])[0]
        category = parsed.path.split("/")[-1]  # "restaurants", "hotels", or "experiences"

        cache_key = f"single-{category}:{city}"
        cached = cache_get(cache_key)
        if cached:
            dt = int((time.time() - t0) * 1000)
            print(f"  [{category}] {city} → cache hit ({dt}ms)")
            self._gzip_json_response(200, cached)
            return

        query_map = {
            "restaurants": ("best restaurants in", "restaurant"),
            "hotels": ("best hotels in", "lodging"),
            "experiences": ("best experiences in", "tourist_attraction"),
        }
        query_prefix, place_type = query_map[category]

        url = "https://places.googleapis.com/v1/places:searchText"
        headers = {
            "Content-Type": "application/json",
            "X-Goog-Api-Key": API_KEY,
            "X-Goog-FieldMask": (
                "places.id,places.displayName,places.formattedAddress,"
                "places.rating,places.userRatingCount,places.priceLevel,"
                "places.currentOpeningHours,places.photos,places.primaryType,"
                "places.location"
            ),
        }
        req_body = {"textQuery": f"{query_prefix} {city}", "maxResultCount": 10}
        if place_type:
            req_body["includedType"] = place_type
        body = json.dumps(req_body).encode()

        try:
            req = urllib.request.Request(url, data=body, headers=headers, method="POST")
            with urllib.request.urlopen(req, timeout=8) as resp:
                data = json.loads(resp.read())
            for p in data.get("places", []):
                if p.get("photos"):
                    p["photos"] = p["photos"][:1]
            result = {"places": data.get("places", [])}
            cache_set(cache_key, result, TOP10_CACHE_TTL)
            dt = int((time.time() - t0) * 1000)
            print(f"  [{category}] {city} → {len(result['places'])} places ({dt}ms)")
            self._gzip_json_response(200, result)
        except Exception as e:
            print(f"  [{category}] fetch error: {e}")
            self._json_response(500, {"error": str(e)})

    # ---- /api/experience/{placeId}/details — enriched detail + AI content ---
    def _handle_experience_details(self, parsed):
        t0 = time.time()
        # Extract placeId from path: /api/experience/{placeId}/details
        path_parts = parsed.path.split("/")
        # ['', 'api', 'experience', '{placeId}', 'details']
        place_id = urllib.parse.unquote(path_parts[3]) if len(path_parts) >= 5 else ""
        if not place_id:
            self._json_response(400, {"error": "Missing placeId"})
            return

        # Check full response cache
        cache_key = f"experience-detail:{place_id}"
        cached = cache_get(cache_key)
        if cached:
            dt = int((time.time() - t0) * 1000)
            print(f"  [experience] {place_id[:20]}… → cache hit ({dt}ms)")
            self._gzip_json_response(200, cached)
            return

        # Fetch full place details from Google Places API
        detail_url = f"https://places.googleapis.com/v1/places/{place_id}"
        headers = {
            "X-Goog-Api-Key": API_KEY,
            "X-Goog-FieldMask": (
                "id,displayName,formattedAddress,rating,userRatingCount,"
                "priceLevel,primaryType,location,photos,currentOpeningHours,"
                "regularOpeningHours,googleMapsUri,websiteUri,"
                "editorialSummary,reviews"
            ),
        }
        try:
            req = urllib.request.Request(detail_url, headers=headers, method="GET")
            with urllib.request.urlopen(req, timeout=10) as resp:
                place_data = json.loads(resp.read())
        except Exception as e:
            print(f"  [experience] Google Places detail error: {e}")
            self._json_response(502, {"error": f"Failed to fetch place details: {e}"})
            return

        # Check AI content cache separately
        ai_cache_key = f"experience-ai:{place_id}"
        ai_content = cache_get(ai_cache_key)

        if not ai_content and ANTHROPIC_KEY:
            # Build context for Claude from place data
            place_name = (place_data.get("displayName") or {}).get("text", "Unknown")
            place_type = place_data.get("primaryType", "place")
            place_address = place_data.get("formattedAddress", "")
            editorial = (place_data.get("editorialSummary") or {}).get("text", "")

            # Collect review snippets (up to 5 reviews, first 200 chars each)
            reviews = place_data.get("reviews", [])
            review_snippets = []
            for r in reviews[:5]:
                txt = (r.get("text") or {}).get("text", "")
                if txt:
                    review_snippets.append(txt[:200])
            reviews_context = "\n".join(review_snippets) if review_snippets else "No reviews available."

            prompt = (
                f"You are a travel content writer. Generate rich content for this place:\n"
                f"Name: {place_name}\n"
                f"Type: {place_type}\n"
                f"Address: {place_address}\n"
                f"Editorial: {editorial}\n"
                f"Reviews:\n{reviews_context}\n\n"
                f"Return ONLY valid JSON (no markdown fences, no extra text) with these fields:\n"
                f'{{"description": "2-3 sentence engaging description",'
                f'"highlights": ["highlight1", "highlight2", "highlight3", "highlight4", "highlight5"],'
                f'"inclusions": ["inclusion1", "inclusion2", "inclusion3"],'
                f'"cancellationPolicy": "One paragraph cancellation policy",'
                f'"knowBeforeYouGo": ["tip1", "tip2", "tip3", "tip4"],'
                f'"estimatedDuration": "e.g. 2-3 hours",'
                f'"featureTags": ["tag1", "tag2", "tag3", "tag4", "tag5"]}}'
            )

            try:
                api_body = json.dumps({
                    "model": "claude-haiku-4-5-20251001",
                    "max_tokens": 800,
                    "messages": [{"role": "user", "content": prompt}],
                }).encode()
                req = urllib.request.Request(
                    "https://api.anthropic.com/v1/messages",
                    data=api_body,
                    headers={
                        "x-api-key": ANTHROPIC_KEY,
                        "anthropic-version": "2023-06-01",
                        "content-type": "application/json",
                    },
                    method="POST",
                )
                with urllib.request.urlopen(req, timeout=30) as resp:
                    claude_resp = json.loads(resp.read())

                # Extract text from Claude response
                raw_text = ""
                for block in claude_resp.get("content", []):
                    if block.get("type") == "text":
                        raw_text += block["text"]

                # Strip markdown fences if present
                raw_text = raw_text.strip()
                if raw_text.startswith("```"):
                    lines = raw_text.split("\n")
                    # Remove first and last lines (```json and ```)
                    lines = [l for l in lines if not l.strip().startswith("```")]
                    raw_text = "\n".join(lines)

                ai_content = json.loads(raw_text)
                cache_set(ai_cache_key, ai_content, EXPERIENCE_DETAIL_CACHE_TTL)
                print(f"  [experience] AI content generated for {place_name}")
            except Exception as e:
                print(f"  [experience] Claude AI content error: {e}")
                # Provide fallback content
                ai_content = {
                    "description": editorial or f"Discover {(place_data.get('displayName') or {}).get('text', 'this place')} — a must-visit destination.",
                    "highlights": ["Highly rated by visitors", "Unique atmosphere", "Great location"],
                    "inclusions": ["Standard entry", "Access to main areas"],
                    "cancellationPolicy": "Please check with the venue for their cancellation policy.",
                    "knowBeforeYouGo": ["Check opening hours before visiting", "Comfortable shoes recommended"],
                    "estimatedDuration": "1-2 hours",
                    "featureTags": [place_type.replace("_", " ").title() if place_type else "Experience"],
                }
        elif not ai_content:
            # No Anthropic key and no cache
            place_name = (place_data.get("displayName") or {}).get("text", "this place")
            editorial = (place_data.get("editorialSummary") or {}).get("text", "")
            ai_content = {
                "description": editorial or f"Discover {place_name} — a must-visit destination.",
                "highlights": ["Highly rated by visitors", "Unique atmosphere", "Great location"],
                "inclusions": ["Standard entry", "Access to main areas"],
                "cancellationPolicy": "Please check with the venue for their cancellation policy.",
                "knowBeforeYouGo": ["Check opening hours before visiting", "Comfortable shoes recommended"],
                "estimatedDuration": "1-2 hours",
                "featureTags": [place_data.get("primaryType", "Experience").replace("_", " ").title()],
            }

        result = {"place": place_data, "experience": ai_content}
        cache_set(cache_key, result, EXPERIENCE_DETAIL_CACHE_TTL)
        dt = int((time.time() - t0) * 1000)
        print(f"  [experience] {place_id[:20]}… → OK ({dt}ms)")
        self._gzip_json_response(200, result)

    # ---- /api/restaurant/{placeId}/details — restaurant detail + AI content ---
    def _handle_restaurant_details(self, parsed):
        t0 = time.time()
        path_parts = parsed.path.split("/")
        place_id = urllib.parse.unquote(path_parts[3]) if len(path_parts) >= 5 else ""
        if not place_id:
            self._json_response(400, {"error": "Missing placeId"})
            return

        # Check full response cache
        cache_key = f"restaurant-detail:{place_id}"
        cached = cache_get(cache_key)
        if cached:
            dt = int((time.time() - t0) * 1000)
            print(f"  [restaurant] {place_id[:20]}… → cache hit ({dt}ms)")
            self._gzip_json_response(200, cached)
            return

        # Fetch full place details from Google Places API
        detail_url = f"https://places.googleapis.com/v1/places/{place_id}"
        headers = {
            "X-Goog-Api-Key": API_KEY,
            "X-Goog-FieldMask": (
                "id,displayName,formattedAddress,rating,userRatingCount,"
                "priceLevel,primaryType,location,photos,currentOpeningHours,"
                "regularOpeningHours,googleMapsUri,websiteUri,"
                "editorialSummary,reviews"
            ),
        }
        try:
            req = urllib.request.Request(detail_url, headers=headers, method="GET")
            with urllib.request.urlopen(req, timeout=10) as resp:
                place_data = json.loads(resp.read())
        except Exception as e:
            print(f"  [restaurant] Google Places detail error: {e}")
            self._json_response(502, {"error": f"Failed to fetch place details: {e}"})
            return

        place_name = (place_data.get("displayName") or {}).get("text", "Unknown")
        place_type = place_data.get("primaryType", "restaurant")
        place_address = place_data.get("formattedAddress", "")
        editorial = (place_data.get("editorialSummary") or {}).get("text", "")
        location = place_data.get("location", {})

        # Collect review snippets
        reviews = place_data.get("reviews", [])
        review_snippets = []
        for r in reviews[:8]:
            txt = (r.get("text") or {}).get("text", "")
            if txt:
                review_snippets.append(txt[:250])
        reviews_context = "\n".join(review_snippets) if review_snippets else "No reviews available."

        # Fetch nearby landmarks and AI content in parallel
        ai_content = None
        nearby_landmarks = []

        def fetch_nearby():
            """Fetch nearby landmarks using Google Places nearby search."""
            lat = location.get("latitude")
            lng = location.get("longitude")
            if not lat or not lng:
                return []
            nearby_cache_key = f"restaurant-nearby:{place_id}"
            cached_nearby = cache_get(nearby_cache_key)
            if cached_nearby:
                return cached_nearby
            try:
                url = "https://places.googleapis.com/v1/places:searchNearby"
                req_headers = {
                    "Content-Type": "application/json",
                    "X-Goog-Api-Key": API_KEY,
                    "X-Goog-FieldMask": "places.displayName,places.location",
                }
                body = json.dumps({
                    "includedTypes": ["tourist_attraction", "shopping_mall", "museum"],
                    "maxResultCount": 5,
                    "locationRestriction": {
                        "circle": {
                            "center": {"latitude": lat, "longitude": lng},
                            "radius": 5000.0,
                        }
                    },
                }).encode()
                req = urllib.request.Request(url, data=body, headers=req_headers, method="POST")
                with urllib.request.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                landmarks = []
                for p in data.get("places", [])[:5]:
                    p_name = (p.get("displayName") or {}).get("text", "")
                    p_loc = p.get("location", {})
                    if p_name and p_loc:
                        dist_km = self._haversine(lat, lng, p_loc.get("latitude", 0), p_loc.get("longitude", 0))
                        dist_mi = dist_km * 0.621371
                        drive_min = max(1, int(dist_km * 2.5))
                        landmarks.append({
                            "name": p_name,
                            "distance": f"{dist_mi:.1f} mi",
                            "driveTime": f"{drive_min} min drive",
                        })
                cache_set(nearby_cache_key, landmarks, EXPERIENCE_DETAIL_CACHE_TTL)
                return landmarks
            except Exception as e:
                print(f"  [restaurant] Nearby search error: {e}")
                return []

        def fetch_ai_content():
            """Generate AI content for restaurant."""
            ai_cache_key = f"restaurant-ai:{place_id}"
            cached_ai = cache_get(ai_cache_key)
            if cached_ai:
                return cached_ai
            if not ANTHROPIC_KEY:
                return None
            prompt = (
                f"You are a restaurant reviewer and food writer. Generate content for this restaurant:\n"
                f"Name: {place_name}\n"
                f"Type: {place_type}\n"
                f"Address: {place_address}\n"
                f"Editorial: {editorial}\n"
                f"Reviews from guests:\n{reviews_context}\n\n"
                f"Return ONLY valid JSON (no markdown fences, no extra text) with these fields:\n"
                f'{{"guestsLoveSummary": "2-3 sentence summary of what guests love about this restaurant based on reviews",'
                f'"aboutDescription": "2-3 sentence engaging description of the restaurant atmosphere and experience",'
                f'"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],'
                f'"cuisineType": "e.g. French restaurant, Lebanese cuisine",'
                f'"priceRange": "e.g. 50 - 100"}}'
            )
            try:
                api_body = json.dumps({
                    "model": "claude-haiku-4-5-20251001",
                    "max_tokens": 600,
                    "messages": [{"role": "user", "content": prompt}],
                }).encode()
                req = urllib.request.Request(
                    "https://api.anthropic.com/v1/messages",
                    data=api_body,
                    headers={
                        "x-api-key": ANTHROPIC_KEY,
                        "anthropic-version": "2023-06-01",
                        "content-type": "application/json",
                    },
                    method="POST",
                )
                with urllib.request.urlopen(req, timeout=30) as resp:
                    claude_resp = json.loads(resp.read())
                raw_text = ""
                for block in claude_resp.get("content", []):
                    if block.get("type") == "text":
                        raw_text += block["text"]
                raw_text = raw_text.strip()
                if raw_text.startswith("```"):
                    lines = raw_text.split("\n")
                    lines = [l for l in lines if not l.strip().startswith("```")]
                    raw_text = "\n".join(lines)
                result = json.loads(raw_text)
                cache_set(ai_cache_key, result, EXPERIENCE_DETAIL_CACHE_TTL)
                print(f"  [restaurant] AI content generated for {place_name}")
                return result
            except Exception as e:
                print(f"  [restaurant] Claude AI content error: {e}")
                return None

        # Run both in parallel
        with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
            f_nearby = pool.submit(fetch_nearby)
            f_ai = pool.submit(fetch_ai_content)
            nearby_landmarks = f_nearby.result()
            ai_content = f_ai.result()

        # Build fallback if AI failed
        if not ai_content:
            ai_content = {
                "guestsLoveSummary": editorial or f"Guests enjoy the dining experience at {place_name}.",
                "aboutDescription": editorial or f"{place_name} is a popular dining destination.",
                "tags": [place_type.replace("_", " ").title()] if place_type else ["Restaurant"],
                "cuisineType": place_type.replace("_", " ").title() if place_type else "Restaurant",
                "priceRange": "Check with venue",
            }

        restaurant_content = {
            "guestsLoveSummary": ai_content.get("guestsLoveSummary", ""),
            "aboutDescription": ai_content.get("aboutDescription", ""),
            "tags": ai_content.get("tags", []),
            "cuisineType": ai_content.get("cuisineType", ""),
            "priceRange": ai_content.get("priceRange", ""),
            "nearbyLandmarks": nearby_landmarks,
        }

        result = {"place": place_data, "restaurant": restaurant_content}
        cache_set(cache_key, result, EXPERIENCE_DETAIL_CACHE_TTL)
        dt = int((time.time() - t0) * 1000)
        print(f"  [restaurant] {place_name} → OK ({dt}ms)")
        self._gzip_json_response(200, result)

    @staticmethod
    def _haversine(lat1, lon1, lat2, lon2):
        """Calculate distance in km between two coordinates."""
        R = 6371
        dlat = math.radians(lat2 - lat1)
        dlon = math.radians(lon2 - lon1)
        a = (math.sin(dlat / 2) ** 2 +
             math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
             math.sin(dlon / 2) ** 2)
        return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))

    # ---- /api/search?q=...&type=... ----------------------------------------
    def _handle_search(self, parsed):
        t0 = time.time()
        params = urllib.parse.parse_qs(parsed.query)
        query = params.get("q", [""])[0]
        place_type = params.get("type", [""])[0]
        max_results = min(20, max(1, int(params.get("maxResults", ["20"])[0])))

        if not query:
            self._json_response(400, {"error": "Missing ?q= parameter"})
            return

        # Check cache
        cache_key = f"search:{query}:{place_type}:{max_results}"
        cached = cache_get(cache_key)
        if cached:
            dt = int((time.time() - t0) * 1000)
            print(f"  [search] cache hit: \"{query}\" ({dt}ms)")
            self._raw_response(200, "application/json", cached)
            return

        url = "https://places.googleapis.com/v1/places:searchText"
        headers = {
            "Content-Type": "application/json",
            "X-Goog-Api-Key": API_KEY,
            "X-Goog-FieldMask": (
                "places.id,places.displayName,places.formattedAddress,"
                "places.rating,places.userRatingCount,places.priceLevel,"
                "places.currentOpeningHours,places.photos,places.primaryType,"
                "places.location"
            ),
        }
        req_body = {"textQuery": query, "maxResultCount": max_results}
        if place_type:
            req_body["includedType"] = place_type
        body = json.dumps(req_body).encode()

        try:
            req = urllib.request.Request(url, data=body, headers=headers, method="POST")
            with urllib.request.urlopen(req, timeout=8) as resp:
                raw_data = resp.read()
            # Trim photos to 1 per place to reduce payload
            parsed_data = json.loads(raw_data)
            for p in parsed_data.get("places", []):
                if p.get("photos"):
                    p["photos"] = p["photos"][:1]
                # Strip rarely-used fields
                p.pop("regularOpeningHours", None)
                p.pop("websiteUri", None)
                p.pop("googleMapsUri", None)
            trimmed = json.dumps(parsed_data).encode()
            cache_set(cache_key, trimmed, SEARCH_CACHE_TTL)
            dt = int((time.time() - t0) * 1000)
            print(f"  [search] \"{query}\" → {len(parsed_data.get('places', []))} places ({dt}ms)")
            self._raw_response(200, "application/json", trimmed)
        except urllib.error.HTTPError as e:
            error_body = e.read().decode()
            self._raw_response(e.code, "application/json", error_body.encode())
        except Exception as e:
            self._json_response(500, {"error": str(e)})

    # ---- /api/photo?name=...&maxWidth=... ---------------------------------
    def _handle_photo(self, parsed):
        params = urllib.parse.parse_qs(parsed.query)
        name = params.get("name", [""])[0]
        max_w = params.get("maxWidth", ["400"])[0]
        max_h = params.get("maxHeight", ["300"])[0]

        if not name:
            self._json_response(400, {"error": "Missing ?name= parameter"})
            return

        url = (
            f"https://places.googleapis.com/v1/{name}/media"
            f"?maxWidthPx={max_w}&maxHeightPx={max_h}&key={API_KEY}"
        )

        try:
            req = urllib.request.Request(url)
            with urllib.request.urlopen(req) as resp:
                content_type = resp.headers.get("Content-Type", "image/jpeg")
                data = resp.read()
            self._raw_response(200, content_type, data)
        except urllib.error.HTTPError as e:
            self._json_response(e.code, {"error": f"Photo fetch failed: {e.code}"})
        except Exception as e:
            self._json_response(500, {"error": str(e)})

    # ---- /api/flights?origin=...&destination=...&date=...&passengers=... ----
    def _handle_flights(self, parsed):
        params = urllib.parse.parse_qs(parsed.query)
        origin = params.get("origin", [""])[0]
        destination = params.get("destination", [""])[0]
        date = params.get("date", [""])[0]
        passengers = int(params.get("passengers", ["1"])[0])

        if not origin or not destination:
            self._json_response(400, {"error": "Missing origin or destination"})
            return

        # Check cache
        cache_key = f"flights:{origin}:{destination}:{date}:{passengers}"
        cached = cache_get(cache_key)
        if cached:
            self._json_response(200, cached)
            return

        flights = self._search_flights(origin, destination, date, passengers)
        result = {"flights": flights}
        cache_set(cache_key, result, FLIGHT_CACHE_TTL)
        self._json_response(200, result)

    # ---- /api/chat (POST) --------------------------------------------------
    # Max number of recent messages to send to Claude (saves tokens)
    MAX_HISTORY = 6

    @staticmethod
    def _slim_place(place):
        """Return only the fields Claude needs to write a response."""
        return {
            "name": (place.get("displayName") or {}).get("text", "Unknown"),
            "address": place.get("formattedAddress", ""),
            "rating": place.get("rating"),
            "reviews": place.get("userRatingCount"),
            "price": place.get("priceLevel", ""),
            "type": place.get("primaryType", ""),
        }

    def _handle_chat(self, body):
        if not ANTHROPIC_KEY:
            self._json_response(500, {"error": "ANTHROPIC_API_KEY not configured"})
            return

        messages = body.get("messages", [])
        if not messages:
            self._json_response(400, {"error": "No messages provided"})
            return

        # Trim to last N messages to stay under token limits
        if len(messages) > self.MAX_HISTORY:
            messages = messages[-self.MAX_HISTORY:]

        # --- Flight shortcut: handle locally via Amadeus, not Claude tool-use ---
        user_message = ""
        for m in reversed(messages):
            if m.get("role") == "user":
                user_message = m.get("content", "") if isinstance(m.get("content"), str) else ""
                break

        intents, detected_city = self._detect_intent(user_message)

        # Day planner shortcut — handle before trip planner
        if "day_planner" in intents and user_message:
            session_id = body.get("tripSessionId", "") or f"day-{id(self)}-{int(time.time()*1000)}"
            result = self._handle_day_planner(body, user_message, session_id)
            self._json_response(200, result)
            return

        # Trip planner shortcut — handle before flight/other intents
        session_id = body.get("tripSessionId", "")
        if ("trip_planner" in intents or user_message.startswith("__TRIP_")) and user_message:
            if not session_id:
                session_id = f"trip-{id(self)}-{int(time.time()*1000)}"
            result = self._handle_trip_planner(body, user_message, session_id)
            self._json_response(200, result)
            return

        # Check if there's an active trip session for this chat (follow-up messages)
        if session_id:
            session = trip_session_get(session_id)
            if session and session.get("status") in ("collecting", "choosing_flight", "choosing_hotel", "done"):
                result = self._handle_trip_planner(body, user_message, session_id)
                self._json_response(200, result)
                return

        if "flights" in intents and user_message:
            fallback_city = body.get("city", "Dubai")
            result = self._handle_chat_flight_local(
                messages, user_message, intents,
                detected_city or fallback_city, fallback_city,
            )
            self._json_response(200, result)
            return

        sections = []

        # Tool-use loop: keep calling Claude until we get a final text response
        while True:
            api_body = json.dumps({
                "model": "claude-sonnet-4-20250514",
                "max_tokens": 1024,
                "system": SYSTEM_PROMPT,
                "tools": CLAUDE_TOOLS,
                "messages": messages,
            }).encode()

            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages",
                data=api_body,
                headers={
                    "x-api-key": ANTHROPIC_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
                method="POST",
            )

            try:
                with urllib.request.urlopen(req, timeout=60) as resp:
                    claude_resp = json.loads(resp.read())
            except urllib.error.HTTPError as e:
                error_body = e.read().decode()
                self._json_response(502, {"error": f"Claude API error: {error_body}"})
                return
            except Exception as e:
                self._json_response(502, {"error": f"Claude API request failed: {e}"})
                return

            # If stop reason is not tool_use, we're done
            if claude_resp.get("stop_reason") != "tool_use":
                text_parts = []
                for block in claude_resp.get("content", []):
                    if block.get("type") == "text":
                        text_parts.append(block["text"])
                raw_text = "\n".join(text_parts)

                # Parse SUGGESTIONS: line
                suggestions = []
                lines = raw_text.split("\n")
                clean_lines = []
                for line in lines:
                    if line.strip().startswith("SUGGESTIONS:"):
                        parts = line.strip()[len("SUGGESTIONS:"):].split("|")
                        suggestions = [s.strip() for s in parts if s.strip()]
                    else:
                        clean_lines.append(line)
                display_text = "\n".join(clean_lines).strip()

                # Collect all places across sections for legacy compat
                all_places = []
                for sec in sections:
                    if sec.get("places"):
                        all_places.extend(sec["places"])

                self._json_response(200, {
                    "text": display_text,
                    "places": all_places,
                    "sections": sections,
                    "suggestions": suggestions,
                })
                return

            # Process tool calls IN PARALLEL using ThreadPoolExecutor
            assistant_content = claude_resp.get("content", [])
            messages.append({"role": "assistant", "content": assistant_content})

            tool_blocks = [b for b in assistant_content if b.get("type") == "tool_use"]

            def _exec_tool(block):
                """Execute a single tool call, return (section, tool_result)."""
                tool_name = block["name"]
                tool_input = block["input"]
                tool_id = block["id"]

                if tool_name == "search_places":
                    places = self._search_places(
                        tool_input.get("query", ""),
                        tool_input.get("type", ""),
                    )
                    sec_type, sec_icon = self._classify_section(tool_input)
                    query = tool_input.get("query", "")
                    city_hint = ""
                    for word in ["Paris", "London", "New York", "Tokyo", "Dubai", "Rome", "Barcelona", "Berlin", "Istanbul", "Bangkok"]:
                        if word.lower() in query.lower():
                            city_hint = word
                            break
                    label = f"{sec_type.title()} in {city_hint}" if city_hint else sec_type.title()
                    section = {"type": sec_type, "label": label, "icon": sec_icon, "places": places}
                    slim = [self._slim_place(p) for p in places]
                    result = {"type": "tool_result", "tool_use_id": tool_id, "content": json.dumps({"places": slim})}
                    return section, result

                elif tool_name == "search_flights":
                    flights = self._search_flights(
                        origin=tool_input.get("origin", ""),
                        destination=tool_input.get("destination", ""),
                        date=tool_input.get("date", ""),
                        passengers=tool_input.get("passengers", 1),
                    )
                    origin = self._resolve_airport(tool_input.get("origin", ""))
                    dest = self._resolve_airport(tool_input.get("destination", ""))
                    section = {"type": "flights", "label": f"Flights from {origin} to {dest}", "icon": "flight", "flights": flights}
                    slim = [{"airline": f["airline"], "flight": f["flightNumber"],
                             "price": f["price"], "duration": f["duration"],
                             "stops": f["stops"]} for f in flights[:5]]
                    result = {"type": "tool_result", "tool_use_id": tool_id, "content": json.dumps({"flights": slim})}
                    return section, result

                elif tool_name == "search_lists":
                    lists = self._search_lists(
                        city=tool_input.get("city", ""),
                        keywords=tool_input.get("keywords", ""),
                        creator=tool_input.get("creator", ""),
                    )
                    city = tool_input.get("city", "")
                    creator = tool_input.get("creator", "")
                    if creator:
                        label = f"{creator.title()}'s Lists"
                    elif city:
                        label = f"Curated Lists for {city}"
                    else:
                        label = "Curated Lists"
                    section = {"type": "lists", "label": label, "icon": "list", "lists": lists}
                    result = {"type": "tool_result", "tool_use_id": tool_id,
                              "content": json.dumps({"lists": [{"title": l["title"], "creator": l["userName"], "likes": l["likes"]} for l in lists]})}
                    return section, result

                else:
                    return None, {"type": "tool_result", "tool_use_id": tool_id,
                                  "content": json.dumps({"error": f"Unknown tool: {tool_name}"}), "is_error": True}

            # Execute all tool calls in parallel
            tool_results = []
            if len(tool_blocks) > 1:
                with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_blocks)) as pool:
                    futures = [pool.submit(_exec_tool, b) for b in tool_blocks]
                    for future in futures:
                        section, result = future.result()
                        if section:
                            sections.append(section)
                        tool_results.append(result)
            else:
                for b in tool_blocks:
                    section, result = _exec_tool(b)
                    if section:
                        sections.append(section)
                    tool_results.append(result)

            messages.append({"role": "user", "content": tool_results})

    # ---- Flight-local handler (bypasses Claude tool-use) --------------------
    def _handle_chat_flight_local(self, messages, user_message, intents, city, fallback_city):
        """Handle flight queries locally: parse params, call Amadeus, Claude only for text."""
        parsed = self._parse_flight_query(user_message, fallback_city)
        sections = []

        # 1) Flight results from Amadeus
        if parsed["destination"]:
            flights = self._search_flights(
                parsed["origin"], parsed["destination"],
                parsed["date"], parsed["passengers"],
            )
            origin_code = self._resolve_airport(parsed["origin"])
            dest_code = self._resolve_airport(parsed["destination"])
            sections.append({
                "type": "flights",
                "label": f"Flights from {origin_code} to {dest_code}",
                "icon": "flight",
                "flights": flights,
                "searchMeta": {
                    "origin": origin_code,
                    "destination": dest_code,
                    "date": parsed["date"],
                    "passengers": parsed["passengers"],
                },
            })

        # 2) Handle any non-flight intents in parallel (restaurants, hotels, etc.)
        other_intents = [i for i in intents if i != "flights"]
        if other_intents:
            def _fetch_intent(intent):
                if intent == "restaurants":
                    places = self._search_places(f"best restaurants in {city}", "restaurant")
                    return {"type": "restaurants", "label": f"Restaurants in {city}", "icon": "restaurant", "places": places}
                elif intent == "hotels":
                    places = self._search_places(f"best hotels in {city}", "lodging")
                    return {"type": "hotels", "label": f"Hotels in {city}", "icon": "hotel", "places": places}
                elif intent == "experiences":
                    places = self._search_places(f"best experiences in {city}", "tourist_attraction")
                    return {"type": "experiences", "label": f"Experiences in {city}", "icon": "experience", "places": places}
                elif intent == "lists":
                    lists = self._search_lists(city=city)
                    label = f"Curated Lists for {city}" if city else "Curated Lists"
                    return {"type": "lists", "label": label, "icon": "list", "lists": lists}
                return None

            with concurrent.futures.ThreadPoolExecutor(max_workers=len(other_intents)) as pool:
                futures = [pool.submit(_fetch_intent, i) for i in other_intents]
                for f in futures:
                    sec = f.result()
                    if sec:
                        sections.append(sec)

        # 3) Claude for conversational text only (non-blocking, fallback on failure)
        display_text = ""
        try:
            origin_code = self._resolve_airport(parsed["origin"])
            dest_code = self._resolve_airport(parsed["destination"]) if parsed["destination"] else ""
            text_prompt = (
                f"The user asked: \"{user_message}\"\n"
                f"We found flight results from {origin_code} to {dest_code}. "
                f"Write a brief, warm 1-2 sentence intro. Don't list specific flights — "
                f"cards are shown in the UI. Keep it conversational and concise."
            )
            api_body = json.dumps({
                "model": "claude-haiku-4-5-20251001",
                "max_tokens": 150,
                "messages": [{"role": "user", "content": text_prompt}],
            }).encode()
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages",
                data=api_body,
                headers={
                    "x-api-key": ANTHROPIC_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=15) as resp:
                claude_resp = json.loads(resp.read())
            for block in claude_resp.get("content", []):
                if block.get("type") == "text":
                    display_text += block["text"]
        except Exception as e:
            print(f"  [chat-flight] Claude text error (non-fatal): {e}")

        # Fallback text if Claude failed
        if not display_text:
            origin_code = self._resolve_airport(parsed["origin"])
            dest_code = self._resolve_airport(parsed["destination"]) if parsed["destination"] else "your destination"
            display_text = f"Here are the available flights from {origin_code} to {dest_code}."

        suggestions = self._generate_suggestions(intents)
        all_places = []
        for sec in sections:
            if sec.get("places"):
                all_places.extend(sec["places"])

        return {
            "text": display_text,
            "places": all_places,
            "sections": sections,
            "suggestions": suggestions,
        }

    # ---- Trip planner handler ------------------------------------------------
    # ---- Day Planner (single-day, no flights/hotels) --------------------------
    def _handle_day_planner(self, body, user_message, session_id):
        """Handle simplified single-day planning flow."""
        # Extract destination from message
        fallback_city = body.get("city", "Dubai")
        parsed = self._parse_trip_query(user_message, fallback_city)
        destination = parsed.get("destination", "")

        if not destination:
            # Try to find a city name in the message
            msg_lower = user_message.lower()
            sorted_cities = sorted(SUPPORTED_CITIES, key=len, reverse=True)
            for c in sorted_cities:
                if c in msg_lower:
                    destination = c.title()
                    break

        if not destination:
            return {
                "text": "I'd love to plan your day! Which city would you like to explore?",
                "sections": [],
                "suggestions": ["Dubai", "Paris", "Tokyo", "Barcelona"],
                "mode": "day_planner",
                "tripStatus": "collecting_day",
                "tripProgress": {"current": 0, "total": 1, "label": "Planning"},
                "tripSessionId": session_id,
            }

        # Build day plan directly (no flights/hotels)
        day_plan = self._build_day_plan(destination)

        return {
            "text": f"Here's your perfect day in {destination}! A curated timeline from morning to night.",
            "sections": [],
            "suggestions": ["Make it more adventurous", "More food-focused", f"Plan my day in Paris"],
            "mode": "day_planner",
            "tripStatus": "done",
            "tripProgress": {"current": 1, "total": 1, "label": "Day Plan"},
            "tripSessionId": session_id,
            "tripDestination": destination,
            "dayPlan": day_plan,
        }

    def _build_day_plan(self, destination):
        """Call Claude Sonnet to build a single-day timeline with 7-8 slots."""
        prompt = f"""Create a perfect single-day plan for spending a day in {destination}.

Return ONLY a JSON array with 7-8 time slots covering 9 AM to 10 PM:
[
  {{"time": "9:00 AM", "name": "Place Name", "type": "breakfast", "area": "Neighborhood", "reason": "Why visit"}},
  {{"time": "10:30 AM", "name": "Place Name", "type": "activity", "area": "Neighborhood", "reason": "Why visit"}},
  {{"time": "1:00 PM", "name": "Restaurant Name", "type": "lunch", "area": "Neighborhood", "reason": "Why eat here"}},
  {{"time": "2:30 PM", "name": "Place Name", "type": "activity_pm", "area": "Neighborhood", "reason": "Why visit"}},
  {{"time": "4:30 PM", "name": "Cafe Name", "type": "coffee", "area": "Neighborhood", "reason": "Why visit"}},
  {{"time": "7:00 PM", "name": "Restaurant Name", "type": "dinner", "area": "Neighborhood", "reason": "Why dine here"}},
  {{"time": "9:00 PM", "name": "Bar/Lounge Name", "type": "nightlife", "area": "Neighborhood", "reason": "Why visit"}}
]

Types must be one of: breakfast, activity, lunch, activity_pm, coffee, dinner, nightlife.
Use real, well-known places in {destination}. Be specific with names.
Return ONLY the JSON array, no other text."""

        try:
            api_body = json.dumps({
                "model": "claude-sonnet-4-20250514",
                "max_tokens": 1024,
                "messages": [{"role": "user", "content": prompt}],
            }).encode()
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages",
                data=api_body,
                headers={
                    "x-api-key": ANTHROPIC_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                claude_resp = json.loads(resp.read())

            raw_text = ""
            for block in claude_resp.get("content", []):
                if block.get("type") == "text":
                    raw_text += block["text"]

            raw_text = raw_text.strip()
            if raw_text.startswith("```"):
                lines = raw_text.split("\n")
                lines = [l for l in lines if not l.strip().startswith("```")]
                raw_text = "\n".join(lines)

            day_plan = json.loads(raw_text)

            # Enrich each slot with Google Places data
            self._enrich_day_plan_items(day_plan, destination)

            print(f"  [day_plan] Built {len(day_plan)}-slot day plan for {destination}")
            return day_plan

        except Exception as e:
            print(f"  [day_plan] Generation error: {e}")
            return self._day_plan_fallback(destination)

    def _enrich_day_plan_items(self, day_plan, destination):
        """Enrich day plan slots with Google Places data."""
        items_to_enrich = []
        for slot in day_plan:
            if not slot.get("placeId"):
                place_type = "restaurant" if slot.get("type") in ("breakfast", "lunch", "dinner", "coffee") else ""
                items_to_enrich.append((slot, place_type))

        if not items_to_enrich:
            return

        def enrich_one(args):
            slot, place_type = args
            query = f"{slot['name']} in {destination}"
            try:
                places = self._search_places(query, place_type)
                if places:
                    p = places[0]
                    slot["placeId"] = p.get("id", "")
                    slot["rating"] = p.get("rating")
                    slot["primaryType"] = p.get("primaryType", "")
                    slot["formattedAddress"] = p.get("formattedAddress", "")
                    slot["location"] = p.get("location")
                    photos = p.get("photos", [])[:4]
                    slot["photos"] = photos
                    if photos:
                        slot["photoUrl"] = photos[0].get("name", "")
            except Exception as e:
                print(f"  [day_plan enrich] Error enriching '{slot.get('name')}': {e}")

        try:
            with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
                executor.map(enrich_one, items_to_enrich)
        except Exception as e:
            print(f"  [day_plan enrich] ThreadPool error: {e}")

    @staticmethod
    def _day_plan_fallback(destination):
        """Fallback day plan if Claude fails."""
        return [
            {"time": "9:00 AM", "name": f"Breakfast spot in {destination}", "type": "breakfast", "area": "City Center", "reason": "Start your day right"},
            {"time": "10:30 AM", "name": f"Top attraction in {destination}", "type": "activity", "area": "Downtown", "reason": "Must-see landmark"},
            {"time": "1:00 PM", "name": f"Popular restaurant", "type": "lunch", "area": "City Center", "reason": "Local cuisine"},
            {"time": "2:30 PM", "name": f"Cultural site in {destination}", "type": "activity_pm", "area": "Old Town", "reason": "Rich history"},
            {"time": "4:30 PM", "name": f"Local café", "type": "coffee", "area": "Trendy District", "reason": "Relax and recharge"},
            {"time": "7:00 PM", "name": f"Fine dining in {destination}", "type": "dinner", "area": "Waterfront", "reason": "Evening atmosphere"},
            {"time": "9:00 PM", "name": f"Night spot", "type": "nightlife", "area": "Entertainment District", "reason": "End the day in style"},
        ]

    def _handle_trip_planner(self, body, user_message, session_id):
        """Main dispatcher for multi-step trip planning flow."""
        import time as _time

        # Handle selection messages
        if user_message.startswith("__TRIP_SELECT_FLIGHT:"):
            return self._trip_select_flight(session_id, user_message)
        if user_message.startswith("__TRIP_SELECT_HOTEL:"):
            return self._trip_select_hotel(session_id, user_message)

        # Check for existing session
        session = trip_session_get(session_id)

        # Follow-up on existing session (e.g. filling missing params or adjusting itinerary)
        if session:
            if session.get("status") == "collecting":
                return self._trip_handle_followup(session_id, session, user_message)
            if session.get("status") == "done":
                return self._trip_adjust_itinerary(session_id, session, user_message)
            # If choosing_flight or choosing_hotel, user might be chatting — remind them to select
            if session.get("status") in ("choosing_flight", "choosing_hotel"):
                step = "a flight" if session["status"] == "choosing_flight" else "a hotel"
                return {
                    "text": f"Please select {step} from the options above, or tell me if you'd like to see different options.",
                    "sections": [], "suggestions": [f"Show different {step.split()[-1]}s"],
                    "mode": "trip_planner",
                    "tripStatus": session["status"],
                    "tripProgress": session.get("progress", {"current": 1, "total": 3, "label": "Flights"}),
                    "tripSessionId": session_id,
                }

        # New trip — parse parameters
        fallback_city = body.get("city", "Dubai")
        parsed = self._parse_trip_query(user_message, fallback_city)

        # Check what's missing
        missing = []
        if not parsed["destination"]:
            missing.append("destination")
        if not parsed["trip_days"]:
            missing.append("trip_days")
        if not parsed["date"]:
            missing.append("date")

        if missing:
            # Save partial session, ask for missing info
            session = {
                "status": "collecting",
                "parsed": parsed,
                "missing": missing,
                "updated_at": _time.time(),
            }
            trip_session_set(session_id, session)
            return self._trip_ask_clarification(session_id, parsed, missing)

        # All params present — go to flights
        return self._trip_start_flights(session_id, parsed)

    def _trip_ask_clarification(self, session_id, parsed, missing):
        """Ask user for missing trip parameters."""
        parts = []
        suggestions = []

        if "destination" in missing:
            parts.append("Where would you like to go?")
            suggestions.extend(["Paris", "London", "Tokyo", "Barcelona"])
        if "trip_days" in missing:
            parts.append("How many days is your trip?")
            suggestions.extend(["3 days", "5 days", "7 days"])
        if "date" in missing:
            parts.append("When would you like to travel?")
            from datetime import datetime, timedelta
            next_week = (datetime.now() + timedelta(days=7)).strftime("%B %d")
            suggestions.append(f"{next_week}")

        text = "I'd love to help plan your trip! " + " ".join(parts)

        return {
            "text": text,
            "sections": [],
            "suggestions": suggestions[:4],
            "mode": "trip_planner",
            "tripStatus": "collecting",
            "tripProgress": {"current": 0, "total": 3, "label": "Planning"},
            "tripSessionId": session_id,
        }

    def _trip_handle_followup(self, session_id, session, user_message):
        """Handle follow-up messages to fill missing trip parameters."""
        import time as _time
        parsed = session["parsed"]
        missing = list(session.get("missing", []))

        # Try to extract missing fields from the follow-up
        followup_parsed = self._parse_trip_query(user_message, parsed.get("origin", "Dubai"))

        if "destination" in missing and followup_parsed["destination"]:
            parsed["destination"] = followup_parsed["destination"]
            missing.remove("destination")
        if "trip_days" in missing and followup_parsed["trip_days"]:
            parsed["trip_days"] = followup_parsed["trip_days"]
            missing.remove("trip_days")
        if "date" in missing and followup_parsed["date"]:
            parsed["date"] = followup_parsed["date"]
            missing.remove("date")

        # Also try simple number for days
        if "trip_days" in missing:
            m = re.search(r'(\d+)', user_message)
            if m:
                parsed["trip_days"] = max(1, min(14, int(m.group(1))))
                missing.remove("trip_days")

        # Also try city name match for destination
        if "destination" in missing:
            for city in sorted(SUPPORTED_CITIES, key=len, reverse=True):
                if city in user_message.lower():
                    parsed["destination"] = city.title()
                    missing.remove("destination")
                    break

        if missing:
            session["parsed"] = parsed
            session["missing"] = missing
            session["updated_at"] = _time.time()
            trip_session_set(session_id, session)
            return self._trip_ask_clarification(session_id, parsed, missing)

        # All params filled — default date if still empty
        if not parsed["date"]:
            from datetime import datetime, timedelta
            parsed["date"] = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")

        return self._trip_start_flights(session_id, parsed)

    def _trip_start_flights(self, session_id, parsed):
        """Fetch flights and present for selection."""
        import time as _time

        # Default date if not provided
        if not parsed["date"]:
            from datetime import datetime, timedelta
            parsed["date"] = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")

        origin = parsed["origin"]
        destination = parsed["destination"]
        flights = self._search_flights(origin, destination, parsed["date"], parsed["passengers"])

        origin_code = self._resolve_airport(origin)
        dest_code = self._resolve_airport(destination)

        session = {
            "status": "choosing_flight",
            "parsed": parsed,
            "flights": flights,
            "updated_at": _time.time(),
            "progress": {"current": 1, "total": 3, "label": "Flights"},
        }
        trip_session_set(session_id, session)

        trip_type_label = parsed.get("trip_type", "general")
        days = parsed.get("trip_days", 5)
        text = (
            f"Let's plan your {trip_type_label} {days}-day trip to {destination}! "
            f"First, pick a flight from {origin_code} to {dest_code}."
        )

        return {
            "text": text,
            "sections": [{
                "type": "flights",
                "label": f"Flights from {origin_code} to {dest_code}",
                "icon": "flight",
                "flights": flights,
                "searchMeta": {
                    "origin": origin_code,
                    "destination": dest_code,
                    "date": parsed["date"],
                    "passengers": parsed["passengers"],
                },
            }],
            "suggestions": ["Show cheapest options", "Show nonstop only"],
            "mode": "trip_planner",
            "tripStatus": "choosing_flight",
            "tripProgress": {"current": 1, "total": 3, "label": "Flights"},
            "tripSessionId": session_id,
        }

    def _trip_select_flight(self, session_id, msg):
        """Handle flight selection, move to hotel search."""
        import time as _time
        session = trip_session_get(session_id)
        if not session or session.get("status") != "choosing_flight":
            return {"text": "No active trip session. Start a new trip plan!", "sections": [], "suggestions": ["Plan a trip"]}

        try:
            idx = int(msg.split(":")[1])
        except (IndexError, ValueError):
            idx = 0

        flights = session.get("flights", [])
        if idx < 0 or idx >= len(flights):
            idx = 0
        selected_flight = flights[idx] if flights else None

        session["selected_flight"] = selected_flight
        session["status"] = "choosing_hotel"
        session["progress"] = {"current": 2, "total": 3, "label": "Hotels"}
        session["updated_at"] = _time.time()

        # Search for hotels at destination
        destination = session["parsed"]["destination"]
        trip_type = session["parsed"].get("trip_type", "general")
        hotel_query = f"best {trip_type} hotels in {destination}" if trip_type != "general" else f"best hotels in {destination}"
        hotels = self._search_places(hotel_query, "lodging")

        session["hotels"] = hotels
        trip_session_set(session_id, session)

        airline = selected_flight.get("airline", "your flight") if selected_flight else "your flight"
        text = f"Great choice — {airline}! Now let's find you the perfect hotel in {destination}."

        return {
            "text": text,
            "sections": [{
                "type": "hotels",
                "label": f"Hotels in {destination}",
                "icon": "hotel",
                "places": hotels,
            }],
            "suggestions": ["Show luxury options", "Show budget-friendly"],
            "mode": "trip_planner",
            "tripStatus": "choosing_hotel",
            "tripProgress": {"current": 2, "total": 3, "label": "Hotels"},
            "tripSessionId": session_id,
        }

    def _trip_select_hotel(self, session_id, msg):
        """Handle hotel selection, build itinerary."""
        import time as _time
        session = trip_session_get(session_id)
        if not session or session.get("status") != "choosing_hotel":
            return {"text": "No active trip session. Start a new trip plan!", "sections": [], "suggestions": ["Plan a trip"]}

        try:
            idx = int(msg.split(":")[1])
        except (IndexError, ValueError):
            idx = 0

        hotels = session.get("hotels", [])
        if idx < 0 or idx >= len(hotels):
            idx = 0
        selected_hotel = hotels[idx] if hotels else None

        session["selected_hotel"] = selected_hotel
        session["status"] = "building_itinerary"
        session["updated_at"] = _time.time()
        trip_session_set(session_id, session)

        # Build the itinerary
        itinerary = self._trip_build_itinerary(session)

        session["itinerary"] = itinerary
        session["status"] = "done"
        session["progress"] = {"current": 3, "total": 3, "label": "Itinerary"}
        session["updated_at"] = _time.time()
        trip_session_set(session_id, session)

        hotel_name = (selected_hotel.get("displayName", {}).get("text", "your hotel")) if selected_hotel else "your hotel"
        destination = session["parsed"]["destination"]
        days = session["parsed"].get("trip_days", 5)
        text = f"You're all set! Here's your {days}-day itinerary in {destination}, staying at {hotel_name}."

        # Compute trip cost estimate
        trip_costs = self._compute_trip_costs(session)

        return {
            "text": text,
            "sections": [],
            "suggestions": ["Swap Day 1 dinner", "Make it more adventurous", "Add more restaurants"],
            "mode": "trip_planner",
            "tripStatus": "done",
            "tripProgress": {"current": 3, "total": 3, "label": "Itinerary"},
            "tripSessionId": session_id,
            "itinerary": itinerary,
            "tripDestination": destination,
            "tripCosts": trip_costs,
        }

    def _trip_build_itinerary(self, session):
        """Call Claude Sonnet to build a day-by-day itinerary JSON."""
        parsed = session["parsed"]
        destination = parsed["destination"]
        days = parsed.get("trip_days", 5)
        trip_type = parsed.get("trip_type", "general")
        date = parsed.get("date", "")

        hotel_name = ""
        if session.get("selected_hotel"):
            hotel_name = session["selected_hotel"].get("displayName", {}).get("text", "")

        type_guidance = {
            "romantic": "Focus on scenic spots, cozy restaurants, sunset views, and intimate experiences.",
            "business": "Include efficient dining, upscale restaurants, and key landmarks for short visits.",
            "family": "Include kid-friendly activities, parks, casual dining, and easy-to-navigate areas.",
            "friends": "Include nightlife, trendy restaurants, fun activities, and social hotspots.",
            "solo": "Include cultural experiences, local food spots, walking tours, and hidden gems.",
            "adventure": "Include outdoor activities, hiking, unique experiences, and local adventure spots.",
            "general": "Include a good mix of sightseeing, dining, and cultural experiences.",
        }
        guidance = type_guidance.get(trip_type, type_guidance["general"])

        prompt = f"""Create a {days}-day travel itinerary for {destination}.
Trip type: {trip_type}
Hotel: {hotel_name or 'central hotel'}
{guidance}

Return ONLY a JSON array with this exact structure, no other text:
[
  {{
    "dayIndex": 1,
    "themeTitle": "Arrival & First Impressions",
    "morning": {{"name": "Place Name", "area": "Neighborhood", "reason": "Why visit"}},
    "lunch": {{"name": "Restaurant Name", "area": "Neighborhood", "reason": "Why eat here"}},
    "afternoon": {{"name": "Activity/Place", "area": "Neighborhood", "reason": "Why visit"}},
    "dinner": {{"name": "Restaurant Name", "area": "Neighborhood", "reason": "Why dine here"}}
  }}
]

Each day MUST have all 4 slots: morning, lunch, afternoon, dinner.
Use real, well-known places in {destination}. Be specific with names."""

        try:
            api_body = json.dumps({
                "model": "claude-sonnet-4-20250514",
                "max_tokens": 2048,
                "messages": [{"role": "user", "content": prompt}],
            }).encode()
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages",
                data=api_body,
                headers={
                    "x-api-key": ANTHROPIC_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                claude_resp = json.loads(resp.read())

            raw_text = ""
            for block in claude_resp.get("content", []):
                if block.get("type") == "text":
                    raw_text += block["text"]

            # Extract JSON from response (may be wrapped in ```json ... ```)
            raw_text = raw_text.strip()
            if raw_text.startswith("```"):
                lines = raw_text.split("\n")
                lines = [l for l in lines if not l.strip().startswith("```")]
                raw_text = "\n".join(lines)

            itinerary = json.loads(raw_text)

            # Add dates to each day
            if date:
                from datetime import datetime, timedelta
                try:
                    start_date = datetime.strptime(date, "%Y-%m-%d")
                    for day in itinerary:
                        day_offset = day.get("dayIndex", 1) - 1
                        day["date"] = (start_date + timedelta(days=day_offset)).strftime("%Y-%m-%d")
                except ValueError:
                    for day in itinerary:
                        day["date"] = ""
            else:
                for day in itinerary:
                    day["date"] = ""

            # Add stable slot IDs
            for day in itinerary:
                for slot_key in ["morning", "lunch", "afternoon", "dinner"]:
                    item = day.get(slot_key)
                    if item:
                        item["slotId"] = f"day{day.get('dayIndex', 1)}_{slot_key}"

            # Enrich items with Google Places data
            self._enrich_itinerary_items(itinerary, destination)

            print(f"  [trip] Built {len(itinerary)}-day itinerary for {destination}")
            return itinerary

        except Exception as e:
            print(f"  [trip] Itinerary generation error: {e}")
            return self._trip_fallback_itinerary(destination, days, date)

    @staticmethod
    def _trip_fallback_itinerary(destination, days, date):
        """Generate basic fallback itinerary if Claude fails."""
        from datetime import datetime, timedelta
        itinerary = []
        try:
            start_date = datetime.strptime(date, "%Y-%m-%d") if date else datetime.now() + timedelta(days=7)
        except ValueError:
            start_date = datetime.now() + timedelta(days=7)

        themes = ["Arrival & Exploration", "Cultural Discovery", "Local Favorites",
                  "Hidden Gems", "Adventure Day", "Relaxation", "Grand Finale"]
        for i in range(days):
            day_idx = i + 1
            itinerary.append({
                "dayIndex": day_idx,
                "date": (start_date + timedelta(days=i)).strftime("%Y-%m-%d"),
                "themeTitle": themes[i % len(themes)],
                "morning": {"name": f"Morning exploration in {destination}", "area": "City Center", "reason": "Start the day with local sights", "slotId": f"day{day_idx}_morning"},
                "lunch": {"name": f"Local restaurant", "area": "City Center", "reason": "Try authentic local cuisine", "slotId": f"day{day_idx}_lunch"},
                "afternoon": {"name": f"Afternoon activity", "area": destination, "reason": "Experience the best of the city", "slotId": f"day{day_idx}_afternoon"},
                "dinner": {"name": f"Evening dining", "area": destination, "reason": "End the day with a great meal", "slotId": f"day{day_idx}_dinner"},
            })
        return itinerary

    def _trip_budget_replan(self, session_id, session, target_amount):
        """Full budget replan: re-search flights/hotels, rebuild itinerary."""
        import time as _time
        itinerary = session.get("itinerary", [])
        destination = session["parsed"]["destination"]
        trip_type = session["parsed"].get("trip_type", "general")
        parsed = session.get("parsed", {})
        origin = parsed.get("origin", "Dubai")
        nights = max(parsed.get("trip_days", 3) - 1, 1)
        num_days = parsed.get("trip_days", 3)
        is_downgrade = target_amount < self._compute_trip_costs(session).get("totalCost", 0)

        prev_costs = self._compute_trip_costs(session)
        prev_total = prev_costs.get("totalCost", 0)
        prev_flight = session.get("selected_flight")
        prev_hotel = session.get("selected_hotel")

        # Budget allocations
        flight_budget = target_amount * 0.30
        hotel_budget = target_amount * 0.35

        hotel_price_map = {
            "PRICE_LEVEL_INEXPENSIVE": 150,
            "PRICE_LEVEL_MODERATE": 300,
            "PRICE_LEVEL_EXPENSIVE": 500,
            "PRICE_LEVEL_VERY_EXPENSIVE": 800,
        }

        # 1. Re-search flights within budget
        new_flight = prev_flight
        flight_changed = False
        try:
            flights = self._search_flights(
                origin, destination,
                parsed.get("date", ""),
                parsed.get("passengers", 1),
            )
            affordable = [f for f in flights if f.get("price", 99999) <= flight_budget]
            if not affordable:
                affordable = sorted(flights, key=lambda f: f.get("price", 99999))[:3]
            if affordable:
                best = affordable[0]
                prev_price = (prev_flight or {}).get("price", 0)
                if abs(best.get("price", 0) - prev_price) > 10:
                    new_flight = best
                    flight_changed = True
        except Exception as e:
            print(f"  [replan] Flight search error: {e}")

        # 2. Re-search hotels within budget
        new_hotel = prev_hotel
        hotel_changed = False
        max_nightly = hotel_budget / max(nights, 1)
        try:
            if max_nightly < 200:
                q = f"budget affordable hotels in {destination}"
            elif max_nightly < 400:
                q = f"mid-range hotels in {destination}"
            else:
                q = f"luxury premium hotels in {destination}"
            hotels = self._search_places(q, "lodging")
            if hotels:
                prev_id = (prev_hotel or {}).get("id", "")
                for h in hotels:
                    pl = h.get("priceLevel", "PRICE_LEVEL_MODERATE")
                    nightly = hotel_price_map.get(pl, 300)
                    if nightly <= max_nightly * 1.2:
                        if h.get("id") != prev_id:
                            new_hotel = h
                            hotel_changed = True
                        break
                if not hotel_changed and hotels[0].get("id") != prev_id:
                    new_hotel = hotels[0]
                    hotel_changed = True
        except Exception as e:
            print(f"  [replan] Hotel search error: {e}")

        # Update session selections
        session["selected_flight"] = new_flight
        session["selected_hotel"] = new_hotel

        # Calculate remaining for food + activities
        actual_flight_cost = (new_flight or {}).get("price", 0)
        hotel_pl = (new_hotel or {}).get("priceLevel", "PRICE_LEVEL_MODERATE")
        actual_hotel_nightly = hotel_price_map.get(hotel_pl, 300)
        actual_hotel_cost = actual_hotel_nightly * nights
        remaining = max(target_amount - actual_flight_cost - actual_hotel_cost, 0)
        meal_budget_per = int(remaining * 0.55 / max(nights * 2, 1))
        activity_budget_per = int(remaining * 0.45 / max(nights * 1.5, 1))

        hotel_name = (new_hotel or {}).get("displayName", {}).get("text", "the hotel")
        first_date = itinerary[0].get("date", "") if itinerary else ""

        prompt = f"""Build a {num_days}-day itinerary for {destination} ({trip_type} trip).
The traveler is staying at {hotel_name}.

Budget constraints:
- Total trip budget: AED {target_amount:,}
- Budget per meal: ~AED {meal_budget_per}
- Budget per activity: ~AED {activity_budget_per}
{"- Choose budget-friendly restaurants and free/cheap activities." if is_downgrade else "- Choose premium restaurants and premium activities."}

Return a JSON array with {num_days} objects, each with:
- dayIndex (1-based)
- date ("{first_date}" for day 1, increment by 1 day each)
- themeTitle (short theme)
- morning: {{"name": "...", "area": "...", "reason": "..."}} (activity)
- lunch: {{"name": "...", "area": "...", "reason": "..."}} (restaurant)
- afternoon: {{"name": "...", "area": "...", "reason": "..."}} (activity)
- dinner: {{"name": "...", "area": "...", "reason": "..."}} (restaurant)

Use real, well-known places in {destination}. Return ONLY the JSON array."""

        try:
            api_body = json.dumps({
                "model": "claude-sonnet-4-20250514",
                "max_tokens": 2048,
                "messages": [{"role": "user", "content": prompt}],
            }).encode()
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages",
                data=api_body,
                headers={
                    "x-api-key": ANTHROPIC_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                claude_resp = json.loads(resp.read())

            raw_text = ""
            for block in claude_resp.get("content", []):
                if block.get("type") == "text":
                    raw_text += block["text"]
            raw_text = raw_text.strip()
            if raw_text.startswith("```"):
                lines = raw_text.split("\n")
                lines = [l for l in lines if not l.strip().startswith("```")]
                raw_text = "\n".join(lines)

            updated_itinerary = json.loads(raw_text)

            for day in updated_itinerary:
                for slot_key in ["morning", "lunch", "afternoon", "dinner"]:
                    item = day.get(slot_key)
                    if item:
                        item["slotId"] = f"day{day.get('dayIndex', 1)}_{slot_key}"

            self._enrich_itinerary_items(updated_itinerary, destination)

            session["itinerary"] = updated_itinerary
            session["updated_at"] = _time.time()
            trip_session_set(session_id, session)

            trip_costs = self._compute_trip_costs(session)

            replan_data = {
                "previousTotal": prev_total,
                "flightChanged": flight_changed,
                "hotelChanged": hotel_changed,
            }
            if flight_changed and new_flight:
                replan_data["newFlight"] = new_flight
            if hotel_changed and new_hotel:
                replan_data["newHotel"] = new_hotel

            direction = "reduced" if is_downgrade else "upgraded"
            return {
                "text": f"Done! I've replanned your trip with a {direction} budget of AED {target_amount:,}.",
                "sections": [],
                "suggestions": ["Looks great!", "Adjust budget again", "Show me alternatives"],
                "mode": "trip_planner",
                "tripStatus": "done",
                "tripProgress": {"current": 3, "total": 3, "label": "Itinerary"},
                "tripSessionId": session_id,
                "itinerary": updated_itinerary,
                "tripDestination": destination,
                "tripCosts": trip_costs,
                "replanData": replan_data,
            }
        except Exception as e:
            print(f"  [replan] Budget replan error: {e}")
            trip_costs = self._compute_trip_costs(session)
            return {
                "text": "I had trouble replanning. Here's your current plan.",
                "sections": [],
                "suggestions": ["Try again", "Keep current plan"],
                "mode": "trip_planner",
                "tripStatus": "done",
                "tripProgress": {"current": 3, "total": 3, "label": "Itinerary"},
                "tripSessionId": session_id,
                "itinerary": itinerary,
                "tripDestination": destination,
                "tripCosts": trip_costs,
            }

    def _trip_adjust_itinerary(self, session_id, session, user_message):
        """Adjust existing itinerary based on user request."""
        import time as _time
        import re as _re
        itinerary = session.get("itinerary", [])
        destination = session["parsed"]["destination"]
        trip_type = session["parsed"].get("trip_type", "general")

        # Detect budget adjustment — delegate to full replan
        budget_match = _re.search(r'budget\s+to\s+AED\s+([\d,]+)', user_message, _re.IGNORECASE)
        if budget_match:
            target_amount = int(budget_match.group(1).replace(",", ""))
            return self._trip_budget_replan(session_id, session, target_amount)

        prompt = f"""Here is the current {len(itinerary)}-day itinerary for {destination} ({trip_type} trip):

{json.dumps(itinerary, indent=2)}

The user wants to make this change: "{user_message}"

Return the COMPLETE updated itinerary as a JSON array with the same structure. Only modify what the user asked for. Return ONLY the JSON array, no other text."""

        try:
            api_body = json.dumps({
                "model": "claude-sonnet-4-20250514",
                "max_tokens": 2048,
                "messages": [{"role": "user", "content": prompt}],
            }).encode()
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages",
                data=api_body,
                headers={
                    "x-api-key": ANTHROPIC_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                claude_resp = json.loads(resp.read())

            raw_text = ""
            for block in claude_resp.get("content", []):
                if block.get("type") == "text":
                    raw_text += block["text"]

            raw_text = raw_text.strip()
            if raw_text.startswith("```"):
                lines = raw_text.split("\n")
                lines = [l for l in lines if not l.strip().startswith("```")]
                raw_text = "\n".join(lines)

            updated_itinerary = json.loads(raw_text)

            # Add stable slot IDs
            for day in updated_itinerary:
                for slot_key in ["morning", "lunch", "afternoon", "dinner"]:
                    item = day.get(slot_key)
                    if item:
                        item["slotId"] = f"day{day.get('dayIndex', 1)}_{slot_key}"

            # Enrich items with Google Places data
            self._enrich_itinerary_items(updated_itinerary, destination)

            session["itinerary"] = updated_itinerary
            session["updated_at"] = _time.time()
            trip_session_set(session_id, session)

            trip_costs = self._compute_trip_costs(session)
            text = "Done! I've updated your itinerary based on your request."
            return {
                "text": text,
                "sections": [],
                "suggestions": ["Swap Day 1 dinner", "Make it more adventurous", "Looks great!"],
                "mode": "trip_planner",
                "tripStatus": "done",
                "tripProgress": {"current": 3, "total": 3, "label": "Itinerary"},
                "tripSessionId": session_id,
                "itinerary": updated_itinerary,
                "tripDestination": destination,
                "tripCosts": trip_costs,
            }
        except Exception as e:
            print(f"  [trip] Itinerary adjustment error: {e}")
            trip_costs = self._compute_trip_costs(session)
            return {
                "text": "I had trouble updating the itinerary. Here's your current plan.",
                "sections": [],
                "suggestions": ["Try again", "Keep current itinerary"],
                "mode": "trip_planner",
                "tripStatus": "done",
                "tripProgress": {"current": 3, "total": 3, "label": "Itinerary"},
                "tripSessionId": session_id,
                "itinerary": itinerary,
                "tripDestination": destination,
                "tripCosts": trip_costs,
            }

    # ---- Enrich itinerary items with Google Places data -----------------------
    def _enrich_itinerary_items(self, itinerary, destination):
        """Enrich itinerary items with Google Places data (placeId, photos, rating, etc.)."""
        items_to_enrich = []
        for day in itinerary:
            for slot_key in ["morning", "lunch", "afternoon", "dinner"]:
                item = day.get(slot_key)
                if item and not item.get("placeId"):
                    place_type = "restaurant" if slot_key in ("lunch", "dinner") else ""
                    items_to_enrich.append((item, slot_key, place_type))

        if not items_to_enrich:
            return

        def enrich_one(args):
            item, slot_key, place_type = args
            query = f"{item['name']} in {destination}"
            try:
                places = self._search_places(query, place_type)
                if places:
                    p = places[0]
                    item["placeId"] = p.get("id", "")
                    item["rating"] = p.get("rating")
                    item["primaryType"] = p.get("primaryType", "")
                    item["formattedAddress"] = p.get("formattedAddress", "")
                    item["location"] = p.get("location")
                    item["priceLevel"] = p.get("priceLevel", "")
                    photos = p.get("photos", [])[:4]
                    item["photos"] = photos
                    if photos:
                        item["photoUrl"] = photos[0].get("name", "")
            except Exception as e:
                print(f"  [enrich] Error enriching '{item.get('name')}': {e}")

        try:
            with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
                executor.map(enrich_one, items_to_enrich)
            enriched_count = sum(1 for it, _, _ in items_to_enrich if it.get("placeId"))
            print(f"  [enrich] Enriched {enriched_count}/{len(items_to_enrich)} items for {destination}")
        except Exception as e:
            print(f"  [enrich] ThreadPool error: {e}")

    # ---- Compute trip cost estimates -------------------------------------------
    @staticmethod
    def _compute_trip_costs(session):
        """Compute estimated trip costs from session data."""
        parsed = session.get("parsed", {})
        days = parsed.get("trip_days", 3)
        itinerary = session.get("itinerary", [])

        # Flight cost
        flight_cost = 0
        selected_flight = session.get("selected_flight")
        if selected_flight:
            flight_cost = selected_flight.get("price", 0)

        # Hotel cost estimation by priceLevel
        hotel_cost = 0
        selected_hotel = session.get("selected_hotel")
        hotel_price_map = {
            "PRICE_LEVEL_INEXPENSIVE": 150,
            "PRICE_LEVEL_MODERATE": 300,
            "PRICE_LEVEL_EXPENSIVE": 500,
            "PRICE_LEVEL_VERY_EXPENSIVE": 800,
        }
        if selected_hotel:
            hotel_pl = selected_hotel.get("priceLevel", "PRICE_LEVEL_MODERATE")
            per_night = hotel_price_map.get(hotel_pl, 300)
            nights = max(days - 1, 1)
            hotel_cost = per_night * nights

        # Food and activity cost estimation from itinerary items
        food_price_map = {
            "PRICE_LEVEL_INEXPENSIVE": 40,
            "PRICE_LEVEL_MODERATE": 80,
            "PRICE_LEVEL_EXPENSIVE": 150,
            "PRICE_LEVEL_VERY_EXPENSIVE": 250,
        }
        activity_price_map = {
            "PRICE_LEVEL_FREE": 0,
            "PRICE_LEVEL_INEXPENSIVE": 50,
            "PRICE_LEVEL_MODERATE": 120,
            "PRICE_LEVEL_EXPENSIVE": 250,
            "PRICE_LEVEL_VERY_EXPENSIVE": 400,
        }

        total_food = 0
        total_activities = 0
        per_day = []

        for day in itinerary:
            day_food = 0
            day_activities = 0
            for slot_key in ["morning", "lunch", "afternoon", "dinner"]:
                item = day.get(slot_key)
                if not item:
                    continue
                pl = item.get("priceLevel", "PRICE_LEVEL_MODERATE")
                if slot_key in ("lunch", "dinner"):
                    day_food += food_price_map.get(pl, 80)
                else:
                    day_activities += activity_price_map.get(pl, 120)
            total_food += day_food
            total_activities += day_activities
            per_day.append({
                "dayIndex": day.get("dayIndex", len(per_day) + 1),
                "food": day_food,
                "activities": day_activities,
                "subtotal": day_food + day_activities,
            })

        total_cost = flight_cost + hotel_cost + total_food + total_activities

        return {
            "flightCost": flight_cost,
            "hotelCost": hotel_cost,
            "foodCost": total_food,
            "activityCost": total_activities,
            "totalCost": total_cost,
            "currency": "AED",
            "perDay": per_day,
        }

    # ---- Handle chat action (delete/swap) ------------------------------------
    def _handle_chat_action(self, body):
        """Handle itinerary actions: delete_item, swap_item."""
        action = body.get("action", "")
        session_id = body.get("tripSessionId", "")
        slot_id = body.get("slotId", "")

        if not session_id or not slot_id:
            self._json_response(400, {"error": "tripSessionId and slotId required"})
            return

        session = trip_session_get(session_id)
        if not session or not session.get("itinerary"):
            self._json_response(404, {"error": "Trip session not found"})
            return

        # Parse slotId → dayIndex + slot_key (e.g. "day2_dinner" → 2, "dinner")
        import re as _re
        m = _re.match(r"day(\d+)_(\w+)", slot_id)
        if not m:
            self._json_response(400, {"error": "Invalid slotId format"})
            return
        target_day_idx = int(m.group(1))
        target_slot = m.group(2)

        itinerary = session["itinerary"]
        target_day = None
        for day in itinerary:
            if day.get("dayIndex") == target_day_idx:
                target_day = day
                break

        if not target_day:
            self._json_response(404, {"error": f"Day {target_day_idx} not found"})
            return

        if action == "delete_item":
            if target_slot in target_day:
                target_day[target_slot] = None
            session["updated_at"] = time.time()
            trip_session_set(session_id, session)
            self._json_response(200, {"ok": True, "itinerary": itinerary})

        elif action == "swap_item":
            new_place = body.get("newPlace", {})
            if not new_place:
                self._json_response(400, {"error": "newPlace required for swap"})
                return
            new_item = {
                "name": new_place.get("displayName", {}).get("text", new_place.get("name", "Unknown")),
                "slotId": slot_id,
                "placeId": new_place.get("id", ""),
                "rating": new_place.get("rating"),
                "primaryType": new_place.get("primaryType", ""),
                "formattedAddress": new_place.get("formattedAddress", ""),
                "location": new_place.get("location"),
                "photos": (new_place.get("photos") or [])[:4],
                "photoUrl": (new_place.get("photos") or [{}])[0].get("name", ""),
                "area": new_place.get("formattedAddress", "").split(",")[0] if new_place.get("formattedAddress") else "",
                "reason": "Swapped by you",
            }
            target_day[target_slot] = new_item
            session["updated_at"] = time.time()
            trip_session_set(session_id, session)
            self._json_response(200, {"ok": True, "itinerary": itinerary})

        else:
            self._json_response(400, {"error": f"Unknown action: {action}"})

    # ---- GET /api/places/related — related suggestions -------------------------
    def _handle_places_related(self, parsed):
        params = urllib.parse.parse_qs(parsed.query)
        place_id = params.get("place_id", [""])[0]
        vertical = params.get("vertical", ["restaurant"])[0]
        city = params.get("city", [""])[0]

        if not city:
            self._json_response(400, {"error": "city parameter required"})
            return

        cache_key = f"related:{vertical}:{city}:{place_id}"
        cached = cache_get(cache_key)
        if cached:
            self._json_response(200, {"places": cached})
            return

        if vertical == "restaurant":
            query = f"popular restaurants in {city}"
            place_type = "restaurant"
        else:
            query = f"popular attractions in {city}"
            place_type = ""

        places = self._search_places(query, place_type)
        # Filter out original place
        places = [p for p in places if p.get("id") != place_id]

        # If not enough results, do a broader search
        if len(places) < 6:
            broader_query = f"best {vertical}s to visit in {city}"
            more_places = self._search_places(broader_query, "")
            existing_ids = {p.get("id") for p in places}
            existing_ids.add(place_id)
            for p in more_places:
                if p.get("id") not in existing_ids:
                    places.append(p)
                    existing_ids.add(p.get("id"))

        places = places[:10]
        cache_set(cache_key, places, SEARCH_CACHE_TTL)
        self._json_response(200, {"places": places})

    # ---- GET /api/trip/{id}/itinerary — fetch current itinerary ---------------
    def _handle_trip_itinerary(self, parsed):
        # Extract session ID from path: /api/trip/{id}/itinerary
        parts = parsed.path.split("/")
        # parts: ['', 'api', 'trip', '{id}', 'itinerary']
        if len(parts) < 5:
            self._json_response(400, {"error": "Invalid path"})
            return
        session_id = urllib.parse.unquote(parts[3])
        session = trip_session_get(session_id)
        if not session or not session.get("itinerary"):
            self._json_response(404, {"error": "Trip session not found"})
            return
        self._json_response(200, {"itinerary": session["itinerary"]})

    # ---- search_lists helper -----------------------------------------------
    def _search_lists(self, city="", keywords="", creator=""):
        results = []
        city_lower = city.lower().strip() if city else ""
        kw_tokens = keywords.lower().split() if keywords else []
        creator_lower = creator.lower().strip() if creator else ""

        for lst in LISTS_DATA:
            if city_lower and city_lower not in lst["city"].lower():
                continue
            if creator_lower:
                user = USERS_DATA.get(lst["userId"], {})
                if creator_lower not in user.get("name", "").lower():
                    continue
            if kw_tokens:
                hay = (lst["title"] + " " + " ".join(lst["keywords"]) + " " + lst["city"]).lower()
                if not any(kw in hay for kw in kw_tokens):
                    continue
            user = USERS_DATA.get(lst["userId"], {})
            results.append({
                "id": lst["id"],
                "title": lst["title"],
                "city": lst["city"],
                "userId": lst["userId"],
                "userName": user.get("name", "Unknown"),
                "likes": lst["likes"],
                "commentCount": lst["commentCount"],
                "keywords": lst["keywords"],
            })

        results.sort(key=lambda x: x["likes"], reverse=True)
        return results[:5]

    # ---- search_flights helper ---------------------------------------------
    @staticmethod
    def _resolve_airport(city_or_code):
        """Resolve a city name or IATA code to an IATA code."""
        code = city_or_code.strip().upper()
        if len(code) == 3 and code.isalpha():
            return code
        return CITY_AIRPORTS.get(city_or_code.strip().lower(), code[:3].upper())

    @staticmethod
    def _parse_flight_query(message, fallback_city="Dubai"):
        """Extract origin, destination, date, passengers from natural language."""
        msg = message.strip()
        msg_lower = msg.lower()
        origin = ""
        destination = ""
        date = ""
        passengers = 1

        # --- Passengers: "for 2 passengers", "2 adults", "2 people" ---
        pax_match = re.search(r'(\d+)\s*(?:passengers?|adults?|people|persons?|travelers?|travellers?|pax)', msg_lower)
        if pax_match:
            passengers = max(1, min(9, int(pax_match.group(1))))

        # --- Date parsing ---
        from datetime import datetime, timedelta
        import calendar
        now = datetime.now()

        # "YYYY-MM-DD"
        date_match = re.search(r'(\d{4}-\d{2}-\d{2})', msg_lower)
        if date_match:
            date = date_match.group(1)

        # "DD/MM" or "MM/DD" — assume DD/MM for travel context
        if not date:
            slash_match = re.search(r'(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?', msg_lower)
            if slash_match:
                d1, d2 = int(slash_match.group(1)), int(slash_match.group(2))
                yr = int(slash_match.group(3)) if slash_match.group(3) else now.year
                if yr < 100:
                    yr += 2000
                # DD/MM format
                try:
                    dt = datetime(yr, d2, d1)
                    if dt.date() < now.date():
                        dt = datetime(yr + 1, d2, d1)
                    date = dt.strftime("%Y-%m-%d")
                except ValueError:
                    pass

        if not date:
            months_map = {
                "january": 1, "february": 2, "march": 3, "april": 4,
                "may": 5, "june": 6, "july": 7, "august": 8,
                "september": 9, "october": 10, "november": 11, "december": 12,
                "jan": 1, "feb": 2, "mar": 3, "apr": 4,
                "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
            }
            # Word ordinals: "first of April", "second of May", etc.
            word_ordinals = {
                "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5,
                "sixth": 6, "seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10,
                "eleventh": 11, "twelfth": 12, "thirteenth": 13, "fourteenth": 14,
                "fifteenth": 15, "sixteenth": 16, "seventeenth": 17, "eighteenth": 18,
                "nineteenth": 19, "twentieth": 20, "twenty-first": 21, "twenty-second": 22,
                "twenty-third": 23, "twenty-fourth": 24, "twenty-fifth": 25,
                "twenty-sixth": 26, "twenty-seventh": 27, "twenty-eighth": 28,
                "twenty-ninth": 29, "thirtieth": 30, "thirty-first": 31,
            }

            for mname, mnum in sorted(months_map.items(), key=lambda x: -len(x[0])):
                if mname not in msg_lower:
                    continue

                year = now.year
                if mnum < now.month or (mnum == now.month and now.day > 25):
                    year += 1

                day = None

                # "Month 15" / "15 Month" / "Month 15th" / "15th of Month"
                m = re.search(rf'{mname}\s+(\d{{1,2}})(?:st|nd|rd|th)?', msg_lower)
                if not m:
                    m = re.search(rf'(\d{{1,2}})(?:st|nd|rd|th)?\s+(?:of\s+)?{mname}', msg_lower)
                if m:
                    day = int(m.group(1))

                # Word ordinals: "first of April", "third April"
                if day is None:
                    for word, num in word_ordinals.items():
                        if re.search(rf'{word}\s+(?:of\s+)?{mname}', msg_lower):
                            day = num
                            break

                # Qualifiers: "beginning of April", "mid April", "end of April", etc.
                if day is None:
                    if re.search(rf'(?:beginning|start)\s+(?:of\s+)?{mname}', msg_lower):
                        day = 1
                    elif re.search(rf'early\s+{mname}', msg_lower):
                        day = 5
                    elif re.search(rf'mid(?:dle)?\s+(?:of\s+)?{mname}', msg_lower):
                        day = 15
                    elif re.search(rf'(?:end|late)\s+(?:of\s+)?{mname}', msg_lower):
                        day = 25
                    elif re.search(rf'(?:in|around)\s+{mname}', msg_lower) or \
                         re.search(rf'^{mname}$', msg_lower.strip()) or \
                         (mname in msg_lower and day is None):
                        # Month only mentioned without qualifier — default to 1st
                        day = 1

                if day is not None:
                    max_day = calendar.monthrange(year, mnum)[1]
                    day = min(day, max_day)
                    try:
                        d = datetime(year, mnum, day)
                        if d.date() < now.date():
                            d = datetime(year + 1, mnum, day)
                        date = d.strftime("%Y-%m-%d")
                    except ValueError:
                        pass
                break

        if not date:
            # "tomorrow" / "today" / "tonight"
            if "tomorrow" in msg_lower:
                date = (now + timedelta(days=1)).strftime("%Y-%m-%d")
            elif any(w in msg_lower for w in ("today", "tonight")):
                date = now.strftime("%Y-%m-%d")
            elif "next week" in msg_lower:
                date = (now + timedelta(days=7)).strftime("%Y-%m-%d")
            elif "this weekend" in msg_lower or "the weekend" in msg_lower:
                days_until_sat = (5 - now.weekday()) % 7
                if days_until_sat == 0:
                    days_until_sat = 7
                date = (now + timedelta(days=days_until_sat)).strftime("%Y-%m-%d")
            elif "in 2 weeks" in msg_lower or "in two weeks" in msg_lower:
                date = (now + timedelta(days=14)).strftime("%Y-%m-%d")
            elif "in a week" in msg_lower or "in one week" in msg_lower:
                date = (now + timedelta(days=7)).strftime("%Y-%m-%d")
            else:
                # "next monday", "this friday", etc.
                day_names = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
                             "friday": 4, "saturday": 5, "sunday": 6}
                for dname, dnum in day_names.items():
                    if dname in msg_lower:
                        days_ahead = dnum - now.weekday()
                        if days_ahead <= 0:
                            days_ahead += 7
                        date = (now + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
                        break

        # --- Origin / destination extraction ---
        # Clean message for city matching (remove date/pax fragments)
        clean = re.sub(r'\d{4}-\d{2}-\d{2}', '', msg_lower)
        clean = re.sub(r'\d+\s*(?:passengers?|adults?|people|persons?)', '', clean)

        # Pattern 1: "from X to Y"
        ft = re.search(r'from\s+([a-z\s]+?)\s+to\s+([a-z\s]+?)(?:\s+on\b|\s+for\b|\s+next\b|\s+tomorrow\b|\s+this\b|\s*$)', clean)
        if ft:
            origin = ft.group(1).strip()
            destination = ft.group(2).strip()
        else:
            # Pattern 2: "fly/flight/travel to Y from X"
            tf = re.search(r'(?:fly|flight|flights|travel|trip|go|going)\s+to\s+([a-z\s]+?)(?:\s+from\s+([a-z\s]+?))?(?:\s+on\b|\s+for\b|\s+next\b|\s+tomorrow\b|\s*$)', clean)
            if tf:
                destination = tf.group(1).strip()
                if tf.group(2):
                    origin = tf.group(2).strip()
            else:
                # Pattern 3: "X to Y" (generic)
                xy = re.search(r'([a-z\s]+?)\s+to\s+([a-z\s]+?)(?:\s+flights?\b|\s+on\b|\s+for\b|\s+next\b|\s+tomorrow\b|\s*$)', clean)
                if xy:
                    origin = xy.group(1).strip()
                    destination = xy.group(2).strip()

        # Resolve city names against known airports/cities
        def resolve_city(name):
            name = name.strip().rstrip('.,!?')
            for prefix in ("find", "search", "book", "get", "show", "me", "a", "the", "flights", "flight"):
                if name.startswith(prefix + " "):
                    name = name[len(prefix):].strip()
            if not name:
                return ""
            # Direct IATA code
            if len(name) == 3 and name.upper().isalpha():
                return name.upper()
            # Exact match in airport map
            if name.lower() in CITY_AIRPORTS:
                return name.title()
            # Fuzzy match against known cities
            for city in sorted(SUPPORTED_CITIES, key=len, reverse=True):
                if city in name.lower() or name.lower() in city:
                    return city.title()
            return name.title()

        origin = resolve_city(origin)
        destination = resolve_city(destination)

        # Fallback: if no origin, use fallback city
        if not origin:
            origin = fallback_city
        # If no destination, scan message for any known city that isn't the origin
        if not destination:
            for city in sorted(SUPPORTED_CITIES, key=len, reverse=True):
                if city in msg_lower and city.title().lower() != origin.lower():
                    destination = city.title()
                    break

        return {"origin": origin, "destination": destination, "date": date, "passengers": passengers}

    @staticmethod
    def _parse_trip_query(message, fallback_city="Dubai"):
        """Extract trip parameters: destination, origin, days, trip_type, date, passengers."""
        msg_lower = message.lower().strip()

        # Reuse flight parser for origin/dest/date/passengers
        flight_parsed = Handler._parse_flight_query(message, fallback_city)
        origin = flight_parsed["origin"]
        destination = flight_parsed["destination"]
        date = flight_parsed["date"]
        passengers = flight_parsed["passengers"]

        # Trip days: "for 5 days", "5-day", "a week", "weekend", "3 nights"
        trip_days = 0
        days_match = re.search(r'(?:for\s+)?(\d+)\s*(?:-?\s*)?(?:days?|nights?)', msg_lower)
        if days_match:
            trip_days = max(1, min(14, int(days_match.group(1))))
        elif "two weeks" in msg_lower or "2 weeks" in msg_lower:
            trip_days = 14
        elif "a week" in msg_lower or "one week" in msg_lower or "1 week" in msg_lower:
            trip_days = 7
        elif "long weekend" in msg_lower:
            trip_days = 4
        elif "weekend" in msg_lower:
            trip_days = 3

        # Trip type detection
        trip_type = "general"
        type_keywords = {
            "romantic": ["romantic", "honeymoon", "couples", "anniversary", "valentine"],
            "business": ["business", "work", "conference", "meeting", "corporate"],
            "family": ["family", "kids", "children", "family-friendly"],
            "friends": ["friends", "group trip", "buddies", "mates", "squad"],
            "solo": ["solo", "alone", "by myself", "on my own"],
            "adventure": ["adventure", "hiking", "outdoor", "extreme", "backpacking"],
        }
        for ttype, keywords in type_keywords.items():
            if any(kw in msg_lower for kw in keywords):
                trip_type = ttype
                break

        return {
            "origin": origin,
            "destination": destination,
            "date": date,
            "passengers": passengers,
            "trip_days": trip_days,
            "trip_type": trip_type,
        }

    def _search_flights(self, origin, destination, date="", passengers=1):
        origin_code = self._resolve_airport(origin)
        dest_code = self._resolve_airport(destination)
        if not date:
            from datetime import datetime, timedelta
            date = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
        passengers = max(1, min(9, passengers or 1))

        # Try Amadeus API first
        if AMADEUS_KEY and AMADEUS_SECRET:
            token = get_amadeus_token()
            if token:
                try:
                    params = urllib.parse.urlencode({
                        "originLocationCode": origin_code,
                        "destinationLocationCode": dest_code,
                        "departureDate": date,
                        "adults": passengers,
                        "max": 8,
                        "currencyCode": "USD",
                    })
                    url = f"https://test.api.amadeus.com/v2/shopping/flight-offers?{params}"
                    req = urllib.request.Request(url, headers={
                        "Authorization": f"Bearer {token}",
                    })
                    with urllib.request.urlopen(req, timeout=15) as resp:
                        data = json.loads(resp.read())

                    carriers = data.get("dictionaries", {}).get("carriers", {})
                    flights = []
                    for offer in data.get("data", [])[:8]:
                        itin = offer.get("itineraries", [{}])[0]
                        segments = itin.get("segments", [])
                        if not segments:
                            continue
                        first_seg = segments[0]
                        last_seg = segments[-1]
                        carrier = first_seg.get("carrierCode", "")
                        airline_name = carriers.get(carrier, AIRLINE_NAMES.get(carrier, carrier))
                        flight_num = f"{carrier}{first_seg.get('number', '')}"
                        dep_time = first_seg.get("departure", {}).get("at", "")[:16]
                        arr_time = last_seg.get("arrival", {}).get("at", "")[:16]
                        dep_code = first_seg.get("departure", {}).get("iataCode", origin_code)
                        arr_code = last_seg.get("arrival", {}).get("iataCode", dest_code)
                        duration = itin.get("duration", "")
                        stops = len(segments) - 1
                        price = offer.get("price", {})
                        total = price.get("grandTotal", price.get("total", "0"))

                        flights.append({
                            "airline": airline_name,
                            "airlineCode": carrier,
                            "flightNumber": flight_num,
                            "origin": dep_code,
                            "destination": arr_code,
                            "departureTime": dep_time,
                            "arrivalTime": arr_time,
                            "duration": duration,
                            "stops": stops,
                            "price": float(total),
                            "currency": price.get("currency", "USD"),
                            "baseFare": float(price.get("base", total)),
                            "source": "amadeus",
                        })

                    if flights:
                        flights.sort(key=lambda f: f["price"])
                        print(f"  ✓ Amadeus returned {len(flights)} flights {origin_code}→{dest_code}")
                        return flights
                except urllib.error.HTTPError as e:
                    print(f"  Amadeus search error {e.code}: {e.read().decode()[:200]}")
                except Exception as e:
                    print(f"  Amadeus search error: {e}")

        # Fallback: generate realistic fake flights
        return self._generate_fake_flights(origin_code, dest_code, date, passengers)

    @staticmethod
    def _generate_fake_flights(origin, dest, date, passengers):
        """Generate realistic fake flight data as fallback."""
        airlines = [
            ("EK", "Emirates"), ("BA", "British Airways"), ("AF", "Air France"),
            ("LH", "Lufthansa"), ("AA", "American Airlines"), ("DL", "Delta Air Lines"),
            ("UA", "United Airlines"), ("QR", "Qatar Airways"),
        ]
        KNOWN_ROUTES = {
            ("JFK","CDG"): (420,7.5), ("JFK","LHR"): (380,7.0), ("LHR","CDG"): (95,1.3),
            ("JFK","NRT"): (750,14.0), ("LHR","NRT"): (680,12.0), ("CDG","NRT"): (700,12.5),
            ("JFK","DXB"): (650,13.0), ("LHR","DXB"): (450,7.0), ("CDG","DXB"): (480,7.5),
            ("LHR","BCN"): (120,2.3), ("CDG","FCO"): (130,2.0), ("JFK","FCO"): (500,9.0),
        }
        key = (origin, dest)
        rev_key = (dest, origin)
        if key in KNOWN_ROUTES:
            base_price, base_hrs = KNOWN_ROUTES[key]
        elif rev_key in KNOWN_ROUTES:
            base_price, base_hrs = KNOWN_ROUTES[rev_key]
        else:
            base_price = random.randint(200, 800)
            base_hrs = random.uniform(2, 14)

        flights = []
        for i in range(random.randint(6, 8)):
            al = random.choice(airlines)
            dep_hour = random.randint(5, 22)
            dep_min = random.choice([0, 15, 30, 45])
            variation = random.uniform(0.8, 1.3)
            price = round(base_price * variation * passengers)
            dur_hrs = base_hrs * random.uniform(0.9, 1.2)
            stops = 0 if dur_hrs < 8 else random.choices([0, 1, 2], weights=[3, 5, 1])[0]
            if stops > 0:
                dur_hrs *= 1 + stops * 0.3
            h = int(dur_hrs)
            m = int((dur_hrs - h) * 60)

            flights.append({
                "airline": al[1],
                "airlineCode": al[0],
                "flightNumber": f"{al[0]}{random.randint(100,999)}",
                "origin": origin,
                "destination": dest,
                "departureTime": f"{date}T{dep_hour:02d}:{dep_min:02d}",
                "arrivalTime": "",
                "duration": f"PT{h}H{m}M",
                "stops": stops,
                "price": price,
                "currency": "USD",
                "baseFare": round(price * 0.82),
                "source": "generated",
            })
        flights.sort(key=lambda f: f["price"])
        return flights

    # ---- intent detection (local, <1ms) ------------------------------------
    @staticmethod
    def _detect_intent(message):
        """Keyword-based intent detection. Returns (intents, city)."""
        msg_lower = message.lower()
        intents = []

        # Day planner detection — check BEFORE trip planner since "plan my day" could match trip patterns
        if any(phrase in msg_lower for phrase in DAY_PLANNER_PHRASES) or \
           any(p.search(msg_lower) for p in DAY_PLANNER_PATTERNS):
            intents.append("day_planner")

        # Trip planner detection — check before other intents (skip if day_planner already matched)
        if "day_planner" not in intents and (
            any(phrase in msg_lower for phrase in TRIP_PLANNER_PHRASES) or \
            any(p.search(msg_lower) for p in TRIP_PLANNER_PATTERNS)):
            intents.append("trip_planner")

        # Multi-word phrase checks first
        multi_phrases = {
            "restaurants": ["book a table", "places to eat", "where to eat", "good food", "dining out"],
            "hotels": ["book a room", "places to stay", "where to stay", "check in"],
            "experiences": ["things to do", "what to do", "places to visit", "must see"],
            "flights": ["travel to", "trip to", "fly to", "book a flight"],
        }
        for intent, phrases in multi_phrases.items():
            if any(p in msg_lower for p in phrases):
                if intent not in intents:
                    intents.append(intent)

        # Single-word keyword checks via set intersection
        msg_words = set(msg_lower.split())
        if msg_words & RESTAURANT_KEYWORDS and "restaurants" not in intents:
            intents.append("restaurants")
        if msg_words & HOTEL_KEYWORDS and "hotels" not in intents:
            intents.append("hotels")
        if msg_words & EXPERIENCE_KEYWORDS and "experiences" not in intents:
            intents.append("experiences")
        if msg_words & FLIGHT_KEYWORDS and "flights" not in intents:
            intents.append("flights")
        if msg_words & LISTS_KEYWORDS and "lists" not in intents:
            intents.append("lists")

        # Detect city — longest match first
        city = ""
        sorted_cities = sorted(SUPPORTED_CITIES, key=len, reverse=True)
        for c in sorted_cities:
            if c in msg_lower:
                city = c.title()
                break

        # Fallback intents
        if not intents:
            intents = ["restaurants", "experiences"]

        return intents, city

    @staticmethod
    def _generate_suggestions(intents):
        """Pick up to 3 suggestions based on detected intents."""
        seen = set()
        suggestions = []
        for intent in intents:
            for s in INTENT_SUGGESTIONS.get(intent, []):
                if s not in seen:
                    seen.add(s)
                    suggestions.append(s)
                    if len(suggestions) >= 3:
                        return suggestions
        return suggestions

    # ---- SSE helpers --------------------------------------------------------
    def _send_sse_headers(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "keep-alive")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()

    def _send_sse_event(self, data):
        line = f"data: {json.dumps(data)}\n\n"
        self.wfile.write(line.encode())
        self.wfile.flush()

    # ---- /api/chat/stream (POST) — SSE streaming endpoint ------------------
    def _handle_chat_stream(self, body):
        if not ANTHROPIC_KEY:
            self._json_response(500, {"error": "ANTHROPIC_API_KEY not configured"})
            return

        messages = body.get("messages", [])
        if not messages:
            self._json_response(400, {"error": "No messages provided"})
            return

        user_message = ""
        for m in reversed(messages):
            if m.get("role") == "user":
                user_message = m.get("content", "")
                break
        if not user_message:
            self._json_response(400, {"error": "No user message found"})
            return

        fallback_city = body.get("city", "Dubai")

        # Cache check
        cache_key = f"chat-stream:{user_message.lower().strip()}:{fallback_city.lower()}"
        cached = cache_get(cache_key)
        if cached:
            print(f"  [stream] cache hit for: \"{user_message[:40]}\"")
            self._send_sse_headers()
            for event in cached:
                self._send_sse_event(event)
            return

        # Detect intent locally (<1ms)
        intents, detected_city = self._detect_intent(user_message)
        city = detected_city or fallback_city
        print(f"  [stream] intents={intents}, city={city} for: \"{user_message[:50]}\"")

        # Send SSE headers + intent event
        self._send_sse_headers()
        intent_event = {"type": "intent", "intents": intents, "city": city}
        self._send_sse_event(intent_event)
        all_events = [intent_event]

        # Queue for interleaving text chunks and API results
        q = queue.Queue()

        # Trim history
        api_messages = messages[-self.MAX_HISTORY:]

        # --- Claude thread: streaming text ---
        def claude_thread():
            try:
                system = (
                    "You are Etera, a friendly AI travel concierge. "
                    "Write a brief, warm 1-2 sentence intro about what the user is looking for. "
                    "Don't list specific places or give detailed recommendations — "
                    "the search results will be shown as cards below your message. "
                    "Keep it conversational and concise."
                )
                api_body = json.dumps({
                    "model": "claude-haiku-4-5-20251001",
                    "max_tokens": 150,
                    "stream": True,
                    "system": system,
                    "messages": api_messages,
                }).encode()

                req = urllib.request.Request(
                    "https://api.anthropic.com/v1/messages",
                    data=api_body,
                    headers={
                        "x-api-key": ANTHROPIC_KEY,
                        "anthropic-version": "2023-06-01",
                        "content-type": "application/json",
                    },
                    method="POST",
                )

                with urllib.request.urlopen(req, timeout=30) as resp:
                    buffer = ""
                    for raw_line in resp:
                        line = raw_line.decode("utf-8").strip()
                        if not line or line.startswith("event:"):
                            continue
                        if line.startswith("data: "):
                            data_str = line[6:]
                            if data_str == "[DONE]":
                                break
                            try:
                                event = json.loads(data_str)
                                if event.get("type") == "content_block_delta":
                                    delta = event.get("delta", {})
                                    text = delta.get("text", "")
                                    if text:
                                        q.put({"type": "text_chunk", "text": text})
                            except json.JSONDecodeError:
                                pass

                q.put({"type": "text_done"})
            except Exception as e:
                print(f"  [stream] Claude error: {e}")
                q.put({"type": "text_chunk", "text": "I'd be happy to help you explore that!"})
                q.put({"type": "text_done"})
            finally:
                q.put({"type": "_thread_done"})

        # --- API result threads ---
        def api_thread(intent):
            try:
                if intent == "restaurants":
                    query = f"best restaurants in {city}"
                    ptype = "restaurant"
                    places = self._search_places(query, ptype)
                    sec = {"type": "restaurants", "label": f"Restaurants in {city}", "icon": "restaurant", "places": places}
                    q.put({"type": "results", "section": sec})
                elif intent == "hotels":
                    query = f"best hotels in {city}"
                    ptype = "lodging"
                    places = self._search_places(query, ptype)
                    sec = {"type": "hotels", "label": f"Hotels in {city}", "icon": "hotel", "places": places}
                    q.put({"type": "results", "section": sec})
                elif intent == "experiences":
                    query = f"best experiences in {city}"
                    ptype = "tourist_attraction"
                    places = self._search_places(query, ptype)
                    sec = {"type": "experiences", "label": f"Experiences in {city}", "icon": "experience", "places": places}
                    q.put({"type": "results", "section": sec})
                elif intent == "flights":
                    parsed = self._parse_flight_query(user_message, fallback_city)
                    origin = parsed["origin"]
                    destination = parsed["destination"] or (city if city != fallback_city else "London")
                    flights = self._search_flights(origin, destination, parsed["date"], parsed["passengers"])
                    origin_code = self._resolve_airport(origin)
                    dest_code = self._resolve_airport(destination)
                    sec = {"type": "flights", "label": f"Flights from {origin_code} to {dest_code}", "icon": "flight", "flights": flights}
                    q.put({"type": "results", "section": sec})
                elif intent == "lists":
                    lists = self._search_lists(city=city)
                    label = f"Curated Lists for {city}" if city else "Curated Lists"
                    sec = {"type": "lists", "label": label, "icon": "list", "lists": lists}
                    q.put({"type": "results", "section": sec})
            except Exception as e:
                print(f"  [stream] API thread error ({intent}): {e}")
            finally:
                q.put({"type": "_thread_done"})

        # Spawn all threads
        total_threads = 1 + len(intents)  # claude + one per intent
        threads = []
        t = threading.Thread(target=claude_thread, daemon=True)
        t.start()
        threads.append(t)
        for intent in intents:
            t = threading.Thread(target=api_thread, args=(intent,), daemon=True)
            t.start()
            threads.append(t)

        # Main thread: read from queue, send SSE events
        done_count = 0
        try:
            while done_count < total_threads:
                try:
                    event = q.get(timeout=30)
                except queue.Empty:
                    break
                if event.get("type") == "_thread_done":
                    done_count += 1
                    continue
                self._send_sse_event(event)
                all_events.append(event)
        except (BrokenPipeError, ConnectionResetError):
            print("  [stream] Client disconnected")
            return

        # Send done event with suggestions
        suggestions = self._generate_suggestions(intents)
        done_event = {"type": "done", "suggestions": suggestions}
        try:
            self._send_sse_event(done_event)
            all_events.append(done_event)
        except (BrokenPipeError, ConnectionResetError):
            pass

        # Cache all events
        cache_set(cache_key, all_events, CHAT_STREAM_CACHE_TTL)

    # ---- classify section helper -------------------------------------------
    @staticmethod
    def _classify_section(tool_input):
        """Map tool inputs to (section_type, icon_name, label)."""
        ptype = tool_input.get("type", "").lower()
        query = tool_input.get("query", "").lower()

        food_kw = ["restaurant", "food", "eat", "dining", "sushi", "pizza", "cafe", "bistro", "brunch"]
        hotel_kw = ["hotel", "stay", "lodge", "lodging", "resort", "hostel", "accommodation"]
        exp_kw = ["attraction", "experience", "things to do", "museum", "tour", "activity", "sightseeing"]
        flight_kw = ["flight", "fly", "flying", "airfare", "airline", "plane"]

        if any(k in query for k in flight_kw):
            return ("flights", "flight")

        if ptype == "restaurant" or any(k in query for k in food_kw):
            return ("restaurants", "restaurant")
        if ptype == "lodging" or any(k in query for k in hotel_kw):
            return ("hotels", "hotel")
        if ptype == "tourist_attraction" or any(k in query for k in exp_kw):
            return ("experiences", "experience")
        return ("places", "pin")

    # ---- search_places helper (reused by chat) ---------------------------
    def _search_places(self, query, place_type=""):
        # Check cache first
        cache_key = f"chat-search:{query}:{place_type}"
        cached = cache_get(cache_key)
        if cached:
            return cached

        url = "https://places.googleapis.com/v1/places:searchText"
        headers = {
            "Content-Type": "application/json",
            "X-Goog-Api-Key": API_KEY,
            "X-Goog-FieldMask": (
                "places.id,places.displayName,places.formattedAddress,"
                "places.rating,places.userRatingCount,places.priceLevel,"
                "places.currentOpeningHours,places.photos,places.primaryType,"
                "places.location"
            ),
        }
        req_body = {"textQuery": query, "maxResultCount": 5}
        if place_type:
            req_body["includedType"] = place_type
        body = json.dumps(req_body).encode()

        try:
            req = urllib.request.Request(url, data=body, headers=headers, method="POST")
            with urllib.request.urlopen(req, timeout=8) as resp:
                data = json.loads(resp.read())
            places = data.get("places", [])
            # Trim photos to 4 per place (for carousel)
            for p in places:
                if p.get("photos"):
                    p["photos"] = p["photos"][:4]
            cache_set(cache_key, places, SEARCH_CACHE_TTL)
            return places
        except Exception as e:
            print(f"  search_places error: {e}")
            return []

    # ---- /api/transcribe (POST) — speech-to-text ----------------------------
    def _handle_transcribe(self, raw_audio):
        """Accept raw audio bytes, transcribe using SpeechRecognition.
        Handles WAV, M4A, CAF, and other formats via pydub/ffmpeg conversion.
        """
        print(f"  [transcribe] Received {len(raw_audio) if raw_audio else 0} bytes")
        if not raw_audio:
            self._json_response(400, {"error": "No audio data received"})
            return

        # Reject tiny files (< 1KB = accidental tap, not real speech)
        if len(raw_audio) < 1000:
            print(f"  [transcribe] Audio too small ({len(raw_audio)}B), ignoring")
            self._json_response(200, {"text": "", "error": "Recording too short"})
            return

        try:
            import speech_recognition as sr
        except ImportError:
            self._json_response(500, {"error": "SpeechRecognition not installed on server"})
            return

        # Detect format from header bytes
        header = raw_audio[:12]
        is_wav = header[:4] == b'RIFF' and header[8:12] == b'WAVE'
        is_m4a = b'ftyp' in header[:12]
        is_caf = header[:4] == b'caff'
        fmt = "WAV" if is_wav else "M4A" if is_m4a else "CAF" if is_caf else "unknown"
        print(f"  [transcribe] Detected format: {fmt}")

        tmp_in = None
        tmp_wav = None
        try:
            # Write incoming audio to temp file
            ext = ".wav" if is_wav else ".m4a" if is_m4a else ".caf" if is_caf else ".bin"
            tmp_in = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
            tmp_in.write(raw_audio)
            tmp_in.close()

            wav_path = tmp_in.name

            # Normalize audio to 16kHz mono WAV via pydub (handles all formats)
            try:
                from pydub import AudioSegment
                print(f"  [transcribe] Normalizing {fmt} → 16kHz mono WAV via pydub")
                if is_wav:
                    seg = AudioSegment.from_wav(tmp_in.name)
                else:
                    seg = AudioSegment.from_file(tmp_in.name)
                seg = seg.set_channels(1).set_frame_rate(16000).set_sample_width(2)
                tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
                tmp_wav.close()
                seg.export(tmp_wav.name, format="wav")
                wav_path = tmp_wav.name
                print(f"  [transcribe] Normalized OK, {os.path.getsize(wav_path)}B WAV")
            except Exception as conv_err:
                print(f"  [transcribe] pydub normalization failed: {conv_err}")
                print(f"  [transcribe] Trying raw file fallback...")
                wav_path = tmp_in.name

            recognizer = sr.Recognizer()
            with sr.AudioFile(wav_path) as source:
                audio = recognizer.record(source)
            print(f"  [transcribe] Audio loaded, sending to Google...")

            text = recognizer.recognize_google(audio)
            print(f"  ✓ Transcribed: \"{text}\"")
            self._json_response(200, {"text": text})

        except sr.UnknownValueError:
            print(f"  [transcribe] Google could not understand audio")
            self._json_response(200, {"text": "", "error": "Could not understand audio"})
        except sr.RequestError as e:
            print(f"  [transcribe] Google API error: {e}")
            self._json_response(502, {"error": f"Speech recognition service error: {e}"})
        except Exception as e:
            print(f"  [transcribe] Transcription failed: {type(e).__name__}: {e}")
            self._json_response(500, {"error": f"Transcription failed: {e}"})
        finally:
            for p in [tmp_in, tmp_wav]:
                if p and os.path.exists(p.name):
                    try: os.unlink(p.name)
                    except: pass

    # ---- helpers -----------------------------------------------------------
    def _json_response(self, code, obj):
        body = json.dumps(obj).encode()
        self._raw_response(code, "application/json", body)

    def _gzip_json_response(self, code, obj):
        """Send JSON response with gzip compression for large payloads."""
        body = json.dumps(obj, separators=(',', ':')).encode()
        accept = self.headers.get("Accept-Encoding", "")
        if len(body) > 1024 and "gzip" in accept:
            compressed = gzip.compress(body)
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Encoding", "gzip")
            self.send_header("Content-Length", str(len(compressed)))
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Cache-Control", "public, max-age=300")
            self.end_headers()
            self.wfile.write(compressed)
        else:
            self._raw_response(code, "application/json", body)

    def _raw_response(self, code, content_type, body):
        self.send_response(code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Connection", "keep-alive")
        self.end_headers()
        self.wfile.write(body)

    # Log all requests for debugging
    def log_message(self, fmt, *args):
        super().log_message(fmt, *args)

# ---------------------------------------------------------------------------
class ThreadedServer(http.server.ThreadingHTTPServer):
    daemon_threads = True

if __name__ == "__main__":
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    with ThreadedServer(("0.0.0.0", PORT), Handler) as srv:
        print(f"\033[1mEtera Mini running → http://0.0.0.0:{PORT} (threaded, accessible from network)\033[0m")
        srv.serve_forever()
