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

part of '../../scheduling_engine.dart';

/// Starter scheduling engine.
///
/// The engine is a pure domain service: it receives immutable-ish input values
/// and returns new values. It does not persist data, render UI, send reminders,
/// or read the clock except where an optional [updatedAt] timestamp is omitted.
///
/// Current V1 responsibilities:
/// - insert backlog tasks into the earliest available flexible slot;
/// - push flexible tasks later today;
/// - move flexible tasks to tomorrow's queue;
/// - roll unfinished flexible tasks into a new planning window;
/// - analyze overlaps against locked/required time;
/// - perform small state transitions such as missed/backlog handling.
///
/// Important rule vocabulary:
/// - `fixedBlocks` are intervals the engine will not move.
/// - `queue` is the ordered set of flexible tasks that may be placed or shifted.
/// - `placement` is a map from task id to the interval chosen by the planner.
class SchedulingEngine {
  /// Creates a `SchedulingEngine` 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 SchedulingEngine({
    this.clock = const SystemClock(),
  });

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

  /// Insert a backlog task into the earliest available slot today.
  ///
  /// Locked, inflexible, and critical time is treated as fixed. Planned
  /// flexible tasks at or after the insertion point may shift later, preserving
  /// their relative order.
  ///
  /// The selected task must already exist in `input.tasks`, be flexible, be in
  /// backlog status, and have a positive duration. If any precondition fails, the
  /// original task list is returned with a no-fit/overflow notice.
  SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
    required SchedulingInput input,
    required String taskId,
    DateTime? updatedAt,
  }) {
    logger.debug(() => 'Scheduling backlog task into next available slot. '
        'taskId=$taskId taskCount=${input.tasks.length} '
        'windowStart=${input.window.start.toIso8601String()} '
        'windowEnd=${input.window.end.toIso8601String()}');
    // Step 1: resolve and validate the task. The engine does not create tasks;
    // quick capture or persistence code is responsible for adding it to the list.
    final task = _taskById(input.tasks, taskId);
    if (task == null) {
      logger.warn(
          () => 'Backlog scheduling failed: task not found. taskId=$taskId');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task was not found.',
          type: SchedulingNoticeType.noFit,
          taskId: taskId,
          issueCode: SchedulingIssueCode.taskNotFound,
        ),
        operationCode:
            SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.notFound,
      );
    }

    if (!task.isFlexible || !task.isBacklog) {
      logger.warn(() => 'Backlog scheduling failed: invalid task state. '
          'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Only backlog flexible tasks can be inserted.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.invalidTaskState,
        ),
        operationCode:
            SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    final taskDuration = _durationFromMinutes(task.durationMinutes);
    if (taskDuration == null) {
      logger.warn(() => 'Backlog scheduling failed: missing positive duration. '
          'taskId=${task.id} durationMinutes=${task.durationMinutes}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task needs a positive duration before scheduling.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.missingDuration,
        ),
        operationCode:
            SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    // Step 2: compute placements without mutating any task. Planning returns null
    // if the inserted task and shifted queue cannot fit inside the window.
    final placement = _planBacklogInsertion(
      input: input,
      task: task,
      taskDuration: taskDuration,
    );

    if (placement == null) {
      logger.warn(() => 'Backlog scheduling failed: no available slot. '
          'taskId=${task.id} blockedIntervalCount=${input.blockedIntervals.length}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'No available flexible slot today.',
          type: SchedulingNoticeType.overflow,
          taskId: task.id,
          issueCode: SchedulingIssueCode.noAvailableSlot,
        ),
        operationCode:
            SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.noSlot,
      );
    }

    return _applyPlacement(
      input: input,
      insertedTask: task,
      placement: placement,
      updatedAt: updatedAt ?? clock.now(),
    );
  }

  /// Push a planned flexible task to the next available slot today.
  ///
  /// The selected task moves after its current slot. Planned flexible tasks
  /// after it may shift later, preserving their relative order.
  ///
  /// This is the "not now, later today" action. Anything before the pushed
  /// task's current end time becomes fixed for this operation, while later
  /// planned flexible tasks may be shifted if necessary.
  SchedulingResult pushFlexibleTaskToNextAvailableSlot({
    required SchedulingInput input,
    required String taskId,
    DateTime? updatedAt,
    DateTime? earliestStart,
    bool countAsManualPush = true,
  }) {
    logger.debug(() => 'Pushing flexible task to next available slot. '
        'taskId=$taskId taskCount=${input.tasks.length} '
        'earliestStart=${earliestStart?.toIso8601String()} '
        'countAsManualPush=$countAsManualPush');
    // Resolve the selected task by id so UI code only needs to pass a stable
    // identifier, not object references.
    final task = _taskById(input.tasks, taskId);
    if (task == null) {
      logger.warn(() => 'Flexible push failed: task not found. taskId=$taskId');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task was not found.',
          type: SchedulingNoticeType.noFit,
          taskId: taskId,
          issueCode: SchedulingIssueCode.taskNotFound,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.notFound,
      );
    }

    if (!task.isFlexible || task.status != TaskStatus.planned) {
      logger.warn(() => 'Flexible push failed: invalid task state. '
          'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Only planned flexible tasks can be pushed.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.invalidTaskState,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    final currentInterval = _scheduledIntervalFor(task);
    if (currentInterval == null ||
        (earliestStart == null && !input.window.contains(currentInterval))) {
      logger.warn(() =>
          'Flexible push failed: missing or out-of-window scheduled slot. '
          'taskId=${task.id} hasCurrentInterval=${currentInterval != null}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task needs a current scheduled slot before pushing.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.missingScheduledSlot,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    if (currentInterval.duration.inMicroseconds <= 0) {
      logger.warn(() =>
          'Flexible push failed: non-positive scheduled duration. '
          'taskId=${task.id} durationMicroseconds=${currentInterval.duration.inMicroseconds}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task needs a positive scheduled duration before pushing.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.nonPositiveDuration,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    final placement = _planFlexiblePush(
      input: input,
      task: task,
      currentInterval: currentInterval,
      earliestStart: earliestStart,
    );

    if (placement == null) {
      logger.warn(() =>
          'Flexible push failed: no available slot today. taskId=${task.id}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'No available flexible slot today.',
          type: SchedulingNoticeType.overflow,
          taskId: task.id,
          issueCode: SchedulingIssueCode.noAvailableSlot,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
        outcomeCode: SchedulingOutcomeCode.noSlot,
      );
    }

    return _applyPushPlacement(
      input: input,
      pushedTask: task,
      placement: placement,
      pushedTaskMessage: 'Flexible task pushed to next available slot.',
      pushedTaskMovementCode:
          SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot,
      operationCode:
          SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
      updatedAt: updatedAt ?? clock.now(),
      countPushedTaskAsManual: countAsManualPush,
    );
  }

  /// Move a planned flexible task to the top of tomorrow's flexible queue.
  ///
  /// The input window represents tomorrow's scheduling window. Existing planned
  /// flexible tasks in that window may shift later, preserving their order.
  ///
  /// The method name says "tomorrow" because that is the product action, but the
  /// engine only trusts `input.window`. Tests can pass any future window.
  SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({
    required SchedulingInput input,
    required String taskId,
    DateTime? updatedAt,
  }) {
    logger.debug(() => 'Pushing flexible task to tomorrow top of queue. '
        'taskId=$taskId taskCount=${input.tasks.length} '
        'windowStart=${input.window.start.toIso8601String()} '
        'windowEnd=${input.window.end.toIso8601String()}');
    final task = _taskById(input.tasks, taskId);
    if (task == null) {
      logger.warn(() => 'Tomorrow push failed: task not found. taskId=$taskId');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task was not found.',
          type: SchedulingNoticeType.noFit,
          taskId: taskId,
          issueCode: SchedulingIssueCode.taskNotFound,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
        outcomeCode: SchedulingOutcomeCode.notFound,
      );
    }

    if (!task.isFlexible || task.status != TaskStatus.planned) {
      logger.warn(() => 'Tomorrow push failed: invalid task state. '
          'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Only planned flexible tasks can be moved to tomorrow.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.invalidTaskState,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    final taskDuration = _scheduledIntervalFor(task)?.duration ??
        _durationFromMinutes(task.durationMinutes);
    if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
      logger.warn(() => 'Tomorrow push failed: missing positive duration. '
          'taskId=${task.id} durationMinutes=${task.durationMinutes}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task needs a positive duration before moving to tomorrow.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
          issueCode: SchedulingIssueCode.missingDuration,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
        outcomeCode: SchedulingOutcomeCode.invalidState,
      );
    }

    final placement = _planTomorrowQueueInsertion(
      input: input,
      task: task,
      taskDuration: taskDuration,
    );

    if (placement == null) {
      logger.warn(
          () => 'Tomorrow push failed: no available slot. taskId=${task.id}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'No available flexible slot tomorrow.',
          type: SchedulingNoticeType.overflow,
          taskId: task.id,
          issueCode: SchedulingIssueCode.noAvailableSlot,
        ),
        operationCode:
            SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
        outcomeCode: SchedulingOutcomeCode.noSlot,
      );
    }

    return _applyPushPlacement(
      input: input,
      pushedTask: task,
      placement: placement,
      pushedTaskMessage: 'Flexible task moved to tomorrow.',
      pushedTaskMovementCode:
          SchedulingMovementCode.flexibleTaskMovedToTomorrow,
      operationCode:
          SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
      updatedAt: updatedAt ?? clock.now(),
    );
  }

  /// Move unfinished flexible tasks to the top of tomorrow's flexible queue.
  ///
  /// The input window represents tomorrow's scheduling window. Only planned and
  /// active flexible tasks are rolled; required, locked, completed, and
  /// cancelled tasks remain unchanged.
  ///
  /// Rollover is bulk push behavior for day-end recovery. It collects unfinished
  /// flexible tasks outside the target window, preserves their relative order,
  /// then places them at the start of the new window while shifting already
  /// planned flexible tasks as needed. When [sourceWindow] is supplied, only
  /// unfinished flexible tasks from that source window are rolled over. This
  /// prevents future planned tasks from being pulled into tomorrow accidentally.
  SchedulingResult rollOverUnfinishedFlexibleTasks({
    required SchedulingInput input,
    SchedulingWindow? sourceWindow,
    DateTime? updatedAt,
  }) {
    logger.debug(() => 'Rolling over unfinished flexible tasks. '
        'taskCount=${input.tasks.length} targetWindowStart=${input.window.start.toIso8601String()} '
        'targetWindowEnd=${input.window.end.toIso8601String()} '
        'hasSourceWindow=${sourceWindow != null}');
    // Build the explicit queue of tasks to roll before asking the planner to
    // place anything. This keeps selection separate from placement.
    final rolledItems = <_PlacementItem>[];
    final rolloverTasks = input.flexibleTasks
        .where((task) => _shouldRollOver(
              task: task,
              targetWindow: input.window,
              sourceWindow: sourceWindow,
            ))
        .toList()
      ..sort((a, b) {
        final aStart =
            a.scheduledStart ?? sourceWindow?.start ?? input.window.start;
        final bStart =
            b.scheduledStart ?? sourceWindow?.start ?? input.window.start;

        return aStart.compareTo(bStart);
      });

    for (final task in rolloverTasks) {
      final duration = _scheduledIntervalFor(task)?.duration ??
          _durationFromMinutes(task.durationMinutes);
      if (duration == null || duration.inMicroseconds <= 0) {
        continue;
      }

      rolledItems.add(
        _PlacementItem(
          task: task,
          duration: duration,
          earliestStart: input.window.start,
        ),
      );
    }

    if (rolledItems.isEmpty) {
      logger
          .debug(() => 'Rollover finished with no unfinished flexible tasks.');
      return SchedulingResult(
        tasks: input.tasks,
        operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
        outcomeCode: SchedulingOutcomeCode.noOp,
        notices: [
          SchedulingNotice(
            'No unfinished flexible tasks to roll over.',
            issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks,
          ),
        ],
      );
    }

    final placement = _planQueueAtWindowStart(
      input: input,
      queue: rolledItems,
      excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(),
    );

    if (placement == null) {
      logger.warn(
          () => 'Rollover failed: unfinished flexible tasks could not fit. '
              'rolledCount=${rolledItems.length}');
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Unfinished flexible tasks could not fit tomorrow.',
          type: SchedulingNoticeType.overflow,
          issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit,
        ),
        operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
        outcomeCode: SchedulingOutcomeCode.overflow,
      );
    }

    return _applyRolloverPlacement(
      input: input,
      rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
      placement: placement,
      updatedAt: updatedAt ?? clock.now(),
    );
  }

  /// Analyze the current in-memory schedule without moving tasks.
  ///
  /// This is a validation/debugging helper. It scans scheduled flexible tasks and
  /// reports any overlap with blocked intervals. It deliberately returns the
  /// original task list unchanged.
  SchedulingResult analyzeSchedule(SchedulingInput input) {
    logger.debug(() => 'Analyzing schedule. taskCount=${input.tasks.length} '
        'conflictOccupancyCount=${input.conflictReportingOccupancies.length}');
    final overlaps = <SchedulingOverlap>[];
    final notices = <SchedulingNotice>[];
    final conflictOccupancies = input.conflictReportingOccupancies;

    for (final task in input.movablePlannedFlexibleTasks) {
      final taskInterval = _scheduledIntervalFor(task);
      if (taskInterval == null || !input.window.contains(taskInterval)) {
        continue;
      }

      for (final occupancy in conflictOccupancies) {
        final blockedInterval = occupancy.interval;
        if (blockedInterval == null) {
          continue;
        }

        if (!taskInterval.overlaps(blockedInterval)) {
          continue;
        }

        overlaps.add(
          SchedulingOverlap(
            taskId: task.id,
            taskInterval: taskInterval,
            blockedInterval: blockedInterval,
          ),
        );
        notices.add(
          SchedulingNotice(
            'Flexible task overlaps blocked time.',
            type: SchedulingNoticeType.overlap,
            taskId: task.id,
            conflictCode:
                SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
            parameters: {
              'blockedStart': blockedInterval.start,
              'blockedEnd': blockedInterval.end,
            },
          ),
        );
      }
    }

    logger.debug(
        () => 'Schedule analysis finished. overlapCount=${overlaps.length} '
            'noticeCount=${notices.length}');
    return SchedulingResult(
      tasks: input.tasks,
      operationCode: SchedulingOperationCode.analyzeSchedule,
      outcomeCode: overlaps.isEmpty
          ? SchedulingOutcomeCode.success
          : SchedulingOutcomeCode.conflict,
      notices: List<SchedulingNotice>.unmodifiable(notices),
      overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
    );
  }

  /// Move a task to backlog.
  ///
  /// Backlog does not preserve original schedule/order placement. The task's
  /// schedule is cleared and its moved-to-backlog counter is incremented so
  /// reports can distinguish this from a task that was never scheduled.
  Task moveToBacklog(Task task, {DateTime? updatedAt}) {
    final now = updatedAt ?? clock.now();
    logger.debug(() => 'Legacy move-to-backlog requested. taskId=${task.id} '
        'taskStatus=${task.status.name} occurredAt=${now.toIso8601String()}');
    final result = _transitionService.apply(
      task: task,
      transitionCode: TaskTransitionCode.moveToBacklog,
      operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}',
      activityId:
          'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
      occurredAt: now,
    );
    logger.debug(() => 'Legacy move-to-backlog finished. taskId=${task.id} '
        'outcome=${result.outcomeCode.name} nextStatus=${result.task.status.name}');
    return result.applied ? result.task : task;
  }

  /// Mark a flexible task pushed manually.
  ///
  /// This updates statistics only. Use the push methods above when the task's
  /// actual scheduled slot should change.
  Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
    final now = updatedAt ?? clock.now();
    logger.debug(
        () => 'Legacy manual push statistic requested. taskId=${task.id} '
            'occurredAt=${now.toIso8601String()}');
    final result = _transitionService.apply(
      task: task,
      transitionCode: TaskTransitionCode.manualPush,
      operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}',
      activityId:
          'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
      occurredAt: now,
    );
    logger
        .debug(() => 'Legacy manual push statistic finished. taskId=${task.id} '
            'outcome=${result.outcomeCode.name}');
    return result.applied ? result.task : task;
  }

  /// Mark missed according to the current MVP rules.
  ///
  /// Critical missed tasks go to backlog so they remain actionable. Inflexible
  /// missed tasks stay in place as missed because they represented a fixed event
  /// or time block that cannot simply be rescheduled automatically.
  Task markMissed(Task task, {DateTime? updatedAt}) {
    final now = updatedAt ?? clock.now();
    logger.debug(() => 'Legacy mark-missed requested. taskId=${task.id} '
        'taskType=${task.type.name} taskStatus=${task.status.name} '
        'occurredAt=${now.toIso8601String()}');
    final result = _transitionService.apply(
      task: task,
      transitionCode: TaskTransitionCode.miss,
      operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}',
      activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
      occurredAt: now,
    );
    logger.debug(() => 'Legacy mark-missed finished. taskId=${task.id} '
        'outcome=${result.outcomeCode.name} nextStatus=${result.task.status.name}');
    return result.applied ? result.task : task;
  }

  /// Finds the first interval that can fit the requested duration while avoiding
  /// blocked intervals.
  ///
  /// This public helper is deliberately simple and does not shift existing
  /// flexible tasks. It is useful for UI previews or tests that only need to know
  /// the first open gap. Full bump/queue behavior lives in the private planning
  /// helpers below.
  TimeInterval? findFirstOpenInterval({
    required DateTime windowStart,
    required DateTime windowEnd,
    required Duration duration,
    required List<TimeInterval> blocked,
  }) {
    logger.debug(() => 'Finding first open interval. '
        'windowStart=${windowStart.toIso8601String()} '
        'windowEnd=${windowEnd.toIso8601String()} '
        'durationMinutes=${duration.inMinutes} blockedCount=${blocked.length}');
    final sortedBlocked = [...blocked]
      ..sort((a, b) => a.start.compareTo(b.start));
    var cursor = windowStart;

    for (final interval in sortedBlocked) {
      if (cursor.add(duration).isBefore(interval.start) ||
          cursor.add(duration).isAtSameMomentAs(interval.start)) {
        return TimeInterval(start: cursor, end: cursor.add(duration));
      }

      if (interval.end.isAfter(cursor)) {
        cursor = interval.end;
      }
    }

    final candidateEnd = cursor.add(duration);
    if (candidateEnd.isBefore(windowEnd) ||
        candidateEnd.isAtSameMomentAs(windowEnd)) {
      return TimeInterval(start: cursor, end: candidateEnd);
    }

    return null;
  }
}

/// Convert a scheduled task into an interval, or null if it is unplaced.
///
/// This helper does not validate positive duration; callers that require a valid
/// duration check that separately.
TimeInterval? _scheduledIntervalFor(Task task) {
  final start = task.scheduledStart;
  final end = task.scheduledEnd;

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

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

/// Convert all placed tasks in [tasks] into intervals.
List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
  final intervals = <TimeInterval>[];

  for (final task in tasks) {
    final interval = _scheduledIntervalFor(task);
    if (interval != null) {
      intervals.add(interval);
    }
  }

  return List<TimeInterval>.unmodifiable(intervals);
}

/// Return the original task list with one explanatory notice.
///
/// Most validation failures use this so callers can keep rendering the existing
/// schedule while showing why the requested action did not apply.
SchedulingResult _unchangedResult(
  SchedulingInput input,
  SchedulingNotice notice, {
  SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified,
  SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState,
}) {
  return SchedulingResult(
    tasks: input.tasks,
    operationCode: operationCode,
    outcomeCode: outcomeCode,
    notices: [notice],
  );
}

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

  return null;
}

/// Convert a nullable minute estimate into a positive [Duration].
Duration? _durationFromMinutes(int? minutes) {
  if (minutes == null || minutes <= 0) {
    return null;
  }

  return Duration(minutes: minutes);
}

/// Plan insertion of a backlog task plus any flexible tasks that must shift.
///
/// This function only calculates intervals. It does not update task objects. The
/// returned plan is later applied by [_applyPlacement], which creates notices,
/// changes, and updated task copies.
_BacklogInsertionPlan? _planBacklogInsertion({
  required SchedulingInput input,
  required Task task,
  required Duration taskDuration,
}) {
  // Start with intervals that the algorithm is not allowed to move.
  final fixedBlocks = <TimeInterval>[
    ...input.blockedIntervals,
  ];
  final queue = <_PlacementItem>[
    _PlacementItem(
      task: task,
      duration: taskDuration,
      earliestStart: input.window.start,
    ),
  ];

  // Existing flexible tasks are inspected in timeline order. Planned tasks inside
  // the movable portion become part of the placement queue; everything else is
  // treated as fixed.
  final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
      .where((flexibleTask) => flexibleTask.id != task.id)
      .toList(growable: false)
    ..sort((a, b) {
      final aStart = a.scheduledStart ?? input.window.end;
      final bStart = b.scheduledStart ?? input.window.end;

      return aStart.compareTo(bStart);
    });

  for (final flexibleTask in scheduledFlexibleTasks) {
    final interval = _scheduledIntervalFor(flexibleTask);
    if (interval == null) {
      continue;
    }

    final startsBeforeWindow = interval.start.isBefore(input.window.start);
    final startsAfterWindow = interval.start.isAfter(input.window.end) ||
        interval.start.isAtSameMomentAs(input.window.end);

    if (startsBeforeWindow || startsAfterWindow) {
      fixedBlocks.add(interval);
      continue;
    }

    queue.add(
      _PlacementItem(
        task: flexibleTask,
        duration: interval.duration,
        earliestStart: interval.start,
      ),
    );
  }

  fixedBlocks.sort((a, b) => a.start.compareTo(b.start));

  // The cursor tracks the earliest point after the previously placed queue item.
  // Each queued task is placed no earlier than both the cursor and its own
  // original earliest start.
  var cursor = input.window.start;
  final placements = <String, TimeInterval>{};

  for (final item in queue) {
    final earliestStart = _laterOf(cursor, item.earliestStart);
    final interval = _firstOpenIntervalFrom(
      earliestStart: earliestStart,
      windowEnd: input.window.end,
      duration: item.duration,
      blocked: fixedBlocks,
    );

    if (interval == null) {
      return null;
    }

    placements[item.task.id] = interval;
    cursor = interval.end;
  }

  return _BacklogInsertionPlan(placements: placements);
}

/// Plan the "push later today" behavior for one flexible task.
///
/// Items before the pushed task's current end are fixed. The pushed task starts
/// the queue at its current end, followed by later planned flexible tasks that
/// may need to move to preserve order.
_BacklogInsertionPlan? _planFlexiblePush({
  required SchedulingInput input,
  required Task task,
  required TimeInterval currentInterval,
  DateTime? earliestStart,
}) {
  final pushPoint = _laterOf(
    currentInterval.end,
    earliestStart ?? input.window.start,
  );
  final fixedBlocks = <TimeInterval>[
    ...input.blockedIntervals,
  ];
  final queue = <_PlacementItem>[
    _PlacementItem(
      task: task,
      duration: currentInterval.duration,
      earliestStart: pushPoint,
    ),
  ];

  final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
      .where((flexibleTask) => flexibleTask.id != task.id)
      .toList(growable: false)
    ..sort((a, b) {
      final aStart = a.scheduledStart ?? input.window.end;
      final bStart = b.scheduledStart ?? input.window.end;

      return aStart.compareTo(bStart);
    });

  for (final flexibleTask in scheduledFlexibleTasks) {
    final interval = _scheduledIntervalFor(flexibleTask);
    if (interval == null) {
      continue;
    }

    final startsBeforePushPoint = interval.start.isBefore(pushPoint);
    final startsAfterWindow = interval.start.isAfter(input.window.end) ||
        interval.start.isAtSameMomentAs(input.window.end);

    if (startsBeforePushPoint || startsAfterWindow) {
      fixedBlocks.add(interval);
      continue;
    }

    queue.add(
      _PlacementItem(
        task: flexibleTask,
        duration: interval.duration,
        earliestStart: interval.start,
      ),
    );
  }

  fixedBlocks.sort((a, b) => a.start.compareTo(b.start));

  var cursor = pushPoint;
  final placements = <String, TimeInterval>{};

  for (final item in queue) {
    final earliestStart = _laterOf(cursor, item.earliestStart);
    final interval = _firstOpenIntervalFrom(
      earliestStart: earliestStart,
      windowEnd: input.window.end,
      duration: item.duration,
      blocked: fixedBlocks,
    );

    if (interval == null) {
      return null;
    }

    placements[item.task.id] = interval;
    cursor = interval.end;
  }

  return _BacklogInsertionPlan(placements: placements);
}

/// Plan putting a single task at the start of the supplied future window.
_BacklogInsertionPlan? _planTomorrowQueueInsertion({
  required SchedulingInput input,
  required Task task,
  required Duration taskDuration,
}) {
  final queue = <_PlacementItem>[
    _PlacementItem(
      task: task,
      duration: taskDuration,
      earliestStart: input.window.start,
    ),
  ];

  return _planQueueAtWindowStart(
    input: input,
    queue: queue,
    excludeTaskIds: {task.id},
  );
}

/// Plan a queue of flexible tasks at the beginning of [input.window].
///
/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies
/// tasks already represented in the incoming [queue] so they are not also pulled
/// from existing scheduled flexible tasks.
_BacklogInsertionPlan? _planQueueAtWindowStart({
  required SchedulingInput input,
  required List<_PlacementItem> queue,
  required Set<String> excludeTaskIds,
}) {
  final fixedBlocks = <TimeInterval>[
    ...input.blockedIntervals,
  ];
  final placementQueue = <_PlacementItem>[
    ...queue,
  ];

  final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
      .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id))
      .toList(growable: false)
    ..sort((a, b) {
      final aStart = a.scheduledStart ?? input.window.end;
      final bStart = b.scheduledStart ?? input.window.end;

      return aStart.compareTo(bStart);
    });

  for (final flexibleTask in scheduledFlexibleTasks) {
    final interval = _scheduledIntervalFor(flexibleTask);
    if (interval == null) {
      continue;
    }

    final overlapsWindow = interval.overlaps(input.window.interval);
    if (!overlapsWindow) {
      continue;
    }

    if (!input.window.contains(interval)) {
      fixedBlocks.add(interval);
      continue;
    }

    placementQueue.add(
      _PlacementItem(
        task: flexibleTask,
        duration: interval.duration,
        earliestStart: interval.start,
      ),
    );
  }

  fixedBlocks.sort((a, b) => a.start.compareTo(b.start));

  var cursor = input.window.start;
  final placements = <String, TimeInterval>{};

  for (final item in placementQueue) {
    final earliestStart = _laterOf(cursor, item.earliestStart);
    final interval = _firstOpenIntervalFrom(
      earliestStart: earliestStart,
      windowEnd: input.window.end,
      duration: item.duration,
      blocked: fixedBlocks,
    );

    if (interval == null) {
      return null;
    }

    placements[item.task.id] = interval;
    cursor = interval.end;
  }

  return _BacklogInsertionPlan(placements: placements);
}

/// Apply a backlog insertion plan to the task list.
///
/// The inserted backlog task becomes planned and increments
/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room
/// increment `autoPushedCount`.
SchedulingResult _applyPlacement({
  required SchedulingInput input,
  required Task insertedTask,
  required _BacklogInsertionPlan placement,
  required DateTime updatedAt,
}) {
  final changes = <SchedulingChange>[];
  final notices = <SchedulingNotice>[];
  final updatedTasks = <Task>[];

  for (final task in input.tasks) {
    final interval = placement.placements[task.id];
    if (interval == null) {
      updatedTasks.add(task);
      continue;
    }

    final isInsertedTask = task.id == insertedTask.id;
    final moved = isInsertedTask ||
        !_sameDateTime(task.scheduledStart, interval.start) ||
        !_sameDateTime(task.scheduledEnd, interval.end);

    if (!moved) {
      updatedTasks.add(task);
      continue;
    }

    final placedTask = task.copyWith(
      status: isInsertedTask ? TaskStatus.planned : task.status,
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt,
      clearBacklogEntry: isInsertedTask,
    );
    final updatedTask = _applySchedulingActivity(
      originalTask: task,
      updatedTask: placedTask,
      operationCode:
          SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
      activityCode: isInsertedTask
          ? TaskActivityCode.restoredFromBacklog
          : TaskActivityCode.automaticallyPushed,
      occurredAt: updatedAt,
      previousStart: task.scheduledStart,
      previousEnd: task.scheduledEnd,
      nextStart: interval.start,
      nextEnd: interval.end,
    );

    updatedTasks.add(updatedTask);
    changes.add(
      SchedulingChange(
        taskId: task.id,
        previousStart: task.scheduledStart,
        previousEnd: task.scheduledEnd,
        nextStart: interval.start,
        nextEnd: interval.end,
      ),
    );
    notices.add(
      SchedulingNotice(
        isInsertedTask
            ? 'Backlog task inserted into schedule.'
            : 'Flexible task moved to make room.',
        type: SchedulingNoticeType.moved,
        taskId: task.id,
        movementCode: isInsertedTask
            ? SchedulingMovementCode.backlogTaskInserted
            : SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
        parameters: {
          'previousStart': task.scheduledStart,
          'previousEnd': task.scheduledEnd,
          'nextStart': interval.start,
          'nextEnd': interval.end,
        },
      ),
    );
  }

  logger.debug(() => 'Backlog insertion placement applied. '
      'insertedTaskId=${insertedTask.id} changeCount=${changes.length} '
      'noticeCount=${notices.length}');
  return SchedulingResult(
    tasks: List<Task>.unmodifiable(updatedTasks),
    operationCode:
        SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
    outcomeCode: SchedulingOutcomeCode.success,
    notices: List<SchedulingNotice>.unmodifiable(notices),
    changes: List<SchedulingChange>.unmodifiable(changes),
  );
}

/// Apply a push/tomorrow placement plan to the task list.
///
/// The explicitly pushed task increments `manuallyPushedCount`; other moved
/// flexible tasks increment `autoPushedCount` because the scheduler moved them as
/// a side effect.
SchedulingResult _applyPushPlacement({
  required SchedulingInput input,
  required Task pushedTask,
  required _BacklogInsertionPlan placement,
  required String pushedTaskMessage,
  required SchedulingMovementCode pushedTaskMovementCode,
  required SchedulingOperationCode operationCode,
  required DateTime updatedAt,
  bool countPushedTaskAsManual = true,
}) {
  final changes = <SchedulingChange>[];
  final notices = <SchedulingNotice>[];
  final updatedTasks = <Task>[];

  for (final task in input.tasks) {
    final interval = placement.placements[task.id];
    if (interval == null) {
      updatedTasks.add(task);
      continue;
    }

    final moved = !_sameDateTime(task.scheduledStart, interval.start) ||
        !_sameDateTime(task.scheduledEnd, interval.end);

    if (!moved) {
      updatedTasks.add(task);
      continue;
    }

    final isPushedTask = task.id == pushedTask.id;
    final placedTask = task.copyWith(
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt,
      clearBacklogEntry: true,
    );
    final updatedTask = _applySchedulingActivity(
      originalTask: task,
      updatedTask: placedTask,
      operationCode: operationCode,
      activityCode: isPushedTask && countPushedTaskAsManual
          ? TaskActivityCode.manuallyPushed
          : TaskActivityCode.automaticallyPushed,
      occurredAt: updatedAt,
      previousStart: task.scheduledStart,
      previousEnd: task.scheduledEnd,
      nextStart: interval.start,
      nextEnd: interval.end,
    );

    updatedTasks.add(updatedTask);
    changes.add(
      SchedulingChange(
        taskId: task.id,
        previousStart: task.scheduledStart,
        previousEnd: task.scheduledEnd,
        nextStart: interval.start,
        nextEnd: interval.end,
      ),
    );
    notices.add(
      SchedulingNotice(
        isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.',
        type: SchedulingNoticeType.moved,
        taskId: task.id,
        movementCode: isPushedTask
            ? pushedTaskMovementCode
            : SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
        parameters: {
          'previousStart': task.scheduledStart,
          'previousEnd': task.scheduledEnd,
          'nextStart': interval.start,
          'nextEnd': interval.end,
        },
      ),
    );
  }

  logger.debug(() => 'Flexible push placement applied. '
      'operationCode=${operationCode.name} pushedTaskId=${pushedTask.id} '
      'changeCount=${changes.length} noticeCount=${notices.length}');
  return SchedulingResult(
    tasks: List<Task>.unmodifiable(updatedTasks),
    operationCode: operationCode,
    outcomeCode: SchedulingOutcomeCode.success,
    notices: List<SchedulingNotice>.unmodifiable(notices),
    changes: List<SchedulingChange>.unmodifiable(changes),
  );
}

/// Apply a bulk rollover placement plan.
///
/// Rolled tasks are set back to planned status in the target window. Existing
/// tasks moved to make room receive normal movement notices.
SchedulingResult _applyRolloverPlacement({
  required SchedulingInput input,
  required Set<String> rolledTaskIds,
  required _BacklogInsertionPlan placement,
  required DateTime updatedAt,
}) {
  final changes = <SchedulingChange>[];
  final notices = <SchedulingNotice>[];
  final updatedTasks = <Task>[];
  var rolledCount = 0;

  for (final task in input.tasks) {
    final interval = placement.placements[task.id];
    if (interval == null) {
      updatedTasks.add(task);
      continue;
    }

    final moved = !_sameDateTime(task.scheduledStart, interval.start) ||
        !_sameDateTime(task.scheduledEnd, interval.end);

    if (!moved) {
      updatedTasks.add(task);
      continue;
    }

    final isRolledTask = rolledTaskIds.contains(task.id);
    final placedTask = task.copyWith(
      status: isRolledTask ? TaskStatus.planned : task.status,
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt,
    );
    final updatedTask = _applySchedulingActivity(
      originalTask: task,
      updatedTask: placedTask,
      operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
      activityCode: TaskActivityCode.automaticallyPushed,
      occurredAt: updatedAt,
      previousStart: task.scheduledStart,
      previousEnd: task.scheduledEnd,
      nextStart: interval.start,
      nextEnd: interval.end,
    );

    updatedTasks.add(updatedTask);
    changes.add(
      SchedulingChange(
        taskId: task.id,
        previousStart: task.scheduledStart,
        previousEnd: task.scheduledEnd,
        nextStart: interval.start,
        nextEnd: interval.end,
      ),
    );

    if (isRolledTask) {
      rolledCount += 1;
    } else {
      notices.add(
        SchedulingNotice(
          'Flexible task moved to make room.',
          type: SchedulingNoticeType.moved,
          taskId: task.id,
          movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
          parameters: {
            'previousStart': task.scheduledStart,
            'previousEnd': task.scheduledEnd,
            'nextStart': interval.start,
            'nextEnd': interval.end,
          },
        ),
      );
    }
  }

  notices.insert(
    0,
    SchedulingNotice(
      '$rolledCount unfinished flexible tasks were moved to tomorrow.',
      type: SchedulingNoticeType.moved,
      movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
      parameters: {
        'rolledCount': rolledCount,
      },
    ),
  );

  logger.debug(() => 'Rollover placement applied. rolledCount=$rolledCount '
      'changeCount=${changes.length} noticeCount=${notices.length}');
  return SchedulingResult(
    tasks: List<Task>.unmodifiable(updatedTasks),
    operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
    outcomeCode: SchedulingOutcomeCode.success,
    notices: List<SchedulingNotice>.unmodifiable(notices),
    changes: List<SchedulingChange>.unmodifiable(changes),
  );
}

/// Whether [task] belongs in the rollover queue.
///
/// Planned/active flexible tasks already inside the target window are not rolled
/// again; they are handled as existing tasks that may shift to make room.
bool _shouldRollOver({
  required Task task,
  required SchedulingWindow targetWindow,
  SchedulingWindow? sourceWindow,
}) {
  final interval = _scheduledIntervalFor(task);
  final isTomorrowTask =
      interval != null && interval.overlaps(targetWindow.interval);

  final isInSourceWindow = sourceWindow == null ||
      (interval != null && sourceWindow.contains(interval));

  return task.isFlexible &&
      !isTomorrowTask &&
      isInSourceWindow &&
      (task.status == TaskStatus.planned || task.status == TaskStatus.active);
}

/// Return whichever timestamp is later.
DateTime _laterOf(DateTime first, DateTime second) {
  if (first.isAfter(second)) {
    return first;
  }

  return second;
}

/// Null-safe exact timestamp comparison.
bool _sameDateTime(DateTime? first, DateTime? second) {
  if (first == null || second == null) {
    return first == null && second == null;
  }

  return first.isAtSameMomentAs(second);
}

/// Top-level helper that performs the `_applySchedulingActivity` 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 _applySchedulingActivity({
  required Task originalTask,
  required Task updatedTask,
  required SchedulingOperationCode operationCode,
  required TaskActivityCode activityCode,
  required DateTime occurredAt,
  required DateTime? previousStart,
  required DateTime? previousEnd,
  required DateTime? nextStart,
  required DateTime? nextEnd,
}) {
  final operationId =
      '${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}';
  final activity = TaskActivity(
    id: '$operationId:${activityCode.name}',
    operationId: operationId,
    code: activityCode,
    taskId: originalTask.id,
    projectId: originalTask.projectId,
    occurredAt: occurredAt,
    metadata: {
      'previousStatus': originalTask.status.name,
      'nextStatus': updatedTask.status.name,
      'previousStart': previousStart,
      'previousEnd': previousEnd,
      'nextStart': nextStart,
      'nextEnd': nextEnd,
    },
  );

  return _activityAccountingService
      .apply(task: updatedTask, activities: [activity]).task;
}

/// Find the first candidate interval at or after [earliestStart].
///
/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps
/// a blocked interval, the cursor jumps to that blocked interval's end and tries
/// again. This makes the algorithm easy to follow and adequate for the starter
/// in-memory engine.
TimeInterval? _firstOpenIntervalFrom({
  required DateTime earliestStart,
  required DateTime windowEnd,
  required Duration duration,
  required List<TimeInterval> blocked,
}) {
  var cursor = earliestStart;

  while (true) {
    final candidateEnd = cursor.add(duration);
    if (candidateEnd.isAfter(windowEnd)) {
      return null;
    }

    final candidate = TimeInterval(start: cursor, end: candidateEnd);
    TimeInterval? overlappingBlock;

    for (final block in blocked) {
      if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) {
        continue;
      }

      if (block.start.isAfter(candidate.end) ||
          block.start.isAtSameMomentAs(candidate.end)) {
        break;
      }

      if (candidate.overlaps(block)) {
        overlappingBlock = block;
        break;
      }
    }

    if (overlappingBlock == null) {
      return candidate;
    }

    cursor = overlappingBlock.end;
  }
}
