import 'models.dart';

/// Category for scheduler notices.
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,
}

/// Window of time available to a scheduling operation.
class SchedulingWindow {
  const SchedulingWindow({
    required this.start,
    required this.end,
  });

  final DateTime start;
  final DateTime end;

  TimeInterval get interval => TimeInterval(start: start, end: end);

  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.
class SchedulingInput {
  const SchedulingInput({
    required this.tasks,
    required this.window,
    this.lockedIntervals = const <TimeInterval>[],
    this.requiredVisibleIntervals = const <TimeInterval>[],
  });

  final List<Task> tasks;
  final SchedulingWindow window;
  final List<TimeInterval> lockedIntervals;
  final List<TimeInterval> requiredVisibleIntervals;

  List<Task> get flexibleTasks {
    return tasks.where((task) => task.isFlexible).toList(growable: false);
  }

  List<Task> get lockedTasks {
    return tasks.where((task) => task.isLocked).toList(growable: false);
  }

  List<Task> get requiredVisibleTasks {
    return tasks
        .where((task) => task.isRequiredVisible)
        .toList(growable: false);
  }

  List<TimeInterval> get flexibleIntervals {
    return _scheduledIntervalsFor(flexibleTasks);
  }

  List<TimeInterval> get blockedIntervals {
    final intervals = <TimeInterval>[
      ...lockedIntervals,
      ..._scheduledIntervalsFor(lockedTasks),
      ...requiredVisibleIntervals,
      ..._scheduledIntervalsFor(requiredVisibleTasks),
    ]..sort((a, b) => a.start.compareTo(b.start));

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

/// Exact placement change made by a scheduling operation.
class SchedulingChange {
  const SchedulingChange({
    required this.taskId,
    required this.previousStart,
    required this.previousEnd,
    required this.nextStart,
    required this.nextEnd,
  });

  final String taskId;
  final DateTime? previousStart;
  final DateTime? previousEnd;
  final DateTime? nextStart;
  final DateTime? nextEnd;
}

/// Overlap between a scheduled task and blocked time.
class SchedulingOverlap {
  const SchedulingOverlap({
    required this.taskId,
    required this.taskInterval,
    required this.blockedInterval,
  });

  final String taskId;
  final TimeInterval taskInterval;
  final TimeInterval blockedInterval;
}

/// Starter notice type returned by scheduling operations.
class SchedulingNotice {
  const SchedulingNotice(
    this.message, {
    this.type = SchedulingNoticeType.info,
    this.taskId,
  });

  final String message;
  final SchedulingNoticeType type;
  final String? taskId;
}

/// Starter result wrapper for scheduling operations.
class SchedulingResult {
  const SchedulingResult({
    required this.tasks,
    this.notices = const <SchedulingNotice>[],
    this.changes = const <SchedulingChange>[],
    this.overlaps = const <SchedulingOverlap>[],
  });

  final List<Task> tasks;
  final List<SchedulingNotice> notices;
  final List<SchedulingChange> changes;
  final List<SchedulingOverlap> overlaps;
}

/// Starter scheduling engine.
///
/// This is intentionally small. Codex should expand this according to the V1
/// plan documents and add tests for every scheduling rule.
class SchedulingEngine {
  const SchedulingEngine();

  /// 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.
  SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
    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,
        ),
      );
    }

    if (!task.isFlexible || !task.isBacklog) {
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Only backlog flexible tasks can be inserted.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
        ),
      );
    }

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

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

    return _applyPlacement(
      input: input,
      insertedTask: task,
      placement: placement,
      updatedAt: updatedAt ?? DateTime.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.
  SchedulingResult pushFlexibleTaskToNextAvailableSlot({
    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,
        ),
      );
    }

    if (!task.isFlexible || task.status != TaskStatus.planned) {
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Only planned flexible tasks can be pushed.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
        ),
      );
    }

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

    if (currentInterval.duration.inMicroseconds <= 0) {
      return _unchangedResult(
        input,
        SchedulingNotice(
          'Task needs a positive scheduled duration before pushing.',
          type: SchedulingNoticeType.noFit,
          taskId: task.id,
        ),
      );
    }

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

    return _applyPushPlacement(
      input: input,
      pushedTask: task,
      placement: placement,
      pushedTaskMessage: 'Flexible task pushed to next available slot.',
      updatedAt: updatedAt ?? DateTime.now(),
    );
  }

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

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

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

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

    return _applyPushPlacement(
      input: input,
      pushedTask: task,
      placement: placement,
      pushedTaskMessage: 'Flexible task moved to tomorrow.',
      updatedAt: updatedAt ?? DateTime.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.
  SchedulingResult rollOverUnfinishedFlexibleTasks({
    required SchedulingInput input,
    DateTime? updatedAt,
  }) {
    final rolledItems = <_PlacementItem>[];
    final rolloverTasks = input.flexibleTasks
        .where((task) => _shouldRollOver(task, input.window))
        .toList()
      ..sort((a, b) {
        final aStart = a.scheduledStart ?? input.window.start;
        final bStart = b.scheduledStart ?? 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,
        notices: const [
          SchedulingNotice('No unfinished flexible tasks to roll over.'),
        ],
      );
    }

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

    if (placement == null) {
      return _unchangedResult(
        input,
        const SchedulingNotice(
          'Unfinished flexible tasks could not fit tomorrow.',
          type: SchedulingNoticeType.overflow,
        ),
      );
    }

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

  /// Analyze the current in-memory schedule without moving tasks.
  SchedulingResult analyzeSchedule(SchedulingInput input) {
    final overlaps = <SchedulingOverlap>[];
    final notices = <SchedulingNotice>[];
    final blockedIntervals = input.blockedIntervals;

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

      for (final blockedInterval in blockedIntervals) {
        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,
          ),
        );
      }
    }

    return SchedulingResult(
      tasks: input.tasks,
      notices: List<SchedulingNotice>.unmodifiable(notices),
      overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
    );
  }

  /// Move a task to backlog.
  ///
  /// Backlog does not preserve original schedule/order placement.
  Task moveToBacklog(Task task, {DateTime? updatedAt}) {
    return task.copyWith(
      status: TaskStatus.backlog,
      updatedAt: updatedAt ?? DateTime.now(),
      stats: task.stats.incrementMovedToBacklog(),
      clearSchedule: true,
    );
  }

  /// Mark a flexible task pushed manually.
  Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
    return task.copyWith(
      updatedAt: updatedAt ?? DateTime.now(),
      stats: task.stats.incrementManualPush(),
    );
  }

  /// Mark missed according to the current MVP rules.
  ///
  /// Critical missed tasks go to backlog. Inflexible missed tasks stay in place.
  Task markMissed(Task task, {DateTime? updatedAt}) {
    final nextStats = task.stats.incrementMissed();
    final now = updatedAt ?? DateTime.now();

    if (task.type == TaskType.critical) {
      return task.copyWith(
        status: TaskStatus.backlog,
        updatedAt: now,
        stats: nextStats.incrementMovedToBacklog(),
        clearSchedule: true,
      );
    }

    return task.copyWith(
      status: TaskStatus.missed,
      updatedAt: now,
      stats: nextStats,
    );
  }

  /// Finds the first interval that can fit the requested duration while avoiding
  /// blocked intervals.
  ///
  /// This helper is deliberately simple. Full flexible bump behavior belongs in
  /// later plan chunks.
  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;
  }
}

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

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

SchedulingResult _unchangedResult(
  SchedulingInput input,
  SchedulingNotice notice,
) {
  return SchedulingResult(
    tasks: input.tasks,
    notices: [notice],
  );
}

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

  return null;
}

Duration? _durationFromMinutes(int? minutes) {
  if (minutes == null || minutes <= 0) {
    return null;
  }

  return Duration(minutes: minutes);
}

_BacklogInsertionPlan? _planBacklogInsertion({
  required SchedulingInput input,
  required Task task,
  required Duration taskDuration,
}) {
  final fixedBlocks = <TimeInterval>[
    ...input.blockedIntervals,
  ];
  final queue = <_PlacementItem>[
    _PlacementItem(
      task: task,
      duration: taskDuration,
      earliestStart: input.window.start,
    ),
  ];

  final scheduledFlexibleTasks = input.flexibleTasks
      .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;
    }

    if (flexibleTask.status != TaskStatus.planned) {
      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 = 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);
}

_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.flexibleTasks
      .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;
    }

    if (flexibleTask.status != TaskStatus.planned) {
      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);
}

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

_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.flexibleTasks
      .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) ||
        flexibleTask.status != TaskStatus.planned) {
      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);
}

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 updatedTask = task.copyWith(
      status: isInsertedTask ? TaskStatus.planned : task.status,
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt,
      stats: isInsertedTask
          ? task.stats.incrementRestoredFromBacklog()
          : task.stats.incrementAutoPush(),
    );

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

  return SchedulingResult(
    tasks: List<Task>.unmodifiable(updatedTasks),
    notices: List<SchedulingNotice>.unmodifiable(notices),
    changes: List<SchedulingChange>.unmodifiable(changes),
  );
}

SchedulingResult _applyPushPlacement({
  required SchedulingInput input,
  required Task pushedTask,
  required _BacklogInsertionPlan placement,
  required String pushedTaskMessage,
  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 moved = !_sameDateTime(task.scheduledStart, interval.start) ||
        !_sameDateTime(task.scheduledEnd, interval.end);

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

    final isPushedTask = task.id == pushedTask.id;
    final updatedTask = task.copyWith(
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt,
      stats: isPushedTask
          ? task.stats.incrementManualPush()
          : task.stats.incrementAutoPush(),
    );

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

  return SchedulingResult(
    tasks: List<Task>.unmodifiable(updatedTasks),
    notices: List<SchedulingNotice>.unmodifiable(notices),
    changes: List<SchedulingChange>.unmodifiable(changes),
  );
}

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 updatedTask = task.copyWith(
      status: isRolledTask ? TaskStatus.planned : task.status,
      scheduledStart: interval.start,
      scheduledEnd: interval.end,
      updatedAt: updatedAt,
      stats: task.stats.incrementAutoPush(),
    );

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

  notices.insert(
    0,
    SchedulingNotice(
      '$rolledCount unfinished flexible tasks were moved to tomorrow.',
      type: SchedulingNoticeType.moved,
    ),
  );

  return SchedulingResult(
    tasks: List<Task>.unmodifiable(updatedTasks),
    notices: List<SchedulingNotice>.unmodifiable(notices),
    changes: List<SchedulingChange>.unmodifiable(changes),
  );
}

bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) {
  final interval = _scheduledIntervalFor(task);
  final isTomorrowTask =
      interval != null && interval.overlaps(tomorrowWindow.interval);

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

DateTime _laterOf(DateTime first, DateTime second) {
  if (first.isAfter(second)) {
    return first;
  }

  return second;
}

bool _sameDateTime(DateTime? first, DateTime? second) {
  if (first == null || second == null) {
    return first == null && second == null;
  }

  return first.isAtSameMomentAs(second);
}

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

class _PlacementItem {
  const _PlacementItem({
    required this.task,
    required this.duration,
    required this.earliestStart,
  });

  final Task task;
  final Duration duration;
  final DateTime earliestStart;
}

class _BacklogInsertionPlan {
  const _BacklogInsertionPlan({required this.placements});

  final Map<String, TimeInterval> placements;
}
