# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)

"""
Fusion Technician Task
Scheduling and task management for field technicians.
Replaces Monday.com for technician schedule tracking.
"""

from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
from markupsafe import Markup
import logging
import json
import uuid
import requests
from datetime import datetime as dt_datetime, timedelta
import urllib.parse

_logger = logging.getLogger(__name__)


class FusionTechnicianTask(models.Model):
    _name = 'fusion.technician.task'
    _description = 'Technician Task'
    _order = 'scheduled_date, sequence, time_start, id'
    _inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin']
    _rec_name = 'name'

    def _compute_display_name(self):
        """Richer display name: Client - Type | 9:00 AM - 10:00 AM [+2 techs]."""
        type_labels = dict(self._fields['task_type'].selection)
        for task in self:
            client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '')
            ttype = type_labels.get(task.task_type, task.task_type or '')
            start = self._float_to_time_str(task.time_start)
            end = self._float_to_time_str(task.time_end)
            parts = [client, ttype]
            label = ' - '.join(p for p in parts if p)
            if start and end:
                label += f' | {start} - {end}'
            extra = len(task.additional_technician_ids)
            if extra:
                label += f' [+{extra} tech{"s" if extra > 1 else ""}]'
            task.display_name = label or task.name

    # ------------------------------------------------------------------
    # STORE HOURS HELPER
    # ------------------------------------------------------------------
    def _get_store_hours(self):
        """Return (open_hour, close_hour) from settings. Defaults 9.0 / 18.0."""
        ICP = self.env['ir.config_parameter'].sudo()
        try:
            open_h = float(ICP.get_param('fusion_claims.store_open_hour', '9.0') or '9.0')
        except (ValueError, TypeError):
            open_h = 9.0
        try:
            close_h = float(ICP.get_param('fusion_claims.store_close_hour', '18.0') or '18.0')
        except (ValueError, TypeError):
            close_h = 18.0
        return (open_h, close_h)

    # ------------------------------------------------------------------
    # CORE FIELDS
    # ------------------------------------------------------------------
    name = fields.Char(
        string='Task Reference',
        required=True,
        copy=False,
        readonly=True,
        default=lambda self: _('New'),
    )
    active = fields.Boolean(default=True)

    # Cross-instance sync fields
    x_fc_sync_source = fields.Char(
        'Source Instance', readonly=True, index=True,
        help='Origin instance ID if this is a synced shadow task (e.g. westin, mobility)',
    )
    x_fc_sync_remote_id = fields.Integer(
        'Remote Task ID', readonly=True,
        help='ID of the task on the remote instance',
    )
    x_fc_sync_uuid = fields.Char(
        'Sync UUID', readonly=True, index=True, copy=False,
        help='Unique ID for cross-instance deduplication',
    )
    x_fc_is_shadow = fields.Boolean(
        'Shadow Task', compute='_compute_is_shadow', store=True,
        help='True if this task was synced from another instance',
    )
    x_fc_sync_client_name = fields.Char(
        'Synced Client Name', readonly=True,
        help='Client name from the remote instance (shadow tasks only)',
    )
    x_fc_sync_client_phone = fields.Char(
        'Synced Client Phone', readonly=True,
        help='Client phone from the remote instance (shadow tasks only)',
    )

    client_display_name = fields.Char(
        compute='_compute_client_display', string='Client Name (Display)',
    )
    client_display_phone = fields.Char(
        compute='_compute_client_display', string='Client Phone (Display)',
    )

    x_fc_source_label = fields.Char(
        'Source', compute='_compute_is_shadow', store=True,
    )

    @api.depends('x_fc_sync_source')
    def _compute_is_shadow(self):
        local_id = self.env['ir.config_parameter'].sudo().get_param(
            'fusion_claims.sync_instance_id', '')
        for task in self:
            task.x_fc_is_shadow = bool(task.x_fc_sync_source)
            task.x_fc_source_label = task.x_fc_sync_source or local_id

    @api.depends('x_fc_sync_source', 'x_fc_sync_client_name',
                 'x_fc_sync_client_phone', 'partner_id')
    def _compute_client_display(self):
        for task in self:
            if task.x_fc_sync_source:
                task.client_display_name = task.x_fc_sync_client_name or task.name or ''
                task.client_display_phone = task.x_fc_sync_client_phone or ''
            else:
                task.client_display_name = task.partner_id.name if task.partner_id else ''
                task.client_display_phone = task.partner_id.phone if task.partner_id else ''

    technician_id = fields.Many2one(
        'res.users',
        string='Technician',
        required=True,
        tracking=True,
        domain="[('x_fc_is_field_staff', '=', True)]",
        help='Lead technician responsible for this task',
    )
    technician_name = fields.Char(
        related='technician_id.name',
        string='Technician Name',
        store=True,
    )
    additional_technician_ids = fields.Many2many(
        'res.users',
        'technician_task_additional_tech_rel',
        'task_id',
        'user_id',
        string='Additional Technicians',
        domain="[('x_fc_is_field_staff', '=', True)]",
        tracking=True,
        help='Additional technicians assigned to assist on this task',
    )
    all_technician_ids = fields.Many2many(
        'res.users',
        compute='_compute_all_technician_ids',
        string='All Technicians',
        help='Lead + additional technicians combined',
    )
    additional_tech_count = fields.Integer(
        compute='_compute_all_technician_ids',
        string='Extra Techs',
    )
    all_technician_names = fields.Char(
        compute='_compute_all_technician_ids',
        string='All Technician Names',
    )

    @api.depends('technician_id', 'additional_technician_ids')
    def _compute_all_technician_ids(self):
        for task in self:
            all_techs = task.technician_id | task.additional_technician_ids
            task.all_technician_ids = all_techs
            task.additional_tech_count = len(task.additional_technician_ids)
            task.all_technician_names = ', '.join(all_techs.mapped('name'))


    task_type = fields.Selection([
        ('delivery', 'Delivery'),
        ('repair', 'Repair'),
        ('pickup', 'Pickup'),
        ('troubleshoot', 'Troubleshooting'),
        ('assessment', 'Assessment'),
        ('installation', 'Installation'),
        ('maintenance', 'Maintenance'),
        ('ltc_visit', 'LTC Visit'),
        ('other', 'Other'),
    ], string='Task Type', required=True, default='delivery', tracking=True)

    # ------------------------------------------------------------------
    # SCHEDULING
    # ------------------------------------------------------------------
    scheduled_date = fields.Date(
        string='Scheduled Date',
        tracking=True,
        default=fields.Date.context_today,
        index=True,
    )
    time_start = fields.Float(
        string='Start Time',
        help='Start time in hours (e.g. 9.5 = 9:30 AM)',
        default=9.0,
    )
    time_end = fields.Float(
        string='End Time',
        help='End time in hours (e.g. 10.5 = 10:30 AM)',
        default=10.0,
    )
    time_start_display = fields.Char(
        string='Start',
        compute='_compute_time_displays',
    )
    time_end_display = fields.Char(
        string='End',
        compute='_compute_time_displays',
    )
    # Legacy 12h selection fields -- kept for DB compatibility, hidden on form
    time_start_12h = fields.Selection(
        selection='_get_time_selection',
        string='Start Time (12h)',
        compute='_compute_time_12h',
        inverse='_inverse_time_start_12h',
        store=True,
    )
    time_end_12h = fields.Selection(
        selection='_get_time_selection',
        string='End Time (12h)',
        compute='_compute_time_12h',
        inverse='_inverse_time_end_12h',
        store=True,
    )
    sequence = fields.Integer(
        string='Sequence',
        default=10,
        help='Order of task within the day',
    )
    duration_hours = fields.Float(
        string='Duration',
        default=1.0,
        help='Task duration in hours. Auto-calculates end time.',
    )

    # Task type -> default duration mapping
    TASK_TYPE_DURATIONS = {
        'delivery': 1.0,
        'repair': 2.0,
        'pickup': 0.5,
        'troubleshoot': 1.5,
        'assessment': 1.5,
        'installation': 2.0,
        'maintenance': 1.5,
        'ltc_visit': 3.0,
        'other': 1.0,
    }

    # Previous task travel warning banner
    prev_task_summary_html = fields.Html(
        string='Previous Task',
        compute='_compute_prev_task_summary',
        sanitize=False,
    )

    # Datetime fields for calendar view (computed from date + float time)
    datetime_start = fields.Datetime(
        string='Start',
        compute='_compute_datetimes',
        inverse='_inverse_datetime_start',
        store=True,
        help='Combined start datetime for calendar display',
    )
    datetime_end = fields.Datetime(
        string='End',
        compute='_compute_datetimes',
        inverse='_inverse_datetime_end',
        store=True,
        help='Combined end datetime for calendar display',
    )

    calendar_event_id = fields.Many2one(
        'calendar.event',
        string='Calendar Event',
        copy=False,
        ondelete='set null',
        help='Linked calendar event for external calendar sync',
    )

    # Schedule info helper for the form
    schedule_info_html = fields.Html(
        string='Schedule Info',
        compute='_compute_schedule_info',
        sanitize=False,
    )

    # ------------------------------------------------------------------
    # STATUS
    # ------------------------------------------------------------------
    status = fields.Selection([
        ('pending', 'Pending'),
        ('scheduled', 'Scheduled'),
        ('en_route', 'En Route'),
        ('in_progress', 'In Progress'),
        ('completed', 'Completed'),
        ('cancelled', 'Cancelled'),
        ('rescheduled', 'Rescheduled'),
    ], string='Status', default='scheduled', required=True, tracking=True, index=True)

    priority = fields.Selection([
        ('0', 'Normal'),
        ('1', 'Urgent'),
        ('2', 'Emergency'),
    ], string='Priority', default='0')

    color = fields.Integer(
        string='Color Index',
        compute='_compute_color',
    )

    # ------------------------------------------------------------------
    # CLIENT / ADDRESS
    # ------------------------------------------------------------------
    partner_id = fields.Many2one(
        'res.partner',
        string='Client Name',
        tracking=True,
        help='Client for this task',
    )
    partner_phone = fields.Char(
        related='partner_id.phone',
        string='Client Phone',
    )

    # Address fields - computed from shipping address or manually set
    address_partner_id = fields.Many2one(
        'res.partner',
        string='Task Address',
        help='Partner record containing the task address (usually shipping address)',
    )
    address_street = fields.Char(string='Street')
    address_street2 = fields.Char(string='Unit/Suite #')
    address_city = fields.Char(string='City')
    address_state_id = fields.Many2one('res.country.state', string='Province')
    address_zip = fields.Char(string='Postal Code')
    address_buzz_code = fields.Char(string='Buzz Code', help='Building buzzer code for entry')
    address_display = fields.Text(
        string='Full Address',
        compute='_compute_address_display',
    )

    # In-store flag -- uses company address instead of client address
    is_in_store = fields.Boolean(
        string='In Store',
        default=False,
        help='Task takes place at the store/office. Uses company address automatically.',
    )

    # Geocoding
    address_lat = fields.Float(string='Latitude', digits=(10, 7))
    address_lng = fields.Float(string='Longitude', digits=(10, 7))

    # ------------------------------------------------------------------
    # TASK DETAILS
    # ------------------------------------------------------------------
    description = fields.Text(
        string='Task Description',
        required=True,
        help='What needs to be done',
    )
    equipment_needed = fields.Text(
        string='Equipment / Materials Needed',
        help='Tools and materials the technician should bring',
    )
    pod_required = fields.Boolean(
        string='POD Required',
        default=False,
        help='Proof of Delivery signature required',
    )
    pod_signature = fields.Binary(
        string='POD Signature', attachment=True,
    )
    pod_client_name = fields.Char(string='POD Signer Name')
    pod_signature_date = fields.Date(string='POD Signature Date')
    pod_signed_by_user_id = fields.Many2one(
        'res.users', string='POD Collected By', readonly=True,
    )
    pod_signed_datetime = fields.Datetime(
        string='POD Collected At', readonly=True,
    )

    # ------------------------------------------------------------------
    # CLIENT EMAIL / REVIEW OPTIONS
    # ------------------------------------------------------------------
    x_fc_send_client_updates = fields.Boolean(
        string='Send Client Email Updates',
        default=True,
        help='Send automatic emails to the client when the technician is en route and when the task is completed',
    )
    x_fc_ask_google_review = fields.Boolean(
        string='Request Google Review',
        default=True,
        help='Include a Google review request in the completion email to the client',
    )
    x_fc_late_notified = fields.Boolean(
        string='Late Notification Sent',
        default=False,
        readonly=True,
        help='Internal flag: whether a late-arrival notification has already been sent for this task',
    )

    # ------------------------------------------------------------------
    # COMPLETION
    # ------------------------------------------------------------------
    completion_notes = fields.Html(
        string='Completion Notes',
        help='Notes from the technician about what was done',
    )
    completion_datetime = fields.Datetime(
        string='Completed At',
        tracking=True,
    )

    # GPS location captured at task actions
    started_latitude = fields.Float(
        string='Started Latitude', digits=(10, 7), readonly=True,
    )
    started_longitude = fields.Float(
        string='Started Longitude', digits=(10, 7), readonly=True,
    )
    completed_latitude = fields.Float(
        string='Completed Latitude', digits=(10, 7), readonly=True,
    )
    completed_longitude = fields.Float(
        string='Completed Longitude', digits=(10, 7), readonly=True,
    )
    action_latitude = fields.Float(
        string='Last Action Latitude', digits=(10, 7), readonly=True,
    )
    action_longitude = fields.Float(
        string='Last Action Longitude', digits=(10, 7), readonly=True,
    )
    action_location_accuracy = fields.Float(
        string='Location Accuracy (m)', readonly=True,
    )

    voice_note_audio = fields.Binary(
        string='Voice Recording',
        attachment=True,
    )
    voice_note_transcription = fields.Text(
        string='Voice Transcription',
    )

    # ------------------------------------------------------------------
    # TRAVEL
    # ------------------------------------------------------------------
    travel_time_minutes = fields.Integer(
        string='Travel Time (min)',
        help='Estimated travel time from previous task in minutes',
    )
    travel_distance_km = fields.Float(
        string='Travel Distance (km)',
        digits=(8, 1),
    )
    travel_origin = fields.Char(
        string='Travel From',
        help='Origin address for travel calculation',
    )
    previous_task_id = fields.Many2one(
        'fusion.technician.task',
        string='Previous Task',
        help='The task before this one in the schedule (for travel calculation)',
    )

    # ------------------------------------------------------------------
    # PUSH NOTIFICATION TRACKING
    # ------------------------------------------------------------------
    push_notified = fields.Boolean(
        string='Push Notified',
        default=False,
        help='Whether a push notification was sent for this task',
    )
    push_notified_datetime = fields.Datetime(
        string='Notified At',
    )

    # ------------------------------------------------------------------
    # COMPUTED FIELDS
    # ------------------------------------------------------------------

    # ------------------------------------------------------------------
    # SLOT AVAILABILITY HELPERS
    # ------------------------------------------------------------------

    def _find_next_available_slot(self, tech_id, date, preferred_start=9.0,
                                  duration=1.0, exclude_task_id=False,
                                  dest_lat=0, dest_lng=0):
        """Find the next available time slot for a technician on a given date.

        Scans all non-cancelled tasks for that tech+date, sorts them, and
        walks through the day (9 AM - 6 PM) looking for a gap that fits
        the requested duration PLUS travel time from the previous task.

        :param tech_id: res.users id of the technician
        :param date: date object for the day to check
        :param preferred_start: float hour to start looking from (default 9.0)
        :param duration: required slot length in hours (default 1.0)
        :param exclude_task_id: task id to exclude (when editing an existing task)
        :param dest_lat: latitude of the destination (new task location)
        :param dest_lng: longitude of the destination (new task location)
        :returns: (start_float, end_float) or (False, False) if fully booked
        """
        STORE_OPEN, STORE_CLOSE = self._get_store_hours()

        if not tech_id or not date:
            return (preferred_start, preferred_start + duration)

        domain = [
            '|',
            ('technician_id', '=', tech_id),
            ('additional_technician_ids', 'in', [tech_id]),
            ('scheduled_date', '=', date),
            ('status', 'not in', ['cancelled']),
        ]
        if exclude_task_id:
            domain.append(('id', '!=', exclude_task_id))

        booked = self.sudo().search(domain, order='time_start')

        # Build sorted list of (start, end, lat, lng) intervals
        intervals = []
        for b in booked:
            intervals.append((
                max(b.time_start, STORE_OPEN),
                min(b.time_end, STORE_CLOSE),
                b.address_lat or 0,
                b.address_lng or 0,
            ))

        def _travel_hours(from_lat, from_lng, to_lat, to_lng):
            """Calculate travel time in hours between two locations.
            Returns 0 if coordinates are missing. Rounds up to 15-min."""
            if not from_lat or not from_lng or not to_lat or not to_lng:
                return 0
            travel_min = self._quick_travel_time(
                from_lat, from_lng, to_lat, to_lng)
            if travel_min > 0:
                import math
                return math.ceil(travel_min / 15.0) * 0.25
            return 0

        def _travel_from_prev(iv_lat, iv_lng):
            """Travel from a previous booked task TO the new task."""
            return _travel_hours(iv_lat, iv_lng, dest_lat, dest_lng)

        def _travel_to_next(next_lat, next_lng):
            """Travel FROM the new task TO the next booked task."""
            return _travel_hours(dest_lat, dest_lng, next_lat, next_lng)

        def _check_gap_fits(cursor, dur, idx):
            """Check if a slot at 'cursor' for 'dur' hours fits before
            the interval at index 'idx' (accounting for travel TO that task)."""
            if idx >= len(intervals):
                return cursor + dur <= STORE_CLOSE
            next_start, _ne, next_lat, next_lng = intervals[idx]
            travel_fwd = _travel_to_next(next_lat, next_lng)
            return cursor + dur + travel_fwd <= next_start

        # Walk through gaps, starting from preferred_start
        cursor = max(preferred_start, STORE_OPEN)

        for i, (iv_start, iv_end, iv_lat, iv_lng) in enumerate(intervals):
            if cursor + duration <= iv_start:
                # Check travel time from new task end TO next booked task
                if _check_gap_fits(cursor, duration, i):
                    return (cursor, cursor + duration)
                # Not enough travel time -- try pushing start earlier or skip
                # If we can't fit here, fall through to jump past this interval
            # Jump past this booked interval + travel buffer from prev to new
            new_cursor = max(cursor, iv_end)
            travel = _travel_from_prev(iv_lat, iv_lng)
            new_cursor += travel
            # Snap to nearest 15 min
            new_cursor = round(new_cursor * 4) / 4
            cursor = new_cursor

        # Check gap after last interval (no next task, so no forward travel needed)
        if cursor + duration <= STORE_CLOSE:
            return (cursor, cursor + duration)

        # No gap found from preferred_start onward -- wrap and try from start
        if preferred_start > STORE_OPEN:
            cursor = STORE_OPEN
            for i, (iv_start, iv_end, iv_lat, iv_lng) in enumerate(intervals):
                if cursor + duration <= iv_start:
                    if _check_gap_fits(cursor, duration, i):
                        return (cursor, cursor + duration)
                new_cursor = max(cursor, iv_end)
                travel = _travel_from_prev(iv_lat, iv_lng)
                new_cursor += travel
                new_cursor = round(new_cursor * 4) / 4
                cursor = new_cursor
            if cursor + duration <= STORE_CLOSE:
                return (cursor, cursor + duration)

        return (False, False)

    def _get_available_gaps(self, tech_id, date, exclude_task_id=False):
        """Return a list of available (start, end) gaps for a technician on a date.

        Used by schedule_info_html to show green "available" badges.
        Considers tasks where the tech is either lead or additional.
        """
        STORE_OPEN, STORE_CLOSE = self._get_store_hours()

        if not tech_id or not date:
            return [(STORE_OPEN, STORE_CLOSE)]

        domain = [
            '|',
            ('technician_id', '=', tech_id),
            ('additional_technician_ids', 'in', [tech_id]),
            ('scheduled_date', '=', date),
            ('status', 'not in', ['cancelled']),
        ]
        if exclude_task_id:
            domain.append(('id', '!=', exclude_task_id))

        booked = self.sudo().search(domain, order='time_start')
        intervals = [(max(b.time_start, STORE_OPEN), min(b.time_end, STORE_CLOSE))
                     for b in booked]

        gaps = []
        cursor = STORE_OPEN
        for iv_start, iv_end in intervals:
            if cursor < iv_start:
                gaps.append((cursor, iv_start))
            cursor = max(cursor, iv_end)
        if cursor < STORE_CLOSE:
            gaps.append((cursor, STORE_CLOSE))
        return gaps

    @api.model
    def _get_time_selection(self):
        """Generate 12-hour time slots every 15 minutes, store hours only (9 AM - 6 PM)."""
        times = []
        for hour in range(9, 18):  # 9 AM to 5:45 PM
            for minute in (0, 15, 30, 45):
                float_val = hour + minute / 60.0
                key = f'{float_val:.2f}'
                period = 'AM' if hour < 12 else 'PM'
                display_hour = hour % 12 or 12
                label = f'{display_hour}:{minute:02d} {period}'
                times.append((key, label))
        # Add 6:00 PM as end-time option
        times.append(('18.00', '6:00 PM'))
        return times

    @api.depends('time_start', 'time_end')
    def _compute_time_12h(self):
        """Sync the 12h selection fields from the raw float values."""
        for task in self:
            task.time_start_12h = f'{(task.time_start or 9.0):.2f}'
            task.time_end_12h = f'{(task.time_end or 10.0):.2f}'

    def _inverse_time_start_12h(self):
        for task in self:
            if task.time_start_12h:
                task.time_start = float(task.time_start_12h)

    def _inverse_time_end_12h(self):
        for task in self:
            if task.time_end_12h:
                task.time_end = float(task.time_end_12h)

    @api.depends('time_start', 'time_end')
    def _compute_time_displays(self):
        """Convert float hours to readable time strings."""
        for task in self:
            task.time_start_display = self._float_to_time_str(task.time_start)
            task.time_end_display = self._float_to_time_str(task.time_end)

    @api.onchange('task_type')
    def _onchange_task_type_duration(self):
        """Set default duration based on task type."""
        if self.task_type:
            self.duration_hours = self.TASK_TYPE_DURATIONS.get(self.task_type, 1.0)
            # Also recalculate end time
            if self.time_start:
                _open, close = self._get_store_hours()
                self.time_end = min(self.time_start + self.duration_hours, close)

    @api.onchange('time_start', 'duration_hours')
    def _onchange_compute_end_time(self):
        """Auto-compute end time from start + duration. Also run overlap check."""
        if self.time_start and self.duration_hours:
            _open, close = self._get_store_hours()
            new_end = min(self.time_start + self.duration_hours, close)
            self.time_end = new_end
        # Run overlap snap if we have enough data
        if self.technician_id and self.scheduled_date and self.time_start and self.time_end:
            result = self._snap_if_overlap()
            if result:
                return result

    @api.depends('scheduled_date', 'time_start', 'time_end')
    def _compute_datetimes(self):
        """Combine date + float time into proper Datetime fields for calendar.
        time_start/time_end are LOCAL hours; datetime_start/end must be UTC for Odoo."""
        import pytz
        user_tz = pytz.timezone(self.env.user.tz or 'UTC')
        for task in self:
            if task.scheduled_date:
                # Build local datetime, then convert to UTC
                base = dt_datetime.combine(task.scheduled_date, dt_datetime.min.time())
                store_open, _close = task._get_store_hours()
                local_start = user_tz.localize(base + timedelta(hours=task.time_start or store_open))
                local_end = user_tz.localize(base + timedelta(hours=task.time_end or (store_open + 1.0)))
                task.datetime_start = local_start.astimezone(pytz.utc).replace(tzinfo=None)
                task.datetime_end = local_end.astimezone(pytz.utc).replace(tzinfo=None)
            else:
                task.datetime_start = False
                task.datetime_end = False

    def _inverse_datetime_start(self):
        """When datetime_start is changed (e.g. from calendar drag), update date + time."""
        import pytz
        user_tz = pytz.timezone(self.env.user.tz or 'UTC')
        for task in self:
            if task.datetime_start:
                local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
                task.scheduled_date = local_dt.date()
                task.time_start = local_dt.hour + local_dt.minute / 60.0

    def _inverse_datetime_end(self):
        """When datetime_end is changed (e.g. from calendar resize), update time_end."""
        import pytz
        user_tz = pytz.timezone(self.env.user.tz or 'UTC')
        for task in self:
            if task.datetime_end:
                local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
                task.time_end = local_dt.hour + local_dt.minute / 60.0

    @api.depends('technician_id', 'scheduled_date')
    def _compute_schedule_info(self):
        """Show booked + available time slots for the technician on the selected date."""
        for task in self:
            if not task.technician_id or not task.scheduled_date:
                task.schedule_info_html = ''
                continue

            exclude_id = task.id if task.id else 0
            # Find other tasks for the same technician+date (lead or additional)
            others = self.sudo().search([
                '|',
                ('technician_id', '=', task.technician_id.id),
                ('additional_technician_ids', 'in', [task.technician_id.id]),
                ('scheduled_date', '=', task.scheduled_date),
                ('status', 'not in', ['cancelled']),
                ('id', '!=', exclude_id),
            ], order='time_start')

            if not others:
                s_open, s_close = self._get_store_hours()
                open_str = self._float_to_time_str(s_open)
                close_str = self._float_to_time_str(s_close)
                task.schedule_info_html = Markup(
                    f'<div class="text-success" style="padding: 4px 0;">'
                    f'<i class="fa fa-check-circle"/> All slots available ({open_str} - {close_str})</div>'
                )
                continue

            # Booked badges
            booked_lines = []
            for o in others:
                start_str = self._float_to_time_str(o.time_start)
                end_str = self._float_to_time_str(o.time_end)
                type_label = dict(self._fields['task_type'].selection).get(o.task_type, o.task_type)
                client_name = o.partner_id.name or ''
                booked_lines.append(
                    f'<span class="badge text-bg-danger" style="margin:2px;">'
                    f'{start_str} - {end_str} ({type_label}{" - " + client_name if client_name else ""})'
                    f'</span>'
                )

            # Available gaps badges
            gaps = self._get_available_gaps(
                task.technician_id.id, task.scheduled_date,
                exclude_task_id=exclude_id,
            )
            avail_lines = []
            for g_start, g_end in gaps:
                # Only show gaps >= 15 min
                if g_end - g_start >= 0.25:
                    avail_lines.append(
                        f'<span class="badge text-bg-success" style="margin:2px;">'
                        f'{self._float_to_time_str(g_start)} - {self._float_to_time_str(g_end)}'
                        f'</span>'
                    )

            html_parts = [
                '<div style="padding:4px 0;">',
                '<strong class="text-danger"><i class="fa fa-ban"/> Booked:</strong> ',
                ' '.join(booked_lines),
            ]
            if avail_lines:
                html_parts.append(
                    '<br/><strong class="text-success"><i class="fa fa-check-circle"/> '
                    'Available:</strong> '
                    + ' '.join(avail_lines)
                )
            elif not avail_lines:
                html_parts.append(
                    '<br/><span class="text-warning"><i class="fa fa-warning"/> '
                    'Fully booked</span>'
                )
            html_parts.append('</div>')

            task.schedule_info_html = Markup(''.join(html_parts))

    @api.depends('technician_id', 'scheduled_date', 'time_start',
                 'address_lat', 'address_lng', 'address_street')
    def _compute_prev_task_summary(self):
        """Show previous task info + travel time warning with color coding."""
        for task in self:
            if not task.technician_id or not task.scheduled_date:
                task.prev_task_summary_html = ''
                continue

            exclude_id = task.id if task.id else 0
            # Find the task that ends just before this one starts (lead or additional)
            prev_tasks = self.sudo().search([
                '|',
                ('technician_id', '=', task.technician_id.id),
                ('additional_technician_ids', 'in', [task.technician_id.id]),
                ('scheduled_date', '=', task.scheduled_date),
                ('status', 'not in', ['cancelled']),
                ('id', '!=', exclude_id),
                ('time_end', '<=', task.time_start or 99.0),
            ], order='time_end desc', limit=1)

            if not prev_tasks:
                # Check if this is the first task of the day -- show start location info
                task.prev_task_summary_html = Markup(
                    '<div class="alert alert-info mb-0 p-2" style="font-size:0.9em;">'
                    '<i class="fa fa-info-circle"/> First task of the day -- '
                    'travel calculated from start location.</div>'
                )
                continue

            prev = prev_tasks[0]
            prev_start = self._float_to_time_str(prev.time_start)
            prev_end = self._float_to_time_str(prev.time_end)
            type_label = dict(self._fields['task_type'].selection).get(
                prev.task_type, prev.task_type or '')
            client_name = prev.partner_id.name or ''
            prev_addr = prev.address_display or 'No address'

            # Calculate gap between prev task end and this task start
            s_open, _s_close = self._get_store_hours()
            gap_hours = (task.time_start or s_open) - (prev.time_end or s_open)
            gap_minutes = int(gap_hours * 60)

            # Try to get travel time if both have coordinates
            travel_minutes = 0
            travel_text = ''
            if (prev.address_lat and prev.address_lng and
                    task.address_lat and task.address_lng):
                travel_minutes = self._quick_travel_time(
                    prev.address_lat, prev.address_lng,
                    task.address_lat, task.address_lng,
                )
                if travel_minutes > 0:
                    travel_text = f'{travel_minutes} min drive'
                else:
                    travel_text = 'Could not calculate travel time'
            elif prev.address_street and task.address_street:
                travel_text = 'Save to calculate travel time'
            else:
                travel_text = 'Address missing -- cannot calculate travel'

            # Determine color coding
            if travel_minutes > 0 and gap_minutes >= travel_minutes:
                bg_class = 'alert-success'  # Green -- enough time
                icon = 'fa-check-circle'
                status_text = (
                    f'{gap_minutes} min gap -- enough travel time '
                    f'(~{travel_minutes} min drive)'
                )
            elif travel_minutes > 0 and gap_minutes > 0:
                bg_class = 'alert-warning'  # Yellow -- tight
                icon = 'fa-exclamation-triangle'
                status_text = (
                    f'{gap_minutes} min gap -- tight! '
                    f'Travel is ~{travel_minutes} min drive'
                )
            elif travel_minutes > 0 and gap_minutes <= 0:
                bg_class = 'alert-danger'  # Red -- impossible
                icon = 'fa-times-circle'
                status_text = (
                    f'No gap! Previous task ends at {prev_end}. '
                    f'Travel is ~{travel_minutes} min drive'
                )
            else:
                bg_class = 'alert-info'  # Blue -- no travel data yet
                icon = 'fa-info-circle'
                status_text = travel_text

            html = (
                f'<div class="alert {bg_class} mb-0 p-2" '
                f'style="font-size:0.9em;">'
                f'<i class="fa {icon}"/> '
                f'<strong>Previous:</strong> {prev.name} '
                f'({type_label}) {prev_start} - {prev_end}'
                f'{" -- " + client_name if client_name else ""}'
                f'<br/>'
                f'<small><i class="fa fa-map-marker"/> {prev_addr}</small>'
                f'<br/>'
                f'<small><i class="fa fa-car"/> {status_text}</small>'
                f'</div>'
            )
            task.prev_task_summary_html = Markup(html)

    def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
        """Quick inline travel time calculation using Google Distance Matrix API.
        Returns travel time in minutes, or 0 if unavailable."""
        try:
            api_key = self.env['ir.config_parameter'].sudo().get_param(
                'fusion_claims.google_maps_api_key', '')
            if not api_key:
                return 0

            url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
            params = {
                'origins': f'{from_lat},{from_lng}',
                'destinations': f'{to_lat},{to_lng}',
                'mode': 'driving',
                'avoid': 'tolls',
                'departure_time': 'now',
                'key': api_key,
            }
            resp = requests.get(url, params=params, timeout=5)
            data = resp.json()
            if data.get('status') == 'OK':
                elements = data['rows'][0]['elements'][0]
                if elements.get('status') == 'OK':
                    # Use duration_in_traffic if available, else duration
                    duration = elements.get(
                        'duration_in_traffic', elements.get('duration', {}))
                    seconds = duration.get('value', 0)
                    return max(1, int(seconds / 60))
        except Exception:
            _logger.warning('Failed to calculate travel time', exc_info=True)
        return 0

    @api.depends('status')
    def _compute_color(self):
        color_map = {
            'pending': 5,       # purple
            'scheduled': 0,     # grey
            'en_route': 4,      # blue
            'in_progress': 2,   # orange
            'completed': 10,    # green
            'cancelled': 1,     # red
            'rescheduled': 3,   # yellow
        }
        for task in self:
            task.color = color_map.get(task.status, 0)

    @api.depends('address_street', 'address_street2', 'address_city',
                 'address_state_id', 'address_zip')
    def _compute_address_display(self):
        for task in self:
            street = task.address_street or ''
            # If the street field already contains a full address (has a comma),
            # use it directly -- Google Places stores the formatted address here.
            if ',' in street and (
                (task.address_city and task.address_city in street) or
                (task.address_zip and task.address_zip in street)
            ):
                # Street already has full address; just append unit if separate
                if task.address_street2 and task.address_street2 not in street:
                    task.address_display = f"{street}, {task.address_street2}"
                else:
                    task.address_display = street
            else:
                # Build from components (manual entry or legacy data)
                parts = [
                    street,
                    task.address_street2,
                    task.address_city,
                    task.address_state_id.name if task.address_state_id else '',
                    task.address_zip,
                ]
                task.address_display = ', '.join([p for p in parts if p])

    # ------------------------------------------------------------------
    # ONCHANGE - Auto-fill address from client
    # ------------------------------------------------------------------

    @api.onchange('is_in_store')
    def _onchange_is_in_store(self):
        """Auto-fill company address when task is marked as in-store."""
        if self.is_in_store:
            company_partner = self.env.company.partner_id
            if company_partner and company_partner.street:
                self._fill_address_from_partner(company_partner)
            else:
                self.address_street = self.env.company.name or 'In Store'

    @api.onchange('partner_id')
    def _onchange_partner_id(self):
        """Auto-fill address fields from the selected client's address."""
        if self.is_in_store:
            return
        if self.partner_id:
            addr = self.partner_id
            self.address_partner_id = addr.id
            self.address_street = addr.street or ''
            self.address_street2 = addr.street2 or ''
            self.address_city = addr.city or ''
            self.address_state_id = addr.state_id.id if addr.state_id else False
            self.address_zip = addr.zip or ''
            self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
            self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0

    def _fill_address_from_partner(self, addr):
        """Populate address fields from a partner record."""
        if not addr:
            return
        self.address_partner_id = addr.id
        self.address_street = addr.street or ''
        self.address_street2 = addr.street2 or ''
        self.address_city = addr.city or ''
        self.address_state_id = addr.state_id.id if addr.state_id else False
        self.address_zip = addr.zip or ''
        self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
        self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0

    # ------------------------------------------------------------------
    # CONSTRAINTS + VALIDATION
    # ------------------------------------------------------------------

    @api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store')
    def _check_address_required(self):
        """Non-in-store tasks must have a geocoded address."""
        for task in self:
            if task.x_fc_sync_source:
                continue
            if task.is_in_store:
                continue
            if not task.address_street:
                raise ValidationError(_(
                    "A valid address is required. If this task is at the store, "
                    "please check the 'In Store' option."
                ))

    @api.constrains('technician_id', 'additional_technician_ids',
                     'scheduled_date', 'time_start', 'time_end')
    def _check_no_overlap(self):
        """Prevent overlapping bookings for the same technician on the same date.

        Checks both the lead technician and all additional technicians.
        """
        for task in self:
            if task.status == 'cancelled':
                continue
            if task.x_fc_sync_source:
                continue
            # Validate time range
            if task.time_start >= task.time_end:
                raise ValidationError(_("Start time must be before end time."))
            # Validate store hours
            s_open, s_close = self._get_store_hours()
            if task.time_start < s_open or task.time_end > s_close:
                open_str = self._float_to_time_str(s_open)
                close_str = self._float_to_time_str(s_close)
                raise ValidationError(_(
                    "Tasks must be scheduled within store hours (%s - %s)."
                ) % (open_str, close_str))
            # Validate not in the past (only for new/scheduled local tasks)
            if task.status == 'scheduled' and task.scheduled_date and not task.x_fc_sync_source:
                local_now = self._local_now()
                today = local_now.date()
                if task.scheduled_date < today:
                    raise ValidationError(_("Cannot schedule tasks in the past."))
                if task.scheduled_date == today:
                    current_hour = local_now.hour + local_now.minute / 60.0
                    if task.time_start < current_hour:
                        pass  # Allow editing existing tasks that started earlier today
            # Check overlap for lead + additional technicians
            all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
            for tech_id in all_tech_ids:
                tech_name = self.env['res.users'].browse(tech_id).name
                overlapping = self.sudo().search([
                    '|',
                    ('technician_id', '=', tech_id),
                    ('additional_technician_ids', 'in', [tech_id]),
                    ('scheduled_date', '=', task.scheduled_date),
                    ('status', 'not in', ['cancelled']),
                    ('id', '!=', task.id),
                    ('time_start', '<', task.time_end),
                    ('time_end', '>', task.time_start),
                ], limit=1)
                if overlapping:
                    start_str = self._float_to_time_str(overlapping.time_start)
                    end_str = self._float_to_time_str(overlapping.time_end)
                    raise ValidationError(_(
                        "%(tech)s has a time conflict with %(task)s "
                        "(%(start)s - %(end)s). Please choose a different time.",
                        tech=tech_name,
                        task=overlapping.name,
                        start=start_str,
                        end=end_str,
                    ))

            # Check travel time gaps for lead technician only
            # (additional techs travel with the lead, same destination)
            next_task = self.sudo().search([
                '|',
                ('technician_id', '=', task.technician_id.id),
                ('additional_technician_ids', 'in', [task.technician_id.id]),
                ('scheduled_date', '=', task.scheduled_date),
                ('status', 'not in', ['cancelled']),
                ('id', '!=', task.id),
                ('time_start', '>=', task.time_end),
            ], order='time_start', limit=1)
            if next_task and task.address_lat and task.address_lng and \
                    next_task.address_lat and next_task.address_lng:
                travel_min = self._quick_travel_time(
                    task.address_lat, task.address_lng,
                    next_task.address_lat, next_task.address_lng,
                )
                if travel_min > 0:
                    gap_min = int((next_task.time_start - task.time_end) * 60)
                    if gap_min < travel_min:
                        raise ValidationError(_(
                            "Not enough travel time to the next task!\n\n"
                            "This task ends at %(end)s, and %(next)s starts "
                            "at %(next_start)s (%(gap)d min gap).\n"
                            "Travel time is ~%(travel)d minutes.\n\n"
                            "Please allow at least %(travel)d minutes between tasks.",
                            end=self._float_to_time_str(task.time_end),
                            next=next_task.name,
                            next_start=self._float_to_time_str(next_task.time_start),
                            gap=gap_min,
                            travel=travel_min,
                        ))

            prev_task = self.sudo().search([
                '|',
                ('technician_id', '=', task.technician_id.id),
                ('additional_technician_ids', 'in', [task.technician_id.id]),
                ('scheduled_date', '=', task.scheduled_date),
                ('status', 'not in', ['cancelled']),
                ('id', '!=', task.id),
                ('time_end', '<=', task.time_start),
            ], order='time_end desc', limit=1)
            if prev_task and task.address_lat and task.address_lng and \
                    prev_task.address_lat and prev_task.address_lng:
                travel_min = self._quick_travel_time(
                    prev_task.address_lat, prev_task.address_lng,
                    task.address_lat, task.address_lng,
                )
                if travel_min > 0:
                    gap_min = int((task.time_start - prev_task.time_end) * 60)
                    if gap_min < travel_min:
                        raise ValidationError(_(
                            "Not enough travel time from the previous task!\n\n"
                            "%(prev)s ends at %(prev_end)s, and this task starts "
                            "at %(start)s (%(gap)d min gap).\n"
                            "Travel time is ~%(travel)d minutes.\n\n"
                            "Please allow at least %(travel)d minutes between tasks.",
                            prev=prev_task.name,
                            prev_end=self._float_to_time_str(prev_task.time_end),
                            start=self._float_to_time_str(task.time_start),
                            gap=gap_min,
                            travel=travel_min,
                        ))

    @api.onchange('technician_id', 'scheduled_date')
    def _onchange_technician_date_autoset(self):
        """Auto-set start/end time to the first available slot when tech+date change."""
        if not self.technician_id or not self.scheduled_date:
            return
        exclude_id = self._origin.id if self._origin else False
        duration = self.duration_hours or 1.0
        s_open, _s_close = self._get_store_hours()
        preferred = self.time_start or s_open
        start, end = self._find_next_available_slot(
            self.technician_id.id,
            self.scheduled_date,
            preferred_start=preferred,
            duration=duration,
            exclude_task_id=exclude_id,
            dest_lat=self.address_lat or 0,
            dest_lng=self.address_lng or 0,
        )
        if start is not False:
            self.time_start = start
            self.time_end = end
            self.duration_hours = end - start
        else:
            return {'warning': {
                'title': _('Fully Booked'),
                'message': _(
                    '%s is fully booked on %s. No available slots.'
                ) % (self.technician_id.name,
                     self.scheduled_date.strftime('%B %d, %Y')),
            }}

    def _snap_if_overlap(self):
        """Check if current time_start/time_end overlaps with another task.
        If so, auto-snap to the next available slot and return a warning dict."""
        if not self.technician_id or not self.scheduled_date or not self.time_start:
            return None
        exclude_id = self._origin.id if self._origin else 0
        duration = max(self.duration_hours or 1.0, 0.25)

        all_tech_ids = (self.technician_id | self.additional_technician_ids).ids
        overlapping = self.sudo().search([
            '|',
            ('technician_id', 'in', all_tech_ids),
            ('additional_technician_ids', 'in', all_tech_ids),
            ('scheduled_date', '=', self.scheduled_date),
            ('status', 'not in', ['cancelled']),
            ('id', '!=', exclude_id),
            ('time_start', '<', self.time_end),
            ('time_end', '>', self.time_start),
        ], limit=1)
        if overlapping:
            conflict_name = overlapping.name
            conflict_start = self._float_to_time_str(overlapping.time_start)
            conflict_end = self._float_to_time_str(overlapping.time_end)
            start, end = self._find_next_available_slot(
                self.technician_id.id,
                self.scheduled_date,
                preferred_start=self.time_start,
                duration=duration,
                exclude_task_id=exclude_id,
                dest_lat=self.address_lat or 0,
                dest_lng=self.address_lng or 0,
            )
            if start is not False:
                new_start_str = self._float_to_time_str(start)
                new_end_str = self._float_to_time_str(end)
                self.time_start = start
                self.time_end = end
                self.duration_hours = end - start
                return {'warning': {
                    'title': _('Moved to Available Slot'),
                    'message': _(
                        'The selected time conflicts with %s (%s - %s).\n'
                        'Automatically moved to: %s - %s.'
                    ) % (conflict_name, conflict_start, conflict_end,
                         new_start_str, new_end_str),
                }}
            else:
                return {'warning': {
                    'title': _('No Available Slots'),
                    'message': _(
                        'The selected time conflicts with %s (%s - %s) '
                        'and no other slots are available on this day.'
                    ) % (conflict_name, conflict_start, conflict_end),
                }}
        return None

    # ------------------------------------------------------------------
    # DEFAULT_GET - Calendar pre-fill
    # ------------------------------------------------------------------

    def _snap_to_quarter(self, hour_float):
        """Round a float hour to the nearest 15-minute slot and clamp to store hours."""
        s_open, s_close = self._get_store_hours()
        snapped = round(hour_float * 4) / 4
        return max(s_open, min(s_close, snapped))

    @api.model
    def default_get(self, fields_list):
        """Handle calendar time range selection: pre-fill date + times from context."""
        res = super().default_get(fields_list)
        ctx = self.env.context

        # Set duration default based on task type from context
        task_type = ctx.get('default_task_type', res.get('task_type', 'delivery'))
        if 'duration_hours' not in res or not res.get('duration_hours'):
            res['duration_hours'] = self.TASK_TYPE_DURATIONS.get(task_type, 1.0)

        # When user clicks a time range on the calendar, Odoo passes
        # default_datetime_start/end in UTC
        dt_start_utc = None
        dt_end_utc = None
        if ctx.get('default_datetime_start'):
            try:
                dt_start_utc = fields.Datetime.from_string(ctx['default_datetime_start'])
            except (ValueError, TypeError):
                pass
        if ctx.get('default_datetime_end'):
            try:
                dt_end_utc = fields.Datetime.from_string(ctx['default_datetime_end'])
            except (ValueError, TypeError):
                pass

        if dt_start_utc or dt_end_utc:
            import pytz
            user_tz = pytz.timezone(self.env.user.tz or 'UTC')

            if dt_start_utc:
                dt_start_local = pytz.utc.localize(dt_start_utc).astimezone(user_tz)
                res['scheduled_date'] = dt_start_local.date()
                start_float = self._snap_to_quarter(
                    dt_start_local.hour + dt_start_local.minute / 60.0)
                res['time_start'] = start_float

            if dt_end_utc:
                dt_end_local = pytz.utc.localize(dt_end_utc).astimezone(user_tz)
                end_float = self._snap_to_quarter(
                    dt_end_local.hour + dt_end_local.minute / 60.0)
                if 'time_start' in res and end_float <= res['time_start']:
                    end_float = res['time_start'] + 1.0
                res['time_end'] = end_float
                # Compute duration from the calendar drag
                if 'time_start' in res:
                    res['duration_hours'] = end_float - res['time_start']

        # Always compute end from start + duration if not already set
        if 'time_end' not in res and 'time_start' in res and 'duration_hours' in res:
            _open, close = self._get_store_hours()
            res['time_end'] = min(
                res['time_start'] + res['duration_hours'], close)

        return res

    # ------------------------------------------------------------------
    # CRUD OVERRIDES
    # ------------------------------------------------------------------

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            if vals.get('name', _('New')) == _('New'):
                vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
            if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
                vals['x_fc_sync_uuid'] = str(uuid.uuid4())
            # In-store tasks: auto-fill company address
            if vals.get('is_in_store') and not vals.get('address_street'):
                company_partner = self.env.company.partner_id
                if company_partner and company_partner.street:
                    self._fill_address_vals(vals, company_partner)
                else:
                    vals['address_street'] = self.env.company.name or 'In Store'
            # Hook: fill address from linked records (overridden by fusion_claims)
            self._create_vals_fill(vals)
        records = super().create(vals_list)
        # Hook: post-create actions for linked records
        records._on_create_post_actions()
        # Auto-calculate travel times for the full day chain
        if not self.env.context.get('skip_travel_recalc'):
            records._recalculate_day_travel_chains()
        # Send "Appointment Scheduled" email
        for rec in records:
            rec._send_task_scheduled_email()
        # Push new local tasks to remote instances
        local_records = records.filtered(lambda r: not r.x_fc_sync_source)
        if local_records and not self.env.context.get('skip_task_sync'):
            self.env['fusion.task.sync.config']._push_tasks(local_records, 'create')
        # Sync to calendar for external calendar integrations
        records._sync_calendar_event()
        return records

    def _create_vals_fill(self, vals):
        """Hook: fill address from linked records during create.

        Base implementation fills from partner_id. Override in fusion_claims
        to also fill from sale_order_id or purchase_order_id.
        """
        if vals.get('partner_id') and not vals.get('address_street'):
            partner = self.env['res.partner'].browse(vals['partner_id'])
            if partner.street:
                self._fill_address_vals(vals, partner)

    def _on_create_post_actions(self):
        """Hook: post-create side-effects for linked records.

        Override in fusion_claims to post chatter messages to linked orders,
        mark sale orders as ready for delivery, etc.
        """
        pass

    def write(self, vals):
        if self.env.context.get('skip_travel_recalc'):
            res = super().write(vals)
            if ('status' in vals and vals['status'] in ('completed', 'cancelled')
                    and not self.env.context.get('skip_task_sync')):
                shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
                if shadow_records:
                    self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
                local_records = self.filtered(lambda r: not r.x_fc_sync_source)
                if local_records:
                    self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
            return res

        # Safety: ensure time_end is consistent when start/duration change
        # but time_end wasn't sent (readonly field in view may not save)
        if ('time_start' in vals or 'duration_hours' in vals) and 'time_end' not in vals:
            _open, close = self._get_store_hours()
            start = vals.get('time_start', self[:1].time_start if len(self) == 1 else 9.0)
            dur = vals.get('duration_hours', self[:1].duration_hours if len(self) == 1 else 1.0) or 1.0
            vals['time_end'] = min(start + dur, close)

        # Detect reschedule mode: capture old values BEFORE write
        reschedule_mode = self.env.context.get('reschedule_mode')
        old_schedule = {}
        schedule_fields = {'scheduled_date', 'time_start', 'time_end',
                           'duration_hours', 'technician_id'}
        schedule_changed = schedule_fields & set(vals.keys())
        if reschedule_mode and schedule_changed:
            for task in self:
                old_schedule[task.id] = {
                    'date': task.scheduled_date,
                    'time_start': task.time_start,
                    'time_end': task.time_end,
                }

        # Capture old tech+date combos BEFORE write for travel recalc
        travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
                         'scheduled_date', 'sequence', 'time_start', 'technician_id',
                         'additional_technician_ids'}
        needs_travel_recalc = travel_fields & set(vals.keys())
        old_combos = set()
        if needs_travel_recalc:
            for t in self:
                old_combos.add((t.technician_id.id, t.scheduled_date))
                for tech in t.additional_technician_ids:
                    old_combos.add((tech.id, t.scheduled_date))
        res = super().write(vals)
        if needs_travel_recalc:
            new_combos = set()
            for t in self:
                new_combos.add((t.technician_id.id, t.scheduled_date))
                for tech in t.additional_technician_ids:
                    new_combos.add((tech.id, t.scheduled_date))
            all_combos = old_combos | new_combos
            self._recalculate_combos_travel(all_combos)

        # After write: send reschedule email if schedule actually changed
        if reschedule_mode and old_schedule:
            for task in self:
                old = old_schedule.get(task.id, {})
                if old and (
                    old['date'] != task.scheduled_date
                    or abs(old['time_start'] - task.time_start) > 0.01
                    or abs(old['time_end'] - task.time_end) > 0.01
                ):
                    task._post_status_message('rescheduled')
                    task._send_task_rescheduled_email(
                        old_date=old['date'],
                        old_start=old['time_start'],
                        old_end=old['time_end'],
                    )
        # Push updates to remote instances for local tasks
        sync_fields = {'technician_id', 'additional_technician_ids',
                       'scheduled_date', 'time_start', 'time_end',
                       'duration_hours', 'status', 'task_type', 'address_street',
                       'address_city', 'address_zip', 'address_lat', 'address_lng',
                       'partner_id'}
        if sync_fields & set(vals.keys()) and not self.env.context.get('skip_task_sync'):
            local_records = self.filtered(lambda r: not r.x_fc_sync_source)
            if local_records:
                self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
            if 'status' in vals and vals['status'] in ('completed', 'cancelled'):
                shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
                if shadow_records:
                    self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
        # Re-sync calendar event when schedule fields change
        cal_fields = {'scheduled_date', 'time_start', 'time_end',
                      'duration_hours', 'technician_id', 'task_type',
                      'partner_id', 'address_street', 'address_city', 'notes'}
        if cal_fields & set(vals.keys()):
            self._sync_calendar_event()
        return res

    def _sync_calendar_event(self):
        """Create or update a linked calendar.event for external calendar sync.

        Only syncs tasks that have a scheduled date and an assigned technician.
        Uses sudo() because portal users should not need calendar write access.
        Falls back gracefully if external calendar validation fails (e.g.
        Microsoft Calendar requires the organizer to have Outlook synced).
        """
        CalendarEvent = self.env['calendar.event'].sudo()
        for task in self:
            if not task.datetime_start or not task.datetime_end or not task.technician_id:
                if task.calendar_event_id:
                    task.calendar_event_id.unlink()
                    task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False})
                continue

            order = task._get_linked_order()
            partner = task.partner_id or (order.partner_id if order else False)
            client_name = partner.name if partner else ''
            type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type or '')

            event_name = f"{type_label}: {client_name}" if client_name else f"{type_label} - {task.name}"
            location_parts = [task.address_street, task.address_city]
            location = ', '.join(p for p in location_parts if p) or ''

            description_parts = []
            if order:
                description_parts.append(f"Ref: {order.name}")
            if task.description:
                description_parts.append(task.description)

            vals = {
                'name': event_name,
                'start': task.datetime_start,
                'stop': task.datetime_end,
                'user_id': task.technician_id.id,
                'location': location,
                'partner_ids': [(6, 0, [task.technician_id.partner_id.id])],
                'show_as': 'busy',
                'description': '\n'.join(description_parts),
            }

            try:
                if task.calendar_event_id:
                    task.calendar_event_id.write(vals)
                else:
                    event = CalendarEvent.create(vals)
                    task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
            except Exception as e:
                _logger.warning(
                    "Calendar sync skipped for task %s (tech=%s): %s",
                    task.name, task.technician_id.name, e,
                )
                if not task.calendar_event_id:
                    try:
                        vals['user_id'] = self.env.uid
                        event = CalendarEvent.create(vals)
                        task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
                    except Exception:
                        pass

    @api.model
    def _fill_address_vals(self, vals, partner):
        """Helper to fill address vals dict from a partner record."""
        vals.update({
            'address_partner_id': partner.id,
            'address_street': partner.street or '',
            'address_street2': partner.street2 or '',
            'address_city': partner.city or '',
            'address_state_id': partner.state_id.id if partner.state_id else False,
            'address_zip': partner.zip or '',
            'address_lat': partner.x_fc_latitude if hasattr(partner, 'x_fc_latitude') else 0,
            'address_lng': partner.x_fc_longitude if hasattr(partner, 'x_fc_longitude') else 0,
        })

    def _post_task_created_to_linked_order(self):
        """Hook: post task creation notice to linked order chatter.
        Override in fusion_claims."""
        pass

    def _mark_sale_order_ready_for_delivery(self):
        """Hook: mark linked sale orders as ready for delivery.
        Override in fusion_claims."""
        pass

    def _recalculate_day_travel_chains(self):
        """Recalculate travel for all tech+date combos affected by these tasks.

        Includes combos for additional technicians so their schedules update too.
        """
        combos = set()
        for t in self:
            if not t.scheduled_date:
                continue
            if t.technician_id:
                combos.add((t.technician_id.id, t.scheduled_date))
            for tech in t.additional_technician_ids:
                combos.add((tech.id, t.scheduled_date))
        self._recalculate_combos_travel(combos)

    def _get_technician_start_address(self, tech_id):
        """Get the start address for a technician.

        Priority:
          1. Technician's personal x_fc_start_address (if set)
          2. Company default HQ address (fusion_claims.technician_start_address)
        Returns the address string or ''.
        """
        tech_user = self.env['res.users'].sudo().browse(tech_id)
        if tech_user.exists() and tech_user.x_fc_start_address:
            return tech_user.x_fc_start_address.strip()
        # Fallback to company default
        return (self.env['ir.config_parameter'].sudo()
                .get_param('fusion_claims.technician_start_address', '') or '').strip()

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

    def _recalculate_combos_travel(self, combos):
        """Recalculate travel for a set of (tech_id, date) combinations.

        Start-point priority per technician (for today only):
          1. Latest GPS location (from periodic tracking or task actions)
          2. Actual GPS from today's fusion_clock check-in
          3. Personal start address (x_fc_start_address)
          4. Company default HQ address
        For future dates, only 3 and 4 apply.
        """
        ICP = self.env['ir.config_parameter'].sudo()
        enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False)
        if not enabled:
            return
        api_key = self._get_google_maps_api_key()

        start_coords_cache = {}
        today = self._local_now().date()
        today_str = str(today)

        today_tech_ids = {tid for tid, d in combos
                         if tid and str(d) == today_str}
        clock_locations = {}
        if today_tech_ids:
            clock_locations = self._get_clock_in_locations(today_tech_ids, today)

        for tech_id, date in combos:
            if not tech_id or not date:
                continue

            cache_key = (tech_id, str(date))
            if cache_key not in start_coords_cache:
                if str(date) == today_str:
                    # Try latest GPS first (most accurate real-time position)
                    lat, lng = self._get_tech_current_location(tech_id)
                    if lat and lng:
                        start_coords_cache[cache_key] = (lat, lng)
                    elif tech_id in clock_locations:
                        cl = clock_locations[tech_id]
                        start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
                    else:
                        addr = self._get_technician_start_address(tech_id)
                        start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
                else:
                    addr = self._get_technician_start_address(tech_id)
                    start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)

            all_day_tasks = self.sudo().search([
                '|',
                ('technician_id', '=', tech_id),
                ('additional_technician_ids', 'in', [tech_id]),
                ('scheduled_date', '=', date),
                ('status', 'not in', ['cancelled']),
            ], order='time_start, sequence, id')
            if not all_day_tasks:
                continue

            prev_lat, prev_lng = start_coords_cache[cache_key]
            for i, task in enumerate(all_day_tasks):
                if not (task.address_lat and task.address_lng):
                    task._geocode_address()
                travel_vals = {}
                if prev_lat and prev_lng and task.address_lat and task.address_lng:
                    task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng)
                    travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False
                    travel_vals['travel_origin'] = 'Clock-In Location' if i == 0 and str(date) == today_str and tech_id in clock_locations else ('Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}')
                if travel_vals:
                    task.with_context(skip_travel_recalc=True).write(travel_vals)
                prev_lat = task.address_lat or prev_lat
                prev_lng = task.address_lng or prev_lng

    # ------------------------------------------------------------------
    # LIVE TRAVEL RECALCULATION (uses tech's current GPS position)
    # ------------------------------------------------------------------

    def _get_tech_current_location(self, tech_id):
        """Get the technician's most recent GPS location.

        Priority:
          1. Latest fusion.technician.location record from last 30 min
          2. Latest action_latitude/longitude from today's tasks
          3. Clock-in location
          4. None (caller falls back to start address)
        """
        Location = self.env['fusion.technician.location'].sudo()
        cutoff = fields.Datetime.subtract(fields.Datetime.now(), minutes=30)
        latest = Location.search([
            ('user_id', '=', tech_id),
            ('logged_at', '>', cutoff),
            ('source', '!=', 'sync'),
        ], order='logged_at desc', limit=1)
        if latest and latest.latitude and latest.longitude:
            return latest.latitude, latest.longitude

        # Fallback: last completed task's location today
        today = self._local_now().date()
        last_completed = self.sudo().search([
            ('technician_id', '=', tech_id),
            ('scheduled_date', '=', today),
            ('status', '=', 'completed'),
            ('completed_latitude', '!=', 0),
            ('completed_longitude', '!=', 0),
        ], order='completion_datetime desc', limit=1)
        if last_completed:
            return last_completed.completed_latitude, last_completed.completed_longitude

        # Fallback: clock-in location
        clock_locs = self._get_clock_in_locations({tech_id}, today)
        if tech_id in clock_locs:
            cl = clock_locs[tech_id]
            return cl['lat'], cl['lng']

        return None, None

    def _recalculate_travel_from_current_location(self):
        """Recalculate travel time for THIS task from the tech's current GPS.

        Called when tech starts en_route to get a live ETA.
        """
        self.ensure_one()
        ICP = self.env['ir.config_parameter'].sudo()
        if not ICP.get_param('fusion_claims.google_distance_matrix_enabled', False):
            return
        tech_id = self.technician_id.id
        if not tech_id:
            return
        lat, lng = self._get_tech_current_location(tech_id)
        if lat and lng and self.address_lat and self.address_lng:
            self.with_context(skip_travel_recalc=True)._calculate_travel_time(lat, lng)
            self.with_context(skip_travel_recalc=True).write({
                'travel_origin': 'Current Location (Live)',
            })

    def _recalculate_remaining_tasks_travel(self):
        """After completing a task, recalculate travel for all remaining tasks
        in the chain using the completion location as the new origin.

        This ensures ETAs update in real-time as the tech progresses through
        their schedule, and the route reflects their actual position.
        """
        self.ensure_one()
        ICP = self.env['ir.config_parameter'].sudo()
        if not ICP.get_param('fusion_claims.google_distance_matrix_enabled', False):
            return

        tech_id = self.technician_id.id
        if not tech_id or not self.scheduled_date:
            return

        # Use completion GPS as origin for next task
        origin_lat = self.completed_latitude or self.action_latitude
        origin_lng = self.completed_longitude or self.action_longitude

        # If no GPS from completion, try task address (tech was physically there)
        if not origin_lat or not origin_lng:
            origin_lat = self.address_lat
            origin_lng = self.address_lng

        if not origin_lat or not origin_lng:
            return

        remaining = self.sudo().search([
            '|',
            ('technician_id', '=', tech_id),
            ('additional_technician_ids', 'in', [tech_id]),
            ('scheduled_date', '=', self.scheduled_date),
            ('status', 'not in', ['completed', 'cancelled']),
            ('time_start', '>=', self.time_start),
        ], order='time_start, sequence, id')

        if not remaining:
            return

        prev_lat, prev_lng = origin_lat, origin_lng
        for i, task in enumerate(remaining):
            if not (task.address_lat and task.address_lng):
                task._geocode_address()
            if prev_lat and prev_lng and task.address_lat and task.address_lng:
                task.with_context(skip_travel_recalc=True)._calculate_travel_time(
                    prev_lat, prev_lng)
                origin_label = (f'Completed: {self.name}' if i == 0
                                else f'Task {remaining[i - 1].name}')
                task.with_context(skip_travel_recalc=True).write({
                    'previous_task_id': self.id if i == 0 else remaining[i - 1].id,
                    'travel_origin': origin_label,
                })
            prev_lat = task.address_lat or prev_lat
            prev_lng = task.address_lng or prev_lng

    # ------------------------------------------------------------------
    # STATUS ACTIONS
    # ------------------------------------------------------------------

    def _check_previous_tasks_completed(self):
        """Check that all earlier tasks for the same technician+date are completed.

        Considers tasks where the technician is either lead or additional.
        """
        self.ensure_one()
        earlier_incomplete = self.sudo().search([
            '|',
            ('technician_id', '=', self.technician_id.id),
            ('additional_technician_ids', 'in', [self.technician_id.id]),
            ('scheduled_date', '=', self.scheduled_date),
            ('time_start', '<', self.time_start),
            ('status', 'not in', ['completed', 'cancelled']),
            ('id', '!=', self.id),
        ], limit=1)
        if earlier_incomplete:
            raise UserError(_(
                "Please complete previous task %s first before starting this one."
            ) % earlier_incomplete.name)

    def _write_action_location(self, extra_vals=None):
        """Write GPS coordinates from context onto the task record."""
        ctx = self.env.context
        lat = ctx.get('action_latitude', 0)
        lng = ctx.get('action_longitude', 0)
        acc = ctx.get('action_accuracy', 0)
        vals = {
            'action_latitude': lat,
            'action_longitude': lng,
            'action_location_accuracy': acc,
        }
        if extra_vals:
            vals.update(extra_vals)
        if lat and lng:
            self.with_context(skip_travel_recalc=True).write(vals)

    def action_start_en_route(self):
        """Mark task as En Route."""
        for task in self:
            if task.status != 'scheduled':
                raise UserError(_("Only scheduled tasks can be marked as En Route."))
            task._check_previous_tasks_completed()
            task.status = 'en_route'
            task._write_action_location()
            task._post_status_message('en_route')
            task._send_task_en_route_email()
            # Recalculate travel from tech's current location to THIS task
            task._recalculate_travel_from_current_location()
            if task.x_fc_sync_source:
                try:
                    self.env['fusion.task.sync.config']._push_shadow_status(task)
                except Exception:
                    _logger.exception(
                        "Failed to push en_route for shadow %s", task.name)
            try:
                remaining = self.sudo().search_count([
                    ('technician_id', '=', task.technician_id.id),
                    ('scheduled_date', '=', task.scheduled_date),
                    ('status', 'in', ['scheduled', 'en_route']),
                    ('id', '!=', task.id),
                ])
                client = task.client_display_name or 'your next client'
                ttype = dict(self._fields['task_type'].selection).get(
                    task.task_type, task.task_type or 'Task')
                task._send_push_notification(
                    f'En Route to {client}',
                    f'{ttype} at {task.address_display or "scheduled location"}. '
                    f'{remaining} more task(s) today.',
                )
            except Exception:
                pass

    def action_start_task(self):
        """Mark task as In Progress."""
        for task in self:
            if task.status not in ('scheduled', 'en_route'):
                raise UserError(_("Task must be scheduled or en route to start."))
            task._check_previous_tasks_completed()
            task.status = 'in_progress'
            ctx = self.env.context
            task._write_action_location({
                'started_latitude': ctx.get('action_latitude', 0),
                'started_longitude': ctx.get('action_longitude', 0),
            })
            task._post_status_message('in_progress')

    def action_complete_task(self):
        """Mark task as Completed."""
        for task in self:
            if task.status not in ('in_progress', 'en_route', 'scheduled'):
                raise UserError(_("Task must be in progress to complete."))

            task._check_completion_requirements()

            ctx = self.env.context
            task.with_context(skip_travel_recalc=True).write({
                'status': 'completed',
                'completion_datetime': fields.Datetime.now(),
                'completed_latitude': ctx.get('action_latitude', 0),
                'completed_longitude': ctx.get('action_longitude', 0),
                'action_latitude': ctx.get('action_latitude', 0),
                'action_longitude': ctx.get('action_longitude', 0),
                'action_location_accuracy': ctx.get('action_accuracy', 0),
            })
            task._post_status_message('completed')
            task._post_completion_to_linked_order()
            task._notify_scheduler_on_completion()
            task._send_task_completion_email()

            # Recalculate travel for remaining tasks from this completion location
            task._recalculate_remaining_tasks_travel()

            task._on_complete_extra()

    def _check_completion_requirements(self):
        """Hook: check additional requirements before task completion.
        Override in fusion_claims for rental inspection checks."""
        pass

    def _on_complete_extra(self):
        """Hook: additional side-effects after task completion.
        Override in fusion_claims for ODSP advancement and rental inspection."""
        pass

    def action_cancel_task(self):
        """Cancel the task. Sends cancellation email and runs cancel hooks."""
        for task in self:
            if task.status == 'completed':
                raise UserError(_("Cannot cancel a completed task."))
            task.status = 'cancelled'
            task._write_action_location()
            task._post_status_message('cancelled')
            if task.x_fc_sync_source:
                try:
                    self.env['fusion.task.sync.config']._push_shadow_status(task)
                except Exception:
                    _logger.exception(
                        "Failed to push cancel for shadow %s", task.name)
            else:
                task._on_cancel_extra()

    def _on_cancel_extra(self):
        """Hook: additional side-effects after task cancellation.
        Override in fusion_claims for sale order revert and email."""
        self._send_task_cancelled_email()

    def action_reschedule(self):
        """Open the reschedule form for this task.
        Saves old schedule info, then opens the same task form for editing.
        On save, the write() method detects the reschedule and sends emails."""
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'res_model': 'fusion.technician.task',
            'res_id': self.id,
            'view_mode': 'form',
            'target': 'new',
            'context': {
                'reschedule_mode': True,
                'old_date': str(self.scheduled_date) if self.scheduled_date else '',
                'old_time_start': self.time_start,
                'old_time_end': self.time_end,
            },
        }

    def action_reset_to_scheduled(self):
        """Reset task back to scheduled."""
        for task in self:
            task.status = 'scheduled'

    # ------------------------------------------------------------------
    # CHATTER / NOTIFICATIONS
    # ------------------------------------------------------------------

    def _post_status_message(self, new_status):
        """Post a status change message to the task chatter."""
        self.ensure_one()
        status_labels = dict(self._fields['status'].selection)
        label = status_labels.get(new_status, new_status)
        icons = {
            'en_route': 'fa-road',
            'in_progress': 'fa-wrench',
            'completed': 'fa-check-circle',
            'cancelled': 'fa-times-circle',
            'rescheduled': 'fa-calendar',
        }
        icon = icons.get(new_status, 'fa-info-circle')
        body = Markup(
            f'<p><i class="fa {icon}"></i> Task status changed to '
            f'<strong>{label}</strong> by {self.env.user.name}</p>'
        )
        self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note')

    def _post_completion_to_linked_order(self):
        """Hook: post completion notes to linked order chatter.
        Override in fusion_claims."""
        pass

    def _notify_scheduler_on_completion(self):
        """Send an Odoo notification to the person who scheduled the task.

        Shadow tasks skip this -- the push-back to the source instance
        triggers the notification there where the real scheduler exists.
        """
        self.ensure_one()
        if self.x_fc_sync_source:
            return

        recipient = None
        order = self._get_linked_order()
        if order and order.user_id:
            recipient = order.user_id
        elif self.create_uid:
            recipient = self.create_uid

        if not recipient or recipient in self.all_technician_ids:
            return

        task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
        task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
        client_name = self.client_display_name or 'N/A'
        order = self._get_linked_order()
        case_ref = order.name if order else ''
        addr_parts = [p for p in [
            self.address_street,
            self.address_street2,
            self.address_city,
            self.address_state_id.name if self.address_state_id else '',
            self.address_zip,
        ] if p]
        address_str = ', '.join(addr_parts) or 'No address'
        subject = f'Task Completed: {client_name}'
        if case_ref:
            subject += f' ({case_ref})'
        body = Markup(
            f'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;border-radius:4px;margin-bottom:8px;">'
            f'<p style="margin:0 0 8px 0;"><i class="fa fa-check-circle" style="color:#28a745;"></i> '
            f'<strong>{task_type_label} Completed</strong></p>'
            f'<table style="width:100%;border-collapse:collapse;">'
            f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Client:</td>'
            f'<td style="padding:3px 0;">{client_name}</td></tr>'
            f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Case:</td>'
            f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
            f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
            f'<td style="padding:3px 0;">{self.name}</td></tr>'
            f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
            f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
            f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
            f'<td style="padding:3px 0;">{address_str}</td></tr>'
            f'</table>'
            f'<p style="margin:8px 0 0 0;"><a href="{task_url}">View Task</a></p>'
            f'</div>'
        )
        self.env['mail.thread'].sudo().message_notify(
            partner_ids=[recipient.partner_id.id],
            body=body,
            subject=subject,
        )

    # ------------------------------------------------------------------
    # TASK EMAIL NOTIFICATIONS
    # ------------------------------------------------------------------

    def _get_task_email_details(self):
        """Build common detail rows for task emails."""
        self.ensure_one()
        type_label = dict(self._fields['task_type'].selection).get(
            self.task_type, self.task_type or '')
        rows = [
            ('Task', f'{self.name} ({type_label})'),
            ('Client', self.partner_id.name or 'N/A'),
        ]
        if self.scheduled_date:
            date_str = self.scheduled_date.strftime('%B %d, %Y')
            start_str = self._float_to_time_str(self.time_start)
            end_str = self._float_to_time_str(self.time_end)
            rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}'))
        if self.technician_id:
            rows.append(('Technician', self.all_technician_names or self.technician_id.name))
        if self.address_display:
            rows.append(('Address', self.address_display))
        return rows

    def _get_task_email_recipients(self):
        """Get email recipients for task notifications.
        Returns dict with 'to' (client), 'cc' (technician, sales rep, office)."""
        self.ensure_one()
        to_emails = []
        cc_emails = []

        # Client email
        if self.partner_id and self.partner_id.email:
            to_emails.append(self.partner_id.email)

        # Technician emails (lead + additional)
        for tech in (self.technician_id | self.additional_technician_ids):
            if tech.email:
                cc_emails.append(tech.email)

        return {'to': to_emails, 'cc': list(set(cc_emails))}

    def _send_task_cancelled_email(self):
        """Send cancellation email. Base: no-op. Override in fusion_claims."""
        return False

    def _send_task_scheduled_email(self):
        """Send scheduled email. Base: no-op. Override in fusion_claims."""
        return False

    def _send_task_rescheduled_email(self, old_date=None, old_start=None, old_end=None):
        """Send rescheduled email. Base: no-op. Override in fusion_claims."""
        return False

    # ------------------------------------------------------------------
    # CLIENT UPDATE EMAILS (en-route + completion)
    # ------------------------------------------------------------------

    def _get_email_builder(self):
        """Return a record that has the _email_build mixin.

        Base: returns self (task model inherits mixin).
        Override in fusion_claims to prefer linked sale order.
        """
        return self

    def _is_email_notifications_enabled(self):
        """Check if email notifications are enabled.

        Base: always True. Override in fusion_claims to check
        linked sale order's notification settings.
        """
        return True

    def _get_linked_order(self):
        """Return the linked order record (SO or PO), or False.

        Base: always False. Override in fusion_claims to return
        sale_order_id or purchase_order_id.
        """
        return False

    def _send_task_en_route_email(self):
        """Email the client that the technician is on the way."""
        self.ensure_one()
        if self.x_fc_sync_source or not self.x_fc_send_client_updates:
            return False
        if not self.partner_id or not self.partner_id.email:
            return False
        if not self._is_email_notifications_enabled():
            return False

        client_name = self.client_display_name or self.partner_id.name or 'Client'
        tech_name = self.all_technician_names or (self.technician_id.name if self.technician_id else 'Our technician')
        type_label = dict(self._fields['task_type'].selection).get(
            self.task_type, self.task_type or 'service')
        company = self.env.company

        detail_rows = self._get_task_email_details()
        builder = self._get_email_builder()

        time_range = ''
        if self.scheduled_date and self.time_start is not None:
            time_range = (
                f'{self.scheduled_date.strftime("%B %d, %Y")}, '
                f'{self._float_to_time_str(self.time_start)} - '
                f'{self._float_to_time_str(self.time_end or self.time_start + 1.0)}'
            )

        preparation_note = (
            '<strong>Please prepare for the visit:</strong><br/>'
            '<ul style="margin:8px 0 12px 0;padding-left:20px;">'
            '<li>Ensure the area where service is needed is accessible and clear.</li>'
            '<li>If applicable, secure pets away from the work area.</li>'
            '<li>Have any relevant documents or information ready for the technician.</li>'
            '<li>An adult (18+) must be present during the visit.</li>'
            '</ul>'
            '<strong>Important:</strong> Our technicians will not ask for credit card '
            'details or request payment directly unless payment on arrival has been '
            'previously agreed upon. If a cash payment was arranged, please have '
            'the payment ready in an envelope.'
        )

        body_html = builder._email_build(
            title='Your Technician Is On The Way',
            summary=(
                f'Our technician <strong>{tech_name}</strong> is heading to your '
                f'location and is expected to arrive between '
                f'<strong>{time_range or "the scheduled time"}</strong>.'
            ),
            email_type='info',
            sections=[('Visit Details', detail_rows)],
            note=preparation_note,
            note_color='#2B6CB0',
            sender_name=f'The {company.name} Team',
        )

        recipients = self._get_task_email_recipients()
        to_emails = recipients.get('to', [])
        cc_emails = recipients.get('cc', [])
        if not to_emails:
            return False

        email_to = ', '.join(to_emails)
        email_cc = ', '.join(cc_emails)
        order = self._get_linked_order()
        case_ref = order.name if order else self.name

        try:
            mail_vals = {
                'subject': f'Your Technician Is On The Way - {client_name} - {case_ref}',
                'body_html': body_html,
                'email_to': email_to,
                'email_cc': email_cc,
            }
            if order:
                mail_vals['model'] = order._name
                mail_vals['res_id'] = order.id
            self.env['mail.mail'].sudo().create(mail_vals).send()
            _logger.info("Sent en-route email for task %s to %s", self.name, email_to)
            return True
        except Exception as e:
            _logger.error("Failed to send en-route email for %s: %s", self.name, e)
            return False

    def _send_task_completion_email(self):
        """Email the client that the visit is complete.

        Sends one of two variants depending on x_fc_ask_google_review:
        - With Google review request (default)
        - Standard thank-you without review request
        """
        self.ensure_one()
        if self.x_fc_sync_source or not self.x_fc_send_client_updates:
            return False
        if not self.partner_id or not self.partner_id.email:
            return False
        if not self._is_email_notifications_enabled():
            return False

        client_name = self.client_display_name or self.partner_id.name or 'Client'
        tech_name = self.all_technician_names or (self.technician_id.name if self.technician_id else 'Our technician')
        type_label = dict(self._fields['task_type'].selection).get(
            self.task_type, self.task_type or 'service')
        company = self.env.company
        builder = self._get_email_builder()

        summary_rows = [
            ('Client', client_name),
            ('Service', type_label.title()),
        ]
        if self.scheduled_date:
            summary_rows.append(('Date', self.scheduled_date.strftime('%B %d, %Y')))
        if self.technician_id:
            summary_rows.append(('Technician', tech_name))

        contact_note = (
            'If you have any questions or concerns about the service provided, '
            f'please don\'t hesitate to contact us at '
            f'<strong>{company.phone or company.email or "our office"}</strong>.'
        )

        google_url = company.x_fc_google_review_url or ''
        include_review = self.x_fc_ask_google_review and google_url

        extra_html = ''
        button_url = ''
        button_text = ''

        if include_review:
            extra_html = builder._email_note(
                '<strong>We Value Your Feedback</strong><br/><br/>'
                'We hope you had a great experience! Your feedback helps us '
                'improve and serve you better. We would truly appreciate it '
                'if you could take a moment to share your experience.',
                '#38a169',
            )
            button_url = google_url
            button_text = 'Leave a Review'
        else:
            company_website = company.website or ''
            if company_website:
                button_url = company_website
                button_text = 'Visit Our Website'

        body_html = builder._email_build(
            title='Service Visit Completed',
            summary=(
                f'Our technician <strong>{tech_name}</strong> has completed the '
                f'<strong>{type_label.lower()}</strong> at your location. '
                f'Thank you for choosing <strong>{company.name}</strong>.'
            ),
            email_type='success',
            sections=[('Visit Summary', summary_rows)],
            extra_html=extra_html,
            note=contact_note,
            note_color='#38a169',
            button_url=button_url,
            button_text=button_text,
            sender_name=f'The {company.name} Team',
        )

        recipients = self._get_task_email_recipients()
        to_emails = recipients.get('to', [])
        cc_emails = recipients.get('cc', [])
        if not to_emails:
            return False

        email_to = ', '.join(to_emails)
        email_cc = ', '.join(cc_emails)
        order = self._get_linked_order()
        case_ref = order.name if order else self.name

        try:
            mail_vals = {
                'subject': f'Service Visit Completed - {client_name} - {case_ref}',
                'body_html': body_html,
                'email_to': email_to,
                'email_cc': email_cc,
            }
            if order:
                mail_vals['model'] = order._name
                mail_vals['res_id'] = order.id
            self.env['mail.mail'].sudo().create(mail_vals).send()
            _logger.info("Sent completion email for task %s to %s", self.name, email_to)
            return True
        except Exception as e:
            _logger.error("Failed to send completion email for %s: %s", self.name, e)
            return False

    def get_next_task_for_technician(self):
        """Get the next task in sequence for the same technician+date after this one.

        Considers tasks where the technician is either lead or additional.
        """
        self.ensure_one()
        return self.sudo().search([
            '|',
            ('technician_id', '=', self.technician_id.id),
            ('additional_technician_ids', 'in', [self.technician_id.id]),
            ('scheduled_date', '=', self.scheduled_date),
            ('time_start', '>=', self.time_start),
            ('status', 'in', ['scheduled', 'en_route']),
            ('id', '!=', self.id),
        ], order='time_start, sequence, id', limit=1)

    # ------------------------------------------------------------------
    # GOOGLE MAPS INTEGRATION
    # ------------------------------------------------------------------

    def _get_google_maps_api_key(self):
        """Get the Google Maps API key from config."""
        return self.env['ir.config_parameter'].sudo().get_param(
            'fusion_claims.google_maps_api_key', ''
        )

    @api.model
    def get_map_data(self, domain=None):
        """Return task data, technician locations, and Google Maps API key.

        Args:
            domain: optional extra domain from the search bar filters.
        """
        api_key = self.env['ir.config_parameter'].sudo().get_param(
            'fusion_claims.google_maps_api_key', '')
        local_instance = self.env['ir.config_parameter'].sudo().get_param(
            'fusion_claims.sync_instance_id', '')
        base_domain = [
            ('status', 'not in', ['cancelled']),
        ]
        if domain:
            base_domain = expression.AND([base_domain, domain])
        tasks = self.sudo().search_read(
            base_domain,
            ['name', 'partner_id', 'technician_id', 'task_type',
             'address_lat', 'address_lng', 'address_display',
             'time_start', 'time_end', 'time_start_display', 'time_end_display',
             'status', 'scheduled_date', 'travel_time_minutes',
             'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'],
            order='scheduled_date asc NULLS LAST, time_start asc',
            limit=500,
        )
        locations = self.env['fusion.technician.location'].get_latest_locations()
        tech_starts = self._get_tech_start_locations(tasks, api_key)
        return {
            'api_key': api_key,
            'tasks': tasks,
            'locations': locations,
            'local_instance_id': local_instance,
            'tech_start_locations': tech_starts,
        }

    @api.model
    def _get_tech_start_locations(self, tasks, api_key):
        """Build a dict of technician start locations for route origins.

        Priority per technician:
          1. Today's fusion_clock check-in location (if module installed)
          2. Personal start address (x_fc_start_address with cached lat/lng)
          3. Company default HQ address
        """
        tech_ids = {
            t['technician_id'][0]
            for t in tasks
            if t.get('technician_id')
        }
        if not tech_ids:
            return {}

        result = {}
        today = self._local_now().date()

        clock_locations = self._get_clock_in_locations(tech_ids, today)

        hq_address = (
            self.env['ir.config_parameter'].sudo()
            .get_param('fusion_claims.technician_start_address', '') or ''
        ).strip()
        hq_lat, hq_lng = 0.0, 0.0

        for uid in tech_ids:
            if uid in clock_locations:
                result[uid] = clock_locations[uid]
                continue

            user = self.env['res.users'].sudo().browse(uid)
            if not user.exists():
                continue
            partner = user.partner_id

            if partner.x_fc_start_address and partner.x_fc_start_address.strip():
                lat = partner.x_fc_start_address_lat
                lng = partner.x_fc_start_address_lng
                if not lat or not lng:
                    lat, lng = self._geocode_address_string(
                        partner.x_fc_start_address, api_key)
                    if lat and lng:
                        partner.sudo().write({
                            'x_fc_start_address_lat': lat,
                            'x_fc_start_address_lng': lng,
                        })
                if lat and lng:
                    result[uid] = {
                        'lat': lat, 'lng': lng,
                        'address': partner.x_fc_start_address.strip(),
                        'source': 'start_address',
                    }
                    continue

            if hq_address:
                if not hq_lat and not hq_lng:
                    hq_lat, hq_lng = self._geocode_address_string(
                        hq_address, api_key)
                if hq_lat and hq_lng:
                    result[uid] = {
                        'lat': hq_lat, 'lng': hq_lng,
                        'address': hq_address,
                        'source': 'company_hq',
                    }

        return result

    @api.model
    def _get_clock_in_locations(self, tech_ids, today):
        """Get today's clock-in lat/lng from fusion_clock if installed.

        Uses the technician's actual GPS position at the moment they clocked
        in (from the activity log), not the geofenced location's fixed
        coordinates.  Falls back to the geofence center if no activity-log
        GPS is available.
        """
        result = {}
        try:
            module = self.env['ir.module.module'].sudo().search([
                ('name', '=', 'fusion_clock'),
                ('state', '=', 'installed'),
            ], limit=1)
            if not module:
                return result
        except Exception:
            return result

        try:
            Attendance = self.env['hr.attendance'].sudo()
            Employee = self.env['hr.employee'].sudo()
            ActivityLog = self.env['fusion.clock.activity.log'].sudo()
        except KeyError:
            return result

        employees = Employee.search([
            ('user_id', 'in', list(tech_ids)),
        ])
        emp_to_user = {e.id: e.user_id.id for e in employees}

        if not employees:
            return result

        today_start = dt_datetime.combine(today, dt_datetime.min.time())
        today_end = today_start + timedelta(days=1)

        attendances = Attendance.search([
            ('employee_id', 'in', employees.ids),
            ('check_in', '>=', today_start),
            ('check_in', '<', today_end),
        ], order='check_in asc')

        for att in attendances:
            uid = emp_to_user.get(att.employee_id.id)
            if not uid or uid in result:
                continue

            lat, lng, address = 0, 0, ''

            log = ActivityLog.search([
                ('attendance_id', '=', att.id),
                ('log_type', '=', 'clock_in'),
                ('latitude', '!=', 0),
                ('longitude', '!=', 0),
            ], limit=1)
            if log:
                lat, lng = log.latitude, log.longitude
                loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
                address = (loc.address or loc.name) if loc else ''

            if not lat or not lng:
                loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
                if loc and loc.latitude and loc.longitude:
                    lat, lng = loc.latitude, loc.longitude
                    address = loc.address or loc.name or ''

            if lat and lng:
                result[uid] = {
                    'lat': lat,
                    'lng': lng,
                    'address': address,
                    'source': 'clock_in',
                }

        return result

    def _geocode_address(self):
        """Geocode the task address using Google Geocoding API."""
        self.ensure_one()
        api_key = self._get_google_maps_api_key()
        if not api_key or not self.address_display:
            return False

        try:
            url = 'https://maps.googleapis.com/maps/api/geocode/json'
            params = {
                'address': self.address_display,
                'key': api_key,
                'region': 'ca',
            }
            resp = requests.get(url, params=params, timeout=10)
            data = resp.json()
            if data.get('status') == 'OK' and data.get('results'):
                location = data['results'][0]['geometry']['location']
                self.write({
                    'address_lat': location['lat'],
                    'address_lng': location['lng'],
                })
                return True
        except Exception as e:
            _logger.warning(f"Geocoding failed for task {self.name}: {e}")
        return False

    def _calculate_travel_time(self, origin_lat, origin_lng):
        """Calculate travel time from origin to this task using Distance Matrix API."""
        self.ensure_one()
        api_key = self._get_google_maps_api_key()
        if not api_key:
            return False
        if not (origin_lat and origin_lng and self.address_lat and self.address_lng):
            return False

        try:
            url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
            params = {
                'origins': f'{origin_lat},{origin_lng}',
                'destinations': f'{self.address_lat},{self.address_lng}',
                'key': api_key,
                'mode': 'driving',
                'avoid': 'tolls',
                'traffic_model': 'best_guess',
                'departure_time': 'now',
            }
            resp = requests.get(url, params=params, timeout=10)
            data = resp.json()
            if data.get('status') == 'OK':
                element = data['rows'][0]['elements'][0]
                if element.get('status') == 'OK':
                    duration_seconds = element['duration_in_traffic']['value'] if 'duration_in_traffic' in element else element['duration']['value']
                    distance_meters = element['distance']['value']
                    self.write({
                        'travel_time_minutes': round(duration_seconds / 60),
                        'travel_distance_km': round(distance_meters / 1000, 1),
                    })
                    return True
        except Exception as e:
            _logger.warning(f"Travel time calculation failed for task {self.name}: {e}")
        return False

    def action_calculate_travel_times(self):
        """Calculate travel times for a day's schedule. Called from backend button or cron."""
        self._do_calculate_travel_times()
        # Return False to stay on the current form without navigation
        return False

    def _do_calculate_travel_times(self):
        """Internal: calculate travel times for tasks. Does not return an action.

        For today's tasks: uses the tech's current GPS location as origin
        for the first non-completed task, so ETAs reflect reality.
        For future tasks: uses personal start address or company HQ.
        """
        # Group tasks by technician and date
        task_groups = {}
        for task in self:
            key = (task.technician_id.id, task.scheduled_date)
            if key not in task_groups:
                task_groups[key] = self.env['fusion.technician.task']
            task_groups[key] |= task

        api_key = self._get_google_maps_api_key()
        today = self._local_now().date()

        for (tech_id, date), tasks in task_groups.items():
            sorted_tasks = tasks.sorted(lambda t: (t.sequence, t.time_start))

            # For today: try current GPS, then clock-in, then start address
            # For future: use start address
            if date == today:
                lat, lng = self._get_tech_current_location(tech_id)
                if lat and lng:
                    prev_lat, prev_lng = lat, lng
                    origin_label = 'Current Location'
                else:
                    clock_locs = self._get_clock_in_locations({tech_id}, today)
                    if tech_id in clock_locs:
                        cl = clock_locs[tech_id]
                        prev_lat, prev_lng = cl['lat'], cl['lng']
                        origin_label = 'Clock-In Location'
                    else:
                        addr = self._get_technician_start_address(tech_id)
                        prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
                        origin_label = 'Start Location'
            else:
                addr = self._get_technician_start_address(tech_id)
                prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
                origin_label = 'Start Location'

            # Skip already-completed tasks for today (chain starts from
            # last completed task's location instead)
            first_pending_idx = 0
            if date == today:
                for idx, task in enumerate(sorted_tasks):
                    if task.status == 'completed':
                        if task.completed_latitude and task.completed_longitude:
                            prev_lat = task.completed_latitude
                            prev_lng = task.completed_longitude
                        elif task.address_lat and task.address_lng:
                            prev_lat = task.address_lat
                            prev_lng = task.address_lng
                        origin_label = f'Completed: {task.name}'
                        first_pending_idx = idx + 1
                    else:
                        break

            for i, task in enumerate(sorted_tasks):
                if i < first_pending_idx:
                    continue
                # Geocode task if needed
                if not (task.address_lat and task.address_lng):
                    task._geocode_address()

                if prev_lat and prev_lng and task.address_lat and task.address_lng:
                    task._calculate_travel_time(prev_lat, prev_lng)
                    task.previous_task_id = sorted_tasks[i - 1].id if i > 0 else False
                    task.travel_origin = origin_label if i == first_pending_idx else f'Task {sorted_tasks[i - 1].name}'

                prev_lat = task.address_lat or prev_lat
                prev_lng = task.address_lng or prev_lng

    @api.model
    def _cron_calculate_travel_times(self):
        """Cron job: Calculate travel times for today and tomorrow.

        Runs every 15 minutes. For today's tasks, uses the tech's latest
        GPS location so ETAs stay accurate as technicians move.
        Includes completed tasks in the search so the chain can skip
        them and use their completion location as origin.
        """
        today = fields.Date.context_today(self)
        tomorrow = today + timedelta(days=1)
        tasks = self.search([
            ('scheduled_date', 'in', [today, tomorrow]),
            ('status', 'not in', ['cancelled']),
        ])
        if tasks:
            tasks._do_calculate_travel_times()
            _logger.info(f"Calculated travel times for {len(tasks)} tasks")

    @api.model
    def _cron_check_late_arrivals(self):
        """Cron: detect tasks where the technician hasn't started and the
        scheduled start time has passed.  Sends a push notification to the
        tech and an in-app notification to the office (once per task).
        """
        ICP = self.env['ir.config_parameter'].sudo()
        push_enabled = ICP.get_param('fusion_claims.push_enabled', 'False')
        if push_enabled.lower() not in ('true', '1', 'yes'):
            return

        local_now = self._local_now()
        today = local_now.date()
        current_hour = local_now.hour + local_now.minute / 60.0

        late_tasks = self.sudo().search([
            ('scheduled_date', '=', today),
            ('status', '=', 'scheduled'),
            ('time_start', '<', current_hour),
            ('x_fc_late_notified', '=', False),
            ('x_fc_sync_source', '=', False),
            ('technician_id', '!=', False),
        ])

        for task in late_tasks:
            minutes_late = int((current_hour - task.time_start) * 60)
            if minutes_late < 5:
                continue

            client = task.client_display_name or 'Client'
            try:
                task._send_push_notification(
                    f'Running Late - {client}',
                    f'Your {task._float_to_time_str(task.time_start)} '
                    f'{dict(self._fields["task_type"].selection).get(task.task_type, "task")} '
                    f'is {minutes_late} min overdue. Please update your status.',
                )
            except Exception:
                pass

            try:
                order = task._get_linked_order()
                if order and order.user_id:
                    recipient = order.user_id
                elif task.create_uid:
                    recipient = task.create_uid
                else:
                    recipient = None
                if recipient:
                    self.env['mail.thread'].sudo().message_notify(
                        partner_ids=[recipient.partner_id.id],
                        subject=f'Late: {task.name} - {client}',
                        body=Markup(
                            f'<p><i class="fa fa-exclamation-triangle" style="color:#c53030;"></i> '
                            f'<strong>{task.technician_id.name}</strong> has not started '
                            f'<strong>{task.name}</strong> for <strong>{client}</strong>, '
                            f'scheduled at {task._float_to_time_str(task.time_start)}. '
                            f'Currently <strong>{minutes_late} min overdue</strong>.</p>'
                        ),
                    )
            except Exception:
                _logger.warning("Failed to notify office about late task %s", task.name)

            task.with_context(skip_travel_recalc=True).write({
                'x_fc_late_notified': True,
            })

        if late_tasks:
            _logger.info("Late arrival notifications sent for %d tasks", len(late_tasks))

    # ------------------------------------------------------------------
    # PORTAL HELPERS
    # ------------------------------------------------------------------

    def get_technician_tasks_for_date(self, user_id, date):
        """Get all tasks for a technician on a given date, ordered by sequence."""
        return self.sudo().search([
            ('technician_id', '=', user_id),
            ('scheduled_date', '=', date),
            ('status', '!=', 'cancelled'),
        ], order='sequence, time_start, id')

    def get_next_task(self, user_id):
        """Get the next upcoming task for a technician."""
        today = fields.Date.context_today(self)
        return self.sudo().search([
            ('technician_id', '=', user_id),
            ('scheduled_date', '>=', today),
            ('status', 'in', ['scheduled', 'en_route']),
        ], order='scheduled_date, sequence, time_start', limit=1)

    def get_current_task(self, user_id):
        """Get the current in-progress task for a technician."""
        today = fields.Date.context_today(self)
        return self.sudo().search([
            ('technician_id', '=', user_id),
            ('scheduled_date', '=', today),
            ('status', '=', 'in_progress'),
        ], limit=1)

    # ------------------------------------------------------------------
    # PUSH NOTIFICATIONS
    # ------------------------------------------------------------------

    def _send_push_notification(self, title, body_text, url=None):
        """Send a web push notification for this task."""
        self.ensure_one()
        PushSub = self.env['fusion.push.subscription'].sudo()
        subscriptions = PushSub.search([
            ('user_id', '=', self.technician_id.id),
            ('active', '=', True),
        ])
        if not subscriptions:
            return

        ICP = self.env['ir.config_parameter'].sudo()
        vapid_private = ICP.get_param('fusion_claims.vapid_private_key', '')
        vapid_public = ICP.get_param('fusion_claims.vapid_public_key', '')
        if not vapid_private or not vapid_public:
            _logger.warning("VAPID keys not configured, cannot send push notification")
            return

        try:
            from pywebpush import webpush, WebPushException
        except ImportError:
            _logger.warning("pywebpush not installed, cannot send push notifications")
            return

        payload = json.dumps({
            'title': title,
            'body': body_text,
            'url': url or f'/my/technician/task/{self.id}',
            'task_id': self.id,
            'task_type': self.task_type,
        })

        for sub in subscriptions:
            try:
                webpush(
                    subscription_info={
                        'endpoint': sub.endpoint,
                        'keys': {
                            'p256dh': sub.p256dh_key,
                            'auth': sub.auth_key,
                        },
                    },
                    data=payload,
                    vapid_private_key=vapid_private,
                    vapid_claims={'sub': 'mailto:support@nexasystems.ca'},
                )
            except Exception as e:
                _logger.warning(f"Push notification failed for subscription {sub.id}: {e}")
                # Deactivate invalid subscriptions
                if 'gone' in str(e).lower() or '410' in str(e):
                    sub.active = False

        self.write({
            'push_notified': True,
            'push_notified_datetime': fields.Datetime.now(),
        })

    @api.model
    def _cron_send_push_notifications(self):
        """Cron: Send push notifications for upcoming tasks."""
        ICP = self.env['ir.config_parameter'].sudo()
        if not ICP.get_param('fusion_claims.push_enabled', False):
            return

        advance_minutes = int(ICP.get_param('fusion_claims.push_advance_minutes', '30'))
        local_now = self._local_now()

        tasks = self.search([
            ('scheduled_date', '=', local_now.date()),
            ('status', '=', 'scheduled'),
            ('push_notified', '=', False),
        ])

        for task in tasks:
            task_start_hour = int(task.time_start)
            task_start_min = int((task.time_start % 1) * 60)
            task_start_dt = local_now.replace(
                hour=task_start_hour, minute=task_start_min, second=0, microsecond=0)

            minutes_until = (task_start_dt - local_now).total_seconds() / 60
            if 0 <= minutes_until <= advance_minutes:
                task_type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type)
                title = f'Upcoming: {task_type_label}'
                body_text = f'{task.partner_id.name or "Task"} - {task.time_start_display}'
                if task.travel_time_minutes:
                    body_text += f' ({task.travel_time_minutes} min drive)'
                task._send_push_notification(title, body_text)

    # ------------------------------------------------------------------
    # HELPERS
    # ------------------------------------------------------------------

    def _get_local_tz(self):
        """Return the pytz timezone for local time calculations.
        Prefers company resource calendar, then user tz, then Eastern."""
        import pytz
        tz_name = (
            self.env.company.resource_calendar_id.tz
            or self.env.user.tz
            or 'America/Toronto'
        )
        try:
            return pytz.timezone(tz_name)
        except pytz.UnknownTimeZoneError:
            return pytz.timezone('America/Toronto')

    def _utc_to_local(self, dt_utc):
        """Convert a naive UTC datetime to a timezone-aware local datetime."""
        import pytz
        if not dt_utc:
            return None
        return pytz.utc.localize(dt_utc).astimezone(self._get_local_tz())

    def _local_now(self):
        """Current datetime in the local (company) timezone."""
        return self._utc_to_local(fields.Datetime.now())

    @staticmethod
    def _float_to_time_str(value):
        """Convert float hours to time string like '9:30 AM'."""
        if not value and value != 0:
            return ''
        hours = int(value)
        minutes = int(round((value % 1) * 60))
        period = 'AM' if hours < 12 else 'PM'
        display_hour = hours % 12 or 12
        return f'{display_hour}:{minutes:02d} {period}'

    def get_google_maps_url(self):
        """Get Google Maps navigation URL using the text address so the
        destination shows a proper street name instead of raw coordinates.
        Returns a google.com/maps URL that Android auto-opens in the app;
        iOS handling is done client-side via JS to launch comgooglemaps://."""
        self.ensure_one()
        if self.address_display:
            addr = urllib.parse.quote(self.address_display)
            return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving'
        if self.address_lat and self.address_lng:
            return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving'
        return ''
