"""
Resos Statistics Service - Phase 8

Handles matching between SambaPOS tickets and Resos bookings,
calculates spend analysis, and generates statistics for the Bookings Stats Report.
"""
import logging
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func

from models.resos import ResosBooking, ResosOpeningHour
from models.settings import KitchenSettings
from services.sambapos_api import SambaPOSClient

logger = logging.getLogger(__name__)


class ResosStatsService:
    """Service for calculating Resos booking statistics with SambaPOS spend integration."""

    def __init__(self, kitchen_id: int, db: AsyncSession):
        self.kitchen_id = kitchen_id
        self.db = db

    async def _get_settings(self) -> KitchenSettings:
        """Fetch kitchen settings."""
        result = await self.db.execute(
            select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
        )
        return result.scalar_one()

    async def _get_sambapos_client(self, settings: KitchenSettings) -> Optional[SambaPOSClient]:
        """Create SambaPOS client if configured."""
        if not all([settings.sambapos_db_host, settings.sambapos_db_name,
                   settings.sambapos_db_username, settings.sambapos_db_password]):
            logger.warning("SambaPOS not configured, skipping spend analysis")
            return None

        return SambaPOSClient(
            host=settings.sambapos_db_host,
            port=settings.sambapos_db_port or 1433,
            database=settings.sambapos_db_name,
            username=settings.sambapos_db_username,
            password=settings.sambapos_db_password
        )

    def _parse_gl_codes(self, gl_code_str: Optional[str]) -> list[str]:
        """Parse comma-separated GL codes from settings."""
        if not gl_code_str:
            return []
        return [code.strip() for code in gl_code_str.split(',') if code.strip()]

    def _match_ticket_to_booking_by_id(
        self,
        ticket: dict,
        bookings_by_id: dict[str, dict]
    ) -> Optional[dict]:
        """
        Primary matching: Match ticket to booking by Resos booking ID from ticket tag.

        Tag format: "BOOKING_ID - Guest Name" (e.g., "ABC123XYZ - John Smith")

        NOT YET IMPLEMENTED - This is a placeholder for future enhancement.
        Currently returns None, causing fallback to table matching.

        Args:
            ticket: Ticket data from SambaPOS
            bookings_by_id: Dict mapping resos_booking_id to booking data

        Returns:
            Matched booking dict or None if no match
        """
        # TODO: Implement when ticket tagging with booking ID is added to SambaPOS
        # booking_id = ticket.get('booking_id')
        # if booking_id and booking_id in bookings_by_id:
        #     logger.debug(f"Primary match: Ticket {ticket['ticket_id']} matched to booking {booking_id}")
        #     return bookings_by_id[booking_id]

        return None

    def _normalize_table_name(self, resos_table_name: str, sambapos_table_name: str) -> bool:
        """
        Check if Resos and SambaPOS table names match, using smart normalization.

        Handles common naming patterns:
        - "Table 1" (Resos) → "T01" (SambaPOS)
        - "Table 10" (Resos) → "T10" (SambaPOS)

        Args:
            resos_table_name: Table name from Resos booking
            sambapos_table_name: Table name from SambaPOS ticket

        Returns:
            True if names match, False otherwise
        """
        # Handle None values
        if resos_table_name is None or sambapos_table_name is None:
            return False

        # Exact match
        if resos_table_name == sambapos_table_name:
            return True

        # Case-insensitive match
        if resos_table_name.lower() == sambapos_table_name.lower():
            return True

        # Smart normalization: "Table 1" → "T01"
        # Extract number from Resos name (e.g., "Table 1" → "1")
        if resos_table_name.startswith("Table "):
            try:
                table_num = resos_table_name.replace("Table ", "").strip()
                # Try zero-padded format: "1" → "T01"
                if f"T{table_num.zfill(2)}" == sambapos_table_name:
                    return True
                # Try non-padded format: "10" → "T10"
                if f"T{table_num}" == sambapos_table_name:
                    return True
            except:
                pass

        # SambaPOS format to Resos format: "T01" → "Table 1"
        if sambapos_table_name.startswith("T"):
            try:
                table_num = sambapos_table_name[1:].lstrip("0") or "0"
                if f"Table {table_num}" == resos_table_name:
                    return True
            except:
                pass

        return False

    def _match_ticket_to_booking_by_table(
        self,
        ticket: dict,
        bookings_by_table_date: dict[tuple, list[dict]],
        opening_hours_mapping: Optional[list[dict]]
    ) -> Optional[dict]:
        """
        Fallback matching: Match ticket to booking by table + date + service period.

        Uses smart table name normalization to match different naming conventions:
        - "Table 1" (Resos) ↔ "T01" (SambaPOS)

        Args:
            ticket: Ticket data from SambaPOS with table_name, ticket_date, ticket_time
            bookings_by_table_date: Dict mapping (table_name, date) to list of bookings
            opening_hours_mapping: Service period time windows from settings

        Returns:
            Matched booking dict or None if no match
        """
        table_name = ticket.get('table_name')
        ticket_date = ticket.get('ticket_date')
        ticket_time = ticket.get('ticket_time')

        if not table_name or not ticket_date or not ticket_time:
            return None

        # Convert ticket_date to date object if it's a datetime
        from datetime import date as date_type, datetime as datetime_type
        if isinstance(ticket_date, datetime_type):
            ticket_date_only = ticket_date.date()
        else:
            ticket_date_only = ticket_date

        # Try exact match first
        key = (table_name, ticket_date_only)
        candidate_bookings = bookings_by_table_date.get(key, [])

        # If no exact match, try normalized matching
        if not candidate_bookings:
            logger.info(f"No exact match for table '{table_name}' on {ticket_date_only}, trying normalization...")
            matches_found = False
            for (booking_table, booking_date), bookings in bookings_by_table_date.items():
                if booking_date == ticket_date_only:
                    matches = self._normalize_table_name(booking_table, table_name)
                    logger.info(f"  Comparing Resos '{booking_table}' with SambaPOS '{table_name}': {matches}")
                    if matches:
                        candidate_bookings = bookings
                        matches_found = True
                        logger.info(f"✓ Table name MATCHED: '{booking_table}' (Resos) ↔ '{table_name}' (SambaPOS)")
                        break

        if not candidate_bookings:
            logger.info(f"❌ No bookings found for table '{table_name}' on {ticket_date_only}")
            return None

        # Calculate time differences for all bookings (even if only one)
        # We still need to enforce the 60-minute window!
        candidates_with_time_diff = []
        for booking in candidate_bookings:
            booking_time = booking['booking_time']
            ticket_dt = datetime.combine(ticket_date_only, ticket_time)
            booking_dt = datetime.combine(ticket_date_only, booking_time)
            time_diff = abs((ticket_dt - booking_dt).total_seconds() / 60)

            candidates_with_time_diff.append({
                'booking': booking,
                'time_diff': time_diff
            })

        # Filter to bookings within 60 minutes (reduced from 120)
        candidates_within_window = [c for c in candidates_with_time_diff if c['time_diff'] <= 60]

        if not candidates_within_window:
            # No matches within time window
            min_diff = min(c['time_diff'] for c in candidates_with_time_diff) if candidates_with_time_diff else None
            logger.debug(f"No match: Ticket {ticket['ticket_id']} for {table_name} on {ticket_date_only} - closest booking {min_diff:.0f} min away (outside 60min window)")
            return None

        # If multiple matches within window, prefer same service period
        if len(candidates_within_window) > 1 and opening_hours_mapping:
            # Infer ticket's service period
            ticket_period = self._infer_service_period_from_time(
                ticket_time,
                [],  # opening_hours_data not available here, but method handles gracefully
                ticket_datetime=datetime.combine(ticket_date_only, ticket_time),
                settings=None  # settings not available here
            )

            # Find candidates in same service period
            same_period_candidates = [
                c for c in candidates_within_window
                if c['booking'].get('opening_hour_name') == ticket_period
            ]

            if same_period_candidates:
                # Return closest match from same service period
                best_candidate = min(same_period_candidates, key=lambda c: c['time_diff'])
                logger.debug(f"Fallback match: Ticket {ticket['ticket_id']} matched to booking {best_candidate['booking']['resos_booking_id']} (time diff: {best_candidate['time_diff']:.0f} min, same service period: {ticket_period})")
                return best_candidate['booking']

        # Return closest match by time within window
        best_candidate = min(candidates_within_window, key=lambda c: c['time_diff'])
        logger.debug(f"Fallback match: Ticket {ticket['ticket_id']} matched to booking {best_candidate['booking']['resos_booking_id']} (time diff: {best_candidate['time_diff']:.0f} min)")
        return best_candidate['booking']

    async def get_spend_statistics(
        self,
        from_date: date,
        to_date: date
    ) -> dict:
        """
        Calculate spend statistics for date range with resident/non-resident split.

        Uses two-fold matching:
        1. Primary: Match by booking ID from ticket tag (not yet implemented)
        2. Fallback: Match by table + date + service period

        Args:
            from_date: Start date (inclusive)
            to_date: End date (inclusive)

        Returns:
            Dict with:
            - total_spend: Decimal
            - food_spend: Decimal
            - beverage_spend: Decimal
            - resident_spend: Decimal
            - non_resident_spend: Decimal
            - resident_covers: int
            - non_resident_covers: int
            - matched_tickets: int
            - unmatched_tickets: int
            - daily_breakdown: list[dict]
            - service_period_breakdown: list[dict]
        """
        settings = await self._get_settings()

        # Get SambaPOS client
        sambapos = await self._get_sambapos_client(settings)
        if not sambapos:
            return self._empty_stats()

        # Parse GL codes
        food_gl_codes = self._parse_gl_codes(settings.sambapos_food_gl_codes)
        beverage_gl_codes = self._parse_gl_codes(settings.sambapos_beverage_gl_codes)

        if not food_gl_codes and not beverage_gl_codes:
            logger.warning("No GL codes configured for food/beverage split")
            return self._empty_stats()

        # Get tracked categories
        tracked_categories = []
        if settings.sambapos_tracked_categories:
            tracked_categories = [cat.strip() for cat in settings.sambapos_tracked_categories.split(',') if cat.strip()]

        if not tracked_categories:
            logger.warning("No tracked categories configured")
            return self._empty_stats()

        # Fetch SambaPOS tickets
        logger.info(f"Fetching SambaPOS restaurant spend for {from_date} to {to_date}")
        tickets = await sambapos.get_restaurant_spend(
            from_date=from_date,
            to_date=to_date,
            tracked_categories=tracked_categories,
            food_gl_codes=food_gl_codes,
            beverage_gl_codes=beverage_gl_codes
        )

        logger.info(f"Fetched {len(tickets)} tickets from SambaPOS")

        # Fetch Resos bookings (only completed dining events with table assignments)
        result = await self.db.execute(
            select(ResosBooking).where(
                and_(
                    ResosBooking.kitchen_id == self.kitchen_id,
                    ResosBooking.booking_date >= from_date,
                    ResosBooking.booking_date <= to_date,
                    func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived']),  # Only completed bookings
                    ResosBooking.table_name.isnot(None)  # Must have table assignment
                )
            )
        )
        bookings = result.scalars().all()

        logger.info(f"Fetched {len(bookings)} completed bookings with tables from Resos")

        # Fetch opening hours from database to infer service periods for unmatched tickets
        from models.resos import ResosOpeningHour
        opening_hours_result = await self.db.execute(
            select(ResosOpeningHour).where(
                ResosOpeningHour.kitchen_id == self.kitchen_id
            )
        )
        opening_hours = opening_hours_result.scalars().all()

        logger.info(f"Fetched {len(opening_hours)} opening hours from database for kitchen {self.kitchen_id}")

        # Build opening hours data with display names and actual_end times from settings
        opening_hours_data = []
        opening_hours_map = {}
        if settings.resos_opening_hours_mapping:
            logger.debug(f"Settings has {len(settings.resos_opening_hours_mapping)} opening hour mappings")
            for mapping in settings.resos_opening_hours_mapping:
                resos_id = mapping.get('resos_id')
                if resos_id:
                    # Store full mapping (display_name and actual_end)
                    opening_hours_map[resos_id] = mapping
        else:
            logger.warning("No resos_opening_hours_mapping in settings")

        for oh in opening_hours:
            logger.debug(f"Processing opening hour: {oh.name}, start={oh.start_time}, end={oh.end_time}, id={oh.resos_opening_hour_id}")
            if oh.start_time and oh.end_time:
                mapping = opening_hours_map.get(oh.resos_opening_hour_id, {})
                display_name = mapping.get('display_name', oh.name)

                # Use actual_end from mapping if available, otherwise use database end_time
                actual_end_str = mapping.get('actual_end')
                if actual_end_str:
                    # Parse time string to time object
                    from datetime import datetime
                    end_time = datetime.strptime(actual_end_str, '%H:%M').time()
                    logger.debug(f"Using actual_end {actual_end_str} for {display_name} (database has {oh.end_time})")
                else:
                    end_time = oh.end_time

                opening_hours_data.append({
                    'start_time': oh.start_time,
                    'end_time': end_time,
                    'display_name': display_name,
                    'resos_id': oh.resos_opening_hour_id
                })
            else:
                logger.warning(f"Skipping opening hour '{oh.name}' - missing start_time or end_time")

        logger.info(f"Loaded {len(opening_hours_data)} opening hours for service period inference")

        # Build lookup dicts for matching
        bookings_by_id = {b.resos_booking_id: b for b in bookings}

        # Group bookings by (table_name, date) for fallback matching
        bookings_by_table_date = {}
        resos_table_names = set()
        for booking in bookings:
            if booking.table_name:
                resos_table_names.add(booking.table_name)
            key = (booking.table_name, booking.booking_date)
            if key not in bookings_by_table_date:
                bookings_by_table_date[key] = []
            bookings_by_table_date[key].append({
                'resos_booking_id': booking.resos_booking_id,
                'booking_date': booking.booking_date,
                'booking_time': booking.booking_time,
                'is_hotel_guest': booking.is_hotel_guest,
                'people': booking.people,
                'opening_hour_id': booking.opening_hour_id,
                'opening_hour_name': booking.opening_hour_name
            })

        logger.info(f"Resos table names: {sorted(resos_table_names)}")

        # NEW APPROACH: Classify ALL tickets as resident/non-resident
        # Don't throw away unmatched tickets!
        all_tickets = []
        resident_tickets = []
        non_resident_tickets = []

        sambapos_table_names = set()
        for ticket in tickets:
            if ticket.get('table_name'):
                sambapos_table_names.add(ticket['table_name'])

        logger.info(f"SambaPOS table names: {sorted(sambapos_table_names)}")

        for ticket in tickets:
            # Method 1: Check if ticket has a Room entity in SambaPOS
            # TODO: Need to add room_entity field to ticket data from get_restaurant_spend
            has_room_entity = ticket.get('has_room_entity', False)

            is_resident = False
            classification_method = 'non-resident-default'

            if has_room_entity:
                # Definitely a resident - they selected their room
                is_resident = True
                classification_method = 'room-entity'
            else:
                # Method 2: Try to match table to Resos booking
                matched_booking = self._match_ticket_to_booking_by_id(ticket, bookings_by_id)

                if not matched_booking:
                    matched_booking = self._match_ticket_to_booking_by_table(
                        ticket,
                        bookings_by_table_date,
                        settings.resos_opening_hours_mapping
                    )

                if matched_booking:
                    # Check if Resos booking indicates hotel guest
                    is_resident = matched_booking.get('is_hotel_guest', False) or False
                    classification_method = 'resos-booking-match'
                    ticket['matched_booking'] = matched_booking

            ticket['is_resident'] = is_resident
            ticket['classification_method'] = classification_method
            all_tickets.append(ticket)

            if is_resident:
                resident_tickets.append(ticket)
            else:
                non_resident_tickets.append(ticket)

        logger.info(f"Classified {len(all_tickets)} total tickets: {len(resident_tickets)} residents, {len(non_resident_tickets)} non-residents")

        # Calculate totals from ALL tickets (not just matched ones)
        total_spend = sum(t['total_spend'] for t in all_tickets)
        food_spend = sum(t['food_total'] for t in all_tickets)
        beverage_spend = sum(t['beverage_total'] for t in all_tickets)

        # Resident vs non-resident spend (based on classification)
        resident_spend = sum(t['total_spend'] for t in resident_tickets)
        non_resident_spend = sum(t['total_spend'] for t in non_resident_tickets)

        # Count covers from matched bookings (only for tickets that matched to Resos)
        # For tickets classified by room entity, we don't have cover count
        resident_covers = sum(
            t.get('matched_booking', {}).get('people', 0)
            for t in resident_tickets
            if 'matched_booking' in t
        )
        non_resident_covers = sum(
            t.get('matched_booking', {}).get('people', 0)
            for t in non_resident_tickets
            if 'matched_booking' in t
        )

        # Count how many tickets were matched to Resos bookings
        matched_count = sum(1 for t in all_tickets if 'matched_booking' in t)
        unmatched_count = len(all_tickets) - matched_count

        # Count tickets classified by each method
        room_entity_count = sum(1 for t in all_tickets if t.get('classification_method') == 'room-entity')
        resos_match_count = sum(1 for t in all_tickets if t.get('classification_method') == 'resos-booking-match')
        default_count = sum(1 for t in all_tickets if t.get('classification_method') == 'non-resident-default')

        logger.info(f"Classification breakdown: {room_entity_count} by room entity, {resos_match_count} by Resos match, {default_count} defaulted to non-resident")

        # Daily breakdown using ALL tickets
        daily_breakdown = self._calculate_daily_breakdown(all_tickets, from_date, to_date)

        # Service period breakdown using ALL tickets and ALL bookings
        service_period_breakdown = self._calculate_service_period_breakdown(all_tickets, bookings, settings, opening_hours_data)

        # Daily breakdown by service period
        daily_service_breakdown = self._calculate_daily_service_breakdown(all_tickets, bookings, settings, opening_hours_data)
        logger.info(f"📊 Daily service breakdown calculated: {len(daily_service_breakdown)} days")
        if len(daily_service_breakdown) > 0:
            first_day = daily_service_breakdown[0]
            logger.info(f"📊 First day: {first_day['date']}, periods: {list(first_day['periods'].keys())}")

        return {
            'total_spend': float(total_spend),
            'food_spend': float(food_spend),
            'beverage_spend': float(beverage_spend),
            'resident_spend': float(resident_spend),
            'non_resident_spend': float(non_resident_spend),
            'resident_covers': resident_covers,
            'non_resident_covers': non_resident_covers,
            'total_tickets': len(all_tickets),
            'resident_tickets': len(resident_tickets),
            'non_resident_tickets': len(non_resident_tickets),
            'matched_to_resos': matched_count,
            'unmatched_to_resos': unmatched_count,
            'classification': {
                'room_entity': room_entity_count,
                'resos_booking_match': resos_match_count,
                'non_resident_default': default_count
            },
            'daily_breakdown': daily_breakdown,
            'service_period_breakdown': service_period_breakdown,
            'daily_service_breakdown': daily_service_breakdown
        }

    def _empty_stats(self) -> dict:
        """Return empty statistics structure."""
        return {
            'total_spend': 0.0,
            'food_spend': 0.0,
            'beverage_spend': 0.0,
            'resident_spend': 0.0,
            'non_resident_spend': 0.0,
            'resident_covers': 0,
            'non_resident_covers': 0,
            'matched_tickets': 0,
            'unmatched_tickets': 0,
            'daily_breakdown': [],
            'daily_service_breakdown': [],
            'service_period_breakdown': []
        }

    def _calculate_daily_breakdown(
        self,
        matched_tickets: list[dict],
        from_date: date,
        to_date: date
    ) -> list[dict]:
        """Calculate daily spend breakdown."""
        # Group by date
        daily_data = {}
        current = from_date
        while current <= to_date:
            daily_data[current] = {
                'date': current.isoformat(),
                'total_spend': 0.0,
                'food_spend': 0.0,
                'beverage_spend': 0.0,
                'resident_spend': 0.0,
                'non_resident_spend': 0.0,
                'ticket_count': 0
            }
            current += timedelta(days=1)

        for ticket in matched_tickets:
            ticket_date = ticket['ticket_date']
            if ticket_date not in daily_data:
                continue

            daily_data[ticket_date]['total_spend'] += float(ticket['total_spend'])
            daily_data[ticket_date]['food_spend'] += float(ticket['food_total'])
            daily_data[ticket_date]['beverage_spend'] += float(ticket['beverage_total'])
            daily_data[ticket_date]['ticket_count'] += 1

            if ticket['is_resident']:
                daily_data[ticket_date]['resident_spend'] += float(ticket['total_spend'])
            else:
                daily_data[ticket_date]['non_resident_spend'] += float(ticket['total_spend'])

        return sorted(daily_data.values(), key=lambda x: x['date'])

    def _infer_service_period_from_time(
        self,
        ticket_time,
        opening_hours_data,
        ticket_datetime=None,
        settings=None
    ) -> str:
        """
        Infer service period from ticket time using actual opening hours from database.

        Two-pass matching:
        1. First tries exact match with defined service period times (including manual breakfast if configured)
        2. If no match, applies 30-minute buffer to EARLIEST period only (to catch breakfast/early arrivals)
           For example, if Lunch is 12:00-16:00 (earliest), tickets from 11:30-11:59 count as Lunch

        If multiple periods overlap, the later period (by start_time) takes precedence.
        For example, if Lunch is 12:00-15:00 and Dinner is 14:30-22:00, a ticket at 14:45
        will be classified as Dinner (the later period).

        Args:
            ticket_time: Time object from ticket
            opening_hours_data: List of dicts with start_time, end_time, display_name
            ticket_datetime: Optional datetime object to determine day of week for manual breakfast periods
            settings: Optional KitchenSettings object for manual breakfast configuration

        Returns:
            Display name of matched service period or 'Unknown'
        """
        if not ticket_time or not opening_hours_data:
            return 'Unknown'

        from datetime import time as time_type, datetime, timedelta

        # Convert ticket_time to time object if needed
        if not isinstance(ticket_time, time_type):
            return 'Unknown'

        # Merge manual breakfast periods if enabled and ticket_datetime is available
        merged_hours_data = list(opening_hours_data)  # Copy to avoid modifying original

        if (settings and
            ticket_datetime and
            settings.resos_enable_manual_breakfast and
            settings.resos_manual_breakfast_periods):

            # Get day of week from ticket datetime (1=Monday, 7=Sunday)
            # Python's weekday(): Monday=0, Sunday=6, so we add 1
            ticket_day_of_week = ticket_datetime.weekday() + 1

            # Filter manual breakfast periods for this day
            for breakfast_period in settings.resos_manual_breakfast_periods:
                if breakfast_period.get('day') == ticket_day_of_week:
                    # Parse time strings to time objects
                    try:
                        start_str = breakfast_period.get('start')
                        end_str = breakfast_period.get('end')

                        if start_str and end_str:
                            start_time_obj = datetime.strptime(start_str, '%H:%M').time()
                            end_time_obj = datetime.strptime(end_str, '%H:%M').time()

                            merged_hours_data.append({
                                'start_time': start_time_obj,
                                'end_time': end_time_obj,
                                'display_name': 'Breakfast',
                                'resos_id': None,  # Manual periods have no Resos ID
                                'is_manual': True
                            })
                            logger.debug(f"Added manual breakfast period for day {ticket_day_of_week}: {start_str} - {end_str}")
                    except Exception as e:
                        logger.warning(f"Failed to parse manual breakfast period: {e}")

        # Use merged data for matching
        periods_to_check = merged_hours_data

        # 30-minute buffer before each service period to catch early arrivals
        BUFFER_MINUTES = 30

        # Sort periods by start_time (descending) so later periods take precedence
        # Use datetime for proper sorting, handling midnight-crossing periods
        def time_to_minutes(t):
            """Convert time to minutes since midnight for sorting."""
            if not t:
                return -1
            return t.hour * 60 + t.minute

        def subtract_minutes_from_time(t, minutes):
            """Subtract minutes from a time object, handling midnight wraparound."""
            # Convert to datetime, subtract, convert back to time
            temp_dt = datetime.combine(datetime.today(), t)
            result_dt = temp_dt - timedelta(minutes=minutes)
            return result_dt.time()

        def sort_key(period):
            start = period.get('start_time')
            if not start:
                return -1  # Invalid entries sort first (will be skipped)

            start_mins = time_to_minutes(start)
            # If start time is very early (00:00 - 05:59), treat as late night (next day)
            # This ensures late night periods like 22:00-02:00 sort correctly
            if start.hour < 6:
                start_mins += 24 * 60  # Add 24 hours worth of minutes

            return start_mins

        sorted_periods = sorted(periods_to_check, key=sort_key, reverse=True)

        # FIRST PASS: Try to match using actual service period times (no buffer)
        # Later periods are checked first due to reverse sorting
        for period in sorted_periods:
            start_time = period.get('start_time')
            end_time = period.get('end_time')
            display_name = period.get('display_name')

            if not start_time or not end_time or not display_name:
                continue

            # Handle periods that cross midnight (e.g., 22:00 - 02:00)
            if start_time <= end_time:
                # Normal period (e.g., 12:00 - 15:00)
                if start_time <= ticket_time <= end_time:
                    return display_name
            else:
                # Period crosses midnight (e.g., 22:00 - 02:00)
                if ticket_time >= start_time or ticket_time <= end_time:
                    return display_name

        # SECOND PASS: Handle gaps between periods - assign to following period
        # If ticket falls in a gap, assign it to the next period
        # Sort periods by start time (ascending) for this check
        periods_ascending = sorted(periods_to_check, key=sort_key)

        ticket_mins = time_to_minutes(ticket_time)

        for i, period in enumerate(periods_ascending):
            start_time = period.get('start_time')
            end_time = period.get('end_time')
            display_name = period.get('display_name')

            if not start_time or not end_time or not display_name:
                continue

            start_mins = time_to_minutes(start_time)
            end_mins = time_to_minutes(end_time)

            # Check if ticket is in a gap before this period starts
            if i == 0:
                # First period - check if ticket is before it
                if ticket_mins < start_mins:
                    # Ticket is before first period, assign to first period
                    logger.info(f"Ticket {ticket_time} is before first period - assigning to {display_name}")
                    return display_name
            else:
                # Check gap between previous period end and this period start
                prev_period = periods_ascending[i - 1]
                prev_end = prev_period.get('end_time')

                if prev_end:
                    prev_end_mins = time_to_minutes(prev_end)

                    # If ticket is in the gap (after previous period end, before this period start)
                    if prev_end_mins < ticket_mins < start_mins:
                        # Assign to following period (this period)
                        logger.info(f"Ticket {ticket_time} is in gap - assigning to following period {display_name}")
                        return display_name

            # Check if this is the last period and ticket is after it
            if i == len(periods_ascending) - 1:
                if ticket_mins > end_mins:
                    # Ticket is after last period, assign to last period
                    logger.info(f"Ticket {ticket_time} is after last period - assigning to {display_name}")
                    return display_name

        # THIRD PASS: Comprehensive fallback (should rarely be needed now)
        # This handles "No opening hour" bookings and edge cases
        if sorted_periods:
            # Get earliest and latest periods
            earliest_period = sorted_periods[-1]
            latest_period = sorted_periods[0]

            earliest_start = earliest_period.get('start_time')
            earliest_name = earliest_period.get('display_name')
            latest_end = latest_period.get('end_time')
            latest_name = latest_period.get('display_name')

            # Find if there's a manual breakfast period
            breakfast_period = None
            for period in periods_to_check:
                if period.get('display_name', '').lower() == 'breakfast':
                    breakfast_period = period
                    break

            ticket_mins = time_to_minutes(ticket_time)

            # Handle very early tickets (before earliest period)
            if earliest_start:
                earliest_mins = time_to_minutes(earliest_start)

                # If ticket is before earliest period
                if ticket_mins < earliest_mins:
                    # If there's a manual breakfast and ticket is before it, include in breakfast
                    if breakfast_period:
                        breakfast_start = breakfast_period.get('start_time')
                        if breakfast_start and ticket_mins < time_to_minutes(breakfast_start):
                            logger.info(f"Ticket {ticket_time} is before breakfast - including in Breakfast period")
                            return 'Breakfast'

                    # If ticket is after breakfast but before first mapped period, map to first period
                    logger.info(f"Ticket {ticket_time} is before earliest period ({earliest_start}) - mapping to {earliest_name}")
                    return earliest_name

            # Handle very late tickets (after latest period)
            if latest_end:
                latest_end_mins = time_to_minutes(latest_end)

                # Handle midnight-crossing periods
                if latest_period.get('start_time') and latest_period.get('start_time') > latest_end:
                    # Period crosses midnight, latest_end is early morning
                    # Ticket is late if it's after period start and before midnight
                    if ticket_mins > time_to_minutes(latest_period.get('start_time')):
                        logger.info(f"Ticket {ticket_time} is after latest period end - mapping to {latest_name}")
                        return latest_name
                else:
                    # Normal period
                    if ticket_mins > latest_end_mins:
                        logger.info(f"Ticket {ticket_time} is after latest period ({latest_end}) - mapping to {latest_name}")
                        return latest_name

        # Log tickets that couldn't be matched to any period
        logger.warning(f"⚠️ Could not infer service period for ticket time {ticket_time}. Available periods: {len(periods_to_check)}")
        return 'Unknown'

    def _calculate_service_period_breakdown(self, all_tickets: list[dict], all_bookings: list, settings, opening_hours_data: list[dict]) -> list[dict]:
        """
        Calculate spend breakdown by service period, grouped by display name from settings.

        Counts:
        - resos_covers: ALL Resos bookings (matched or unmatched)
        - samba_covers: ALL SambaPOS tickets (matched or unmatched)
        - covers: Max of resos_covers and samba_covers (or resos if matched)
        """
        period_data = {}

        # Build a lookup map from opening_hour_id to display_name
        opening_hours_map = {}
        if settings.resos_opening_hours_mapping:
            for mapping in settings.resos_opening_hours_mapping:
                resos_id = mapping.get('resos_id')
                display_name = mapping.get('display_name')
                if resos_id and display_name:
                    opening_hours_map[resos_id] = display_name

        # Track which Resos bookings we've already counted to avoid double-counting covers
        counted_bookings = set()

        for ticket in all_tickets:
            # Skip tickets with zero spend (all items void/cancelled)
            if float(ticket.get('total_spend', 0)) == 0:
                continue
            # Try to get service period from matched booking first
            if 'matched_booking' in ticket:
                # Try to get display name from mapping first
                opening_hour_id = ticket['matched_booking'].get('opening_hour_id')
                if opening_hour_id and opening_hour_id in opening_hours_map:
                    period = opening_hours_map[opening_hour_id]
                else:
                    # Fall back to opening_hour_name if no mapping found
                    period = ticket['matched_booking'].get('opening_hour_name')

                    # If opening_hour_name is "No opening hour", infer from booking time
                    if period == 'No opening hour':
                        logger.info(f"Booking has 'No opening hour' - inferring from booking time")
                        booking_time = ticket['matched_booking'].get('booking_time')
                        booking_date = ticket['matched_booking'].get('booking_date')

                        if booking_time and booking_date:
                            # Create datetime for day-of-week calculation
                            from datetime import datetime
                            booking_datetime = datetime.combine(booking_date, booking_time)

                            # Infer service period using the same logic as unmatched tickets
                            inferred_period = self._infer_service_period_from_time(
                                booking_time,
                                opening_hours_data,
                                ticket_datetime=booking_datetime,
                                settings=settings
                            )
                            logger.info(f"Inferred period '{inferred_period}' for booking at {booking_time}")

                            # Use inferred period if it's not 'Unknown' or 'No opening hour'
                            if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
                                period = inferred_period
                                logger.info(f"✓ Using inferred period '{period}'")
                            else:
                                logger.warning(f"⚠️ Inference returned '{inferred_period}', keeping 'No opening hour'")
                        else:
                            logger.warning(f"⚠️ Missing booking time or date for inference")

                    if not period:  # None or empty string
                        period = 'No opening hour'
                        logger.warning(f"⚠️ Matched booking has no opening_hour_name: booking_id={ticket['matched_booking'].get('resos_booking_id')}")

                # Get covers from Resos booking
                people = ticket['matched_booking'].get('people', 0)
                samba_covers = ticket.get('estimated_covers', 0)  # Covers from SambaPOS ticket

                # Only count Resos covers once per booking (avoid double-counting split bills)
                booking_id = ticket['matched_booking'].get('resos_booking_id')
                if booking_id and booking_id not in counted_bookings:
                    resos_covers = people
                    counted_bookings.add(booking_id)
                else:
                    resos_covers = 0  # This booking already counted
            else:
                # For unmatched tickets, infer service period from ticket time using actual opening hours
                ticket_time = ticket.get('ticket_time')
                ticket_datetime = ticket.get('ticket_datetime')
                period = self._infer_service_period_from_time(
                    ticket_time,
                    opening_hours_data,
                    ticket_datetime=ticket_datetime,
                    settings=settings
                )
                # Use estimated covers from SambaPOS for unmatched tickets
                people = ticket.get('estimated_covers', 0)
                resos_covers = 0  # No Resos booking for unmatched tickets
                samba_covers = people

            if period not in period_data:
                period_data[period] = {
                    'service_period': period,
                    'total_spend': 0.0,
                    'food_spend': 0.0,
                    'beverage_spend': 0.0,
                    'covers': 0,
                    'resos_covers': 0,  # Track Resos booking covers separately
                    'samba_covers': 0,  # Track SambaPOS estimated covers separately
                    'ticket_count': 0
                }

            period_data[period]['total_spend'] += float(ticket['total_spend'])
            period_data[period]['food_spend'] += float(ticket['food_total'])
            period_data[period]['beverage_spend'] += float(ticket['beverage_total'])
            period_data[period]['covers'] += people  # Use Resos covers for matched, SambaPOS for unmatched
            period_data[period]['resos_covers'] += resos_covers
            period_data[period]['samba_covers'] += samba_covers
            period_data[period]['ticket_count'] += 1

        # Now add ALL Resos bookings that weren't matched (e.g., no-shows, cancellations, unmatched)
        for booking in all_bookings:
            # Skip if this booking was already counted via ticket match
            if booking.resos_booking_id in counted_bookings:
                continue

            # This is an unmatched booking - add its covers to resos_covers
            # Determine service period from booking's opening hour
            opening_hour_id = booking.opening_hour_id
            if opening_hour_id and opening_hour_id in opening_hours_map:
                period = opening_hours_map[opening_hour_id]
            else:
                period = booking.opening_hour_name

                # Infer period for "No opening hour" bookings
                if period == 'No opening hour' and booking.booking_time:
                    from datetime import datetime
                    booking_datetime = datetime.combine(booking.booking_date, booking.booking_time)
                    inferred_period = self._infer_service_period_from_time(
                        booking.booking_time,
                        opening_hours_data,
                        ticket_datetime=booking_datetime,
                        settings=settings
                    )
                    if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
                        period = inferred_period

            if not period:
                period = 'Unknown'

            # Initialize period if not exists
            if period not in period_data:
                period_data[period] = {
                    'service_period': period,
                    'total_spend': 0.0,
                    'food_spend': 0.0,
                    'beverage_spend': 0.0,
                    'covers': 0,
                    'resos_covers': 0,
                    'samba_covers': 0,
                    'ticket_count': 0
                }

            # Add unmatched booking covers to resos_covers
            period_data[period]['resos_covers'] += booking.people
            period_data[period]['covers'] += booking.people  # Also add to total covers
            logger.debug(f"Added unmatched booking {booking.resos_booking_id} ({booking.people} people) to {period}")

        # Calculate average per cover
        result = []
        for period, data in period_data.items():
            if data['covers'] > 0:
                data['avg_spend_per_cover'] = data['total_spend'] / data['covers']
            else:
                data['avg_spend_per_cover'] = 0.0
            result.append(data)

        return sorted(result, key=lambda x: x['total_spend'], reverse=True)

    def _calculate_daily_service_breakdown(self, all_tickets: list[dict], all_bookings: list, settings, opening_hours_data: list[dict]) -> list[dict]:
        """
        Calculate daily breakdown by service period.

        Counts ALL Resos bookings (matched or unmatched) and ALL SambaPOS tickets.

        Returns list of dicts with format:
        {
            'date': '2026-01-20',
            'periods': {
                'Lunch': {'covers': 10, 'resos_covers': 12, 'samba_covers': 10, 'food': 200.0, 'beverage': 50.0, 'total_spend': 250.0},
                'Dinner': {'covers': 25, 'resos_covers': 28, 'samba_covers': 24, 'food': 600.0, 'beverage': 150.0, 'total_spend': 750.0},
                ...
            }
        }
        """
        # Build a lookup map from opening_hour_id to display_name
        opening_hours_map = {}
        if settings.resos_opening_hours_mapping:
            for mapping in settings.resos_opening_hours_mapping:
                resos_id = mapping.get('resos_id')
                display_name = mapping.get('display_name')
                if resos_id and display_name:
                    opening_hours_map[resos_id] = display_name

        # Group by date
        daily_data = {}

        # Track which Resos bookings we've already counted to avoid double-counting covers
        counted_bookings = set()

        for ticket in all_tickets:
            # Skip tickets with zero spend (all items void/cancelled)
            if float(ticket.get('total_spend', 0)) == 0:
                continue

            ticket_date = ticket.get('ticket_date')
            if hasattr(ticket_date, 'date'):
                ticket_date = ticket_date.date()
            date_str = ticket_date.isoformat()

            # Get service period for this ticket
            if 'matched_booking' in ticket:
                opening_hour_id = ticket['matched_booking'].get('opening_hour_id')
                if opening_hour_id and opening_hour_id in opening_hours_map:
                    period = opening_hours_map[opening_hour_id]
                else:
                    period = ticket['matched_booking'].get('opening_hour_name')

                    # Infer period for "No opening hour" bookings
                    if period == 'No opening hour':
                        booking_time = ticket['matched_booking'].get('booking_time')
                        booking_date = ticket['matched_booking'].get('booking_date')

                        if booking_time and booking_date:
                            from datetime import datetime
                            booking_datetime = datetime.combine(booking_date, booking_time)
                            inferred_period = self._infer_service_period_from_time(
                                booking_time,
                                opening_hours_data,
                                ticket_datetime=booking_datetime,
                                settings=settings
                            )
                            if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
                                period = inferred_period

                if not period:
                    period = 'No opening hour'

                # Get covers from Resos booking
                people = ticket['matched_booking'].get('people', 0)
                samba_covers = ticket.get('estimated_covers', 0)  # Covers from SambaPOS ticket

                # Only count Resos covers once per booking (avoid double-counting split bills)
                booking_id = ticket['matched_booking'].get('resos_booking_id')
                if booking_id and booking_id not in counted_bookings:
                    resos_covers = people
                    counted_bookings.add(booking_id)
                else:
                    resos_covers = 0  # This booking already counted
            else:
                ticket_time = ticket.get('ticket_time')
                ticket_datetime = ticket.get('ticket_datetime')
                period = self._infer_service_period_from_time(
                    ticket_time,
                    opening_hours_data,
                    ticket_datetime=ticket_datetime,
                    settings=settings
                )
                # Use estimated covers from SambaPOS for unmatched tickets
                people = ticket.get('estimated_covers', 0)
                resos_covers = 0  # No Resos booking for unmatched tickets
                samba_covers = people

            # Initialize date if not exists
            if date_str not in daily_data:
                daily_data[date_str] = {}

            # Initialize period if not exists
            if period not in daily_data[date_str]:
                daily_data[date_str][period] = {
                    'covers': 0,
                    'resos_covers': 0,  # Track Resos booking covers separately
                    'samba_covers': 0,  # Track SambaPOS estimated covers separately
                    'food': 0.0,
                    'beverage': 0.0,
                    'total_spend': 0.0,
                    'ticket_count': 0
                }

            # Add ticket data
            daily_data[date_str][period]['covers'] += people  # Use Resos covers for matched, SambaPOS for unmatched
            daily_data[date_str][period]['resos_covers'] += resos_covers
            daily_data[date_str][period]['samba_covers'] += samba_covers
            daily_data[date_str][period]['food'] += float(ticket['food_total'])
            daily_data[date_str][period]['beverage'] += float(ticket['beverage_total'])
            daily_data[date_str][period]['total_spend'] += float(ticket['total_spend'])
            daily_data[date_str][period]['ticket_count'] += 1

        # Now add ALL Resos bookings that weren't matched (e.g., no-shows, cancellations, unmatched)
        for booking in all_bookings:
            # Skip if this booking was already counted via ticket match
            if booking.resos_booking_id in counted_bookings:
                continue

            # This is an unmatched booking - add its covers to resos_covers
            date_str = booking.booking_date.isoformat()

            # Determine service period from booking's opening hour
            opening_hour_id = booking.opening_hour_id
            if opening_hour_id and opening_hour_id in opening_hours_map:
                period = opening_hours_map[opening_hour_id]
            else:
                period = booking.opening_hour_name

                # Infer period for "No opening hour" bookings
                if period == 'No opening hour' and booking.booking_time:
                    from datetime import datetime
                    booking_datetime = datetime.combine(booking.booking_date, booking.booking_time)
                    inferred_period = self._infer_service_period_from_time(
                        booking.booking_time,
                        opening_hours_data,
                        ticket_datetime=booking_datetime,
                        settings=settings
                    )
                    if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
                        period = inferred_period

            if not period:
                period = 'Unknown'

            # Initialize date if not exists
            if date_str not in daily_data:
                daily_data[date_str] = {}

            # Initialize period if not exists
            if period not in daily_data[date_str]:
                daily_data[date_str][period] = {
                    'covers': 0,
                    'resos_covers': 0,
                    'samba_covers': 0,
                    'food': 0.0,
                    'beverage': 0.0,
                    'total_spend': 0.0,
                    'ticket_count': 0
                }

            # Add unmatched booking covers to resos_covers
            daily_data[date_str][period]['resos_covers'] += booking.people
            daily_data[date_str][period]['covers'] += booking.people  # Also add to total covers
            logger.debug(f"Added unmatched booking {booking.resos_booking_id} ({booking.people} people) to {date_str} {period}")

        # Convert to list format
        result = []
        for date_str in sorted(daily_data.keys()):
            result.append({
                'date': date_str,
                'periods': daily_data[date_str]
            })

        return result
