import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'dart:convert';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/task.dart';
import '../models/task_activity_log.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/task_assignment.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'tickets_provider.dart';
import 'user_offices_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
import '../utils/app_time.dart';

String _stableJson(dynamic value) {
  if (value is Map) {
    final keys = value.keys.map((k) => k.toString()).toList()..sort();
    final entries = <String, dynamic>{};
    for (final key in keys) {
      entries[key] = _stableJson(value[key]);
    }
    return jsonEncode(entries);
  }
  if (value is List) {
    return jsonEncode(value.map(_stableJson).toList(growable: false));
  }
  return jsonEncode(value);
}

String _activityFingerprint(Map<String, dynamic> row) {
  final taskId = row['task_id']?.toString() ?? '';
  final actorId = row['actor_id']?.toString() ?? '';
  final actionType = row['action_type']?.toString() ?? '';
  final meta = row['meta'];
  return '$taskId|$actorId|$actionType|${_stableJson(meta)}';
}

/// Build a UI-display fingerprint for a log entry so that entries that would
/// render identically are collapsed to a single row.
///
/// For auto-save fields (filled_*), the actual text value changes every millisecond
/// as the user types, creating hundreds of entries. By ignoring the text value and
/// grouping strictly by "Task + Actor + Action + Visual Minute", all auto-save
/// logs within the exact same minute collapse into a single display row.
String _uiFingerprint(TaskActivityLog log) {
  const oncePerTask = {'created', 'started', 'completed'};
  if (oncePerTask.contains(log.actionType)) {
    return '${log.taskId}|${log.actionType}';
  }

  // The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month,
  // day, hour, and minute ensures we collapse duplicates that look identical on screen.
  final visualMinute =
      '${log.createdAt.year}-${log.createdAt.month}-${log.createdAt.day}-${log.createdAt.hour}-${log.createdAt.minute}';

  if (log.actionType.startsWith('filled_')) {
    // DO NOT include the meta/value here! If the user types "A", "AB", "ABC" in the
    // same minute, including the value would make them mathematically "unique" and
    // bypass the deduplication. Grouping by time+action solves the auto-save spam.
    return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$visualMinute';
  }

  // For other actions, we also include the normalized meta to be safe.
  final metaStr = _normaliseMeta(log.meta);
  return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$metaStr|$visualMinute';
}

List<TaskActivityLog> _dedupeActivityLogs(List<TaskActivityLog> logs) {
  if (logs.length < 2) return logs;
  // Sort newest first so we always keep the most recent copy of a duplicate.
  final sorted = [...logs]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
  final seen = <String>{};
  final deduped = <TaskActivityLog>[];
  for (final log in sorted) {
    final fp = _uiFingerprint(log);
    if (seen.add(fp)) {
      deduped.add(log);
    }
  }
  return deduped;
}

/// Normalise a meta value for comparison: treat null and empty-map
/// identically, and sort map keys for stability.
String _normaliseMeta(dynamic meta) {
  if (meta == null) return '';
  final s = _stableJson(meta);
  return (s == 'null' || s == '{}') ? '' : s;
}

// Helper to insert activity log rows while sanitizing nulls and
// avoiding exceptions from malformed payloads. Accepts either a Map
// or a List<Map>.
Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
  try {
    if (rows == null) return;
    if (rows is List) {
      final sanitized = rows
          .map((r) {
            if (r is Map) {
              final m = Map<String, dynamic>.from(r);
              m.removeWhere((k, v) => v == null);
              return m;
            }
            return null;
          })
          .whereType<Map<String, dynamic>>()
          .toList();
      if (sanitized.isEmpty) return;

      final seen = <String>{};
      final dedupedBatch = <Map<String, dynamic>>[];
      for (final row in sanitized) {
        final fingerprint = _activityFingerprint(row);
        if (seen.add(fingerprint)) {
          dedupedBatch.add(row);
        }
      }

      if (dedupedBatch.isEmpty) return;
      await client.from('task_activity_logs').insert(dedupedBatch);
    } else if (rows is Map) {
      final m = Map<String, dynamic>.from(rows);
      m.removeWhere((k, v) => v == null);
      await client.from('task_activity_logs').insert(m);
    }
  } catch (e) {
    // Log for debugging but don't rethrow to avoid breaking caller flows
    try {
      debugPrint('[insertActivityRows] insert failed: $e');
    } catch (_) {}
  }
}

/// Task query parameters for server-side pagination and filtering.
class TaskQuery {
  /// Creates task query parameters.
  const TaskQuery({
    this.offset = 0,
    this.limit = 50,
    this.searchQuery = '',
    this.officeId,
    this.status,
    this.taskNumber,
    this.dateRange,
  });

  /// Offset for pagination.
  final int offset;

  /// Number of items per page (default: 50).
  final int limit;

  /// Full text search query.
  final String searchQuery;

  /// Filter by office ID.
  final String? officeId;

  /// Filter by status.
  final String? status;

  /// Filter by task number (partial match, case-insensitive).
  final String? taskNumber;

  /// Filter by date range.
  final DateTimeRange? dateRange;

  TaskQuery copyWith({
    int? offset,
    int? limit,
    String? searchQuery,
    String? officeId,
    String? status,
    String? taskNumber,
    DateTimeRange? dateRange,
  }) {
    return TaskQuery(
      offset: offset ?? this.offset,
      limit: limit ?? this.limit,
      searchQuery: searchQuery ?? this.searchQuery,
      officeId: officeId ?? this.officeId,
      status: status ?? this.status,
      taskNumber: taskNumber ?? this.taskNumber,
      dateRange: dateRange ?? this.dateRange,
    );
  }
}

/// Builds the isolate payload from a list of [Task] objects and the current
/// query/access context. Extracted so the initial REST seed and the realtime
/// stream listener can share the same logic without duplication.
Map<String, dynamic> _buildTaskPayload({
  required List<Task> tasks,
  required bool isGlobal,
  required List<String> allowedTicketIds,
  required List<String> allowedOfficeIds,
  required TaskQuery query,
}) {
  final rowsList = tasks
      .map(
        (task) => <String, dynamic>{
          'id': task.id,
          'task_number': task.taskNumber,
          'office_id': task.officeId,
          'ticket_id': task.ticketId,
          'title': task.title,
          'description': task.description,
          'status': task.status,
          'priority': task.priority,
          'creator_id': task.creatorId,
          'created_at': task.createdAt.toIso8601String(),
          'started_at': task.startedAt?.toIso8601String(),
          'completed_at': task.completedAt?.toIso8601String(),
          'requested_by': task.requestedBy,
          'noted_by': task.notedBy,
          'received_by': task.receivedBy,
          'queue_order': task.queueOrder,
          'request_type': task.requestType,
          'request_type_other': task.requestTypeOther,
          'request_category': task.requestCategory,
          'action_taken': task.actionTaken,
          'cancellation_reason': task.cancellationReason,
          'cancelled_at': task.cancelledAt?.toIso8601String(),
        },
      )
      .toList();

  return {
    'rows': rowsList,
    'isGlobal': isGlobal,
    'allowedTicketIds': allowedTicketIds,
    'allowedOfficeIds': allowedOfficeIds,
    'officeId': query.officeId,
    'status': query.status,
    'searchQuery': query.searchQuery,
    'taskNumber': query.taskNumber,
    'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
    'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
  };
}

final tasksProvider = StreamProvider<List<Task>>((ref) {
  final client = ref.watch(supabaseClientProvider);
  final profileAsync = ref.watch(currentProfileProvider);
  final ticketsAsync = ref.watch(ticketsProvider);
  final assignmentsAsync = ref.watch(userOfficesProvider);
  final query = ref.watch(tasksQueryProvider);

  final profile = profileAsync.valueOrNull;
  if (profile == null) {
    return Stream.value(const <Task>[]);
  }

  final isGlobal =
      profile.role == 'admin' ||
      profile.role == 'dispatcher' ||
      profile.role == 'it_staff';

  final allowedTicketIds =
      ticketsAsync.valueOrNull?.map((t) => t.id).toList() ?? <String>[];
  final allowedOfficeIds =
      assignmentsAsync.valueOrNull
          ?.where((a) => a.userId == profile.id)
          .map((a) => a.officeId)
          .toSet()
          .toList() ??
      <String>[];

  // For non-global users with no assigned offices/tickets, skip subscribing.
  if (!isGlobal && allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) {
    return Stream.value(const <Task>[]);
  }

  // Wrap realtime stream with recovery logic
  final wrapper = StreamRecoveryWrapper<Task>(
    stream: client.from('tasks').stream(primaryKey: ['id']),
    onPollData: () async {
      final data = await client.from('tasks').select();
      return data.cast<Map<String, dynamic>>().map(Task.fromMap).toList();
    },
    fromMap: Task.fromMap,
    channelName: 'tasks',
    onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
  );

  ref.onDispose(wrapper.dispose);

  var lastResultHash = '';
  Timer? debounceTimer;
  // broadcast() so Riverpod and any other listener can both receive events.
  final controller = StreamController<List<Task>>.broadcast();

  void emitDebounced(List<Task> tasks) {
    debounceTimer?.cancel();
    debounceTimer = Timer(const Duration(milliseconds: 150), () {
      if (!controller.isClosed) controller.add(tasks);
    });
  }

  ref.onDispose(() {
    debounceTimer?.cancel();
    controller.close();
  });

  // ── Immediate REST seed ───────────────────────────────────────────────────
  // Fire a one-shot HTTP fetch right now so the UI can render before the
  // WebSocket realtime channel is fully established. Eliminates loading delay
  // on web and initial flash on mobile. Hash check prevents a duplicate
  // rebuild if both the seed and the realtime stream arrive with the same data.
  unawaited(
    Future(() async {
      try {
        final data = await client.from('tasks').select();
        final raw = data
            .cast<Map<String, dynamic>>()
            .map(Task.fromMap)
            .toList();
        final payload = _buildTaskPayload(
          tasks: raw,
          isGlobal: isGlobal,
          allowedTicketIds: allowedTicketIds,
          allowedOfficeIds: allowedOfficeIds,
          query: query,
        );
        final processed = await compute(_processTasksInIsolate, payload);
        final tasks = (processed as List<dynamic>)
            .cast<Map<String, dynamic>>()
            .map(Task.fromMap)
            .toList();
        final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
        if (!controller.isClosed && hash != lastResultHash) {
          lastResultHash = hash;
          controller.add(tasks); // emit immediately – no debounce
        }
      } catch (e) {
        debugPrint('[tasksProvider] initial seed error: $e');
      }
    }),
  );

  // ── Realtime stream ───────────────────────────────────────────────────────
  // Processes every realtime event through the same isolate. Debounced so
  // rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
  final wrapperSub = wrapper.stream
      .asyncMap((result) async {
        final payload = _buildTaskPayload(
          tasks: result.data,
          isGlobal: isGlobal,
          allowedTicketIds: allowedTicketIds,
          allowedOfficeIds: allowedOfficeIds,
          query: query,
        );
        final processed = await compute(_processTasksInIsolate, payload);
        return (processed as List<dynamic>)
            .cast<Map<String, dynamic>>()
            .map(Task.fromMap)
            .toList();
      })
      .listen(
        (tasks) {
          final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
          if (hash != lastResultHash) {
            lastResultHash = hash;
            emitDebounced(tasks);
          }
        },
        onError: (Object e) {
          debugPrint('[tasksProvider] stream error: $e');
          // Don't forward errors — the wrapper handles recovery internally.
        },
      );

  ref.onDispose(wrapperSub.cancel);

  return controller.stream;
});

// Runs inside a background isolate to filter/sort tasks represented as
// plain maps. Returns a List<Map<String,dynamic>> suitable for
// reconstruction with `Task.fromMap` on the main isolate.
List<Map<String, dynamic>> _processTasksInIsolate(
  Map<String, dynamic> payload,
) {
  var list = List<Map<String, dynamic>>.from(
    (payload['rows'] as List).cast<Map<String, dynamic>>(),
  );

  final isGlobal = payload['isGlobal'] as bool? ?? false;
  final allowedTicketIds =
      (payload['allowedTicketIds'] as List?)?.cast<String>().toSet() ??
      <String>{};
  final allowedOfficeIds =
      (payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
      <String>{};

  if (!isGlobal) {
    if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) {
      return <Map<String, dynamic>>[];
    }
    list = list.where((t) {
      final tid = t['ticket_id'] as String?;
      final oid = t['office_id'] as String?;
      return (tid != null && allowedTicketIds.contains(tid)) ||
          (oid != null && allowedOfficeIds.contains(oid));
    }).toList();
  }

  final officeId = payload['officeId'] as String?;
  if (officeId != null) {
    list = list.where((t) => t['office_id'] == officeId).toList();
  }
  final status = payload['status'] as String?;
  if (status != null) {
    list = list.where((t) => t['status'] == status).toList();
  }

  final searchQuery = (payload['searchQuery'] as String?) ?? '';
  if (searchQuery.isNotEmpty) {
    final q = searchQuery.toLowerCase();
    list = list.where((t) {
      final title = (t['title'] as String?)?.toLowerCase() ?? '';
      final desc = (t['description'] as String?)?.toLowerCase() ?? '';
      final tn = (t['task_number'] as String?)?.toLowerCase() ?? '';
      return title.contains(q) || desc.contains(q) || tn.contains(q);
    }).toList();
  }

  final taskNumberFilter = (payload['taskNumber'] as String?)?.trim();
  if (taskNumberFilter != null && taskNumberFilter.isNotEmpty) {
    final tnLow = taskNumberFilter.toLowerCase();
    list = list
        .where(
          (t) => ((t['task_number'] as String?) ?? '').toLowerCase().contains(
            tnLow,
          ),
        )
        .toList();
  }

  int statusRank(String s) {
    switch (s) {
      case 'queued':
        return 0;
      case 'in_progress':
        return 1;
      case 'completed':
        return 2;
      case 'cancelled':
        return 3;
      default:
        return 4;
    }
  }

  int parseCreatedAt(Map<String, dynamic> m) {
    final v = m['created_at'];
    if (v == null) return 0;
    if (v is int) return v;
    if (v is double) return v.toInt();
    if (v is String) {
      try {
        return DateTime.parse(v).millisecondsSinceEpoch;
      } catch (_) {
        return 0;
      }
    }
    return 0;
  }

  list.sort((a, b) {
    final ra = statusRank((a['status'] as String?) ?? '');
    final rb = statusRank((b['status'] as String?) ?? '');
    final rcmp = ra.compareTo(rb);
    if (rcmp != 0) return rcmp;

    if (ra == 0) {
      // queued: higher priority first (lower number = higher priority),
      // then created_at descending (newest first)
      final pa = (a['priority'] as num?)?.toInt() ?? 1;
      final pb = (b['priority'] as num?)?.toInt() ?? 1;
      final pcmp = pb.compareTo(pa);
      if (pcmp != 0) return pcmp;
      // Same priority: sort by created_at descending (newest first)
      return parseCreatedAt(b).compareTo(parseCreatedAt(a));
    }

    if (ra == 1) {
      // in_progress: higher priority first (lower number = higher priority),
      // then created_at descending (newest first)
      final pa = (a['priority'] as num?)?.toInt() ?? 1;
      final pb = (b['priority'] as num?)?.toInt() ?? 1;
      final pcmp = pb.compareTo(pa);
      if (pcmp != 0) return pcmp;
      // Same priority: sort by created_at descending (newest first)
      return parseCreatedAt(b).compareTo(parseCreatedAt(a));
    }

    // completed and cancelled: sort by created_at descending (newest first)
    return parseCreatedAt(b).compareTo(parseCreatedAt(a));
  });

  return list;
}

/// Provider for task query parameters.
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());

/// Derived provider that selects a single [Task] by ID from the tasks list.
///
/// Because [Task] implements `==`, this provider only notifies watchers when
/// the specific task's data actually changes — not when unrelated tasks in the
/// list are updated.  Use this in detail screens to avoid full-list rebuilds.
final taskByIdProvider = Provider.family<Task?, String>((ref, taskId) {
  return ref
      .watch(tasksProvider)
      .valueOrNull
      ?.where((t) => t.id == taskId)
      .firstOrNull;
});

/// Derived provider that selects a [Task] linked to a given ticket ID.
///
/// Returns the first task whose `ticketId` matches [ticketId], or null.
final taskByTicketIdProvider = Provider.family<Task?, String>((ref, ticketId) {
  return ref
      .watch(tasksProvider)
      .valueOrNull
      ?.where((t) => t.ticketId == ticketId)
      .firstOrNull;
});

final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
  final client = ref.watch(supabaseClientProvider);

  final wrapper = StreamRecoveryWrapper<TaskAssignment>(
    stream: client
        .from('task_assignments')
        .stream(primaryKey: ['task_id', 'user_id']),
    onPollData: () async {
      final data = await client.from('task_assignments').select();
      return data.map(TaskAssignment.fromMap).toList();
    },
    fromMap: TaskAssignment.fromMap,
    channelName: 'task_assignments',
    onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
  );

  ref.onDispose(wrapper.dispose);
  return wrapper.stream.map((result) => result.data);
});

/// Stream of activity logs for a single task.
final taskActivityLogsProvider =
    StreamProvider.family<List<TaskActivityLog>, String>((ref, taskId) {
      final client = ref.watch(supabaseClientProvider);

      final wrapper = StreamRecoveryWrapper<TaskActivityLog>(
        stream: client
            .from('task_activity_logs')
            .stream(primaryKey: ['id'])
            .eq('task_id', taskId)
            .order('created_at', ascending: false),
        onPollData: () async {
          final data = await client
              .from('task_activity_logs')
              .select()
              .eq('task_id', taskId)
              .order('created_at', ascending: false);
          return data.map((r) => TaskActivityLog.fromMap(r)).toList();
        },
        fromMap: TaskActivityLog.fromMap,
        channelName: 'task_activity_logs:$taskId',
        onStatusChanged: ref
            .read(realtimeControllerProvider)
            .handleChannelStatus,
      );

      ref.onDispose(wrapper.dispose);
      return wrapper.stream.map((result) => _dedupeActivityLogs(result.data));
    });

final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
  ref,
) {
  final client = ref.watch(supabaseClientProvider);
  return TaskAssignmentsController(client);
});

final tasksControllerProvider = Provider<TasksController>((ref) {
  final client = ref.watch(supabaseClientProvider);
  return TasksController(client);
});

class TasksController {
  TasksController(this._client);

  // Supabase storage bucket for task action images. Ensure this bucket exists
  // with public read access.
  static const String _actionImageBucket = 'task_action_taken_images';

  // _client is declared dynamic allowing test doubles that mimic only the
  // subset of methods used by this class. In production it will be a
  // SupabaseClient instance.
  final dynamic _client;

  Future<void> createTask({
    required String title,
    required String description,
    String? officeId,
    String? ticketId,
    // optional request metadata when creating a task
    String? requestType,
    String? requestTypeOther,
    String? requestCategory,
  }) async {
    final actorId = _client.auth.currentUser?.id;
    final payload = <String, dynamic>{
      'title': title,
      'description': description,
    };
    if (officeId != null) {
      payload['office_id'] = officeId;
    }
    if (ticketId != null) {
      payload['ticket_id'] = ticketId;
    }
    if (requestType != null) {
      payload['request_type'] = requestType;
    }
    if (requestTypeOther != null) {
      payload['request_type_other'] = requestTypeOther;
    }
    if (requestCategory != null) {
      payload['request_category'] = requestCategory;
    }

    // Prefer server RPC that atomically generates `task_number` and inserts
    // the task; fallback to client-side insert with retry on duplicate-key.
    String? taskId;
    String? assignedNumber;

    try {
      final rpcParams = Map<String, dynamic>.from(payload);
      // Retry RPC on duplicate-key (23505) errors which may occur
      // transiently due to concurrent inserts; prefer RPC always.
      const int rpcMaxAttempts = 3;
      Map<String, dynamic>? rpcRow;
      for (var attempt = 0; attempt < rpcMaxAttempts; attempt++) {
        try {
          final rpcRes = await _client
              .rpc('insert_task_with_number', rpcParams)
              .single();
          if (rpcRes is Map) {
            rpcRow = Map<String, dynamic>.from(rpcRes);
          } else if (rpcRes is List &&
              rpcRes.isNotEmpty &&
              rpcRes.first is Map) {
            rpcRow = Map<String, dynamic>.from(rpcRes.first as Map);
          }
          break;
        } catch (err) {
          final msg = err.toString();
          final isDuplicateKey =
              msg.contains('duplicate key value') || msg.contains('23505');
          if (!isDuplicateKey || attempt == rpcMaxAttempts - 1) {
            rethrow;
          }
          await Future.delayed(Duration(milliseconds: 150 * (attempt + 1)));
          // retry
        }
      }
      if (rpcRow != null) {
        taskId = rpcRow['id'] as String?;
        assignedNumber = rpcRow['task_number'] as String?;
      }
      debugPrint(
        'createTask via RPC assigned number=$assignedNumber id=$taskId',
      );
    } catch (e) {
      // RPC not available or failed; fallback to client insert with retry
      const int maxAttempts = 3;
      Map<String, dynamic>? insertData;
      for (var attempt = 0; attempt < maxAttempts; attempt++) {
        try {
          insertData = await _client
              .from('tasks')
              .insert(payload)
              .select('id, task_number')
              .single();
          break;
        } catch (err) {
          final msg = err.toString();
          final isDuplicateKey =
              msg.contains('duplicate key value') || msg.contains('23505');
          if (!isDuplicateKey || attempt == maxAttempts - 1) {
            rethrow;
          }
          await Future.delayed(Duration(milliseconds: 150 * (attempt + 1)));
        }
      }
      taskId = insertData == null ? null : insertData['id'] as String?;
      assignedNumber = insertData == null
          ? null
          : insertData['task_number'] as String?;
      debugPrint(
        'createTask fallback assigned number=$assignedNumber id=$taskId',
      );
    }

    if (taskId == null) return;

    try {
      await _insertActivityRows(_client, {
        'task_id': taskId,
        'actor_id': actorId,
        'action_type': 'created',
      });
    } catch (_) {
      // non-fatal
    }

    // Auto-assignment should run once on creation (best-effort).
    try {
      await _autoAssignTask(taskId: taskId, officeId: officeId ?? '');
    } catch (e, st) {
      // keep creation successful but surface the error in logs for debugging
      debugPrint('autoAssignTask failed for task=$taskId: $e\n$st');
    }

    unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
  }

  /// Uploads an image for a task's action field and returns the public URL.
  ///
  /// [bytes] should contain the file data and [extension] the file extension
  /// (e.g. 'png' or 'jpg'). The image will be stored under a path that
  /// includes the task ID and a timestamp to avoid collisions. Returns `null`
  /// if the upload fails.
  Future<String?> uploadActionImage({
    required String taskId,
    required Uint8List bytes,
    required String extension,
  }) async {
    final path =
        'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension';
    try {
      // debug: show upload path
      debugPrint('uploadActionImage uploading to path: $path');
      // perform the upload and capture whatever the SDK returns (it varies by platform)
      final dynamic res;
      if (kIsWeb) {
        // on web, upload binary data
        res = await _client.storage
            .from(_actionImageBucket)
            .uploadBinary(path, bytes);
      } else {
        // write bytes to a simple temp file (no nested folders)
        final tmpDir = Directory.systemTemp;
        final localFile = File(
          '${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}.$extension',
        );
        try {
          await localFile.create();
          await localFile.writeAsBytes(bytes);
        } catch (e) {
          debugPrint('uploadActionImage failed writing temp file: $e');
          return null;
        }
        res = await _client.storage
            .from(_actionImageBucket)
            .upload(path, localFile);
        try {
          await localFile.delete();
        } catch (_) {}
      }

      debugPrint(
        'uploadActionImage response type=${res.runtimeType} value=$res',
      );

      // Some SDK methods return a simple String (path) on success, others
      // return a StorageResponse with an error field. Avoid calling .error on a
      // String to prevent NoSuchMethodError as seen in logs earlier.
      if (res is String) {
        // treat as success
      } else if (res is Map && res['error'] != null) {
        // older versions might return a plain map
        debugPrint('uploadActionImage upload error: ${res['error']}');
        return null;
      } else if (res != null && res.error != null) {
        // StorageResponse case
        debugPrint('uploadActionImage upload error: ${res.error}');
        return null;
      }
    } catch (e) {
      debugPrint('uploadActionImage failed upload: $e');
      return null;
    }
    try {
      final urlRes = await _client.storage
          .from(_actionImageBucket)
          .getPublicUrl(path);
      // debug: log full response
      debugPrint('uploadActionImage getPublicUrl response: $urlRes');

      String? url;
      if (urlRes is String) {
        url = urlRes;
      } else if (urlRes is Map && urlRes['data'] is String) {
        url = urlRes['data'] as String;
      } else if (urlRes != null) {
        try {
          url = urlRes.data as String?;
        } catch (_) {
          url = null;
        }
      }

      if (url != null && url.isNotEmpty) {
        // trim whitespace/newline which may be added by SDK or logging
        return url.trim();
      }
      // fallback: construct URL manually using env variable
      final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
      if (supabaseUrl.isEmpty) return null;
      return '$supabaseUrl/storage/v1/object/public/$_actionImageBucket/$path'
          .trim();
    } catch (e) {
      debugPrint('uploadActionImage getPublicUrl error: $e');
      return null;
    }
  }

  Future<void> uploadTaskAttachment({
    required String taskId,
    required String fileName,
    required Uint8List bytes,
  }) async {
    final path = '$taskId/$fileName';
    try {
      debugPrint('uploadTaskAttachment uploading to path: $path');

      final dynamic res;
      if (kIsWeb) {
        // on web, upload binary data
        res = await _client.storage
            .from('task_attachments')
            .uploadBinary(path, bytes);
      } else {
        // write bytes to a simple temp file (no nested folders)
        final tmpDir = Directory.systemTemp;
        final localFile = File(
          '${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}_$fileName',
        );
        try {
          await localFile.create();
          await localFile.writeAsBytes(bytes);
        } catch (e) {
          debugPrint('uploadTaskAttachment failed writing temp file: $e');
          rethrow;
        }

        res = await _client.storage
            .from('task_attachments')
            .upload(path, localFile);

        try {
          await localFile.delete();
        } catch (_) {}
      }

      debugPrint(
        'uploadTaskAttachment response type=${res.runtimeType} value=$res',
      );

      // Check for errors
      if (res is String) {
        // treat as success
      } else if (res is Map && res['error'] != null) {
        debugPrint('uploadTaskAttachment upload error: ${res['error']}');
        throw Exception('Upload error: ${res['error']}');
      } else if (res != null && res.error != null) {
        debugPrint('uploadTaskAttachment upload error: ${res.error}');
        throw Exception('Upload error: ${res.error}');
      }
    } catch (e) {
      debugPrint('uploadTaskAttachment failed: $e');
      rethrow;
    }
  }

  Future<void> _notifyCreated({
    required String taskId,
    required String? actorId,
  }) async {
    try {
      final recipients = await _fetchRoleUserIds(
        roles: const ['dispatcher', 'it_staff'],
        excludeUserId: actorId,
      );
      if (recipients.isEmpty) return;
      final rows = recipients
          .map(
            (userId) => {
              'user_id': userId,
              'actor_id': actorId,
              'task_id': taskId,
              'type': 'created',
            },
          )
          .toList();
      await _client.from('notifications').insert(rows);
      // Send FCM pushes with meaningful text
      try {
        // resolve actor display name if possible
        String actorName = 'Someone';
        if (actorId != null && actorId.isNotEmpty) {
          try {
            final p = await _client
                .from('profiles')
                .select('full_name,display_name,name')
                .eq('id', actorId)
                .maybeSingle();
            if (p != null) {
              if (p['full_name'] != null) {
                actorName = p['full_name'].toString();
              } else if (p['display_name'] != null) {
                actorName = p['display_name'].toString();
              } else if (p['name'] != null) {
                actorName = p['name'].toString();
              }
            }
          } catch (_) {}
        }

        // fetch task_number and office (try embedding office.name); fallback to offices lookup
        String? taskNumber;
        String? officeId;
        String? officeName;
        try {
          final t = await _client
              .from('tasks')
              .select('task_number, office_id, offices(name)')
              .eq('id', taskId)
              .maybeSingle();
          if (t != null) {
            if (t['task_number'] != null) {
              taskNumber = t['task_number'].toString();
            }
            if (t['office_id'] != null) officeId = t['office_id'].toString();
            final dynOffices = t['offices'];
            if (dynOffices != null) {
              if (dynOffices is List &&
                  dynOffices.isNotEmpty &&
                  dynOffices.first['name'] != null) {
                officeName = dynOffices.first['name'].toString();
              } else if (dynOffices is Map && dynOffices['name'] != null) {
                officeName = dynOffices['name'].toString();
              }
            }
          }
        } catch (_) {}

        if ((officeName == null || officeName.isEmpty) &&
            officeId != null &&
            officeId.isNotEmpty) {
          try {
            final o = await _client
                .from('offices')
                .select('name')
                .eq('id', officeId)
                .maybeSingle();
            if (o != null && o['name'] != null) {
              officeName = o['name'].toString();
            }
          } catch (_) {}
        }

        final title = 'New task';
        final body = taskNumber != null
            ? (officeName != null
                  ? '$actorName created task #$taskNumber in $officeName.'
                  : '$actorName created task #$taskNumber.')
            : (officeName != null
                  ? '$actorName created a new task in $officeName.'
                  : '$actorName created a new task.');

        final dataPayload = <String, dynamic>{
          'type': 'created',
          'task_id': taskId,
          'task_number': ?taskNumber,
          'office_id': ?officeId,
          'office_name': ?officeName,
        };

        await _client.functions.invoke(
          'send_fcm',
          body: {
            'user_ids': recipients,
            'title': title,
            'body': body,
            'data': dataPayload,
          },
        );
      } catch (e) {
        // non-fatal: push failure should not break flow
        debugPrint('notifyCreated push error: $e');
      }
    } catch (_) {
      return;
    }
  }

  Future<List<String>> _fetchRoleUserIds({
    required List<String> roles,
    required String? excludeUserId,
  }) async {
    try {
      final data = await _client
          .from('profiles')
          .select('id, role')
          .inFilter('role', roles);
      final rows = data;
      final ids = rows
          .map((row) => row['id'] as String?)
          .whereType<String>()
          .where((id) => id.isNotEmpty && id != excludeUserId)
          .toList();
      return ids;
    } catch (_) {
      return [];
    }
  }

  /// Update only the status of a task.
  ///
  /// Before marking a task as **completed** we enforce that the
  /// request type/category metadata have been provided. This protects the
  /// business rule that details must be specified before closing.
  Future<void> updateTaskStatus({
    required String taskId,
    required String status,
    String? reason,
  }) async {
    Map<String, dynamic>? taskRow;
    try {
      final row = await _client
          .from('tasks')
          .select('status, request_type, request_category, action_taken')
          .eq('id', taskId)
          .maybeSingle();
      if (row is! Map<String, dynamic>) {
        throw Exception('Task not found');
      }
      taskRow = row;
    } catch (e) {
      rethrow;
    }

    final currentStatus = (taskRow['status'] as String?)?.trim() ?? '';
    if (currentStatus == 'closed' ||
        currentStatus == 'cancelled' ||
        currentStatus == 'completed') {
      if (currentStatus != status) {
        throw Exception(
          'Status cannot be changed after a task is $currentStatus.',
        );
      }
      return;
    }

    if (status == 'cancelled') {
      if (reason == null || reason.trim().isEmpty) {
        throw Exception('Cancellation requires a reason.');
      }
    }

    if (status == 'in_progress') {
      final assignmentRows = await _client
          .from('task_assignments')
          .select('user_id')
          .eq('task_id', taskId);
      final assignedUserIds = (assignmentRows as List)
          .map((row) => row['user_id']?.toString())
          .whereType<String>()
          .where((id) => id.isNotEmpty)
          .toSet()
          .toList();
      if (assignedUserIds.isEmpty) {
        throw Exception(
          'Assign at least one IT Staff before starting this task.',
        );
      }

      final itStaffRows = await _client
          .from('profiles')
          .select('id')
          .inFilter('id', assignedUserIds)
          .eq('role', 'it_staff');
      final hasItStaff = (itStaffRows as List).isNotEmpty;
      if (!hasItStaff) {
        throw Exception(
          'Assign at least one IT Staff before starting this task.',
        );
      }
    }

    if (status == 'completed') {
      // Check for IT staff assignment
      final assignmentRows = await _client
          .from('task_assignments')
          .select('user_id')
          .eq('task_id', taskId);
      final assignedUserIds = (assignmentRows as List)
          .map((row) => row['user_id']?.toString())
          .whereType<String>()
          .where((id) => id.isNotEmpty)
          .toSet()
          .toList();
      if (assignedUserIds.isEmpty) {
        throw Exception(
          'Assign at least one IT Staff before completing this task.',
        );
      }

      final itStaffRows = await _client
          .from('profiles')
          .select('id')
          .inFilter('id', assignedUserIds)
          .eq('role', 'it_staff');
      final hasItStaff = (itStaffRows as List).isNotEmpty;
      if (!hasItStaff) {
        throw Exception(
          'Assign at least one IT Staff before completing this task.',
        );
      }

      // Check required fields
      try {
        final rt = taskRow['request_type'];
        final rc = taskRow['request_category'];
        final action = taskRow['action_taken'];

        final missing = <String>[];
        if (rt == null || (rt is String && rt.trim().isEmpty)) {
          missing.add('request type');
        }
        if (rc == null || (rc is String && rc.trim().isEmpty)) {
          missing.add('request category');
        }
        // signatories are no longer required for completion; they can be
        // filled in later.  we still require action taken to document what
        // was done.

        // if you want to enforce action taken you can uncomment this block,
        // but current business rule only mandates request metadata. we keep
        // action taken non-null for clarity.
        if (action == null || (action is String && action.trim().isEmpty)) {
          missing.add('action taken');
        }

        if (missing.isNotEmpty) {
          throw Exception(
            'The following fields must be set before completing a task: ${missing.join(', ')}.',
          );
        }
      } catch (e) {
        // rethrow so callers can handle (UI will display message)
        rethrow;
      }
    }

    // persist status and cancellation reason (when provided)
    final payload = <String, dynamic>{'status': status};
    if (status == 'cancelled') {
      payload['cancellation_reason'] = reason;
    }
    await _client.from('tasks').update(payload).eq('id', taskId);

    // if cancelled, also set cancelled_at timestamp
    if (status == 'cancelled') {
      try {
        final cancelledAt = AppTime.now().toIso8601String();
        await _client
            .from('tasks')
            .update({'cancelled_at': cancelledAt})
            .eq('id', taskId);
      } catch (_) {}
    }

    // Log important status transitions
    try {
      final actorId = _client.auth.currentUser?.id;
      if (status == 'in_progress') {
        await _insertActivityRows(_client, {
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'started',
        });
      } else if (status == 'completed') {
        await _insertActivityRows(_client, {
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'completed',
        });
      } else if (status == 'cancelled') {
        await _insertActivityRows(_client, {
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'cancelled',
          'meta': {'reason': reason},
        });
      }
    } catch (_) {
      // ignore logging failures
    }
  }

  /// Update arbitrary fields on a task row.
  ///
  /// Primarily used to set request metadata after creation or during
  /// status transitions.
  Future<void> updateTask({
    required String taskId,
    String? requestType,
    String? requestTypeOther,
    String? requestCategory,
    String? status,
    String? requestedBy,
    String? notedBy,
    String? receivedBy,
    String? actionTaken,
  }) async {
    final payload = <String, dynamic>{};
    if (requestType != null) {
      payload['request_type'] = requestType;
    }
    if (requestTypeOther != null) {
      payload['request_type_other'] = requestTypeOther;
    }
    if (requestCategory != null) {
      payload['request_category'] = requestCategory;
    }
    if (requestedBy != null) {
      payload['requested_by'] = requestedBy;
    }
    if (notedBy != null) {
      payload['noted_by'] = notedBy;
    }
    // `performed_by` is derived from task assignments; we don't persist it here.
    if (receivedBy != null) {
      payload['received_by'] = receivedBy;
    }
    if (actionTaken != null) {
      try {
        payload['action_taken'] = jsonDecode(actionTaken);
      } catch (_) {
        // fallback: store raw string
        payload['action_taken'] = actionTaken;
      }
    }
    if (status != null) {
      payload['status'] = status;
    }
    if (payload.isEmpty) {
      return;
    }

    await _client.from('tasks').update(payload).eq('id', taskId);

    // Record activity logs for any metadata/signatory fields that were changed
    try {
      final actorId = _client.auth.currentUser?.id;
      final List<Map<String, dynamic>> logRows = [];
      if (requestType != null) {
        logRows.add({
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'filled_request_type',
          'meta': {'value': requestType},
        });
      }
      if (requestCategory != null) {
        logRows.add({
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'filled_request_category',
          'meta': {'value': requestCategory},
        });
      }
      if (requestedBy != null) {
        logRows.add({
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'filled_requested_by',
          'meta': {'value': requestedBy},
        });
      }
      if (notedBy != null) {
        logRows.add({
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'filled_noted_by',
          'meta': {'value': notedBy},
        });
      }
      if (receivedBy != null) {
        logRows.add({
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'filled_received_by',
          'meta': {'value': receivedBy},
        });
      }
      if (actionTaken != null) {
        logRows.add({
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'filled_action_taken',
          'meta': {'value': actionTaken},
        });
      }

      if (logRows.isNotEmpty) {
        await _insertActivityRows(_client, logRows);
      }
    } catch (_) {}
  }

  /// Update editable task fields such as title, description, office or linked ticket.
  Future<void> updateTaskFields({
    required String taskId,
    String? title,
    String? description,
    String? officeId,
    String? ticketId,
  }) async {
    final payload = <String, dynamic>{};
    if (title != null) payload['title'] = title;
    if (description != null) payload['description'] = description;
    if (officeId != null) payload['office_id'] = officeId;
    if (ticketId != null) payload['ticket_id'] = ticketId;
    if (payload.isEmpty) return;
    await _client.from('tasks').update(payload).eq('id', taskId);

    // record an activity log for edit operations (best-effort)
    try {
      final actorId = _client.auth.currentUser?.id;
      await _insertActivityRows(_client, {
        'task_id': taskId,
        'actor_id': actorId,
        'action_type': 'updated',
        'meta': payload,
      });
    } catch (_) {}
  }

  // Auto-assignment logic executed once on creation.
  Future<void> _autoAssignTask({
    required String taskId,
    required String officeId,
  }) async {
    if (officeId.isEmpty) return;

    final now = AppTime.now();
    final startOfDay = DateTime(now.year, now.month, now.day);
    final nextDay = startOfDay.add(const Duration(days: 1));

    try {
      // 1) Find teams covering the office
      final teamsRows =
          (await _client.from('teams').select()) as List<dynamic>? ?? [];
      final teamIds = teamsRows
          .where((r) => (r['office_ids'] as List?)?.contains(officeId) == true)
          .map((r) => r['id'] as String)
          .toSet()
          .toList();
      if (teamIds.isEmpty) return;

      // 2) Get members of those teams
      final memberRows =
          (await _client
                  .from('team_members')
                  .select('user_id')
                  .inFilter('team_id', teamIds))
              as List<dynamic>? ??
          [];
      final candidateIds = memberRows
          .map((r) => r['user_id'] as String)
          .toSet()
          .toList();
      if (candidateIds.isEmpty) return;

      // 3) Filter by "On Duty" (have a check-in record for today)
      final dsRows =
          (await _client
                  .from('duty_schedules')
                  .select('user_id, check_in_at')
                  .inFilter('user_id', candidateIds))
              as List<dynamic>? ??
          [];

      final Map<String, DateTime> onDuty = {};
      for (final r in dsRows) {
        final userId = r['user_id'] as String?;
        final checkIn = r['check_in_at'] as String?;
        if (userId == null || checkIn == null) continue;
        final dt = DateTime.tryParse(checkIn);
        if (dt == null) continue;
        if (dt.isAfter(startOfDay.subtract(const Duration(seconds: 1))) &&
            dt.isBefore(nextDay.add(const Duration(seconds: 1)))) {
          onDuty[userId] = dt;
        }
      }
      if (onDuty.isEmpty) {
        // record a failed auto-assign attempt for observability
        try {
          await _insertActivityRows(_client, {
            'task_id': taskId,
            'actor_id': null,
            'action_type': 'auto_assign_failed',
            'meta': {'reason': 'no_on_duty_candidates'},
          });
        } catch (_) {}
        return;
      }

      // 4) For each on-duty user compute completed_tasks_count for today
      final List<_Candidate> candidates = [];
      for (final userId in onDuty.keys) {
        // get task ids assigned to user
        final taRows =
            (await _client
                    .from('task_assignments')
                    .select('task_id')
                    .eq('user_id', userId))
                as List<dynamic>? ??
            [];
        final assignedTaskIds = taRows
            .map((r) => r['task_id'] as String)
            .toList();
        int completedCount = 0;
        if (assignedTaskIds.isNotEmpty) {
          final tasksRows =
              (await _client
                      .from('tasks')
                      .select('id')
                      .inFilter('id', assignedTaskIds)
                      .gte('completed_at', startOfDay.toIso8601String())
                      .lt('completed_at', nextDay.toIso8601String()))
                  as List<dynamic>? ??
              [];
          completedCount = tasksRows.length;
        }
        candidates.add(
          _Candidate(
            userId: userId,
            checkInAt: onDuty[userId]!,
            completedToday: completedCount,
          ),
        );
      }

      if (candidates.isEmpty) {
        try {
          await _insertActivityRows(_client, {
            'task_id': taskId,
            'actor_id': null,
            'action_type': 'auto_assign_failed',
            'meta': {'reason': 'no_eligible_candidates'},
          });
        } catch (_) {}
        return;
      }

      // 5) Sort: latest check-in first (desc), then lowest completed_today
      candidates.sort((a, b) {
        final c = b.checkInAt.compareTo(a.checkInAt);
        if (c != 0) return c;
        return a.completedToday.compareTo(b.completedToday);
      });

      final chosen = candidates.first;

      // 6) Insert assignment + activity log + notification
      await _client.from('task_assignments').insert({
        'task_id': taskId,
        'user_id': chosen.userId,
      });

      try {
        await _insertActivityRows(_client, {
          'task_id': taskId,
          'actor_id': null,
          'action_type': 'assigned',
          'meta': {'auto': true, 'user_id': chosen.userId},
        });
      } catch (_) {}

      try {
        await _client.from('notifications').insert({
          'user_id': chosen.userId,
          'actor_id': null,
          'task_id': taskId,
          'type': 'assignment',
        });
        // send push for auto-assignment
        try {
          final actorName = 'Dispatcher';
          // fetch task_number and office for nicer deep-linking when available
          String? taskNumber;
          String? officeId;
          String? officeName;
          try {
            final t = await _client
                .from('tasks')
                .select('task_number, office_id, offices(name)')
                .eq('id', taskId)
                .maybeSingle();
            if (t != null) {
              if (t['task_number'] != null) {
                taskNumber = t['task_number'].toString();
              }
              if (t['office_id'] != null) officeId = t['office_id'].toString();
              final dynOffices = t['offices'];
              if (dynOffices != null) {
                if (dynOffices is List &&
                    dynOffices.isNotEmpty &&
                    dynOffices.first['name'] != null) {
                  officeName = dynOffices.first['name'].toString();
                } else if (dynOffices is Map && dynOffices['name'] != null) {
                  officeName = dynOffices['name'].toString();
                }
              }
            }
          } catch (_) {}

          if ((officeName == null || officeName.isEmpty) &&
              officeId != null &&
              officeId.isNotEmpty) {
            try {
              final o = await _client
                  .from('offices')
                  .select('name')
                  .eq('id', officeId)
                  .maybeSingle();
              if (o != null && o['name'] != null) {
                officeName = o['name'].toString();
              }
            } catch (_) {}
          }

          final dataPayload = <String, dynamic>{
            'type': 'assignment',
            'task_id': taskId,
            'task_number': ?taskNumber,
            'office_id': ?officeId,
            'office_name': ?officeName,
          };

          final title = 'Task assigned';
          final body = taskNumber != null
              ? (officeName != null
                    ? '$actorName assigned you task #$taskNumber in $officeName.'
                    : '$actorName assigned you task #$taskNumber.')
              : (officeName != null
                    ? '$actorName assigned you a task in $officeName.'
                    : '$actorName assigned you a task.');

          await _client.functions.invoke(
            'send_fcm',
            body: {
              'user_ids': [chosen.userId],
              'title': title,
              'body': body,
              'data': dataPayload,
            },
          );
        } catch (e) {
          debugPrint('autoAssign push error: $e');
        }
      } catch (_) {}
    } catch (e, st) {
      // Log error for visibility and record a failed auto-assign activity
      debugPrint('autoAssignTask error for task=$taskId: $e\n$st');
      try {
        await _insertActivityRows(_client, {
          'task_id': taskId,
          'actor_id': null,
          'action_type': 'auto_assign_failed',
          'meta': {'reason': 'exception', 'error': e.toString()},
        });
      } catch (_) {}
      return;
    }
  }
}

/// Public DTO used by unit tests to validate selection logic.
class AutoAssignCandidate {
  AutoAssignCandidate({
    required this.userId,
    required this.checkInAt,
    required this.completedToday,
  });

  final String userId;
  final DateTime checkInAt;
  final int completedToday;
}

/// Choose the best candidate according to auto-assignment rules:
/// - latest check-in first (late-comer priority)
/// - tie-breaker: lowest completedTasks (today)
/// Returns the chosen userId or null when candidates is empty.
String? chooseAutoAssignCandidate(List<AutoAssignCandidate> candidates) {
  if (candidates.isEmpty) return null;
  final list = List<AutoAssignCandidate>.from(candidates);
  list.sort((a, b) {
    final c = b.checkInAt.compareTo(a.checkInAt);
    if (c != 0) return c;
    return a.completedToday.compareTo(b.completedToday);
  });
  return list.first.userId;
}

class _Candidate {
  _Candidate({
    required this.userId,
    required this.checkInAt,
    required this.completedToday,
  });
  final String userId;
  final DateTime checkInAt;
  final int completedToday;
}

class TaskAssignmentsController {
  TaskAssignmentsController(this._client);

  final SupabaseClient _client;

  Future<void> replaceAssignments({
    required String taskId,
    required String? ticketId,
    required List<String> newUserIds,
    required List<String> currentUserIds,
  }) async {
    final nextIds = newUserIds.toSet();
    final currentIds = currentUserIds.toSet();
    final toAdd = nextIds.difference(currentIds).toList();
    final toRemove = currentIds.difference(nextIds).toList();

    if (toAdd.isNotEmpty) {
      final rows = toAdd
          .map((userId) => {'task_id': taskId, 'user_id': userId})
          .toList();
      await _client.from('task_assignments').insert(rows);

      // Insert activity log(s) for assignment(s).
      try {
        final actorId = _client.auth.currentUser?.id;
        final logRows = toAdd
            .map(
              (userId) => {
                'task_id': taskId,
                'actor_id': actorId,
                'action_type': 'assigned',
                'meta': {'user_id': userId},
              },
            )
            .toList();
        await _insertActivityRows(_client, logRows);
      } catch (_) {
        // non-fatal
      }

      await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
    }
    if (toRemove.isNotEmpty) {
      await _client
          .from('task_assignments')
          .delete()
          .eq('task_id', taskId)
          .inFilter('user_id', toRemove);

      // Record a reassignment event (who removed -> who added)
      try {
        final actorId = _client.auth.currentUser?.id;
        await _insertActivityRows(_client, {
          'task_id': taskId,
          'actor_id': actorId,
          'action_type': 'reassigned',
          'meta': {'from': toRemove, 'to': toAdd},
        });
      } catch (_) {
        // non-fatal
      }
    }
  }

  Future<void> _notifyAssigned({
    required String taskId,
    required String? ticketId,
    required List<String> userIds,
  }) async {
    if (userIds.isEmpty) return;
    try {
      final actorId = _client.auth.currentUser?.id;
      final rows = userIds
          .map(
            (userId) => {
              'user_id': userId,
              'actor_id': actorId,
              'task_id': taskId,
              'ticket_id': ticketId,
              'type': 'assignment',
            },
          )
          .toList();
      await _client.from('notifications').insert(rows);
      // send FCM pushes for explicit assignments
      try {
        String actorName = 'Someone';
        if (actorId != null && actorId.isNotEmpty) {
          try {
            final p = await _client
                .from('profiles')
                .select('full_name,display_name,name')
                .eq('id', actorId)
                .maybeSingle();
            if (p != null) {
              if (p['full_name'] != null) {
                actorName = p['full_name'].toString();
              } else if (p['display_name'] != null) {
                actorName = p['display_name'].toString();
              } else if (p['name'] != null) {
                actorName = p['name'].toString();
              }
            }
          } catch (_) {}
        }

        // fetch task_number and office (try embedding office.name); fallback to offices lookup
        String? taskNumber;
        String? officeId;
        String? officeName;
        try {
          final t = await _client
              .from('tasks')
              .select('task_number, office_id, offices(name)')
              .eq('id', taskId)
              .maybeSingle();
          if (t != null) {
            if (t['task_number'] != null) {
              taskNumber = t['task_number'].toString();
            }
            if (t['office_id'] != null) officeId = t['office_id'].toString();
            final dynOffices = t['offices'];
            if (dynOffices != null) {
              if (dynOffices is List &&
                  dynOffices.isNotEmpty &&
                  dynOffices.first['name'] != null) {
                officeName = dynOffices.first['name'].toString();
              } else if (dynOffices is Map && dynOffices['name'] != null) {
                officeName = dynOffices['name'].toString();
              }
            }
          }
        } catch (_) {}

        if ((officeName == null || officeName.isEmpty) &&
            officeId != null &&
            officeId.isNotEmpty) {
          try {
            final o = await _client
                .from('offices')
                .select('name')
                .eq('id', officeId)
                .maybeSingle();
            if (o != null && o['name'] != null) {
              officeName = o['name'].toString();
            }
          } catch (_) {}
        }

        final dataPayload = <String, dynamic>{
          'type': 'assignment',
          'task_id': taskId,
          'task_number': ?taskNumber,
          'ticket_id': ?ticketId,
          'office_id': ?officeId,
          'office_name': ?officeName,
        };

        final title = 'Task assigned';
        final body = taskNumber != null
            ? (officeName != null
                  ? '$actorName assigned you task #$taskNumber in $officeName.'
                  : '$actorName assigned you task #$taskNumber.')
            : (officeName != null
                  ? '$actorName assigned you a task in $officeName.'
                  : '$actorName assigned you a task.');

        await _client.functions.invoke(
          'send_fcm',
          body: {
            'user_ids': userIds,
            'title': title,
            'body': body,
            'data': dataPayload,
          },
        );
      } catch (e) {
        debugPrint('notifyAssigned push error: $e');
      }
    } catch (_) {
      return;
    }
  }

  Future<void> removeAssignment({
    required String taskId,
    required String userId,
  }) async {
    await _client
        .from('task_assignments')
        .delete()
        .eq('task_id', taskId)
        .eq('user_id', userId);
  }
}
