// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only

part of '../free_slots.dart';

/// Creates and updates protected Free Slot task records.
class FreeSlotService {
  /// Creates a `FreeSlotService` instance with the values required by its invariants.
  /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
  const FreeSlotService();

  /// Create a scheduled Free Slot record.
  Task create({
    required String id,
    required String title,
    required DateTime start,
    required DateTime end,
    required DateTime createdAt,
    String projectId = 'inbox',
    DateTime? updatedAt,
  }) {
    logger.debug(() => 'Creating protected free slot. taskId=$id '
        'start=${start.toIso8601String()} end=${end.toIso8601String()} '
        'projectId=$projectId');
    final interval = TimeInterval(start: start, end: end, label: id);

    return Task(
      id: id,
      title: title,
      projectId: projectId,
      type: TaskType.freeSlot,
      status: TaskStatus.planned,
      priority: PriorityLevel.medium,
      durationMinutes: interval.duration.inMinutes,
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      createdAt: createdAt,
      updatedAt: updatedAt ?? createdAt,
    );
  }

  /// Update an existing Free Slot record's schedule and optional metadata.
  Task update({
    required Task freeSlot,
    required DateTime start,
    required DateTime end,
    DateTime? updatedAt,
    String? title,
    String? projectId,
  }) {
    logger.debug(() => 'Updating protected free slot. taskId=${freeSlot.id} '
        'start=${start.toIso8601String()} end=${end.toIso8601String()} '
        'projectId=$projectId hasTitle=${title != null}');
    if (freeSlot.type != TaskType.freeSlot) {
      logger.error(() => 'Free slot update failed: invalid task type. '
          'taskId=${freeSlot.id} taskType=${freeSlot.type.name}');
      throw ArgumentError.value(
        freeSlot.type,
        'freeSlot.type',
        'Task must be a Free Slot.',
      );
    }

    final interval = TimeInterval(start: start, end: end, label: freeSlot.id);

    return freeSlot.copyWith(
      title: title,
      projectId: projectId,
      status: TaskStatus.planned,
      durationMinutes: interval.duration.inMinutes,
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt ?? freeSlot.updatedAt,
    );
  }

  /// Explicitly place a critical or inflexible task.
  ///
  /// This operation is allowed to overlap protected Free Slots because required
  /// commitments can interrupt rest. It does not move the Free Slot. Instead it
  /// returns typed conflict data for the application layer to surface calmly.
  RequiredCommitmentScheduleResult scheduleRequiredCommitment({
    required SchedulingInput input,
    required String taskId,
    required DateTime start,
    required DateTime end,
    DateTime? updatedAt,
  }) {
    logger.debug(() => 'Scheduling required commitment. taskId=$taskId '
        'start=${start.toIso8601String()} end=${end.toIso8601String()} '
        'taskCount=${input.tasks.length}');
    final task = _taskById(input.tasks, taskId);
    if (task == null) {
      logger
          .warn(() => 'Required commitment scheduling failed: task not found. '
              'taskId=$taskId');
      return _unchangedRequiredResult(
        input: input,
        status: RequiredCommitmentScheduleStatus.taskNotFound,
        notice: SchedulingNotice(
          'Task was not found.',
          type: SchedulingNoticeType.noFit,
          taskId: taskId,
          issueCode: SchedulingIssueCode.taskNotFound,
        ),
        outcomeCode: SchedulingOutcomeCode.notFound,
      );
    }

    if (!task.isRequiredVisible) {
      logger.warn(
          () => 'Required commitment scheduling failed: invalid task type. '
              'taskId=${task.id} taskType=${task.type.name}');
      return _unchangedRequiredResult(
        input: input,
        status: RequiredCommitmentScheduleStatus.invalidTaskType,
        notice: SchedulingNotice(
          'Only critical or inflexible tasks can be explicitly scheduled here.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.invalidTaskState,
        ),
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    final requiredInterval =
        TimeInterval(start: start, end: end, label: taskId);
    final conflicts = _protectedFreeSlotConflicts(
      input: input,
      requiredTaskId: task.id,
      requiredInterval: requiredInterval,
    );
    final updatedTask = task.copyWith(
      status: TaskStatus.planned,
      durationMinutes: requiredInterval.duration.inMinutes,
      scheduledStart: requiredInterval.start,
      scheduledEnd: requiredInterval.end,
      updatedAt: updatedAt ?? task.updatedAt,
    );
    final scheduleChanged = !_sameDateTime(task.scheduledStart, start) ||
        !_sameDateTime(task.scheduledEnd, end) ||
        task.status != TaskStatus.planned;
    final updatedTasks = input.tasks.map((current) {
      if (current.id == task.id) {
        return updatedTask;
      }
      return current;
    }).toList(growable: false);
    final overlapDetails = conflicts.map((conflict) {
      return SchedulingOverlap(
        taskId: conflict.requiredTaskId,
        taskInterval: conflict.requiredInterval,
        blockedInterval: conflict.freeSlotInterval,
      );
    }).toList(growable: false);
    final notices = <SchedulingNotice>[
      SchedulingNotice(
        conflicts.isEmpty
            ? 'Required commitment scheduled.'
            : 'Required commitment overlaps protected rest.',
        type: conflicts.isEmpty
            ? SchedulingNoticeType.moved
            : SchedulingNoticeType.overlap,
        taskId: task.id,
        movementCode: conflicts.isEmpty
            ? SchedulingMovementCode.requiredCommitmentScheduled
            : null,
        conflictCode: conflicts.isEmpty
            ? null
            : SchedulingConflictCode
                .requiredCommitmentOverlapsProtectedFreeSlot,
        parameters: {
          'requiredStart': requiredInterval.start,
          'requiredEnd': requiredInterval.end,
          if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length,
        },
      ),
    ];
    final changes = <SchedulingChange>[
      if (scheduleChanged)
        SchedulingChange(
          taskId: task.id,
          previousStart: task.scheduledStart,
          previousEnd: task.scheduledEnd,
          nextStart: requiredInterval.start,
          nextEnd: requiredInterval.end,
        ),
    ];

    logger.debug(() =>
        'Required commitment scheduling finished. taskId=${task.id} '
        'status=${conflicts.isEmpty ? RequiredCommitmentScheduleStatus.scheduled.name : RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict.name} '
        'conflictCount=${conflicts.length} changeCount=${changes.length}');

    return RequiredCommitmentScheduleResult(
      status: conflicts.isEmpty
          ? RequiredCommitmentScheduleStatus.scheduled
          : RequiredCommitmentScheduleStatus
              .scheduledWithProtectedFreeSlotConflict,
      protectedFreeSlotConflicts: conflicts,
      schedulingResult: SchedulingResult(
        tasks: updatedTasks,
        operationCode: SchedulingOperationCode.scheduleRequiredCommitment,
        outcomeCode: conflicts.isEmpty
            ? SchedulingOutcomeCode.success
            : SchedulingOutcomeCode.conflict,
        notices: notices,
        changes: changes,
        overlaps: overlapDetails,
      ),
    );
  }
}

/// Top-level helper that performs the `_unchangedRequiredResult` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
RequiredCommitmentScheduleResult _unchangedRequiredResult({
  required SchedulingInput input,
  required RequiredCommitmentScheduleStatus status,
  required SchedulingNotice notice,
  required SchedulingOutcomeCode outcomeCode,
}) {
  return RequiredCommitmentScheduleResult(
    status: status,
    protectedFreeSlotConflicts: const [],
    schedulingResult: SchedulingResult(
      tasks: input.tasks,
      operationCode: SchedulingOperationCode.scheduleRequiredCommitment,
      outcomeCode: outcomeCode,
      notices: [notice],
    ),
  );
}

/// Top-level helper that performs the `_protectedFreeSlotConflicts` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
List<ProtectedFreeSlotConflict> _protectedFreeSlotConflicts({
  required SchedulingInput input,
  required String requiredTaskId,
  required TimeInterval requiredInterval,
}) {
  final conflicts = <ProtectedFreeSlotConflict>[];

  for (final entry in input.occupancyEntries) {
    if (entry.category != OccupancyCategory.protectedFreeSlot) {
      continue;
    }

    final freeSlot = entry.task;
    final freeSlotInterval = entry.interval;
    if (freeSlot == null ||
        freeSlotInterval == null ||
        !requiredInterval.overlaps(freeSlotInterval)) {
      continue;
    }

    conflicts.add(
      ProtectedFreeSlotConflict(
        requiredTaskId: requiredTaskId,
        freeSlotTaskId: freeSlot.id,
        requiredInterval: requiredInterval,
        freeSlotInterval: freeSlotInterval,
      ),
    );
  }

  return List<ProtectedFreeSlotConflict>.unmodifiable(conflicts);
}

/// Top-level helper that performs the `_taskById` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Task? _taskById(List<Task> tasks, String taskId) {
  for (final task in tasks) {
    if (task.id == taskId) {
      return task;
    }
  }

  return null;
}

/// Top-level helper that performs the `_sameDateTime` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
bool _sameDateTime(DateTime? first, DateTime? second) {
  if (first == null || second == null) {
    return first == null && second == null;
  }

  return first.isAtSameMomentAs(second);
}
