// Scheduling engine for the ADHD scheduling starter project.
//
// This file is the core timeline manipulation layer. It takes task data plus a
// planning window and returns a new task list, notices, changes, and analysis
// findings. The implementation is deliberately side-effect free so a first-time
// reader can trace each operation from input validation, to placement planning,
// to application of the plan.
//
// Human reading map:
//   1. Data wrappers: `SchedulingWindow`, `SchedulingInput`, result classes.
//   2. Public engine methods: the operations UI/actions can call.
//   3. Private planning helpers: calculate intervals without changing tasks.
//   4. Private apply helpers: convert plans into updated tasks and notices.

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

const OccupancyPolicy _occupancyPolicy = OccupancyPolicy();
const TaskTransitionService _transitionService = TaskTransitionService();
const TaskActivityAccountingService _activityAccountingService =
    TaskActivityAccountingService();

/// Category for scheduler notices.
///
/// Notices are human-readable summaries attached to a [SchedulingResult]. They
/// are not exceptions. The scheduler returns them alongside the task list so UI
/// can explain what happened without losing the successfully computed output.
enum SchedulingNoticeType {
  /// General informational notice.
  info,

  /// A task was moved by a scheduling operation.
  moved,

  /// A scheduled task overlaps blocked time.
  overlap,

  /// A task could not fit in the requested window.
  noFit,

  /// A task would need to move outside the requested window.
  overflow,
}

/// Stable scheduler operation identifiers.
enum SchedulingOperationCode {
  /// Operation was built outside the core scheduler or is not specified.
  unspecified,

  /// Insert a backlog task into the next available slot.
  insertBacklogTaskIntoNextAvailableSlot,

  /// Push a flexible task later in the current window.
  pushFlexibleTaskToNextAvailableSlot,

  /// Move a flexible task to the top of a future/tomorrow queue.
  pushFlexibleTaskToTomorrowTopOfQueue,

  /// Roll unfinished flexible tasks into a target window.
  rollOverUnfinishedFlexibleTasks,

  /// Analyze overlaps without moving tasks.
  analyzeSchedule,

  /// Move a flexible task to backlog through the action service.
  moveFlexibleTaskToBacklog,

  /// Log completed surprise work.
  logSurpriseTask,

  /// Explicitly schedule a critical or inflexible commitment.
  scheduleRequiredCommitment,
}

/// Stable high-level outcome categories for scheduling operations.
enum SchedulingOutcomeCode {
  /// Operation completed without conflicts.
  success,

  /// Operation intentionally made no changes.
  noOp,

  /// Requested record was not found.
  notFound,

  /// Requested operation does not apply to the current state.
  invalidState,

  /// No placement slot exists for the requested item.
  noSlot,

  /// The requested queue would extend beyond the planning window.
  overflow,

  /// Operation completed or analyzed with a conflict.
  conflict,
}

/// Stable issue identifiers for validation, no-slot, and no-op notices.
enum SchedulingIssueCode {
  taskNotFound,
  invalidTaskState,
  missingDuration,
  missingScheduledSlot,
  nonPositiveDuration,
  noAvailableSlot,
  unfinishedTasksCouldNotFit,
  noUnfinishedFlexibleTasks,
  duplicateSurpriseLog,
}

/// Stable movement identifiers for task placement changes.
enum SchedulingMovementCode {
  backlogTaskInserted,
  flexibleTaskMovedToMakeRoom,
  flexibleTaskPushedToNextAvailableSlot,
  flexibleTaskMovedToTomorrow,
  unfinishedFlexibleTasksRolledOver,
  flexibleTaskMovedToBacklog,
  requiredCommitmentScheduled,
}

/// Stable conflict identifiers for overlap/interrupt notices.
enum SchedulingConflictCode {
  flexibleTaskOverlapsBlockedTime,
  surpriseTaskOverlapsRequiredVisibleTime,
  requiredCommitmentOverlapsProtectedFreeSlot,
}

/// Window of time available to a scheduling operation.
///
/// Most engine methods operate on one planning window: "today", "tomorrow",
/// or any other bounded range supplied by the caller. The window constrains where
/// flexible tasks can be placed. Anything outside this range is treated as out of
/// scope for the operation.
class SchedulingWindow {
  SchedulingWindow({
    required this.start,
    required this.end,
  }) {
    if (!start.isBefore(end)) {
      throw DomainValidationException(
        code: DomainValidationCode.invalidSchedulingWindow,
        invalidValue: {'start': start, 'end': end},
        name: 'SchedulingWindow',
        message: 'Scheduling window end must be after start.',
      );
    }
  }

  /// Inclusive beginning of the scheduling range.
  final DateTime start;

  /// Exclusive ending of the scheduling range.
  final DateTime end;

  /// The window as a [TimeInterval], useful for overlap checks.
  TimeInterval get interval => TimeInterval(start: start, end: end);

  /// Whether [interval] is completely inside this window.
  ///
  /// A task that starts before the window or ends after the window is considered
  /// outside the operation. The engine may treat such tasks as fixed blocks
  /// instead of moving them.
  bool contains(TimeInterval interval) {
    final startsInWindow =
        interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start);
    final endsInWindow =
        interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end);

    return startsInWindow && endsInWindow;
  }
}

/// In-memory input for scheduling operations.
///
/// This is the complete snapshot the pure scheduling engine needs. It contains
/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does
/// not know where the data came from: UI state, a database, tests, or generated
/// examples can all build this same object.
class SchedulingInput {
  SchedulingInput({
    required List<Task> tasks,
    required this.window,
    List<TimeInterval> lockedIntervals = const <TimeInterval>[],
    List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
  })  : tasks = List<Task>.unmodifiable(tasks),
        lockedIntervals = List<TimeInterval>.unmodifiable(lockedIntervals),
        requiredVisibleIntervals =
            List<TimeInterval>.unmodifiable(requiredVisibleIntervals);

  /// All tasks available to this operation. The scheduler returns a replacement
  /// list rather than mutating this one.
  final List<Task> tasks;

  /// Date/time range that the operation is allowed to plan inside.
  final SchedulingWindow window;

  /// External locked time intervals, usually produced by `locked_time.dart`.
  final List<TimeInterval> lockedIntervals;

  /// Extra fixed visible intervals supplied by the caller. This lets UI/backend
  /// code reserve required time even when that time is not represented as a
  /// [Task] in the current list.
  final List<TimeInterval> requiredVisibleIntervals;

  /// Tasks that the flexible movement algorithms are allowed to consider.
  List<Task> get flexibleTasks {
    return tasks.where((task) => task.isFlexible).toList(growable: false);
  }

  /// Central occupancy classification for this scheduling snapshot.
  List<OccupancyEntry> get occupancyEntries {
    return _occupancyPolicy.classify(
      tasks: tasks,
      lockedIntervals: lockedIntervals,
      requiredVisibleIntervals: requiredVisibleIntervals,
    );
  }

  /// Planned flexible task records that automatic scheduling may move.
  List<Task> get movablePlannedFlexibleTasks {
    return occupancyEntries
        .where((entry) => entry.isMovablePlannedFlexible)
        .map((entry) => entry.task)
        .whereType<Task>()
        .toList(growable: false);
  }

  /// Locked task records in [tasks], if the caller represents locked time as
  /// tasks instead of only passing [lockedIntervals].
  List<Task> get lockedTasks {
    return tasks.where((task) => task.isLocked).toList(growable: false);
  }

  /// Critical and inflexible task records that should block flexible placement.
  List<Task> get requiredVisibleTasks {
    return tasks
        .where((task) => task.isRequiredVisible)
        .toList(growable: false);
  }

  /// Scheduled intervals for flexible tasks only. Useful for analysis/debugging.
  List<TimeInterval> get flexibleIntervals {
    return _scheduledIntervalsFor(flexibleTasks);
  }

  /// All intervals that flexible scheduling must avoid.
  ///
  /// This is derived from the central occupancy policy rather than from UI card
  /// categories. The result is sorted to make interval scanning deterministic.
  List<TimeInterval> get blockedIntervals {
    final intervals = occupancyEntries
        .where((entry) => entry.blocksAutomaticFlexiblePlacement)
        .map((entry) => entry.interval)
        .whereType<TimeInterval>()
        .toList(growable: false)
      ..sort((a, b) => a.start.compareTo(b.start));

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

  /// Occupancy entries that should be surfaced as conflicts when overlapped.
  List<OccupancyEntry> get conflictReportingOccupancies {
    return occupancyEntries
        .where((entry) => entry.reportsConflict)
        .toList(growable: false);
  }
}

/// Exact placement change made by a scheduling operation.
///
/// Changes are machine-readable before/after records. UI can use notices for
/// display text, but persistence, undo, analytics, or tests should inspect these
/// fields to know exactly which task moved and where.
class SchedulingChange {
  const SchedulingChange({
    required this.taskId,
    required this.previousStart,
    required this.previousEnd,
    required this.nextStart,
    required this.nextEnd,
  });

  /// Task that moved or had its schedule cleared.
  final String taskId;

  /// Previous scheduled start, or null if the task was previously unplaced.
  final DateTime? previousStart;

  /// Previous scheduled end, or null if the task was previously unplaced.
  final DateTime? previousEnd;

  /// New scheduled start, or null if the task was moved out of the timeline.
  final DateTime? nextStart;

  /// New scheduled end, or null if the task was moved out of the timeline.
  final DateTime? nextEnd;
}

/// Overlap between a scheduled task and blocked time.
///
/// Analysis uses this to report problems without moving anything. This is useful
/// when loading persisted data, debugging imports, or validating a day before the
/// UI presents it as clean.
class SchedulingOverlap {
  const SchedulingOverlap({
    required this.taskId,
    required this.taskInterval,
    required this.blockedInterval,
  });

  /// Flexible task that overlaps blocked time.
  final String taskId;

  /// The task's scheduled interval.
  final TimeInterval taskInterval;

  /// The blocked interval it overlaps.
  final TimeInterval blockedInterval;
}

/// Starter notice type returned by scheduling operations.
///
/// A notice is presentation-friendly context about an operation. It intentionally
/// carries both text and a structured [type] so the UI can decide whether to show
/// it as neutral info, movement, overlap, or failure.
class SchedulingNotice {
  SchedulingNotice(
    this.message, {
    this.type = SchedulingNoticeType.info,
    this.taskId,
    this.issueCode,
    this.movementCode,
    this.conflictCode,
    Map<String, Object?> parameters = const <String, Object?>{},
  }) : parameters = Map<String, Object?>.unmodifiable(parameters);

  /// Human-readable message safe to surface in UI or logs.
  final String message;

  /// Structured category for UI styling and tests.
  final SchedulingNoticeType type;

  /// Optional task related to this notice. Null means the notice applies to the
  /// whole operation.
  final String? taskId;

  /// Stable issue code for validation/no-slot/no-op outcomes.
  final SchedulingIssueCode? issueCode;

  /// Stable movement code for placement changes.
  final SchedulingMovementCode? movementCode;

  /// Stable conflict code for overlap outcomes.
  final SchedulingConflictCode? conflictCode;

  /// Structured notice parameters for callers that need more context.
  final Map<String, Object?> parameters;
}

/// Starter result wrapper for scheduling operations.
///
/// Every engine operation returns a [SchedulingResult], even when nothing moved.
/// This keeps the call pattern predictable: always inspect `tasks`, then surface
/// any `notices`, `changes`, or `overlaps` relevant to the UI.
class SchedulingResult {
  SchedulingResult({
    required List<Task> tasks,
    this.operationCode = SchedulingOperationCode.unspecified,
    this.outcomeCode = SchedulingOutcomeCode.success,
    List<SchedulingNotice> notices = const <SchedulingNotice>[],
    List<SchedulingChange> changes = const <SchedulingChange>[],
    List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
  })  : tasks = List<Task>.unmodifiable(tasks),
        assert(
          _noticeCodesAreSinglePurpose(notices),
          'Each scheduling notice may have only one issue/movement/conflict code.',
        ),
        notices = List<SchedulingNotice>.unmodifiable(notices),
        changes = List<SchedulingChange>.unmodifiable(changes),
        overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);

  /// Replacement task list after the operation.
  final List<Task> tasks;

  /// Stable operation identifier.
  final SchedulingOperationCode operationCode;

  /// Stable high-level outcome category.
  final SchedulingOutcomeCode outcomeCode;

  /// Human-readable operation messages.
  final List<SchedulingNotice> notices;

  /// Machine-readable movements or schedule clears.
  final List<SchedulingChange> changes;

  /// Analysis-only overlap findings.
  final List<SchedulingOverlap> overlaps;
}

bool _noticeCodesAreSinglePurpose(Iterable<SchedulingNotice> notices) {
  for (final notice in notices) {
    final codeCount = [
      notice.issueCode,
      notice.movementCode,
      notice.conflictCode,
    ].where((code) => code != null).length;
    if (codeCount > 1) {
      return false;
    }
  }

  return true;
}

/// 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 {
  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,
  }) {
    // 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) {
      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) {
      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) {
      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) {
      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,
    bool countAsManualPush = true,
  }) {
    // 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) {
      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) {
      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 || !input.window.contains(currentInterval)) {
      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) {
      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,
    );

    if (placement == null) {
      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,
  }) {
    final task = _taskById(input.tasks, taskId);
    if (task == null) {
      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) {
      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) {
      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) {
      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,
  }) {
    // 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) {
      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) {
      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) {
    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,
            },
          ),
        );
      }
    }

    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();
    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,
    );
    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();
    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,
    );
    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();
    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,
    );
    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,
  }) {
    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,
}) {
  final fixedBlocks = <TimeInterval>[
    ...input.blockedIntervals,
  ];
  final queue = <_PlacementItem>[
    _PlacementItem(
      task: task,
      duration: currentInterval.duration,
      earliestStart: currentInterval.end,
    ),
  ];

  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(currentInterval.end);
    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 = currentInterval.end;
  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,
    );
    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,
        },
      ),
    );
  }

  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,
    );
    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,
        },
      ),
    );
  }

  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,
      },
    ),
  );

  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);
}

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;
  }
}

/// One item in a placement queue.
///
/// [earliestStart] preserves a task's natural ordering constraint. For existing
/// scheduled tasks, this is usually their current start; for a pushed task, it is
/// the earliest time the push operation allows.
class _PlacementItem {
  const _PlacementItem({
    required this.task,
    required this.duration,
    required this.earliestStart,
  });

  /// Task represented by this queue entry.
  final Task task;

  /// Duration the planner must reserve.
  final Duration duration;

  /// Earliest allowed start time for this item.
  final DateTime earliestStart;
}

/// Planned task intervals keyed by task id.
///
/// The name is historical from the first insertion feature; it now also supports
/// push and rollover placement plans. It remains private so it can be renamed or
/// expanded later without affecting callers.
class _BacklogInsertionPlan {
  const _BacklogInsertionPlan({required this.placements});

  /// Chosen interval for each task that should be scheduled or moved.
  final Map<String, TimeInterval> placements;
}
