# -*- coding: utf-8 -*-

import json
import hashlib
import logging
import secrets

from odoo import http, _, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
from datetime import datetime, timedelta
from markupsafe import Markup
import pytz

_logger = logging.getLogger(__name__)

# Google OAuth endpoints (same as model constants)
GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/auth'
GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/userinfo.email'

# Microsoft OAuth endpoints
MICROSOFT_AUTH_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
MICROSOFT_SCOPES = 'offline_access openid Calendars.ReadWrite User.Read'


class PortalSchedule(CustomerPortal):
    """Portal controller for appointment scheduling and calendar management."""

    def _get_schedule_values(self):
        """Common values for schedule pages."""
        ICP = request.env['ir.config_parameter'].sudo()
        g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
        g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
        g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
        gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)

        return {
            'portal_gradient': gradient,
            'google_maps_api_key': self._get_maps_api_key(),
        }

    def _get_user_timezone(self):
        return self._resolve_timezone(request.env.user)

    def _resolve_timezone(self, user=None):
        """Resolve timezone: user pref -> browser cookie -> company calendar -> UTC."""
        candidates = []
        if user:
            candidates.append(getattr(user, 'tz', None))
        try:
            candidates.append(request.httprequest.cookies.get('tz'))
        except Exception:
            pass
        try:
            cal = request.env.company.resource_calendar_id
            if cal:
                candidates.append(cal.tz)
        except Exception:
            pass
        for tz_name in candidates:
            if tz_name:
                try:
                    return pytz.timezone(tz_name)
                except pytz.exceptions.UnknownTimeZoneError:
                    continue
        return pytz.UTC

    def _get_appointment_types(self):
        """Get appointment types available to the current user."""
        return request.env['appointment.type'].sudo().search([
            ('staff_user_ids', 'in', [request.env.user.id]),
        ])

    def _get_user_prefs(self, user):
        """Return schedule preferences for the user, falling back to company defaults."""
        ICP = request.env['ir.config_parameter'].sudo()
        def _val(user_field, param_key, default):
            v = getattr(user, user_field, None)
            if v is not None and v != 0 and v != default:
                return v
            stored = ICP.get_param(param_key, '')
            if stored:
                try:
                    return type(default)(stored)
                except (ValueError, TypeError):
                    pass
            return default

        return {
            'work_start': _val('x_fc_work_start', 'fusion_schedule.default_work_start', 9.0),
            'work_end': _val('x_fc_work_end', 'fusion_schedule.default_work_end', 17.0),
            'break_start': _val('x_fc_break_start', 'fusion_schedule.default_break_start', 12.0),
            'break_duration': _val('x_fc_break_duration', 'fusion_schedule.default_break_duration', 0.5),
            'travel_buffer': _val('x_fc_travel_buffer', 'fusion_schedule.default_travel_buffer', 30),
            'home_address': user.x_fc_home_address or '',
            'home_lat': user.x_fc_home_lat or 0.0,
            'home_lng': user.x_fc_home_lng or 0.0,
        }

    def _get_maps_api_key(self):
        """Get Google Maps API key via fusion.api.service with fallback."""
        try:
            return request.env['fusion.api.service'].get_api_key(
                provider_type='google_maps',
                consumer='fusion_schedule',
                feature='distance_matrix',
            )
        except Exception:
            return request.env['ir.config_parameter'].sudo().get_param(
                'fusion_claims.google_maps_api_key', ''
            )

    def _call_ai(self, messages, feature='general'):
        """Call OpenAI via fusion.api.service with fallback to direct HTTP."""
        try:
            return request.env['fusion.api.service'].call_openai(
                consumer='fusion_schedule',
                feature=feature,
                messages=messages,
                model='gpt-4o-mini',
            )
        except Exception:
            import requests as req
            api_key = request.env['ir.config_parameter'].sudo().get_param(
                'fusion_claims.ai_api_key', ''
            )
            if not api_key:
                raise
            resp = req.post(
                'https://api.openai.com/v1/chat/completions',
                headers={
                    'Authorization': 'Bearer %s' % api_key,
                    'Content-Type': 'application/json',
                },
                json={
                    'model': 'gpt-4o-mini',
                    'messages': messages,
                    'max_tokens': 2048,
                    'temperature': 0.2,
                },
                timeout=30,
            )
            data = resp.json()
            return data['choices'][0]['message']['content']

    def _get_travel_time(self, from_lat, from_lng, to_lat, to_lng):
        """Travel time in minutes via Google Distance Matrix API."""
        if not all([from_lat, from_lng, to_lat, to_lng]):
            return 0
        api_key = self._get_maps_api_key()
        if not api_key:
            return 0
        try:
            import requests as req
            resp = req.get(
                'https://maps.googleapis.com/maps/api/distancematrix/json',
                params={
                    'origins': '%s,%s' % (from_lat, from_lng),
                    'destinations': '%s,%s' % (to_lat, to_lng),
                    'mode': 'driving',
                    'avoid': 'tolls',
                    'departure_time': 'now',
                    'key': api_key,
                },
                timeout=5,
            )
            data = resp.json()
            if data.get('status') == 'OK':
                element = data['rows'][0]['elements'][0]
                if element.get('status') == 'OK':
                    duration = element.get('duration_in_traffic', element.get('duration', {}))
                    return max(1, int(duration.get('value', 0) / 60))
        except Exception:
            _logger.warning('Distance Matrix API call failed', exc_info=True)
        return 0

    def _geocode_address(self, address):
        """Geocode an address string, return (lat, lng) or (0.0, 0.0)."""
        if not address:
            return 0.0, 0.0
        api_key = self._get_maps_api_key()
        if not api_key:
            return 0.0, 0.0
        try:
            import requests as req
            resp = req.get(
                'https://maps.googleapis.com/maps/api/geocode/json',
                params={'address': address, 'key': api_key, 'region': 'ca'},
                timeout=10,
            )
            data = resp.json()
            if data.get('status') == 'OK' and data.get('results'):
                loc = data['results'][0]['geometry']['location']
                return loc.get('lat', 0.0), loc.get('lng', 0.0)
        except Exception:
            _logger.warning('Geocoding failed for %s', address, exc_info=True)
        return 0.0, 0.0

    def _create_travel_blocks(self, new_event, staff_user):
        """After booking, calculate travel time from/to adjacent appointments
        and create 'Travel' placeholder events to block the calendar."""
        if not new_event.x_fc_address_lat or not new_event.x_fc_address_lng:
            return

        prefs = self._get_user_prefs(staff_user)
        min_buffer = prefs['travel_buffer']

        tz = self._resolve_timezone(staff_user)

        event_date = new_event.start.date()
        day_start = tz.localize(datetime.combine(event_date, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
        day_end = tz.localize(datetime.combine(event_date, datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)

        day_events = request.env['calendar.event'].sudo().search([
            ('partner_ids', 'in', [staff_user.partner_id.id]),
            ('start', '>=', day_start),
            ('start', '<=', day_end),
            ('x_fc_is_travel_block', '!=', True),
            ('id', '!=', new_event.id),
        ], order='start asc')

        prev_event = None
        next_event = None
        for ev in day_events:
            if ev.stop <= new_event.start:
                prev_event = ev
            elif ev.start >= new_event.stop and not next_event:
                next_event = ev

        CalEvent = request.env['calendar.event'].sudo().with_context(
            no_calendar_sync=True, dont_notify=True,
            mail_create_nosubscribe=True, mail_create_nolog=True,
            no_mail_notification=True, no_mail_to_attendees=True,
        )

        if prev_event and prev_event.x_fc_address_lat and prev_event.x_fc_address_lng:
            travel_min = self._get_travel_time(
                prev_event.x_fc_address_lat, prev_event.x_fc_address_lng,
                new_event.x_fc_address_lat, new_event.x_fc_address_lng,
            )
            travel_min = max(travel_min, min_buffer)
            if travel_min > 0:
                travel_end = new_event.start
                travel_start = travel_end - timedelta(minutes=travel_min)
                if travel_start >= prev_event.stop:
                    CalEvent.create({
                        'name': 'Travel to %s' % (new_event.name or 'appointment'),
                        'start': travel_start,
                        'stop': travel_end,
                        'duration': travel_min / 60.0,
                        'user_id': staff_user.id,
                        'partner_ids': [(4, staff_user.partner_id.id)],
                        'show_as': 'busy',
                        'x_fc_is_travel_block': True,
                        'x_fc_travel_minutes_before': travel_min,
                    })
                    new_event.sudo().write({'x_fc_travel_minutes_before': travel_min})

        if next_event and next_event.x_fc_address_lat and next_event.x_fc_address_lng:
            travel_min = self._get_travel_time(
                new_event.x_fc_address_lat, new_event.x_fc_address_lng,
                next_event.x_fc_address_lat, next_event.x_fc_address_lng,
            )
            travel_min = max(travel_min, min_buffer)
            if travel_min > 0:
                travel_start = new_event.stop
                travel_end = travel_start + timedelta(minutes=travel_min)
                if travel_end <= next_event.start:
                    CalEvent.create({
                        'name': 'Travel to %s' % (next_event.name or 'appointment'),
                        'start': travel_start,
                        'stop': travel_end,
                        'duration': travel_min / 60.0,
                        'user_id': staff_user.id,
                        'partner_ids': [(4, staff_user.partner_id.id)],
                        'show_as': 'busy',
                        'x_fc_is_travel_block': True,
                        'x_fc_travel_minutes_before': travel_min,
                    })
                    next_event.sudo().write({'x_fc_travel_minutes_before': travel_min})

    # -------------------------------------------------------------------------
    # Schedule Page
    # -------------------------------------------------------------------------

    @http.route(['/my/schedule'], type='http', auth='user', website=True)
    def schedule_page(self, **kw):
        """Schedule overview: upcoming appointments, connected calendars, and shareable link."""
        partner = request.env.user.partner_id
        user = request.env.user
        now = fields.Datetime.now()
        tz = self._get_user_timezone()

        now_local = pytz.utc.localize(now).astimezone(tz)
        today_local = now_local.date()
        today_start_local = tz.localize(datetime.combine(today_local, datetime.min.time()))
        today_end_local = tz.localize(datetime.combine(today_local, datetime.max.time()))
        today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
        today_end_utc = today_end_local.astimezone(pytz.utc).replace(tzinfo=None)

        upcoming_events = request.env['calendar.event'].sudo().search([
            ('partner_ids', 'in', [partner.id]),
            ('start', '>=', now),
        ], order='start asc', limit=20)

        today_events = request.env['calendar.event'].sudo().search([
            ('partner_ids', 'in', [partner.id]),
            ('start', '>=', today_start_utc),
            ('start', '<=', today_end_utc),
        ], order='start asc')

        invite = request.env['appointment.invite'].sudo().search([
            ('staff_user_ids', 'in', [user.id]),
        ], limit=1)
        share_url = invite.book_url if invite else ''

        appointment_types = self._get_appointment_types()

        calendar_accounts = request.env['fusion.calendar.account'].sudo().search([
            ('x_fc_user_id', '=', user.id),
            ('x_fc_active', '=', True),
        ])

        booking_slug = user.x_fc_schedule_slug or ''
        booking_enabled = user.x_fc_booking_enabled
        base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
        public_booking_url = '%s/schedule/%s' % (base_url, booking_slug) if booking_slug else ''

        def _event_source(ev):
            if ev.x_fc_source_account_id:
                return {
                    'provider': ev.x_fc_source_account_id.x_fc_provider or '',
                    'email': ev.x_fc_source_account_id.x_fc_email or '',
                }
            return {'provider': 'odoo', 'email': ''}

        event_sources = {ev.id: _event_source(ev) for ev in (today_events | upcoming_events)}

        user_prefs = self._get_user_prefs(user)

        values = self._get_schedule_values()
        values.update({
            'page_name': 'schedule',
            'upcoming_events': upcoming_events,
            'today_events': today_events,
            'share_url': share_url,
            'appointment_types': appointment_types,
            'user_tz': tz,
            'now': now_local,
            'calendar_accounts': calendar_accounts,
            'booking_slug': booking_slug,
            'booking_enabled': booking_enabled,
            'public_booking_url': public_booking_url,
            'event_sources': event_sources,
            'user_prefs': user_prefs,
            'success': kw.get('success', ''),
            'error': kw.get('error', ''),
        })
        return request.render('fusion_schedule.portal_schedule_page', values)

    @http.route('/my/schedule/preferences', type='jsonrpc', auth='user', website=True)
    def schedule_save_preferences(self, **kw):
        """Save the user's schedule preferences."""
        user = request.env.user
        vals = {}
        if 'work_start' in kw:
            vals['x_fc_work_start'] = float(kw['work_start'])
        if 'work_end' in kw:
            vals['x_fc_work_end'] = float(kw['work_end'])
        if 'break_start' in kw:
            vals['x_fc_break_start'] = float(kw['break_start'])
        if 'break_duration' in kw:
            vals['x_fc_break_duration'] = float(kw['break_duration'])
        if 'travel_buffer' in kw:
            vals['x_fc_travel_buffer'] = int(kw['travel_buffer'])
        if 'home_address' in kw:
            addr = (kw['home_address'] or '').strip()
            vals['x_fc_home_address'] = addr
            if addr:
                lat, lng = self._geocode_address(addr)
                vals['x_fc_home_lat'] = lat
                vals['x_fc_home_lng'] = lng
            else:
                vals['x_fc_home_lat'] = 0.0
                vals['x_fc_home_lng'] = 0.0

        if vals:
            user.sudo().write(vals)
        return {'success': True}

    # -------------------------------------------------------------------------
    # Booking Form
    # -------------------------------------------------------------------------

    @http.route(['/my/schedule/book'], type='http', auth='user', website=True)
    def schedule_book(self, appointment_type_id=None, **kw):
        """Booking form for a new appointment."""
        appointment_types = self._get_appointment_types()
        if not appointment_types:
            return request.redirect('/my/schedule')

        if appointment_type_id:
            selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
            if not selected_type.exists():
                selected_type = appointment_types[0]
        else:
            selected_type = appointment_types[0]

        tz = self._get_user_timezone()
        now_local = pytz.utc.localize(fields.Datetime.now()).astimezone(tz)

        values = self._get_schedule_values()
        values.update({
            'page_name': 'schedule_book',
            'appointment_types': appointment_types,
            'selected_type': selected_type,
            'now': now_local,
            'error': kw.get('error'),
            'success': kw.get('success'),
        })
        return request.render('fusion_schedule.portal_schedule_book', values)

    @staticmethod
    def _format_hour(hour_float):
        """Convert a decimal hour (e.g. 13.5) to '1:30 PM'."""
        h = int(hour_float)
        m = int(round((hour_float - h) * 60))
        period = 'AM' if h < 12 else 'PM'
        display_h = h % 12
        if display_h == 0:
            display_h = 12
        return '%d:%02d %s' % (display_h, m, period)

    def _generate_available_slots(self, appointment_type, staff_user, target_date):
        """Build available time slots for *target_date*.

        Uses the staff user's schedule preferences (work hours, break, travel
        buffer) when available, falling back to appointment-type slot
        definitions.  Travel buffer time after each existing appointment is
        enforced so back-to-back bookings respect minimum travel gaps.
        """
        tz = self._resolve_timezone(staff_user)

        duration = appointment_type.appointment_duration or 1.0
        prefs = self._get_user_prefs(staff_user)
        work_start = prefs['work_start']
        work_end = prefs['work_end']
        break_start = prefs['break_start']
        break_end = break_start + prefs['break_duration']
        travel_buffer_hrs = prefs['travel_buffer'] / 60.0

        weekday_str = str(target_date.isoweekday())
        day_slots = appointment_type.sudo().slot_ids.filtered(
            lambda s: s.slot_type == 'recurring' and s.weekday == weekday_str
        )

        candidate_hours = set()

        if day_slots:
            for slot_def in day_slots.sorted(key=lambda s: s.start_hour):
                s = max(slot_def.start_hour, work_start)
                e = slot_def.end_hour or (slot_def.start_hour + duration)
                e = min(e, work_end)
                h = s
                while h + duration <= e + 0.001:
                    candidate_hours.add(round(h, 2))
                    h += duration

        h = work_start
        while h + duration <= work_end + 0.001:
            candidate_hours.add(round(h, 2))
            h += duration

        candidate_hours = sorted(candidate_hours)

        now_utc = datetime.utcnow()
        now_local = pytz.utc.localize(now_utc).astimezone(tz)

        day_start_utc = tz.localize(datetime.combine(target_date, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
        day_end_utc = tz.localize(datetime.combine(target_date, datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)

        existing_events = request.env['calendar.event'].sudo().search([
            ('partner_ids', 'in', [staff_user.partner_id.id]),
            ('start', '<=', day_end_utc),
            ('stop', '>=', day_start_utc),
        ])
        busy_ranges = sorted(
            [(ev.start, ev.stop) for ev in existing_events],
            key=lambda r: r[0],
        )

        midnight_local = datetime.combine(target_date, datetime.min.time())
        result = []
        for start_hour in candidate_hours:
            if start_hour < work_start or start_hour + duration > work_end + 0.001:
                continue

            slot_overlaps_break = (start_hour < break_end and start_hour + duration > break_start)
            if slot_overlaps_break:
                continue

            slot_start_local = tz.localize(midnight_local + timedelta(hours=start_hour))
            if slot_start_local <= now_local:
                continue

            slot_start_utc = slot_start_local.astimezone(pytz.utc).replace(tzinfo=None)
            slot_end_utc = slot_start_utc + timedelta(hours=duration)

            conflict = False
            for b_start, b_end in busy_ranges:
                buffered_end = b_end + timedelta(hours=travel_buffer_hrs)
                if slot_start_utc < buffered_end and slot_end_utc > b_start:
                    conflict = True
                    break
            if conflict:
                continue

            result.append({
                'datetime': slot_start_utc.strftime('%Y-%m-%d %H:%M:%S'),
                'start_hour': self._format_hour(start_hour),
                'end_hour': self._format_hour(start_hour + duration),
                'duration': str(duration),
                'staff_user_id': staff_user.id,
            })

        return result

    @http.route('/my/schedule/available-slots', type='jsonrpc', auth='user', website=True)
    def schedule_available_slots(self, appointment_type_id=0, selected_date=None, **kw):
        """JSON-RPC endpoint: return available time slots for a date."""
        type_id = int(appointment_type_id)
        if type_id:
            appointment_type = request.env['appointment.type'].sudo().browse(type_id)
        else:
            appointment_type = self._get_appointment_types()[:1]
        if not appointment_type.exists():
            return {'error': 'Appointment type not found', 'slots': []}

        user = request.env.user
        tz = self._resolve_timezone(user)

        if not selected_date:
            return {'error': 'Date is required', 'slots': []}

        try:
            target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
        except ValueError:
            return {'error': 'Invalid date format', 'slots': []}

        slots = self._generate_available_slots(appointment_type, user, target_date)

        return {
            'slots': slots,
            'duration': appointment_type.appointment_duration,
            'timezone': str(tz),
        }

    @http.route('/my/schedule/week-events', type='jsonrpc', auth='user', website=True)
    def schedule_week_events(self, selected_date, **kw):
        """Return the user's calendar events for the Mon-Sun week containing selected_date."""
        try:
            target = datetime.strptime(selected_date, '%Y-%m-%d').date()
        except (ValueError, TypeError):
            return {'error': 'Invalid date format', 'events': [], 'week_days': []}

        monday = target - timedelta(days=target.weekday())
        sunday = monday + timedelta(days=6)

        partner = request.env.user.partner_id
        tz = self._get_user_timezone()

        monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
        sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
        monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
        sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)

        events = request.env['calendar.event'].sudo().search([
            ('partner_ids', 'in', [partner.id]),
            ('start', '>=', monday_start_utc),
            ('start', '<=', sunday_end_utc),
        ], order='start asc')

        event_list = []
        for ev in events:
            start_utc = ev.start
            stop_utc = ev.stop
            start_local = pytz.utc.localize(start_utc).astimezone(tz)
            stop_local = pytz.utc.localize(stop_utc).astimezone(tz)

            # Source account info for colour coding
            source_provider = ''
            source_email = ''
            if ev.x_fc_source_account_id:
                source_provider = ev.x_fc_source_account_id.x_fc_provider or ''
                source_email = ev.x_fc_source_account_id.x_fc_email or ''

            event_list.append({
                'name': ev.name or '',
                'start': start_local.strftime('%Y-%m-%d %H:%M'),
                'end': stop_local.strftime('%Y-%m-%d %H:%M'),
                'start_time': start_local.strftime('%I:%M %p'),
                'end_time': stop_local.strftime('%I:%M %p'),
                'day_of_week': start_local.weekday(),
                'date': start_local.strftime('%Y-%m-%d'),
                'location': ev.location or '',
                'duration': ev.duration,
                'source_provider': source_provider,
                'source_email': source_email,
            })

        day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
        week_days = []
        for i in range(7):
            day = monday + timedelta(days=i)
            week_days.append({
                'label': day_labels[i],
                'date': day.strftime('%Y-%m-%d'),
                'day_num': day.day,
                'is_selected': day == target,
            })

        return {
            'events': event_list,
            'week_days': week_days,
            'selected_date': selected_date,
        }

    @http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
    def schedule_book_submit(self, **post):
        """Process the booking form submission."""
        appointment_type_id = int(post.get('appointment_type_id', 0))
        appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
        if not appointment_type.exists():
            return request.redirect('/my/schedule/book?error=Invalid+appointment+type')

        client_name = (post.get('client_name') or '').strip()
        client_email = (post.get('client_email') or '').strip()
        client_phone = (post.get('client_phone') or '').strip()
        client_street = (post.get('client_street') or '').strip()
        client_city = (post.get('client_city') or '').strip()
        client_province = (post.get('client_province') or '').strip()
        client_postal = (post.get('client_postal') or '').strip()
        notes = (post.get('notes') or '').strip()
        slot_datetime = (post.get('slot_datetime') or '').strip()
        slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))

        try:
            client_lat = float(post.get('client_lat', 0))
            client_lng = float(post.get('client_lng', 0))
        except (ValueError, TypeError):
            client_lat, client_lng = 0.0, 0.0

        if not client_name or not slot_datetime:
            return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')

        user = request.env.user
        tz = self._get_user_timezone()

        try:
            start_dt_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
        except (ValueError, Exception) as e:
            _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
            return request.redirect('/my/schedule/book?error=Invalid+time+slot')

        duration = float(slot_duration)
        stop_dt_utc = start_dt_utc + timedelta(hours=duration)

        start_dt_local = pytz.utc.localize(start_dt_utc).astimezone(tz)
        available = self._generate_available_slots(appointment_type, user, start_dt_local.date())
        is_valid = any(s['datetime'] == slot_datetime for s in available)
        if not is_valid:
            return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')

        address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
        location = ', '.join(address_parts)

        description_lines = []
        if client_name:
            description_lines.append("Client: %s" % client_name)
        if client_email:
            description_lines.append("Email: %s" % client_email)
        if client_phone:
            description_lines.append("Phone: %s" % client_phone)
        if location:
            description_lines.append("Address: %s" % location)
        if notes:
            description_lines.append("Notes: %s" % notes)
        description = '\n'.join(description_lines)

        event_name = "%s - %s" % (client_name, appointment_type.name)

        partner_ids = [(4, user.partner_id.id)]
        client_partner = None
        if client_email:
            Partner = request.env['res.partner'].sudo()
            client_partner = Partner.search([('email', '=ilike', client_email)], limit=1)
            if not client_partner:
                client_partner = Partner.create({
                    'name': client_name,
                    'email': client_email,
                    'phone': client_phone or False,
                })
            elif client_phone and not client_partner.phone:
                client_partner.phone = client_phone
            partner_ids.append((4, client_partner.id))

        manage_token = secrets.token_hex(16)

        booking_line_values = [{
            'appointment_user_id': user.id,
            'capacity_reserved': 1,
            'capacity_used': 1,
        }]

        try:
            event_vals = appointment_type._prepare_calendar_event_values(
                asked_capacity=1,
                booking_line_values=booking_line_values,
                description=description,
                duration=duration,
                allday=False,
                appointment_invite=request.env['appointment.invite'],
                guests=request.env['res.partner'],
                name=event_name,
                customer=user.partner_id,
                staff_user=user,
                start=start_dt_utc,
                stop=stop_dt_utc,
            )
            event_vals['location'] = location
            event_vals['partner_ids'] = partner_ids
            if client_partner:
                event_vals.setdefault('attendee_ids', []).append(
                    (0, 0, {'partner_id': client_partner.id, 'state': 'needsAction'})
                )
            event_vals['x_fc_manage_token'] = manage_token
            event_vals['x_fc_client_email'] = client_email or False
            event_vals['x_fc_client_phone'] = client_phone or False
            event_vals['x_fc_address_lat'] = client_lat
            event_vals['x_fc_address_lng'] = client_lng
            event = request.env['calendar.event'].sudo().with_context(
                dont_notify=True, no_mail_notification=True,
            ).create(event_vals)

            _logger.info(
                "Appointment booked: %s at %s (event ID: %s)",
                event_name, start_dt_utc, event.id,
            )

            try:
                if client_email:
                    template = request.env.ref(
                        'fusion_schedule.fusion_schedule_booking_confirmation',
                        raise_if_not_found=False,
                    )
                    if template:
                        template.sudo().send_mail(event.id, force_send=True)
            except Exception as me:
                _logger.warning("Booking confirmation email failed for event %s: %s", event.id, me)

            try:
                self._create_travel_blocks(event, user)
            except Exception as te:
                _logger.warning("Travel block creation failed for event %s: %s", event.id, te)
        except Exception as e:
            _logger.error("Failed to create appointment: %s", e)
            return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')

        return request.redirect('/my/schedule?success=Appointment+booked+successfully')

    # -------------------------------------------------------------------------
    # Event Actions — Reschedule / Cancel (Staff)
    # -------------------------------------------------------------------------

    @http.route('/my/schedule/event/cancel', type='jsonrpc', auth='user', website=True)
    def schedule_event_cancel(self, event_id, **kw):
        """Cancel (delete) a calendar event owned by the current user."""
        partner = request.env.user.partner_id
        event = request.env['calendar.event'].sudo().browse(int(event_id))
        if not event.exists() or partner not in event.partner_ids:
            return {'success': False, 'error': 'Event not found or access denied.'}

        try:
            event.unlink()
            return {'success': True}
        except Exception as e:
            _logger.exception("Failed to cancel event %s: %s", event_id, e)
            return {'success': False, 'error': 'Failed to cancel event.'}

    @http.route('/my/schedule/event/reschedule', type='jsonrpc', auth='user', website=True)
    def schedule_event_reschedule(self, event_id, new_datetime, new_duration=None, **kw):
        """Reschedule a calendar event to a new date/time."""
        partner = request.env.user.partner_id
        event = request.env['calendar.event'].sudo().browse(int(event_id))
        if not event.exists() or partner not in event.partner_ids:
            return {'success': False, 'error': 'Event not found or access denied.'}

        tz = self._get_user_timezone()
        try:
            start_naive = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
            start_local = tz.localize(start_naive)
            start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
        except (ValueError, Exception) as e:
            return {'success': False, 'error': 'Invalid date/time format.'}

        duration = float(new_duration) if new_duration else event.duration
        stop_utc = start_utc + timedelta(hours=duration)

        try:
            event.write({
                'start': start_utc,
                'stop': stop_utc,
                'duration': duration,
            })
            return {'success': True}
        except Exception as e:
            _logger.exception("Failed to reschedule event %s: %s", event_id, e)
            return {'success': False, 'error': 'Failed to reschedule event.'}

    # -------------------------------------------------------------------------
    # Public Manage Page — /schedule/manage/<token>
    # -------------------------------------------------------------------------

    def _get_event_by_token(self, token):
        """Look up a calendar event by its manage token."""
        if not token or len(token) != 32:
            return None
        return request.env['calendar.event'].sudo().search([
            ('x_fc_manage_token', '=', token),
        ], limit=1)

    @http.route('/schedule/manage/<string:token>', type='http', auth='public', website=True, sitemap=False)
    def public_manage_page(self, token, **kw):
        """Public page for visitors to view/manage their booked appointment."""
        event = self._get_event_by_token(token)
        if not event:
            return request.not_found()

        tz = self._resolve_timezone(event.user_id)

        cancelled = kw.get('cancelled')
        rescheduled = kw.get('rescheduled')

        booking_slug = event.user_id.x_fc_schedule_slug or ''

        values = {
            'event': event,
            'token': token,
            'user_tz': tz,
            'cancelled': cancelled,
            'rescheduled': rescheduled,
            'booking_slug': booking_slug,
            'error': kw.get('error', ''),
        }
        return request.render('fusion_schedule.public_manage_page', values)

    @http.route('/schedule/manage/<string:token>/cancel', type='http', auth='public',
                website=True, methods=['POST'], csrf=True, sitemap=False)
    def public_manage_cancel(self, token, **post):
        """Public cancel -- visitor cancels their appointment via token."""
        event = self._get_event_by_token(token)
        if not event:
            return request.not_found()

        try:
            event.unlink()
        except Exception as e:
            _logger.error("Public cancel failed for token %s: %s", token[:8], e)
            return request.redirect('/schedule/manage/%s?error=Failed+to+cancel' % token)

        return request.redirect('/schedule/manage/%s?cancelled=1' % token)

    @http.route('/schedule/manage/<string:token>/reschedule', type='http', auth='public',
                website=True, methods=['POST'], csrf=True, sitemap=False)
    def public_manage_reschedule(self, token, **post):
        """Public reschedule -- visitor picks a new slot via token."""
        event = self._get_event_by_token(token)
        if not event:
            return request.not_found()

        slot_datetime = (post.get('slot_datetime') or '').strip()
        if not slot_datetime:
            return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token)

        tz = self._resolve_timezone(event.user_id)

        try:
            start_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
            start_local = tz.localize(start_naive)
            start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
        except (ValueError, Exception):
            return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token)

        stop_utc = start_utc + timedelta(hours=event.duration)

        try:
            event.write({'start': start_utc, 'stop': stop_utc})
        except Exception as e:
            _logger.error("Public reschedule failed for token %s: %s", token[:8], e)
            return request.redirect('/schedule/manage/%s?error=Failed+to+reschedule' % token)

        return request.redirect('/schedule/manage/%s?rescheduled=1' % token)

    @http.route('/schedule/manage/<string:token>/available-slots', type='jsonrpc',
                auth='public', website=True, csrf=False)
    def public_manage_slots(self, token, selected_date, **kw):
        """Return available slots for the staff user tied to this event."""
        event = self._get_event_by_token(token)
        if not event:
            return {'error': 'Not found', 'slots': []}

        staff_user = event.user_id
        appointment_type = request.env['appointment.type'].sudo().search([
            ('staff_user_ids', 'in', [staff_user.id]),
        ], limit=1)
        if not appointment_type:
            return {'error': 'No appointment types', 'slots': []}

        try:
            target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
        except ValueError:
            return {'error': 'Invalid date', 'slots': []}

        slots = self._generate_available_slots(appointment_type, staff_user, target_date)
        return {'slots': slots, 'timezone': str(self._resolve_timezone(staff_user))}

    # -------------------------------------------------------------------------
    # AI Smart Scheduling
    # -------------------------------------------------------------------------

    def _build_schedule_context(self, staff_user, target_date, new_location=None):
        """Build a structured schedule context string for AI prompts."""
        tz = self._resolve_timezone(staff_user)

        day_start = tz.localize(datetime.combine(target_date, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
        day_end = tz.localize(datetime.combine(target_date, datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)

        events = request.env['calendar.event'].sudo().search([
            ('partner_ids', 'in', [staff_user.partner_id.id]),
            ('start', '>=', day_start),
            ('start', '<=', day_end),
            ('x_fc_is_travel_block', '!=', True),
        ], order='start asc')

        prefs = self._get_user_prefs(staff_user)
        lines = [
            'Date: %s (%s)' % (target_date.strftime('%A, %B %d, %Y'), tz),
            'Work hours: %s - %s' % (self._format_hour(prefs['work_start']), self._format_hour(prefs['work_end'])),
            'Break: %s for %d min' % (self._format_hour(prefs['break_start']), int(prefs['break_duration'] * 60)),
            'Travel buffer: %d min minimum' % prefs['travel_buffer'],
            '',
            'Current appointments:',
        ]

        event_data = []
        for ev in events:
            local_start = pytz.utc.localize(ev.start).astimezone(tz)
            local_end = pytz.utc.localize(ev.stop).astimezone(tz)
            loc = ev.location or 'No location'
            lat = ev.x_fc_address_lat or 0
            lng = ev.x_fc_address_lng or 0
            lines.append('  - %s to %s: %s @ %s (lat:%s, lng:%s)' % (
                local_start.strftime('%I:%M %p'), local_end.strftime('%I:%M %p'),
                ev.name or 'Untitled', loc, lat, lng,
            ))
            event_data.append({
                'name': ev.name, 'start': str(ev.start), 'stop': str(ev.stop),
                'location': loc, 'lat': lat, 'lng': lng,
            })

        if not events:
            lines.append('  (no appointments)')

        if new_location:
            lines.append('')
            lines.append('New appointment location: %s' % new_location)

        return '\n'.join(lines), event_data, prefs

    @http.route('/my/schedule/ai/suggest', type='jsonrpc', auth='user', website=True)
    def schedule_ai_suggest(self, selected_date, appointment_type_id=0,
                            location=None, lat=0, lng=0,
                            duration=1.0, **kw):
        """AI-powered slot suggestions considering travel and schedule."""
        user = request.env.user
        try:
            target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
        except (ValueError, TypeError):
            return {'error': 'Invalid date', 'suggestions': []}

        type_id = int(appointment_type_id or 0)
        if type_id:
            appointment_type = request.env['appointment.type'].sudo().browse(type_id)
        else:
            appointment_type = self._get_appointment_types()[:1]
        if not appointment_type or not appointment_type.exists():
            return {'error': 'No appointment type', 'suggestions': []}

        available_slots = self._generate_available_slots(
            appointment_type, user, target_date,
        )
        if not available_slots:
            return {'error': 'No available slots', 'suggestions': []}

        slot_times = [s['start_hour'] for s in available_slots]

        context_str, event_data, prefs = self._build_schedule_context(
            user, target_date, new_location=location,
        )

        travel_info = []
        new_lat = float(lat or 0)
        new_lng = float(lng or 0)
        if new_lat and new_lng:
            for ed in event_data:
                if ed['lat'] and ed['lng']:
                    t = self._get_travel_time(ed['lat'], ed['lng'], new_lat, new_lng)
                    travel_info.append('%s -> new location: %d min' % (ed['name'], t))

        system_prompt = (
            'You are a scheduling assistant. The ONLY times you may suggest '
            'are from this list: [%s]. Never invent times outside this list.\n\n'
            'Rules:\n'
            '1. Pick exactly 3 DIFFERENT times from the list.\n'
            '2. Spread suggestions across the day -- if morning AND afternoon '
            'slots exist, include at least one from each.\n'
            '3. Rank by: minimize travel, maximize gap from existing events, '
            'prefer earlier available time.\n'
            '4. Each suggestion MUST use the EXACT text from the list '
            '(e.g. "10:00 AM" not "10:00AM").\n'
            '5. Never repeat the same time.\n\n'
            'Return ONLY a JSON array, no markdown:\n'
            '[{"time":"10:00 AM","reason":"short reason"}]\n'
            'Appointment duration: %.1f hours.'
        ) % (', '.join(slot_times), float(duration))

        user_msg = context_str
        if travel_info:
            user_msg += '\n\nTravel times:\n' + '\n'.join(travel_info)
        user_msg += (
            '\n\nAvailable slots: %s\n'
            'Pick 3 different times. Include morning if available.'
        ) % ', '.join(slot_times)

        try:
            ai_response = self._call_ai(
                messages=[
                    {'role': 'system', 'content': system_prompt},
                    {'role': 'user', 'content': user_msg},
                ],
                feature='slot_suggestion',
            )
            suggestions = json.loads(
                ai_response.strip().strip('`').replace('json\n', '').replace('json', '')
            )
        except Exception as e:
            _logger.warning('AI suggestion failed: %s', e)
            return {'error': 'AI suggestion unavailable', 'suggestions': []}

        def _normalize_time(t):
            t = (t or '').strip().upper().replace('  ', ' ')
            parts = t.split(':')
            if len(parts) == 2:
                h = parts[0].lstrip('0') or '0'
                return h + ':' + parts[1]
            return t

        slot_lookup = {}
        for sl in available_slots:
            slot_lookup[_normalize_time(sl['start_hour'])] = sl

        enriched = []
        seen_times = set()
        for s in suggestions:
            key = _normalize_time(s.get('time', ''))
            if key in seen_times:
                continue
            seen_times.add(key)
            matched_slot = slot_lookup.get(key)
            if not matched_slot:
                continue
            enriched.append({
                'time': matched_slot['start_hour'],
                'reason': s.get('reason', ''),
                'datetime': matched_slot['datetime'],
                'duration': matched_slot['duration'],
            })

        return {'suggestions': enriched[:3]}

    @http.route('/my/schedule/ai/optimize', type='jsonrpc', auth='user', website=True)
    def schedule_ai_optimize(self, selected_date=None, **kw):
        """AI-powered schedule optimization for an entire day."""
        user = request.env.user
        try:
            if selected_date:
                target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
            else:
                target_date = datetime.utcnow().date()
        except (ValueError, TypeError):
            target_date = datetime.utcnow().date()

        context_str, event_data, prefs = self._build_schedule_context(user, target_date)

        if len(event_data) < 2:
            return {'error': 'Need at least 2 appointments to optimize', 'optimization': None}

        located_events = [e for e in event_data if e['lat'] and e['lng']]
        travel_matrix = []
        for i, a in enumerate(located_events):
            for j, b in enumerate(located_events):
                if i != j:
                    t = self._get_travel_time(a['lat'], a['lng'], b['lat'], b['lng'])
                    travel_matrix.append('%s -> %s: %d min' % (a['name'], b['name'], t))

        system_prompt = (
            'You are a route optimization assistant. Given the appointments below with '
            'their locations and the travel time matrix, suggest the optimal ordering '
            'to minimize total travel time. Respect the work hours and break constraints. '
            'Return ONLY valid JSON: {"current_order": ["name1", ...], '
            '"optimized_order": ["name1", ...], "current_travel_total_min": N, '
            '"optimized_travel_total_min": N, "savings_min": N, '
            '"schedule": [{"name": "...", "suggested_time": "HH:MM AM/PM", '
            '"reason": "..."}]}. No markdown.'
        )

        user_msg = context_str
        if travel_matrix:
            user_msg += '\n\nTravel time matrix:\n' + '\n'.join(travel_matrix)
        user_msg += '\n\nOptimize this schedule to minimize total travel time.'

        try:
            ai_response = self._call_ai(
                messages=[
                    {'role': 'system', 'content': system_prompt},
                    {'role': 'user', 'content': user_msg},
                ],
                feature='schedule_optimization',
            )
            optimization = json.loads(
                ai_response.strip().strip('`').replace('json\n', '').replace('json', '')
            )
        except Exception as e:
            _logger.warning('AI optimization failed: %s', e)
            return {'error': 'AI optimization unavailable', 'optimization': None}

        return {'optimization': optimization}

    # -------------------------------------------------------------------------
    # OAuth Connect — Google
    # -------------------------------------------------------------------------

    @http.route('/my/schedule/connect/google', type='http', auth='user', website=True)
    def connect_google(self, **kw):
        """Start Google OAuth flow to connect a Google Calendar account."""
        AccountModel = request.env['fusion.calendar.account'].sudo()
        client_id = AccountModel._get_google_client_id()
        if not client_id:
            return request.redirect('/my/schedule?error=Google+OAuth+not+configured.+Contact+administrator.')

        base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
        redirect_uri = '%s/my/schedule/oauth/callback' % base_url

        csrf_token = secrets.token_hex(16)
        request.session['fc_oauth_csrf'] = csrf_token

        state = json.dumps({
            'provider': 'google',
            'csrf': csrf_token,
        })

        params = {
            'client_id': client_id,
            'redirect_uri': redirect_uri,
            'response_type': 'code',
            'scope': GOOGLE_SCOPES,
            'access_type': 'offline',
            'prompt': 'consent',
            'state': state,
        }
        from werkzeug.urls import url_encode
        auth_url = '%s?%s' % (GOOGLE_AUTH_ENDPOINT, url_encode(params))

        return request.redirect(auth_url, local=False)

    # -------------------------------------------------------------------------
    # OAuth Connect — Microsoft
    # -------------------------------------------------------------------------

    @http.route('/my/schedule/connect/microsoft', type='http', auth='user', website=True)
    def connect_microsoft(self, **kw):
        """Start Microsoft OAuth flow to connect an Outlook Calendar account."""
        AccountModel = request.env['fusion.calendar.account'].sudo()
        client_id = AccountModel._get_microsoft_client_id()
        if not client_id:
            return request.redirect('/my/schedule?error=Microsoft+OAuth+not+configured.+Contact+administrator.')

        base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
        redirect_uri = '%s/my/schedule/oauth/callback' % base_url

        csrf_token = secrets.token_hex(16)
        request.session['fc_oauth_csrf'] = csrf_token

        state = json.dumps({
            'provider': 'microsoft',
            'csrf': csrf_token,
        })

        params = {
            'client_id': client_id,
            'redirect_uri': redirect_uri,
            'response_type': 'code',
            'scope': MICROSOFT_SCOPES,
            'response_mode': 'query',
            'prompt': 'select_account',
            'state': state,
        }

        from werkzeug.urls import url_encode
        auth_url = '%s?%s' % (MICROSOFT_AUTH_ENDPOINT, url_encode(params))

        return request.redirect(auth_url, local=False)

    # -------------------------------------------------------------------------
    # OAuth Callback
    # -------------------------------------------------------------------------

    @http.route('/my/schedule/oauth/callback', type='http', auth='user', website=True)
    def oauth_callback(self, **kw):
        """Handle OAuth callback from Google or Microsoft."""
        error = kw.get('error')
        if error:
            _logger.warning("OAuth callback error: %s - %s", error, kw.get('error_description', ''))
            return request.redirect('/my/schedule?error=OAuth+authorisation+was+denied+or+failed.')

        code = kw.get('code')
        state_str = kw.get('state', '{}')

        if not code:
            return request.redirect('/my/schedule?error=No+authorisation+code+received.')

        try:
            state = json.loads(state_str)
        except (json.JSONDecodeError, TypeError):
            return request.redirect('/my/schedule?error=Invalid+OAuth+state.')

        csrf_token = state.get('csrf', '')
        session_csrf = request.session.pop('fc_oauth_csrf', '')

        provider = state.get('provider')
        if provider not in ('google', 'microsoft'):
            return request.redirect('/my/schedule?error=Unknown+calendar+provider.')

        if not csrf_token or csrf_token != session_csrf:
            existing = self._find_recently_connected_account(provider)
            if existing:
                provider_label = 'Google' if provider == 'google' else 'Microsoft'
                return request.redirect(
                    '/my/schedule?success=%s+account+%s+connected.+Initial+sync+in+progress.' % (
                        provider_label, existing.x_fc_email,
                    )
                )
            return request.redirect('/my/schedule?error=Invalid+security+token.+Please+try+again.')

        base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
        redirect_uri = '%s/my/schedule/oauth/callback' % base_url

        AccountModel = request.env['fusion.calendar.account'].sudo()

        try:
            if provider == 'google':
                token_data = AccountModel._exchange_google_code(code, redirect_uri)
                access_token = token_data.get('access_token')
                refresh_token = token_data.get('refresh_token')
                expires_in = token_data.get('expires_in', 3600)
                email = AccountModel._fetch_google_email(access_token)
            else:
                token_data = AccountModel._exchange_microsoft_code(code, redirect_uri)
                access_token = token_data.get('access_token')
                refresh_token = token_data.get('refresh_token')
                expires_in = token_data.get('expires_in', 3600)
                email = AccountModel._fetch_microsoft_email(access_token)

            if not refresh_token:
                return request.redirect(
                    '/my/schedule?error=No+refresh+token+received.+Please+try+again+and+grant+offline+access.'
                )

            existing = AccountModel.search([
                ('x_fc_user_id', '=', request.env.user.id),
                ('x_fc_provider', '=', provider),
                ('x_fc_email', '=', email),
            ], limit=1)

            if existing:
                existing.write({
                    'x_fc_active': True,
                    'x_fc_rtoken': refresh_token,
                    'x_fc_token': access_token,
                    'x_fc_token_validity': fields.Datetime.now() + timedelta(seconds=expires_in),
                    'x_fc_sync_status': 'active',
                    'x_fc_error_message': False,
                    'x_fc_sync_token': False,
                })
            else:
                AccountModel.create({
                    'x_fc_user_id': request.env.user.id,
                    'x_fc_provider': provider,
                    'x_fc_email': email,
                    'x_fc_rtoken': refresh_token,
                    'x_fc_token': access_token,
                    'x_fc_token_validity': fields.Datetime.now() + timedelta(seconds=expires_in),
                    'x_fc_sync_status': 'active',
                })

            provider_label = 'Google' if provider == 'google' else 'Microsoft'
            return request.redirect(
                '/my/schedule?success=%s+account+%s+connected.+Initial+sync+in+progress.' % (
                    provider_label, email,
                )
            )

        except Exception as e:
            _logger.exception("OAuth callback error for %s: %s", provider, e)
            existing = self._find_recently_connected_account(provider)
            if existing:
                provider_label = 'Google' if provider == 'google' else 'Microsoft'
                return request.redirect(
                    '/my/schedule?success=%s+account+%s+connected.+Initial+sync+in+progress.' % (
                        provider_label, existing.x_fc_email,
                    )
                )
            return request.redirect('/my/schedule?error=Failed+to+connect+account.+Please+try+again.')

    def _find_recently_connected_account(self, provider):
        """Check if the current user already has a recently connected account for this provider.

        Handles the case where the user refreshes the callback URL after the
        account was already created but the browser timed out.
        """
        cutoff = fields.Datetime.now() - timedelta(minutes=10)
        return request.env['fusion.calendar.account'].sudo().search([
            ('x_fc_user_id', '=', request.env.user.id),
            ('x_fc_provider', '=', provider),
            ('x_fc_active', '=', True),
            ('x_fc_rtoken', '!=', False),
            ('create_date', '>=', cutoff),
        ], limit=1, order='create_date desc')

    # -------------------------------------------------------------------------
    # Account Management — Disconnect / Sync Now
    # -------------------------------------------------------------------------

    @http.route('/my/schedule/disconnect', type='jsonrpc', auth='user', website=True)
    def schedule_disconnect(self, account_id, **kw):
        """Disconnect a calendar account."""
        account = request.env['fusion.calendar.account'].sudo().browse(int(account_id))
        if not account.exists() or account.x_fc_user_id.id != request.env.user.id:
            return {'success': False, 'error': 'Account not found or access denied.'}

        try:
            account.action_disconnect()
            return {'success': True, 'message': 'Account disconnected.'}
        except Exception as e:
            _logger.exception("Disconnect error for account %s: %s", account_id, e)
            return {'success': False, 'error': 'Failed to disconnect account.'}

    @http.route('/my/schedule/sync-now', type='jsonrpc', auth='user', website=True)
    def schedule_sync_now(self, account_id, **kw):
        """Trigger immediate sync for a calendar account."""
        account = request.env['fusion.calendar.account'].sudo().browse(int(account_id))
        if not account.exists() or account.x_fc_user_id.id != request.env.user.id:
            return {'success': False, 'error': 'Account not found or access denied.'}

        try:
            result = account._sync_pull()
            if result:
                return {
                    'success': True,
                    'message': 'Sync completed.',
                    'last_sync': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
                }
            else:
                return {
                    'success': False,
                    'error': account.x_fc_error_message or 'Sync failed.',
                }
        except Exception as e:
            _logger.exception("Sync error for account %s: %s", account_id, e)
            return {'success': False, 'error': str(e)[:200]}

    # -------------------------------------------------------------------------
    # Public Booking Page — /schedule/<slug>
    # -------------------------------------------------------------------------

    @http.route('/schedule/<string:slug>', type='http', auth='public', website=True, sitemap=False)
    def public_booking_page(self, slug, **kw):
        """Public booking page — no login required."""
        user = request.env['res.users'].sudo().search([
            ('x_fc_schedule_slug', '=', slug),
            ('x_fc_booking_enabled', '=', True),
        ], limit=1)

        if not user:
            return request.not_found()

        # Get appointment types for this user
        appointment_types = request.env['appointment.type'].sudo().search([
            ('staff_user_ids', 'in', [user.id]),
        ])

        if not appointment_types:
            return request.not_found()

        google_maps_api_key = self._get_maps_api_key()

        values = {
            'staff_user': user,
            'appointment_types': appointment_types,
            'selected_type': appointment_types[0] if appointment_types else False,
            'booking_slug': slug,
            'google_maps_api_key': google_maps_api_key,
            'success': kw.get('success', ''),
            'error': kw.get('error', ''),
        }
        return request.render('fusion_schedule.public_booking_page', values)

    @http.route('/schedule/<string:slug>/available-slots', type='jsonrpc', auth='public',
                website=True, csrf=False)
    def public_available_slots(self, slug, appointment_type_id, selected_date=None, **kw):
        """Public endpoint: return available slots for a date (no login needed)."""
        user = request.env['res.users'].sudo().search([
            ('x_fc_schedule_slug', '=', slug),
            ('x_fc_booking_enabled', '=', True),
        ], limit=1)

        if not user:
            return {'error': 'Booking page not found', 'slots': []}

        appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
        if not appointment_type.exists():
            return {'error': 'Appointment type not found', 'slots': []}

        tz = self._resolve_timezone(user)

        if not selected_date:
            return {'error': 'Date is required', 'slots': []}

        try:
            target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
        except ValueError:
            return {'error': 'Invalid date format', 'slots': []}

        slots = self._generate_available_slots(appointment_type, user, target_date)

        return {
            'slots': slots,
            'duration': appointment_type.appointment_duration,
            'timezone': str(tz),
        }

    @http.route('/schedule/<string:slug>/book', type='http', auth='public', website=True,
                methods=['POST'], csrf=True)
    def public_book_submit(self, slug, **post):
        """Process public booking form submission."""
        user = request.env['res.users'].sudo().search([
            ('x_fc_schedule_slug', '=', slug),
            ('x_fc_booking_enabled', '=', True),
        ], limit=1)

        if not user:
            return request.not_found()

        appointment_type_id = int(post.get('appointment_type_id', 0))
        appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
        if not appointment_type.exists():
            return request.redirect('/schedule/%s?error=Invalid+appointment+type' % slug)

        visitor_name = (post.get('visitor_name') or '').strip()
        visitor_email = (post.get('visitor_email') or '').strip()
        visitor_phone = (post.get('visitor_phone') or '').strip()
        visitor_street = (post.get('client_street') or '').strip()
        visitor_city = (post.get('client_city') or '').strip()
        visitor_province = (post.get('client_province') or '').strip()
        visitor_postal = (post.get('client_postal') or '').strip()
        notes = (post.get('visitor_notes') or post.get('notes') or '').strip()
        slot_datetime = (post.get('slot_datetime') or '').strip()
        slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))

        try:
            visitor_lat = float(post.get('client_lat', 0))
            visitor_lng = float(post.get('client_lng', 0))
        except (ValueError, TypeError):
            visitor_lat, visitor_lng = 0.0, 0.0

        if not visitor_name or not visitor_email or not slot_datetime:
            return request.redirect(
                '/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug
            )

        tz = self._resolve_timezone(user)

        try:
            start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
            start_dt_local = tz.localize(start_dt_naive)
            start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
        except (ValueError, Exception) as e:
            _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
            return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug)

        duration = float(slot_duration)
        stop_dt_utc = start_dt_utc + timedelta(hours=duration)

        # Find or create partner for the visitor
        Partner = request.env['res.partner'].sudo()
        partner = Partner.search([('email', '=ilike', visitor_email)], limit=1)
        if not partner:
            partner = Partner.create({
                'name': visitor_name,
                'email': visitor_email,
                'phone': visitor_phone,
            })
        elif visitor_phone and not partner.phone:
            partner.phone = visitor_phone

        address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p]
        location = ', '.join(address_parts)

        description_lines = [
            'Booked via public scheduling link',
            'Visitor: %s' % visitor_name,
            'Email: %s' % visitor_email,
        ]
        if visitor_phone:
            description_lines.append('Phone: %s' % visitor_phone)
        if location:
            description_lines.append('Address: %s' % location)
        if notes:
            description_lines.append('Notes: %s' % notes)
        description = '\n'.join(description_lines)

        event_name = '%s — %s' % (visitor_name, appointment_type.name)

        manage_token = secrets.token_hex(16)

        try:
            event = request.env['calendar.event'].sudo().with_context(
                dont_notify=True, no_mail_notification=True,
            ).create({
                'name': event_name,
                'start': start_dt_utc,
                'stop': stop_dt_utc,
                'duration': duration,
                'description': description,
                'location': location,
                'partner_ids': [(4, user.partner_id.id), (4, partner.id)],
                'user_id': user.id,
                'allday': False,
                'x_fc_manage_token': manage_token,
                'x_fc_client_email': visitor_email,
                'x_fc_client_phone': visitor_phone or False,
                'x_fc_address_lat': visitor_lat,
                'x_fc_address_lng': visitor_lng,
            })

            _logger.info(
                "Public booking: %s at %s by %s (event ID: %s)",
                event_name, start_dt_utc, visitor_email, event.id,
            )

            try:
                self._create_travel_blocks(event, user)
            except Exception as te:
                _logger.warning("Travel block creation failed for public event %s: %s", event.id, te)

            try:
                template = request.env.ref(
                    'fusion_schedule.fusion_schedule_booking_confirmation',
                    raise_if_not_found=False,
                )
                if template:
                    template.sudo().send_mail(event.id, force_send=True)
            except Exception as me:
                _logger.warning("Booking confirmation email failed for public event %s: %s", event.id, me)
        except Exception as e:
            _logger.error("Failed to create public booking: %s", e)
            return request.redirect('/schedule/%s?error=Failed+to+create+booking.+Please+try+again.' % slug)

        base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
        manage_url = '%s/schedule/manage/%s' % (base_url, manage_token)

        return request.redirect(
            '/schedule/%s?success=Appointment+booked!+Manage+it+here:+%s' % (slug, manage_url)
        )

    # -------------------------------------------------------------------------
    # Booking Settings Toggle
    # -------------------------------------------------------------------------

    @http.route('/my/schedule/toggle-booking', type='jsonrpc', auth='user', website=True)
    def schedule_toggle_booking(self, enabled, **kw):
        """Enable or disable public booking page."""
        user = request.env.user
        user.sudo().write({'x_fc_booking_enabled': bool(enabled)})
        return {'success': True, 'enabled': bool(enabled)}
