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

"""
Cross-instance technician task sync.

Enables two Odoo instances (e.g. Westin and Mobility) that share the same
field technicians to see each other's delivery tasks, preventing double-booking.

Remote tasks appear as read-only "shadow" records in the local calendar.
The existing _find_next_available_slot() automatically sees shadow tasks,
so collision detection works without changes to the scheduling algorithm.

Technicians are matched across instances using the x_fc_tech_sync_id field
on res.users. Set the same value (e.g. "gordy") on both instances for the
same person -- no mapping table needed.
"""

from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import requests
from datetime import timedelta

_logger = logging.getLogger(__name__)

SYNC_TASK_FIELDS = [
    'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
    'task_type', 'status',
    'scheduled_date', 'time_start', 'time_end', 'duration_hours',
    'address_street', 'address_street2', 'address_city', 'address_zip',
    'address_state_id', 'address_buzz_code',
    'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
    'pod_required', 'description',
    'travel_time_minutes', 'travel_distance_km', 'travel_origin',
    'completed_latitude', 'completed_longitude',
    'action_latitude', 'action_longitude',
    'completion_datetime',
]

TERMINAL_STATUSES = ('completed', 'cancelled')


class FusionTaskSyncConfig(models.Model):
    _name = 'fusion.task.sync.config'
    _description = 'Task Sync Remote Instance'

    name = fields.Char('Instance Name', required=True,
                       help='e.g. Westin Healthcare, Mobility Specialties')
    instance_id = fields.Char('Instance ID', required=True,
                              help='Short identifier, e.g. westin or mobility')
    url = fields.Char('Odoo URL', required=True,
                      help='e.g. http://192.168.1.40:8069')
    database = fields.Char('Database', required=True)
    username = fields.Char('API Username', required=True)
    api_key = fields.Char('API Key', required=True)
    active = fields.Boolean(default=True)
    last_sync = fields.Datetime('Last Successful Sync', readonly=True)
    last_sync_error = fields.Text('Last Error', readonly=True)

    # ------------------------------------------------------------------
    # JSON-RPC helpers (uses /jsonrpc dispatch, muted on receiving side)
    # ------------------------------------------------------------------

    def _jsonrpc(self, service, method, args):
        """Execute a JSON-RPC call against the remote Odoo instance."""
        self.ensure_one()
        url = f"{self.url.rstrip('/')}/jsonrpc"
        payload = {
            'jsonrpc': '2.0',
            'method': 'call',
            'id': 1,
            'params': {
                'service': service,
                'method': method,
                'args': args,
            },
        }
        try:
            resp = requests.post(url, json=payload, timeout=15)
            resp.raise_for_status()
            result = resp.json()
            if result.get('error'):
                err = result['error'].get('data', {}).get('message', str(result['error']))
                raise UserError(f"Remote error: {err}")
            return result.get('result')
        except requests.exceptions.ConnectionError:
            _logger.warning("Task sync: cannot connect to %s", self.url)
            return None
        except requests.exceptions.Timeout:
            _logger.warning("Task sync: timeout connecting to %s", self.url)
            return None

    def _authenticate(self):
        """Authenticate with the remote instance and return the uid."""
        self.ensure_one()
        uid = self._jsonrpc('common', 'authenticate',
                            [self.database, self.username, self.api_key, {}])
        if not uid:
            _logger.error("Task sync: authentication failed for %s", self.name)
        return uid

    def _rpc(self, model, method, args, kwargs=None):
        """Execute a method on the remote instance via execute_kw."""
        self.ensure_one()
        uid = self._authenticate()
        if not uid:
            return None
        call_args = [self.database, uid, self.api_key, model, method, args]
        if kwargs:
            call_args.append(kwargs)
        return self._jsonrpc('object', 'execute_kw', call_args)

    # ------------------------------------------------------------------
    # Tech sync ID helpers
    # ------------------------------------------------------------------

    def _get_local_tech_map(self):
        """Build {local_user_id: x_fc_tech_sync_id} for all local field staff."""
        techs = self.env['res.users'].sudo().search([
            ('x_fc_is_field_staff', '=', True),
            ('x_fc_tech_sync_id', '!=', False),
            ('active', '=', True),
        ])
        return {u.id: u.x_fc_tech_sync_id for u in techs}

    def _get_remote_tech_map(self):
        """Build {x_fc_tech_sync_id: remote_user_id} from the remote instance."""
        self.ensure_one()
        remote_users = self._rpc('res.users', 'search_read', [
            [('x_fc_is_field_staff', '=', True),
             ('x_fc_tech_sync_id', '!=', False),
             ('active', '=', True)],
        ], {'fields': ['id', 'x_fc_tech_sync_id']})
        if not remote_users:
            return {}
        return {
            ru['x_fc_tech_sync_id']: ru['id']
            for ru in remote_users
            if ru.get('x_fc_tech_sync_id')
        }

    def _get_local_syncid_to_uid(self):
        """Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
        techs = self.env['res.users'].sudo().search([
            ('x_fc_is_field_staff', '=', True),
            ('x_fc_tech_sync_id', '!=', False),
            ('active', '=', True),
        ])
        return {u.x_fc_tech_sync_id: u.id for u in techs}

    # ------------------------------------------------------------------
    # Connection test
    # ------------------------------------------------------------------

    def action_test_connection(self):
        """Test the connection to the remote instance."""
        self.ensure_one()
        uid = self._authenticate()
        if uid:
            remote_map = self._get_remote_tech_map()
            local_map = self._get_local_tech_map()
            matched = set(local_map.values()) & set(remote_map.keys())
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': 'Connection Successful',
                    'message': f'Connected to {self.name}. '
                               f'{len(matched)} technician(s) matched by sync ID.',
                    'type': 'success',
                    'sticky': False,
                },
            }
        raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.")

    # ------------------------------------------------------------------
    # PUSH: send local task changes to remote instance
    # ------------------------------------------------------------------

    def _get_local_instance_id(self):
        """Return this instance's own ID from config parameters."""
        return self.env['ir.config_parameter'].sudo().get_param(
            'fusion_claims.sync_instance_id', '')

    @api.model
    def _push_tasks(self, tasks, operation='create'):
        """Push local task changes to all active remote instances.
        Called from technician_task create/write overrides.
        Non-blocking: errors are logged, not raised.
        """
        configs = self.sudo().search([('active', '=', True)])
        if not configs:
            return
        local_id = configs[0]._get_local_instance_id()
        if not local_id:
            return
        for config in configs:
            try:
                config._push_tasks_to_remote(tasks, operation, local_id)
            except Exception:
                _logger.exception("Task sync push to %s failed", config.name)

    def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
        """Push task data to a single remote instance.

        Maps additional_technician_ids via sync IDs so the remote instance
        also blocks those technicians' schedules.
        """
        self.ensure_one()
        local_map = self._get_local_tech_map()
        remote_map = self._get_remote_tech_map()
        if not local_map or not remote_map:
            return

        ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}

        for task in tasks:
            sync_id = local_map.get(task.technician_id.id)
            if not sync_id:
                continue
            remote_tech_uid = remote_map.get(sync_id)
            if not remote_tech_uid:
                continue

            # Map additional technicians to remote user IDs
            remote_additional_ids = []
            for tech in task.additional_technician_ids:
                add_sync_id = local_map.get(tech.id)
                if add_sync_id:
                    remote_add_uid = remote_map.get(add_sync_id)
                    if remote_add_uid:
                        remote_additional_ids.append(remote_add_uid)

            task_data = {
                'x_fc_sync_uuid': task.x_fc_sync_uuid,
                'x_fc_sync_source': local_instance_id,
                'x_fc_sync_remote_id': task.id,
                'name': f"[{local_instance_id.upper()}] {task.name}",
                'technician_id': remote_tech_uid,
                'additional_technician_ids': [(6, 0, remote_additional_ids)],
                'task_type': task.task_type,
                'status': task.status,
                'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
                'time_start': task.time_start,
                'time_end': task.time_end,
                'duration_hours': task.duration_hours,
                'address_street': task.address_street or '',
                'address_street2': task.address_street2 or '',
                'address_city': task.address_city or '',
                'address_zip': task.address_zip or '',
                'address_lat': float(task.address_lat or 0),
                'address_lng': float(task.address_lng or 0),
                'priority': task.priority or 'normal',
                'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '',
                'travel_time_minutes': task.travel_time_minutes or 0,
                'travel_distance_km': float(task.travel_distance_km or 0),
                'travel_origin': task.travel_origin or '',
                'completed_latitude': float(task.completed_latitude or 0),
                'completed_longitude': float(task.completed_longitude or 0),
                'action_latitude': float(task.action_latitude or 0),
                'action_longitude': float(task.action_longitude or 0),
            }
            if task.completion_datetime:
                task_data['completion_datetime'] = str(task.completion_datetime)

            existing = self._rpc(
                'fusion.technician.task', 'search',
                [[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]],
                {'limit': 1})

            if operation in ('create', 'write'):
                if existing:
                    self._rpc('fusion.technician.task', 'write',
                              [existing, task_data], ctx)
                elif operation == 'create':
                    task_data['sale_order_id'] = False
                    self._rpc('fusion.technician.task', 'create',
                              [[task_data]], ctx)

            elif operation == 'unlink' and existing:
                self._rpc('fusion.technician.task', 'write',
                          [existing, {'status': 'cancelled', 'active': False}], ctx)

    @api.model
    def _push_shadow_status(self, shadow_tasks):
        """Push local status changes on shadow tasks back to their source instance.

        When a tech changes a shadow task status locally, update the original
        task on the remote instance and trigger the appropriate client emails
        there.  Only the parent (originating) instance sends client-facing
        emails -- the child instance skips them via x_fc_sync_source guards.
        """
        configs = self.sudo().search([('active', '=', True)])
        config_by_instance = {c.instance_id: c for c in configs}
        ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}

        for task in shadow_tasks:
            config = config_by_instance.get(task.x_fc_sync_source)
            if not config or not task.x_fc_sync_remote_id:
                continue
            try:
                update_vals = {'status': task.status}
                if task.status == 'completed' and task.completion_datetime:
                    update_vals['completion_datetime'] = str(task.completion_datetime)
                    if task.completed_latitude and task.completed_longitude:
                        update_vals['completed_latitude'] = task.completed_latitude
                        update_vals['completed_longitude'] = task.completed_longitude
                if task.action_latitude and task.action_longitude:
                    update_vals['action_latitude'] = task.action_latitude
                    update_vals['action_longitude'] = task.action_longitude
                config._rpc(
                    'fusion.technician.task', 'write',
                    [[task.x_fc_sync_remote_id], update_vals], ctx)
                _logger.info(
                    "Pushed status '%s' for shadow task %s back to %s (remote id %d)",
                    task.status, task.name, config.name, task.x_fc_sync_remote_id)
                self._trigger_parent_notifications(config, task)
            except Exception:
                _logger.exception(
                    "Failed to push status for shadow task %s to %s",
                    task.name, config.name)

    @api.model
    def _push_technician_location(self, user_id, latitude, longitude, accuracy=0):
        """Push a technician's location update to all remote instances.

        Called when a technician performs a task action (en_route, complete)
        so the other instance immediately knows where the tech is, without
        waiting for the next pull cron cycle.
        """
        configs = self.sudo().search([('active', '=', True)])
        if not configs:
            return
        local_map = configs[0]._get_local_tech_map()
        sync_id = local_map.get(user_id)
        if not sync_id:
            return
        for config in configs:
            try:
                remote_map = config._get_remote_tech_map()
                remote_uid = remote_map.get(sync_id)
                if not remote_uid:
                    continue
                # Create location record on remote instance
                config._rpc(
                    'fusion.technician.location', 'create',
                    [[{
                        'user_id': remote_uid,
                        'latitude': latitude,
                        'longitude': longitude,
                        'accuracy': accuracy,
                        'source': 'sync',
                        'sync_instance': configs[0]._get_local_instance_id(),
                    }]])
            except Exception:
                _logger.warning(
                    "Failed to push location for tech %s to %s",
                    user_id, config.name)

    def _trigger_parent_notifications(self, config, task):
        """After pushing a shadow status, trigger appropriate emails and
        notifications on the parent instance so the client gets notified
        exactly once (from the originating instance only)."""
        remote_id = task.x_fc_sync_remote_id
        if task.status == 'completed':
            for method in ('_notify_scheduler_on_completion',
                           '_send_task_completion_email'):
                try:
                    config._rpc('fusion.technician.task', method, [[remote_id]])
                except Exception:
                    _logger.warning(
                        "Could not call %s on remote for %s", method, task.name)
        elif task.status == 'en_route':
            try:
                config._rpc(
                    'fusion.technician.task',
                    '_send_task_en_route_email', [[remote_id]])
            except Exception:
                _logger.warning(
                    "Could not trigger en-route email on remote for %s",
                    task.name)
        elif task.status == 'cancelled':
            try:
                config._rpc(
                    'fusion.technician.task',
                    '_send_task_cancelled_email', [[remote_id]])
            except Exception:
                _logger.warning(
                    "Could not trigger cancel email on remote for %s",
                    task.name)

    # ------------------------------------------------------------------
    # PULL: cron-based full reconciliation
    # ------------------------------------------------------------------

    @api.model
    def _cron_pull_remote_tasks(self):
        """Cron job: pull tasks and technician locations from all active remote instances."""
        configs = self.sudo().search([('active', '=', True)])
        for config in configs:
            try:
                config._pull_tasks_from_remote()
                config._pull_technician_locations()
                config.sudo().write({
                    'last_sync': fields.Datetime.now(),
                    'last_sync_error': False,
                })
            except Exception as e:
                _logger.exception("Task sync pull from %s failed", config.name)
                config.sudo().write({'last_sync_error': str(e)})

    def _pull_tasks_from_remote(self):
        """Pull all active tasks for matched technicians from the remote instance.

        After syncing, recalculates travel chains for all affected tech+date
        combos so route planning accounts for both local and shadow tasks.
        """
        self.ensure_one()
        local_syncid_to_uid = self._get_local_syncid_to_uid()
        if not local_syncid_to_uid:
            return

        remote_map = self._get_remote_tech_map()
        if not remote_map:
            return

        matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
        if not matched_sync_ids:
            _logger.info("Task sync: no matched technicians between local and %s", self.name)
            return

        remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
        remote_syncid_by_uid = {v: k for k, v in remote_map.items()}

        cutoff = fields.Date.today() - timedelta(days=7)
        remote_tasks = self._rpc(
            'fusion.technician.task', 'search_read',
            [[
                '|',
                ('technician_id', 'in', remote_tech_ids),
                ('additional_technician_ids', 'in', remote_tech_ids),
                ('scheduled_date', '>=', str(cutoff)),
                ('x_fc_sync_source', '=', False),
            ]],
            {'fields': SYNC_TASK_FIELDS + ['id']})

        if remote_tasks is None:
            return

        Task = self.env['fusion.technician.task'].sudo().with_context(
            skip_task_sync=True, skip_travel_recalc=True)

        remote_uuids = set()
        affected_combos = set()

        for rt in remote_tasks:
            sync_uuid = rt.get('x_fc_sync_uuid')
            if not sync_uuid:
                continue
            remote_uuids.add(sync_uuid)

            remote_tech_raw = rt['technician_id']
            remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw
            tech_sync_id = remote_syncid_by_uid.get(remote_uid)
            local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None
            if not local_uid:
                continue

            partner_raw = rt.get('partner_id')
            client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
            client_phone = rt.get('partner_phone', '') or ''

            state_raw = rt.get('address_state_id')
            state_name = ''
            if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
                state_name = state_raw[1]

            # Map additional technicians from remote to local
            local_additional_ids = []
            remote_add_raw = rt.get('additional_technician_ids', [])
            if remote_add_raw and isinstance(remote_add_raw, list):
                for add_uid in remote_add_raw:
                    add_sync_id = remote_syncid_by_uid.get(add_uid)
                    if add_sync_id:
                        local_add_uid = local_syncid_to_uid.get(add_sync_id)
                        if local_add_uid:
                            local_additional_ids.append(local_add_uid)

            sched_date = rt.get('scheduled_date')

            vals = {
                'x_fc_sync_uuid': sync_uuid,
                'x_fc_sync_source': self.instance_id,
                'x_fc_sync_remote_id': rt['id'],
                'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
                'technician_id': local_uid,
                'additional_technician_ids': [(6, 0, local_additional_ids)],
                'task_type': rt.get('task_type', 'delivery'),
                'status': rt.get('status', 'scheduled'),
                'scheduled_date': sched_date,
                'time_start': rt.get('time_start', 9.0),
                'time_end': rt.get('time_end', 10.0),
                'duration_hours': rt.get('duration_hours', 1.0),
                'address_street': rt.get('address_street', ''),
                'address_street2': rt.get('address_street2', ''),
                'address_city': rt.get('address_city', ''),
                'address_zip': rt.get('address_zip', ''),
                'address_buzz_code': rt.get('address_buzz_code', ''),
                'address_lat': rt.get('address_lat', 0),
                'address_lng': rt.get('address_lng', 0),
                'priority': rt.get('priority', 'normal'),
                'pod_required': rt.get('pod_required', False),
                'description': rt.get('description', ''),
                'x_fc_sync_client_name': client_name,
                'x_fc_sync_client_phone': client_phone,
                'travel_time_minutes': rt.get('travel_time_minutes', 0),
                'travel_distance_km': rt.get('travel_distance_km', 0),
                'travel_origin': rt.get('travel_origin', ''),
                'completed_latitude': rt.get('completed_latitude', 0),
                'completed_longitude': rt.get('completed_longitude', 0),
                'action_latitude': rt.get('action_latitude', 0),
                'action_longitude': rt.get('action_longitude', 0),
            }
            if rt.get('completion_datetime'):
                vals['completion_datetime'] = rt['completion_datetime']

            if state_name:
                state_rec = self.env['res.country.state'].sudo().search(
                    [('name', '=', state_name)], limit=1)
                if state_rec:
                    vals['address_state_id'] = state_rec.id

            existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
            if existing:
                if existing.status in TERMINAL_STATUSES:
                    vals.pop('status', None)
                existing.write(vals)
            else:
                vals['sale_order_id'] = False
                Task.create([vals])

            if sched_date:
                affected_combos.add((local_uid, sched_date))
                for add_uid in local_additional_ids:
                    affected_combos.add((add_uid, sched_date))

        stale_shadows = Task.search([
            ('x_fc_sync_source', '=', self.instance_id),
            ('x_fc_sync_uuid', 'not in', list(remote_uuids)),
            ('scheduled_date', '>=', str(cutoff)),
            ('active', '=', True),
        ])
        if stale_shadows:
            for st in stale_shadows:
                if st.scheduled_date and st.technician_id:
                    affected_combos.add((st.technician_id.id, st.scheduled_date))
                for tech in st.additional_technician_ids:
                    if st.scheduled_date:
                        affected_combos.add((tech.id, st.scheduled_date))
            stale_shadows.write({'active': False, 'status': 'cancelled'})
            _logger.info("Deactivated %d stale shadow tasks from %s",
                         len(stale_shadows), self.instance_id)

        if affected_combos:
            today = fields.Date.today()
            today_str = str(today)
            future_combos = set()
            for tid, d in affected_combos:
                if not d:
                    continue
                d_str = str(d) if not isinstance(d, str) else d
                if d_str >= today_str:
                    future_combos.add((tid, d_str))
            if future_combos:
                TaskModel = self.env['fusion.technician.task'].sudo()
                try:
                    ungeocode = TaskModel.search([
                        ('x_fc_sync_source', '=', self.instance_id),
                        ('active', '=', True),
                        ('scheduled_date', '>=', today_str),
                        ('status', 'not in', ['cancelled']),
                        '|',
                        ('address_lat', '=', 0), ('address_lat', '=', False),
                    ])
                    geocoded = 0
                    for shadow in ungeocode:
                        if shadow.address_display:
                            if shadow.with_context(skip_travel_recalc=True)._geocode_address():
                                geocoded += 1
                    if geocoded:
                        _logger.info("Geocoded %d shadow tasks from %s",
                                     geocoded, self.name)
                except Exception:
                    _logger.exception(
                        "Shadow task geocoding after sync from %s failed", self.name)

                try:
                    TaskModel._recalculate_combos_travel(future_combos)
                    _logger.info(
                        "Recalculated travel for %d tech+date combos after sync from %s",
                        len(future_combos), self.name)
                except Exception:
                    _logger.exception(
                        "Travel recalculation after sync from %s failed", self.name)

    # ------------------------------------------------------------------
    # PULL: technician locations from remote instance
    # ------------------------------------------------------------------

    def _pull_technician_locations(self):
        """Pull latest GPS locations for matched technicians from the remote instance.

        Creates local location records with source='sync' so the map view
        shows technician positions from both instances. Only keeps the single
        most recent synced location per technician (replaces older synced
        records to avoid clutter).
        """
        self.ensure_one()
        local_syncid_to_uid = self._get_local_syncid_to_uid()
        if not local_syncid_to_uid:
            return

        remote_map = self._get_remote_tech_map()
        if not remote_map:
            return

        matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
        if not matched_sync_ids:
            return

        remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
        remote_syncid_by_uid = {v: k for k, v in remote_map.items()}

        remote_locations = self._rpc(
            'fusion.technician.location', 'search_read',
            [[
                ('user_id', 'in', remote_tech_ids),
                ('logged_at', '>', str(fields.Datetime.subtract(
                    fields.Datetime.now(), hours=24))),
                ('source', '!=', 'sync'),
            ]],
            {
                'fields': ['user_id', 'latitude', 'longitude',
                           'accuracy', 'logged_at'],
                'order': 'logged_at desc',
            })

        if not remote_locations:
            return

        Location = self.env['fusion.technician.location'].sudo()

        seen_techs = set()
        synced_count = 0
        for rloc in remote_locations:
            remote_uid_raw = rloc['user_id']
            remote_uid = (remote_uid_raw[0]
                          if isinstance(remote_uid_raw, (list, tuple))
                          else remote_uid_raw)
            if remote_uid in seen_techs:
                continue
            seen_techs.add(remote_uid)

            sync_id = remote_syncid_by_uid.get(remote_uid)
            local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
            if not local_uid:
                continue

            lat = rloc.get('latitude', 0)
            lng = rloc.get('longitude', 0)
            if not lat or not lng:
                continue

            old_synced = Location.search([
                ('user_id', '=', local_uid),
                ('source', '=', 'sync'),
                ('sync_instance', '=', self.instance_id),
            ])
            if old_synced:
                old_synced.unlink()

            Location.create({
                'user_id': local_uid,
                'latitude': lat,
                'longitude': lng,
                'accuracy': rloc.get('accuracy', 0),
                'logged_at': rloc.get('logged_at', fields.Datetime.now()),
                'source': 'sync',
                'sync_instance': self.instance_id,
            })
            synced_count += 1

        if synced_count:
            _logger.info("Synced %d technician location(s) from %s",
                         synced_count, self.name)

    # ------------------------------------------------------------------
    # CLEANUP
    # ------------------------------------------------------------------

    @api.model
    def _cron_cleanup_old_shadows(self):
        """Remove shadow tasks older than 30 days (completed/cancelled)."""
        cutoff = fields.Date.today() - timedelta(days=30)
        old_shadows = self.env['fusion.technician.task'].sudo().search([
            ('x_fc_sync_source', '!=', False),
            ('scheduled_date', '<', str(cutoff)),
            ('status', 'in', ['completed', 'cancelled']),
        ])
        if old_shadows:
            count = len(old_shadows)
            old_shadows.unlink()
            _logger.info("Cleaned up %d old shadow tasks", count)

    # ------------------------------------------------------------------
    # Manual trigger
    # ------------------------------------------------------------------

    def action_sync_now(self):
        """Manually trigger a full sync for this config."""
        self.ensure_one()
        self._pull_tasks_from_remote()
        self._pull_technician_locations()
        self.sudo().write({
            'last_sync': fields.Datetime.now(),
            'last_sync_error': False,
        })
        shadow_count = self.env['fusion.technician.task'].sudo().search_count([
            ('x_fc_sync_source', '=', self.instance_id),
        ])
        loc_count = self.env['fusion.technician.location'].sudo().search_count([
            ('source', '=', 'sync'),
            ('sync_instance', '=', self.instance_id),
        ])
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'Sync Complete',
                'message': (f'Synced from {self.name}. '
                            f'{shadow_count} shadow task(s), '
                            f'{loc_count} technician location(s) visible.'),
                'type': 'success',
                'sticky': False,
            },
        }
