// Flexible task card actions.
//
// The main scheduling engine moves tasks through time. This file models the
// small user actions that appear on a flexible task card, such as done, push,
// backlog, and break-up. Keeping these in a service makes UI button handlers
// thin and keeps task-type safety checks in one place.

import 'models.dart';
import 'occupancy_policy.dart';
import 'scheduling_engine.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';

/// Quick actions available from a flexible task card.
///
/// These are the low-friction card controls the UI can expose directly on a
/// planned flexible task. The service below translates each button into either a
/// direct task update, a scheduling operation, or a follow-up flow.
enum FlexibleTaskQuickAction {
  /// Mark the task completed.
  done,

  /// Ask the user where the task should be pushed.
  push,

  /// Move the task out of today's plan and into backlog.
  backlog,

  /// Start a flow that splits the task into child tasks.
  breakUp,
}

/// State transitions available from required visible task cards.
///
/// Required visible tasks are critical or inflexible items. They are not moved by
/// flexible scheduling, but the user still needs simple lifecycle actions for
/// what happened to the commitment.
enum RequiredTaskAction {
  /// Mark the required task completed.
  done,

  /// Mark that the required task did not happen.
  missed,

  /// Intentionally remove it from the active plan.
  cancel,

  /// Dismiss it because it no longer applies, distinct from cancellation.
  noLongerRelevant,
}

/// Explicit push destinations shown after choosing the push quick action.
///
/// Push starts as a simple quick action, but the actual destination requires one
/// more choice. Keeping destinations as a separate enum prevents the initial card
/// action list from becoming too crowded.
enum PushDestination {
  /// Move the task later within the current planning window.
  nextAvailableSlot,

  /// Move the task to the beginning of the supplied tomorrow/future window.
  tomorrowTopOfQueue,

  /// Remove the task from the active timeline and store it for later.
  backlog,
}

/// Domain result for a flexible task quick action.
///
/// This result deliberately supports three outcomes: the task changed, the user
/// must choose a push destination, or the UI should start the child-task flow.
/// That keeps card code from guessing how to interpret each action.
class FlexibleTaskActionResult {
  FlexibleTaskActionResult({
    required this.action,
    required this.task,
    this.pushDestinations = const <PushDestination>[],
    this.startsChildTaskFlow = false,
    List<TaskActivity> activities = const <TaskActivity>[],
    this.transitionOutcomeCode,
  }) : activities = List<TaskActivity>.unmodifiable(activities);

  /// Action the user selected.
  final FlexibleTaskQuickAction action;

  /// Current or updated task, depending on the action.
  final Task task;

  /// Destination choices to show after `push`; empty for direct actions.
  final List<PushDestination> pushDestinations;

  /// Whether the UI should open a child-task creation flow.
  final bool startsChildTaskFlow;

  /// Internal activities produced by direct lifecycle actions.
  final List<TaskActivity> activities;

  /// Typed transition outcome for direct lifecycle actions.
  final TaskTransitionOutcomeCode? transitionOutcomeCode;

  /// True when the action directly produced an updated [task].
  bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty;
}

/// Result from applying a selected push destination.
///
/// The selected destination is included next to the [SchedulingResult] so UI and
/// tests can distinguish "moved later today" from "moved to tomorrow" even if
/// the low-level scheduling change shape is similar.
class PushDestinationResult {
  PushDestinationResult({
    required this.destination,
    required this.schedulingResult,
    List<TaskActivity> activities = const <TaskActivity>[],
  }) : activities = List<TaskActivity>.unmodifiable(activities);

  /// Destination that was applied.
  final PushDestination destination;

  /// Full scheduler output: updated tasks, notices, changes, and overlaps.
  final SchedulingResult schedulingResult;

  /// Internal activities produced by applying the selected destination.
  final List<TaskActivity> activities;

  /// Convenience flag for UI copy or persistence behavior that cares about the
  /// tomorrow queue specifically.
  bool get placesAtTomorrowTopOfQueue {
    return destination == PushDestination.tomorrowTopOfQueue;
  }
}

/// Result from applying a required task state transition.
class RequiredTaskActionResult {
  RequiredTaskActionResult({
    required this.action,
    required this.task,
    required this.removedFromActivePlan,
    List<TaskActivity> activities = const <TaskActivity>[],
    this.transitionOutcomeCode,
  }) : activities = List<TaskActivity>.unmodifiable(activities);

  /// Action the user selected.
  final RequiredTaskAction action;

  /// Updated task after the transition.
  final Task task;

  /// Whether the transition removes the task from the active timeline.
  ///
  /// Critical missed tasks return true because they move to backlog. Cancelled
  /// and no-longer-relevant tasks also return true because they are no longer
  /// active planned work.
  final bool removedFromActivePlan;

  /// Internal activities produced by the transition.
  final List<TaskActivity> activities;

  /// Typed transition outcome.
  final TaskTransitionOutcomeCode? transitionOutcomeCode;
}

/// Input for logging work the user already did outside the plan.
///
/// The user may know only a title, or may also know when it happened and how
/// long it took. When time data is present, the resulting surprise task occupies
/// that interval and flexible work overlapping it is pushed out of the way.
class SurpriseTaskLogRequest {
  const SurpriseTaskLogRequest({
    required this.id,
    required this.title,
    required this.createdAt,
    this.startedAt,
    this.timeUsedMinutes,
    this.projectId = 'inbox',
    this.priority,
    this.reward = RewardLevel.notSet,
    this.difficulty = DifficultyLevel.notSet,
  });

  /// Caller-generated id for the new surprise task.
  ///
  /// This id is also the idempotency key for retrying the same log operation.
  final String id;

  /// User-entered title.
  final String title;

  /// Creation/log timestamp supplied by the caller for testability.
  final DateTime createdAt;

  /// When the surprise work started, if known.
  final DateTime? startedAt;

  /// Minutes spent on the surprise work, if known.
  final int? timeUsedMinutes;

  /// Project id to assign; defaults to inbox when omitted.
  final String projectId;

  /// Optional priority. Null means the user did not set one.
  final PriorityLevel? priority;

  /// Optional reward estimate.
  final RewardLevel reward;

  /// Optional difficulty estimate.
  final DifficultyLevel difficulty;
}

/// Result from logging a surprise task.
class SurpriseTaskLogResult {
  const SurpriseTaskLogResult({
    required this.surpriseTask,
    required this.schedulingResult,
    this.activities = const <TaskActivity>[],
    this.requiredOverlaps = const <SchedulingOverlap>[],
    this.lockedOverlaps = const <TimeInterval>[],
    this.usedNormalPushRules = false,
  });

  /// Newly created completed surprise task.
  final Task surpriseTask;

  /// Updated task list, movement notices, changes, and visible overlaps.
  final SchedulingResult schedulingResult;

  /// Internal activities produced by logging the surprise task.
  final List<TaskActivity> activities;

  /// Critical/inflexible overlaps with the surprise interval.
  final List<SchedulingOverlap> requiredOverlaps;

  /// Locked overlaps tracked separately so they stay hidden by default.
  final List<TimeInterval> lockedOverlaps;

  /// Whether flexible movement was delegated to normal push behavior.
  final bool usedNormalPushRules;
}

/// Applies low-friction quick actions for flexible task cards.
///
/// This service is the adapter between small UI button presses and domain logic.
/// It intentionally only accepts flexible tasks; required/locked/surprise items
/// should have their own action rules so the UI cannot accidentally apply a
/// flexible-only behavior to a fixed commitment.
class FlexibleTaskActionService {
  const FlexibleTaskActionService({
    this.schedulingEngine = const SchedulingEngine(),
    this.transitionService = const TaskTransitionService(),
    this.clock = const SystemClock(),
  });

  /// Scheduling dependency used for actions that need timeline changes.
  final SchedulingEngine schedulingEngine;

  /// Canonical lifecycle transition dependency.
  final TaskTransitionService transitionService;

  /// Clock boundary used when an action timestamp is not supplied.
  final Clock clock;

  /// Apply the first-stage quick action.
  ///
  /// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
  /// list of destinations the UI should present. `breakUp` signals that the UI
  /// should start a child-task flow rather than changing the task immediately.
  FlexibleTaskActionResult apply({
    required Task task,
    required FlexibleTaskQuickAction action,
    DateTime? updatedAt,
    String? operationId,
    DateTime? actualStart,
    DateTime? actualEnd,
    List<TimeInterval> lockedIntervals = const <TimeInterval>[],
    Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
  }) {
    if (!task.isFlexible) {
      throw ArgumentError.value(
          task.type, 'task.type', 'Task must be flexible.');
    }

    switch (action) {
      case FlexibleTaskQuickAction.done:
        final now = updatedAt ?? clock.now();
        final opId =
            operationId ?? _defaultOperationId(action.name, task.id, now);
        final transition = transitionService.apply(
          task: task,
          transitionCode: TaskTransitionCode.complete,
          operationId: opId,
          activityId: _activityId(
            opId,
            task.id,
            TaskActivityCode.completed,
          ),
          occurredAt: now,
          actualStart: actualStart,
          actualEnd: actualEnd,
          lockedIntervals: lockedIntervals,
          existingActivities: existingActivities,
        );
        return FlexibleTaskActionResult(
          action: action,
          task: transition.task,
          activities: transition.activities,
          transitionOutcomeCode: transition.outcomeCode,
        );
      case FlexibleTaskQuickAction.push:
        return FlexibleTaskActionResult(
          action: action,
          task: task,
          pushDestinations: const [
            PushDestination.nextAvailableSlot,
            PushDestination.tomorrowTopOfQueue,
            PushDestination.backlog,
          ],
        );
      case FlexibleTaskQuickAction.backlog:
        final now = updatedAt ?? clock.now();
        final opId =
            operationId ?? _defaultOperationId(action.name, task.id, now);
        final transition = transitionService.apply(
          task: task,
          transitionCode: TaskTransitionCode.moveToBacklog,
          operationId: opId,
          activityId: _activityId(
            opId,
            task.id,
            TaskActivityCode.movedToBacklog,
          ),
          occurredAt: now,
          existingActivities: existingActivities,
        );
        return FlexibleTaskActionResult(
          action: action,
          task: transition.task,
          activities: transition.activities,
          transitionOutcomeCode: transition.outcomeCode,
        );
      case FlexibleTaskQuickAction.breakUp:
        return FlexibleTaskActionResult(
          action: action,
          task: task,
          startsChildTaskFlow: true,
        );
    }
  }

  /// Apply the second-stage destination selected after the `push` action.
  ///
  /// This needs the full [SchedulingInput] because pushing can shift other
  /// flexible tasks and must avoid locked/required intervals.
  PushDestinationResult applyPushDestination({
    required PushDestination destination,
    required SchedulingInput input,
    required String taskId,
    DateTime? updatedAt,
    String? operationId,
    Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
  }) {
    final now = updatedAt ?? clock.now();
    final opId = operationId ??
        _defaultOperationId('push-${destination.name}', taskId, now);
    if (_hasDuplicateActivity(
      existingActivities: existingActivities,
      operationId: opId,
      taskId: taskId,
    )) {
      return PushDestinationResult(
        destination: destination,
        schedulingResult: SchedulingResult(
          tasks: input.tasks,
          operationCode: _operationCodeForPushDestination(destination),
          outcomeCode: SchedulingOutcomeCode.noOp,
          notices: [
            SchedulingNotice('Push operation already applied.'),
          ],
        ),
      );
    }

    final result = switch (destination) {
      PushDestination.nextAvailableSlot =>
        schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
          input: input,
          taskId: taskId,
          updatedAt: now,
        ),
      PushDestination.tomorrowTopOfQueue =>
        schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
          input: input,
          taskId: taskId,
          updatedAt: now,
        ),
      PushDestination.backlog => _moveTaskToBacklog(
          input: input,
          taskId: taskId,
          updatedAt: now,
        ),
    };

    return PushDestinationResult(
      destination: destination,
      schedulingResult: result,
      activities: _activitiesForPushDestination(
        destination: destination,
        input: input,
        result: result,
        taskId: taskId,
        operationId: opId,
        occurredAt: now,
        existingActivities: existingActivities,
      ),
    );
  }

  /// Move one planned flexible task to backlog inside a scheduling result.
  ///
  /// This mirrors the shape of other push destination results so callers can
  /// handle every destination through the same `SchedulingResult` interface.
  SchedulingResult _moveTaskToBacklog({
    required SchedulingInput input,
    required String taskId,
    DateTime? updatedAt,
  }) {
    final task = _taskById(input.tasks, taskId);
    if (task == null) {
      return SchedulingResult(
        tasks: input.tasks,
        operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
        outcomeCode: SchedulingOutcomeCode.notFound,
        notices: [
          SchedulingNotice(
            'Task was not found.',
            type: SchedulingNoticeType.noFit,
            taskId: taskId,
            issueCode: SchedulingIssueCode.taskNotFound,
          ),
        ],
      );
    }

    if (!task.isFlexible || task.status != TaskStatus.planned) {
      return SchedulingResult(
        tasks: input.tasks,
        operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
        outcomeCode: SchedulingOutcomeCode.invalidState,
        notices: [
          SchedulingNotice(
            'Only planned flexible tasks can be moved to backlog.',
            type: SchedulingNoticeType.noFit,
            taskId: task.id,
            issueCode: SchedulingIssueCode.invalidTaskState,
          ),
        ],
      );
    }

    final movedTask = schedulingEngine.moveToBacklog(
      task,
      updatedAt: updatedAt,
    );

    return SchedulingResult(
      tasks: List<Task>.unmodifiable(
        input.tasks.map((current) {
          if (current.id == task.id) {
            return movedTask;
          }

          return current;
        }),
      ),
      operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
      outcomeCode: SchedulingOutcomeCode.success,
      notices: [
        SchedulingNotice(
          'Flexible task moved to backlog.',
          type: SchedulingNoticeType.moved,
          taskId: task.id,
          movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog,
          parameters: {
            'previousStart': task.scheduledStart,
            'previousEnd': task.scheduledEnd,
            'nextStart': null,
            'nextEnd': null,
          },
        ),
      ],
      changes: [
        SchedulingChange(
          taskId: task.id,
          previousStart: task.scheduledStart,
          previousEnd: task.scheduledEnd,
          nextStart: null,
          nextEnd: null,
        ),
      ],
    );
  }
}

/// Applies lifecycle actions for required visible task cards.
///
/// This service only handles critical and inflexible tasks. Locked blocks are
/// scheduling constraints, not normal task cards, and flexible tasks use
/// [FlexibleTaskActionService].
class RequiredTaskActionService {
  const RequiredTaskActionService({
    this.schedulingEngine = const SchedulingEngine(),
    this.transitionService = const TaskTransitionService(),
    this.clock = const SystemClock(),
  });

  /// Scheduling dependency used for required missed behavior.
  final SchedulingEngine schedulingEngine;

  /// Canonical lifecycle transition dependency.
  final TaskTransitionService transitionService;

  /// Clock boundary used when an action timestamp is not supplied.
  final Clock clock;

  /// Apply one required-card state transition.
  RequiredTaskActionResult apply({
    required Task task,
    required RequiredTaskAction action,
    DateTime? updatedAt,
    String? operationId,
    DateTime? actualStart,
    DateTime? actualEnd,
    List<TimeInterval> lockedIntervals = const <TimeInterval>[],
    Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
  }) {
    if (!task.isRequiredVisible) {
      throw ArgumentError.value(
        task.type,
        'task.type',
        'Task must be critical or inflexible.',
      );
    }

    final now = updatedAt ?? clock.now();

    final transitionCode = switch (action) {
      RequiredTaskAction.done => TaskTransitionCode.complete,
      RequiredTaskAction.missed => TaskTransitionCode.miss,
      RequiredTaskAction.cancel => TaskTransitionCode.cancel,
      RequiredTaskAction.noLongerRelevant =>
        TaskTransitionCode.noLongerRelevant,
    };
    final activityCode = switch (action) {
      RequiredTaskAction.done => TaskActivityCode.completed,
      RequiredTaskAction.missed => TaskActivityCode.missed,
      RequiredTaskAction.cancel => TaskActivityCode.cancelled,
      RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant,
    };
    final opId = operationId ?? _defaultOperationId(action.name, task.id, now);
    final transition = transitionService.apply(
      task: task,
      transitionCode: transitionCode,
      operationId: opId,
      activityId: _activityId(opId, task.id, activityCode),
      occurredAt: now,
      actualStart: actualStart,
      actualEnd: actualEnd,
      lockedIntervals: lockedIntervals,
      existingActivities: existingActivities,
    );

    return RequiredTaskActionResult(
      action: action,
      task: transition.task,
      removedFromActivePlan: transition.task.status == TaskStatus.backlog ||
          transition.task.status == TaskStatus.cancelled ||
          transition.task.status == TaskStatus.noLongerRelevant,
      activities: transition.activities,
      transitionOutcomeCode: transition.outcomeCode,
    );
  }
}

/// Logs surprise completed work and repairs affected flexible tasks.
///
/// Surprise work is already done, so the new task is created as completed. When
/// it has a concrete interval, overlapping planned flexible tasks are moved by
/// the existing push scheduler with the surprise interval treated as blocked
/// time. Required visible and locked time is never moved.
class SurpriseTaskLogService {
  const SurpriseTaskLogService({
    this.schedulingEngine = const SchedulingEngine(),
    this.accountingService = const TaskActivityAccountingService(),
    this.clock = const SystemClock(),
    this.idGenerator,
  });

  /// Scheduling dependency used to move overlapping flexible tasks.
  final SchedulingEngine schedulingEngine;

  /// Activity-derived statistics updater.
  final TaskActivityAccountingService accountingService;

  /// Clock boundary used when a log timestamp is not supplied.
  final Clock clock;

  /// Optional ID boundary for outer convenience entry points.
  final IdGenerator? idGenerator;

  /// Log one surprise task against a scheduling snapshot.
  SurpriseTaskLogResult log({
    required SurpriseTaskLogRequest request,
    required SchedulingInput input,
    DateTime? updatedAt,
    bool revealHiddenLockedDetails = false,
  }) {
    final now = updatedAt ?? clock.now();
    final existingTask = _taskById(input.tasks, request.id);
    if (existingTask != null) {
      if (existingTask.type != TaskType.surprise) {
        throw ArgumentError.value(
          request.id,
          'request.id',
          'Surprise task id is already used by another task.',
        );
      }

      return SurpriseTaskLogResult(
        surpriseTask: existingTask,
        schedulingResult: SchedulingResult(
          tasks: input.tasks,
          operationCode: SchedulingOperationCode.logSurpriseTask,
          outcomeCode: SchedulingOutcomeCode.noOp,
          notices: [
            SchedulingNotice(
              'Surprise task already logged.',
              issueCode: SchedulingIssueCode.duplicateSurpriseLog,
              taskId: existingTask.id,
            ),
          ],
        ),
      );
    }

    final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now);
    final completedActivity = _surpriseCompletedActivity(
      task: rawSurpriseTask,
      operationId: request.id,
      occurredAt: now,
    );
    final accountedSurprise = accountingService.apply(
      task: rawSurpriseTask,
      activities: [completedActivity],
      lockedIntervals: _lockedIntervalsForAccounting(input),
    );
    final surpriseTask = accountedSurprise.task;
    final surpriseInterval = _occupyingIntervalForTask(surpriseTask);
    var currentTasks = <Task>[surpriseTask, ...input.tasks];

    if (surpriseInterval == null) {
      return SurpriseTaskLogResult(
        surpriseTask: surpriseTask,
        schedulingResult: SchedulingResult(
          tasks: List<Task>.unmodifiable(currentTasks),
          operationCode: SchedulingOperationCode.logSurpriseTask,
          outcomeCode: SchedulingOutcomeCode.success,
          notices: [
            SchedulingNotice('Surprise task logged.'),
          ],
        ),
        activities: [completedActivity],
      );
    }

    final requiredOverlaps = _requiredOverlaps(
      input: input,
      surpriseInterval: surpriseInterval,
    );
    final lockedOverlaps = _lockedOverlaps(
      input: input,
      surpriseInterval: surpriseInterval,
      revealHiddenLockedDetails: revealHiddenLockedDetails,
    );
    final flexibleTaskIds = _overlappingFlexibleTaskIds(
      input.movablePlannedFlexibleTasks,
      surpriseInterval,
    );
    final changes = <SchedulingChange>[];
    final movementNotices = <SchedulingNotice>[];
    var usedNormalPushRules = false;

    for (final taskId in flexibleTaskIds) {
      final currentTask = _taskById(currentTasks, taskId);
      final currentInterval =
          currentTask == null ? null : _scheduledIntervalForTask(currentTask);
      if (currentTask == null ||
          currentInterval == null ||
          !currentInterval.overlaps(surpriseInterval)) {
        continue;
      }

      final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: currentTasks,
          window: input.window,
          lockedIntervals: input.lockedIntervals,
          requiredVisibleIntervals: input.requiredVisibleIntervals,
        ),
        taskId: taskId,
        updatedAt: now,
        countAsManualPush: false,
      );

      usedNormalPushRules = true;
      currentTasks = pushResult.tasks;
      changes.addAll(pushResult.changes);
      movementNotices.addAll(pushResult.notices);
    }

    final overlapNotices = requiredOverlaps
        .map(
          (overlap) => SchedulingNotice(
            'Surprise task overlaps required visible time.',
            type: SchedulingNoticeType.overlap,
            taskId: overlap.taskId,
            conflictCode:
                SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime,
            parameters: {
              'blockedStart': overlap.blockedInterval.start,
              'blockedEnd': overlap.blockedInterval.end,
            },
          ),
        )
        .toList(growable: false);

    return SurpriseTaskLogResult(
      surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
      schedulingResult: SchedulingResult(
        tasks: List<Task>.unmodifiable(currentTasks),
        operationCode: SchedulingOperationCode.logSurpriseTask,
        outcomeCode: requiredOverlaps.isEmpty
            ? SchedulingOutcomeCode.success
            : SchedulingOutcomeCode.conflict,
        notices: List<SchedulingNotice>.unmodifiable([
          SchedulingNotice('Surprise task logged.'),
          ...movementNotices,
          ...overlapNotices,
        ]),
        changes: List<SchedulingChange>.unmodifiable(changes),
        overlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
      ),
      requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
      lockedOverlaps: List<TimeInterval>.unmodifiable(lockedOverlaps),
      activities: [completedActivity],
      usedNormalPushRules: usedNormalPushRules,
    );
  }

  /// Convenience outer-boundary wrapper that supplies id and creation time.
  SurpriseTaskLogResult logNew({
    required String title,
    required SchedulingInput input,
    DateTime? startedAt,
    int? timeUsedMinutes,
    String projectId = 'inbox',
    PriorityLevel? priority,
    RewardLevel reward = RewardLevel.notSet,
    DifficultyLevel difficulty = DifficultyLevel.notSet,
    DateTime? createdAt,
    DateTime? updatedAt,
    bool revealHiddenLockedDetails = false,
  }) {
    final generator = idGenerator;
    if (generator == null) {
      throw StateError('Surprise task logging needs an id generator.');
    }

    final now = createdAt ?? updatedAt ?? clock.now();

    return log(
      request: SurpriseTaskLogRequest(
        id: generator.nextId(),
        title: title,
        createdAt: now,
        startedAt: startedAt,
        timeUsedMinutes: timeUsedMinutes,
        projectId: projectId,
        priority: priority,
        reward: reward,
        difficulty: difficulty,
      ),
      input: input,
      updatedAt: updatedAt ?? now,
      revealHiddenLockedDetails: revealHiddenLockedDetails,
    );
  }

  Task _createSurpriseTask(
    SurpriseTaskLogRequest request, {
    required DateTime updatedAt,
  }) {
    final trimmedTitle = request.title.trim();
    if (trimmedTitle.isEmpty) {
      throw ArgumentError.value(request.title, 'title', 'Title is required.');
    }

    final duration = request.timeUsedMinutes;
    final start = request.startedAt;
    final hasScheduledTime = start != null && duration != null && duration > 0;

    return Task(
      id: request.id,
      title: trimmedTitle,
      projectId: request.projectId,
      type: TaskType.surprise,
      status: TaskStatus.completed,
      priority: request.priority,
      reward: request.reward,
      difficulty: request.difficulty,
      durationMinutes: duration != null && duration > 0 ? duration : null,
      scheduledStart: hasScheduledTime ? start : null,
      scheduledEnd:
          hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
      actualStart: hasScheduledTime ? start : null,
      actualEnd:
          hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
      completedAt: updatedAt,
      createdAt: request.createdAt,
      updatedAt: updatedAt,
    );
  }
}

List<TaskActivity> _activitiesForPushDestination({
  required PushDestination destination,
  required SchedulingInput input,
  required SchedulingResult result,
  required String taskId,
  required String operationId,
  required DateTime occurredAt,
  required Iterable<TaskActivity> existingActivities,
}) {
  if (result.outcomeCode != SchedulingOutcomeCode.success) {
    return const <TaskActivity>[];
  }

  final activities = <TaskActivity>[];
  for (final change in result.changes) {
    if (_hasDuplicateActivity(
      existingActivities: existingActivities,
      operationId: operationId,
      taskId: change.taskId,
    )) {
      continue;
    }

    final task = _taskById(input.tasks, change.taskId);
    if (task == null) {
      continue;
    }

    final code = _activityCodeForPushChange(
      destination: destination,
      isSelectedTask: change.taskId == taskId,
    );
    activities.add(
      TaskActivity(
        id: _activityId(operationId, change.taskId, code),
        operationId: operationId,
        code: code,
        taskId: change.taskId,
        projectId: task.projectId,
        occurredAt: occurredAt,
        metadata: {
          'previousStatus': task.status.name,
          'nextStatus': code == TaskActivityCode.movedToBacklog
              ? TaskStatus.backlog.name
              : task.status.name,
          'previousStart': change.previousStart,
          'previousEnd': change.previousEnd,
          'nextStart': change.nextStart,
          'nextEnd': change.nextEnd,
          'destination': destination.name,
        },
      ),
    );
  }

  return List<TaskActivity>.unmodifiable(activities);
}

TaskActivityCode _activityCodeForPushChange({
  required PushDestination destination,
  required bool isSelectedTask,
}) {
  if (destination == PushDestination.backlog) {
    return TaskActivityCode.movedToBacklog;
  }

  return isSelectedTask
      ? TaskActivityCode.manuallyPushed
      : TaskActivityCode.automaticallyPushed;
}

SchedulingOperationCode _operationCodeForPushDestination(
  PushDestination destination,
) {
  return switch (destination) {
    PushDestination.nextAvailableSlot =>
      SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
    PushDestination.tomorrowTopOfQueue =>
      SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
    PushDestination.backlog =>
      SchedulingOperationCode.moveFlexibleTaskToBacklog,
  };
}

TaskActivity _surpriseCompletedActivity({
  required Task task,
  required String operationId,
  required DateTime occurredAt,
}) {
  return TaskActivity(
    id: _activityId(operationId, task.id, TaskActivityCode.completed),
    operationId: operationId,
    code: TaskActivityCode.completed,
    taskId: task.id,
    projectId: task.projectId,
    occurredAt: occurredAt,
    metadata: {
      'nextStatus': TaskStatus.completed.name,
      'completedAt': occurredAt,
      'actualStart': task.actualStart,
      'actualEnd': task.actualEnd,
      'durationMinutes': task.durationMinutes,
      'reward': task.reward.name,
      'difficulty': task.difficulty.name,
      if (task.reminderOverride != null)
        'reminderProfile': task.reminderOverride!.name,
      'pushesBeforeCompletion': 0,
      'source': 'surpriseLog',
    },
  );
}

List<TimeInterval> _lockedIntervalsForAccounting(SchedulingInput input) {
  return input.occupancyEntries
      .where(
        (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint,
      )
      .map((entry) => entry.interval)
      .whereType<TimeInterval>()
      .toList(growable: false);
}

bool _hasDuplicateActivity({
  required Iterable<TaskActivity> existingActivities,
  required String operationId,
  required String taskId,
}) {
  for (final activity in existingActivities) {
    if (activity.operationId == operationId && activity.taskId == taskId) {
      return true;
    }
  }

  return false;
}

String _defaultOperationId(
    String actionName, String taskId, DateTime occurredAt) {
  return '$actionName:$taskId:${occurredAt.toIso8601String()}';
}

String _activityId(
  String operationId,
  String taskId,
  TaskActivityCode activityCode,
) {
  return '$operationId:$taskId:${activityCode.name}';
}

/// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) {
  for (final task in tasks) {
    if (task.id == taskId) {
      return task;
    }
  }

  return null;
}

/// Build an interval for a task, or null when it has no usable placement.
TimeInterval? _scheduledIntervalForTask(Task task) {
  final start = task.scheduledStart;
  final end = task.scheduledEnd;

  if (start == null || end == null || !start.isBefore(end)) {
    return null;
  }

  return TimeInterval(start: start, end: end, label: task.id);
}

/// Build an actual-first occupancy interval for completed/active task facts.
TimeInterval? _occupyingIntervalForTask(Task task) {
  final actualStart = task.actualStart;
  final actualEnd = task.actualEnd;

  if (actualStart != null &&
      actualEnd != null &&
      actualStart.isBefore(actualEnd)) {
    return TimeInterval(start: actualStart, end: actualEnd, label: task.id);
  }

  return _scheduledIntervalForTask(task);
}

/// Required visible overlaps with a surprise interval.
List<SchedulingOverlap> _requiredOverlaps({
  required SchedulingInput input,
  required TimeInterval surpriseInterval,
}) {
  final overlaps = <SchedulingOverlap>[];

  for (final occupancy in input.occupancyEntries) {
    final task = occupancy.task;
    final interval = occupancy.interval;
    final isRequiredOccupancy =
        occupancy.category == OccupancyCategory.requiredVisibleCommitment ||
            (occupancy.category == OccupancyCategory.activeWork &&
                task != null &&
                task.isRequiredVisible);

    if (task == null ||
        !isRequiredOccupancy ||
        interval == null ||
        !interval.overlaps(surpriseInterval)) {
      continue;
    }

    overlaps.add(
      SchedulingOverlap(
        taskId: task.id,
        taskInterval: interval,
        blockedInterval: surpriseInterval,
      ),
    );
  }

  return overlaps;
}

/// Locked overlaps with a surprise interval, kept hidden from normal notices.
List<TimeInterval> _lockedOverlaps({
  required SchedulingInput input,
  required TimeInterval surpriseInterval,
  required bool revealHiddenLockedDetails,
}) {
  return input.occupancyEntries
      .where(
        (occupancy) =>
            occupancy.category == OccupancyCategory.hiddenLockedConstraint,
      )
      .map((occupancy) => occupancy.interval)
      .whereType<TimeInterval>()
      .where((interval) => interval.overlaps(surpriseInterval))
      .map((interval) {
    if (revealHiddenLockedDetails) {
      return interval;
    }
    return TimeInterval(start: interval.start, end: interval.end);
  }).toList(growable: false);
}

/// Planned flexible task ids overlapping a surprise interval in timeline order.
List<String> _overlappingFlexibleTaskIds(
  List<Task> tasks,
  TimeInterval surpriseInterval,
) {
  final overlappingTasks = tasks.where((task) {
    if (task.status != TaskStatus.planned) {
      return false;
    }

    final interval = _scheduledIntervalForTask(task);
    return interval != null && interval.overlaps(surpriseInterval);
  }).toList(growable: false)
    ..sort((a, b) {
      final aStart = a.scheduledStart ?? surpriseInterval.end;
      final bStart = b.scheduledStart ?? surpriseInterval.end;

      return aStart.compareTo(bStart);
    });

  return overlappingTasks.map((task) => task.id).toList(growable: false);
}
