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

/// Tests Scheduling Engine behavior for the Scheduler Core package.
library;

import 'package:scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';

/// Entry point for this executable Dart file.
/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API.
void main() {
  group('Domain model', () {
    final now = DateTime(2026, 6, 19, 12);
    const utcTimeZoneId = 'UTC';
    const utcResolver = FixedOffsetTimeZoneResolver.utc();

    test('core enum values are importable', () {
      expect(TaskType.locked, TaskType.locked);
      expect(TaskStatus.noLongerRelevant, TaskStatus.noLongerRelevant);
      expect(PriorityLevel.veryHigh, PriorityLevel.veryHigh);
      expect(RewardLevel.notSet, RewardLevel.notSet);
      expect(DifficultyLevel.notSet, DifficultyLevel.notSet);
      expect(ReminderProfile.gentle, ReminderProfile.gentle);
    });

    test('quick capture uses neutral defaults without schedule details', () {
      final task = Task.quickCapture(
        id: 'capture-1',
        title: 'Buy stamps',
        createdAt: now,
      );

      expect(task.projectId, 'inbox');
      expect(task.type, TaskType.flexible);
      expect(task.status, TaskStatus.backlog);
      expect(task.priority, PriorityLevel.medium);
      expect(task.reward, RewardLevel.notSet);
      expect(task.difficulty, DifficultyLevel.notSet);
      expect(task.durationMinutes, isNull);
      expect(task.scheduledStart, isNull);
      expect(task.scheduledEnd, isNull);
      expect(task.createdAt, now);
      expect(task.updatedAt, now);
      expect(task.backlogEnteredAt, now);
      expect(
        task.backlogEnteredAtProvenance,
        Task.backlogEnteredAtProvenanceQuickCapture,
      );
      expect(task.backlogFreshnessAnchorAt, now);
    });

    test('quick capture requires a title', () {
      expect(
        () => Task.quickCapture(
          id: 'capture-1',
          title: '   ',
          createdAt: now,
        ),
        throwsArgumentError,
      );
    });

    test('backlog filters use derived categories over a unified list', () {
      final inbox = Task.quickCapture(
        id: 'inbox-1',
        title: 'Buy stamps',
        createdAt: now,
      );
      final pushed = Task.quickCapture(
        id: 'pushed-1',
        title: 'Fold laundry',
        createdAt: now,
        projectId: 'home',
      ).copyWith(
        stats: const TaskStatistics(manuallyPushedCount: 1),
      );
      final criticalMissed = Task.quickCapture(
        id: 'critical-1',
        title: 'Pay bill',
        createdAt: now,
        projectId: 'money',
        type: TaskType.critical,
      ).copyWith(
        stats: const TaskStatistics(missedCount: 1),
      );
      final wishlist = Task.quickCapture(
        id: 'wishlist-1',
        title: 'Try new notebook',
        createdAt: now,
        projectId: 'ideas',
        backlogTags: const {BacklogTag.wishlist},
      );
      final stale = Task.quickCapture(
        id: 'stale-1',
        title: 'Sort cables',
        createdAt: now.subtract(const Duration(days: 40)),
        projectId: 'home',
      ).copyWith(updatedAt: now.subtract(const Duration(days: 40)));
      final planned = Task.quickCapture(
        id: 'planned-1',
        title: 'Scheduled task',
        createdAt: now,
      ).copyWith(status: TaskStatus.planned);
      final view = BacklogView(
        tasks: [inbox, pushed, criticalMissed, wishlist, stale, planned],
        now: now,
      );

      expect(view.backlogTasks.map((task) => task.id), [
        'inbox-1',
        'pushed-1',
        'critical-1',
        'wishlist-1',
        'stale-1',
      ]);
      expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [
        'inbox-1',
      ]);
      expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [
        'pushed-1',
      ]);
      expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [
        'critical-1',
      ]);
      expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [
        'wishlist-1',
      ]);
      expect(view.filter(BacklogFilter.stale).map((task) => task.id), [
        'stale-1',
      ]);
      expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [
        'inbox-1',
        'pushed-1',
        'critical-1',
        'wishlist-1',
        'stale-1',
      ]);
    });

    test('backlog sorting uses backlog entry timestamps', () {
      final older = Task.quickCapture(
        id: 'older',
        title: 'Older backlog item',
        createdAt: DateTime(2026, 6, 1),
      ).copyWith(
        scheduledStart: DateTime(2026, 6, 19, 18),
        scheduledEnd: DateTime(2026, 6, 19, 18, 15),
      );
      final newer = Task.quickCapture(
        id: 'newer',
        title: 'Newer backlog item',
        createdAt: DateTime(2026, 6, 10),
      ).copyWith(
        scheduledStart: DateTime(2026, 6, 19, 9),
        scheduledEnd: DateTime(2026, 6, 19, 9, 15),
      );
      final movedOlder = const SchedulingEngine().moveToBacklog(
        older,
        updatedAt: now.subtract(const Duration(minutes: 5)),
      );
      final movedNewer = const SchedulingEngine().moveToBacklog(
        newer,
        updatedAt: now,
      );
      final view = BacklogView(tasks: [movedNewer, movedOlder], now: now);

      expect(movedOlder.scheduledStart, isNull);
      expect(movedNewer.scheduledStart, isNull);
      expect(
        movedOlder.backlogEnteredAt,
        now.subtract(const Duration(minutes: 5)),
      );
      expect(movedNewer.backlogEnteredAt, now);
      expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [
        'older',
        'newer',
      ]);
    });

    test('backlog sorting supports priority reward effort project and pushes',
        () {
      final lowPriority = Task.quickCapture(
        id: 'low',
        title: 'Low',
        createdAt: now,
        priority: PriorityLevel.low,
        reward: RewardLevel.low,
        difficulty: DifficultyLevel.hard,
        projectId: 'zeta',
      ).copyWith(durationMinutes: 90);
      final highPriority = Task.quickCapture(
        id: 'high',
        title: 'High',
        createdAt: now,
        priority: PriorityLevel.high,
        reward: RewardLevel.high,
        difficulty: DifficultyLevel.easy,
        projectId: 'alpha',
      ).copyWith(
        durationMinutes: 15,
        stats: const TaskStatistics(autoPushedCount: 2),
      );
      final mediumPriority = Task.quickCapture(
        id: 'medium',
        title: 'Medium',
        createdAt: now,
        priority: PriorityLevel.medium,
        reward: RewardLevel.medium,
        difficulty: DifficultyLevel.medium,
        projectId: 'beta',
      ).copyWith(
        durationMinutes: 45,
        stats: const TaskStatistics(manuallyPushedCount: 1),
      );
      final view = BacklogView(
        tasks: [lowPriority, mediumPriority, highPriority],
        now: now,
      );

      expect(view.sorted(BacklogSortKey.custom).map((task) => task.id), [
        'low',
        'medium',
        'high',
      ]);
      expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
        'high',
        'medium',
        'low',
      ]);
      expect(
        view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id),
        ['high', 'medium', 'low'],
      );
      expect(view.sorted(BacklogSortKey.project).map((task) => task.id), [
        'high',
        'medium',
        'low',
      ]);
      expect(view.sorted(BacklogSortKey.timesPushed).map((task) => task.id), [
        'high',
        'medium',
        'low',
      ]);
      expect(view.sorted(BacklogSortKey.duration).map((task) => task.id), [
        'high',
        'medium',
        'low',
      ]);
    });

    test('backlog staleness markers cover default thresholds', () {
      final fresh = Task.quickCapture(
        id: 'fresh',
        title: 'Fresh',
        createdAt: now.subtract(const Duration(days: 7)),
      );
      final aging = Task.quickCapture(
        id: 'aging',
        title: 'Aging',
        createdAt: now.subtract(const Duration(days: 8)),
      );
      final stale = Task.quickCapture(
        id: 'stale',
        title: 'Stale',
        createdAt: now.subtract(const Duration(days: 31)),
      );
      final view = BacklogView(tasks: [fresh, aging, stale], now: now);

      expect(view.stalenessMarkerFor(fresh), BacklogStalenessMarker.green);
      expect(view.stalenessMarkerFor(aging), BacklogStalenessMarker.blue);
      expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple);
    });

    test('backlog freshness uses backlog entry time instead of creation time',
        () {
      final oldButRecentlyMoved = Task(
        id: 'recent-backlog',
        title: 'Recently moved',
        projectId: 'home',
        type: TaskType.flexible,
        status: TaskStatus.backlog,
        createdAt: now.subtract(const Duration(days: 60)),
        updatedAt: now,
        backlogEnteredAt: now.subtract(const Duration(days: 1)),
        backlogEnteredAtProvenance:
            Task.backlogEnteredAtProvenanceMovedToBacklog,
      );
      final oldAndStillOld = Task.quickCapture(
        id: 'old-backlog',
        title: 'Old backlog',
        createdAt: now.subtract(const Duration(days: 60)),
      );
      final view = BacklogView(
        tasks: [oldButRecentlyMoved, oldAndStillOld],
        now: now,
      );

      expect(
        view.filter(BacklogFilter.stale).map((task) => task.id),
        ['old-backlog'],
      );
      expect(
        view.stalenessMarkerFor(oldButRecentlyMoved),
        BacklogStalenessMarker.green,
      );
      expect(
        view.sorted(BacklogSortKey.age).map((task) => task.id),
        ['old-backlog', 'recent-backlog'],
      );
    });

    test('backlog staleness markers support configurable thresholds', () {
      final task = Task.quickCapture(
        id: 'custom',
        title: 'Custom',
        createdAt: now.subtract(const Duration(days: 5)),
      );
      final view = BacklogView(
        tasks: [task],
        now: now,
        stalenessSettings: const BacklogStalenessSettings(
          greenMaxAge: Duration(days: 2),
          blueMaxAge: Duration(days: 4),
        ),
      );

      expect(view.stalenessMarkerFor(task), BacklogStalenessMarker.purple);
    });

    test('backlog board bucket policy covers all default columns', () {
      const policy = BacklogBoardBucketPolicy();
      final doNext = Task.quickCapture(
        id: 'do-next',
        title: 'Reply to emails',
        createdAt: now,
        priority: PriorityLevel.medium,
        reward: RewardLevel.medium,
        difficulty: DifficultyLevel.easy,
      ).copyWith(durationMinutes: 20);
      final needTimeBlock = Task.quickCapture(
        id: 'need-time-block',
        title: 'Review budget',
        createdAt: now,
        priority: PriorityLevel.high,
      ).copyWith(durationMinutes: 30);
      final breakUpFirst = Task.quickCapture(
        id: 'break-up-first',
        title: 'Build dashboard',
        createdAt: now,
      ).copyWith(durationMinutes: 120);
      final notNow = Task.quickCapture(
        id: 'not-now',
        title: 'Visit Japan',
        createdAt: now,
        backlogTags: const {BacklogTag.wishlist},
      ).copyWith(durationMinutes: 20);

      expect(
        policy.resolve(task: doNext).bucket,
        BacklogBoardBucket.doNext,
      );
      expect(
        policy.resolve(task: needTimeBlock).bucket,
        BacklogBoardBucket.needTimeBlock,
      );
      expect(
        policy.resolve(task: breakUpFirst).bucket,
        BacklogBoardBucket.breakUpFirst,
      );
      expect(
        policy.resolve(task: notNow).bucket,
        BacklogBoardBucket.notNow,
      );
    });

    test('backlog board bucket policy uses deterministic precedence', () {
      const policy = BacklogBoardBucketPolicy();
      final somedayLarge = Task.quickCapture(
        id: 'someday-large',
        title: 'Start someday company',
        createdAt: now,
        backlogTags: const {BacklogTag.wishlist},
      ).copyWith(durationMinutes: 120);
      final hardFocused = Task.quickCapture(
        id: 'hard-focused',
        title: 'Write strategy',
        createdAt: now,
        difficulty: DifficultyLevel.hard,
      ).copyWith(durationMinutes: 60);
      final lowButFocused = Task.quickCapture(
        id: 'low-focused',
        title: 'Organize closet',
        createdAt: now,
        priority: PriorityLevel.low,
      ).copyWith(durationMinutes: 60);
      final missingEstimate = Task.quickCapture(
        id: 'missing-estimate',
        title: 'Clean up files',
        createdAt: now,
      );

      expect(
        policy.resolve(task: somedayLarge).bucket,
        BacklogBoardBucket.notNow,
      );
      expect(
        policy.resolve(task: hardFocused).bucket,
        BacklogBoardBucket.breakUpFirst,
      );
      expect(
        policy.resolve(task: lowButFocused).bucket,
        BacklogBoardBucket.needTimeBlock,
      );
      expect(
        policy.resolve(task: missingEstimate).bucket,
        BacklogBoardBucket.notNow,
      );
      expect(
        policy.resolve(task: missingEstimate, hasChildTasks: true).bucket,
        BacklogBoardBucket.breakUpFirst,
      );
    });

    test('unchecked quick capture goes to backlog', () {
      const service = QuickCaptureService();

      final result = service.capture(
        QuickCaptureRequest(
          id: 'capture-1',
          title: 'Buy stamps',
          createdAt: now,
        ),
        updatedAt: now,
      );

      expect(result.status, QuickCaptureStatus.addedToBacklog);
      expect(result.isValid, isTrue);
      expect(result.task.status, TaskStatus.backlog);
      expect(result.task.scheduledStart, isNull);
      expect(result.task.priority, PriorityLevel.medium);
      expect(result.task.reward, RewardLevel.notSet);
      expect(result.schedulingResult, isNull);
    });

    test('checked quick capture with duration schedules next available slot',
        () {
      const service = QuickCaptureService();
      final plannedFlexible = Task.quickCapture(
        id: 'flexible-1',
        title: 'Existing flexible task',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        durationMinutes: 30,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );

      final result = service.capture(
        QuickCaptureRequest(
          id: 'capture-1',
          title: 'Buy stamps',
          createdAt: now,
          addToNextAvailableSlot: true,
          durationMinutes: 15,
          projectId: 'errands',
          reward: RewardLevel.low,
        ),
        schedulingInput: SchedulingInput(
          tasks: [plannedFlexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
        ),
        updatedAt: now,
      );

      expect(result.status, QuickCaptureStatus.scheduled);
      expect(result.task.status, TaskStatus.planned);
      expect(result.task.projectId, 'errands');
      expect(result.task.reward, RewardLevel.low);
      expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13));
      expect(result.task.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
      expect(
        result.schedulingResult!.changes.map((change) => change.taskId),
        ['capture-1', 'flexible-1'],
      );
    });

    test('checked quick capture without duration returns validation result',
        () {
      const service = QuickCaptureService();

      final result = service.capture(
        QuickCaptureRequest(
          id: 'capture-1',
          title: 'Buy stamps',
          createdAt: now,
          addToNextAvailableSlot: true,
        ),
        schedulingInput: SchedulingInput(
          tasks: const [],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
        ),
        updatedAt: now,
      );

      expect(result.status, QuickCaptureStatus.validationError);
      expect(result.isValid, isFalse);
      expect(result.task.status, TaskStatus.backlog);
      expect(result.task.scheduledStart, isNull);
      expect(result.messages, [
        'Duration is required to schedule quick capture.',
      ]);
      expect(result.schedulingResult, isNull);
    });

    test('project defaults apply while task overrides remain possible', () {
      final project = ProjectProfile(
        id: 'home',
        name: 'Home',
        colorKey: 'green',
        defaultPriority: PriorityLevel.high,
        defaultReward: RewardLevel.medium,
        defaultDifficulty: DifficultyLevel.easy,
        defaultReminderProfile: ReminderProfile.persistent,
        defaultDurationMinutes: 25,
      );

      final defaulted = project.createTask(
        id: 'task-1',
        title: 'Clean desk',
        createdAt: now,
      );
      final overridden = project.createTask(
        id: 'task-2',
        title: 'Fold laundry',
        createdAt: now,
        priority: PriorityLevel.low,
        reward: RewardLevel.high,
        difficulty: DifficultyLevel.hard,
        durationMinutes: 10,
      );

      expect(defaulted.projectId, 'home');
      expect(defaulted.priority, PriorityLevel.high);
      expect(defaulted.reward, RewardLevel.medium);
      expect(defaulted.difficulty, DifficultyLevel.easy);
      expect(defaulted.durationMinutes, 25);
      expect(overridden.priority, PriorityLevel.low);
      expect(overridden.reward, RewardLevel.high);
      expect(overridden.difficulty, DifficultyLevel.hard);
      expect(overridden.durationMinutes, 10);
    });

    test('statistics increments preserve existing values immutably', () {
      const stats = TaskStatistics(missedCount: 2);

      final updated = stats.incrementCompletedDuringLockedHours(15);

      expect(stats.completedDuringLockedHoursCount, 0);
      expect(stats.completedDuringLockedHoursMinutes, 0);
      expect(updated.missedCount, 2);
      expect(updated.completedDuringLockedHoursCount, 1);
      expect(updated.completedDuringLockedHoursMinutes, 15);
    });

    test('locked block model supports one-off and recurring constraints', () {
      final oneOff = LockedBlock(
        id: 'appointment',
        name: 'Appointment',
        startTime: ClockTime(hour: 13, minute: 0),
        endTime: ClockTime(hour: 14, minute: 0),
        date: CivilDate(2026, 6, 19),
        createdAt: now,
        updatedAt: now,
        projectId: 'health',
      );
      final workHours = LockedBlock(
        id: 'work-hours',
        name: 'Work',
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 17, minute: 0),
        recurrence: LockedBlockRecurrence.weekly(
          weekdays: {
            LockedWeekday.monday,
            LockedWeekday.tuesday,
            LockedWeekday.wednesday,
            LockedWeekday.thursday,
            LockedWeekday.friday,
          },
        ),
        createdAt: now,
        updatedAt: now,
      );

      expect(oneOff.isRecurring, isFalse);
      expect(oneOff.hiddenByDefault, isTrue);
      expect(oneOff.projectId, 'health');
      expect(workHours.isRecurring, isTrue);
      expect(workHours.hiddenByDefault, isTrue);
      expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue);
      expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 20)), isFalse);
    });

    test('locked block override removes only one date', () {
      final override = LockedBlockOverride.remove(
        id: 'friday-off',
        lockedBlockId: 'work-hours',
        date: CivilDate(2026, 6, 19),
        createdAt: now,
      );

      expect(override.type, LockedBlockOverrideType.remove);
      expect(
        override.appliesTo(
          blockId: 'work-hours',
          occurrenceDate: CivilDate(2026, 6, 19),
        ),
        isTrue,
      );
      expect(
        override.appliesTo(
          blockId: 'work-hours',
          occurrenceDate: CivilDate(2026, 6, 20),
        ),
        isFalse,
      );
    });

    test('locked block override modifies one date without recurrence changes',
        () {
      final recurrence = LockedBlockRecurrence.weekly(
        weekdays: {
          LockedWeekday.monday,
          LockedWeekday.tuesday,
          LockedWeekday.wednesday,
          LockedWeekday.thursday,
          LockedWeekday.friday,
        },
      );
      final workHours = LockedBlock(
        id: 'work-hours',
        name: 'Work',
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 17, minute: 0),
        recurrence: recurrence,
        createdAt: now,
        updatedAt: now,
      );
      final halfDay = LockedBlockOverride.replace(
        id: 'half-day',
        lockedBlockId: 'work-hours',
        date: CivilDate(2026, 6, 19),
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 12, minute: 0),
        createdAt: now,
      );
      final interval = halfDay.intervalForDate(
        targetDate: CivilDate(2026, 6, 19),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );

      expect(interval, isNotNull);
      expect(interval!.start, DateTime.utc(2026, 6, 19, 9));
      expect(interval.end, DateTime.utc(2026, 6, 19, 12));
      expect(workHours.recurrence, same(recurrence));
      expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue);
    });

    test('locked block override can add surprise locked time', () {
      final surprise = LockedBlockOverride.add(
        id: 'surprise-lock',
        date: CivilDate(2026, 6, 19),
        name: 'Urgent errand',
        startTime: ClockTime(hour: 15, minute: 0),
        endTime: ClockTime(hour: 16, minute: 0),
        createdAt: now,
        projectId: 'home',
      );
      final interval = surprise.intervalForDate(
        targetDate: CivilDate(2026, 6, 19),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );

      expect(surprise.type, LockedBlockOverrideType.add);
      expect(surprise.lockedBlockId, isNull);
      expect(surprise.projectId, 'home');
      expect(interval, isNotNull);
      expect(interval!.start, DateTime.utc(2026, 6, 19, 15));
      expect(interval.end, DateTime.utc(2026, 6, 19, 16));
    });

    test('locked block expansion returns weekday occurrences', () {
      final workHours = LockedBlock(
        id: 'work-hours',
        name: 'Work',
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 17, minute: 0),
        recurrence: LockedBlockRecurrence.weekly(
          weekdays: {
            LockedWeekday.monday,
            LockedWeekday.tuesday,
            LockedWeekday.wednesday,
            LockedWeekday.thursday,
            LockedWeekday.friday,
          },
        ),
        createdAt: now,
        updatedAt: now,
      );

      final fridayExpansion = expandLockedBlocksForDay(
        blocks: [workHours],
        overrides: const [],
        date: CivilDate(2026, 6, 19),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );
      final saturdayExpansion = expandLockedBlocksForDay(
        blocks: [workHours],
        overrides: const [],
        date: CivilDate(2026, 6, 20),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );
      final occurrence = fridayExpansion.occurrences.single;

      expect(fridayExpansion.date, CivilDate(2026, 6, 19));
      expect(occurrence.lockedBlockId, 'work-hours');
      expect(occurrence.overrideId, isNull);
      expect(occurrence.name, 'Work');
      expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 9));
      expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 17));
      expect(occurrence.hiddenByDefault, isTrue);
      expect(occurrence.schedulingInterval.label, 'Work');
      expect(saturdayExpansion.occurrences, isEmpty);
    });

    test('locked block expansion applies remove and replace overrides', () {
      final workHours = LockedBlock(
        id: 'work-hours',
        name: 'Work',
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 17, minute: 0),
        recurrence: LockedBlockRecurrence.weekly(
          weekdays: {
            LockedWeekday.monday,
            LockedWeekday.tuesday,
            LockedWeekday.wednesday,
            LockedWeekday.thursday,
            LockedWeekday.friday,
          },
        ),
        hiddenByDefault: true,
        createdAt: now,
        updatedAt: now,
      );
      final fridayOff = LockedBlockOverride.remove(
        id: 'friday-off',
        lockedBlockId: 'work-hours',
        date: CivilDate(2026, 6, 19),
        createdAt: now,
      );
      final halfDay = LockedBlockOverride.replace(
        id: 'half-day',
        lockedBlockId: 'work-hours',
        date: CivilDate(2026, 6, 22),
        name: 'Half-day work',
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 12, minute: 0),
        hiddenByDefault: false,
        createdAt: now.add(const Duration(minutes: 1)),
      );

      final removed = expandLockedBlocksForDay(
        blocks: [workHours],
        overrides: [fridayOff, halfDay],
        date: CivilDate(2026, 6, 19),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );
      final modified = expandLockedBlocksForDay(
        blocks: [workHours],
        overrides: [fridayOff, halfDay],
        date: CivilDate(2026, 6, 22),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );
      final normal = expandLockedBlocksForDay(
        blocks: [workHours],
        overrides: [fridayOff, halfDay],
        date: CivilDate(2026, 6, 23),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );
      final halfDayOccurrence = modified.occurrences.single;

      expect(removed.occurrences, isEmpty);
      expect(halfDayOccurrence.lockedBlockId, 'work-hours');
      expect(halfDayOccurrence.overrideId, 'half-day');
      expect(halfDayOccurrence.name, 'Half-day work');
      expect(halfDayOccurrence.interval.start, DateTime.utc(2026, 6, 22, 9));
      expect(halfDayOccurrence.interval.end, DateTime.utc(2026, 6, 22, 12));
      expect(halfDayOccurrence.hiddenByDefault, isFalse);
      expect(
        normal.occurrences.single.interval.end,
        DateTime.utc(2026, 6, 23, 17),
      );
      expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 22)), isTrue);
    });

    test('locked block expansion includes surprise locked time overrides', () {
      final surprise = LockedBlockOverride.add(
        id: 'surprise-lock',
        date: CivilDate(2026, 6, 19),
        name: 'Urgent errand',
        startTime: ClockTime(hour: 15, minute: 0),
        endTime: ClockTime(hour: 16, minute: 0),
        createdAt: now,
        projectId: 'home',
      );

      final expansion = expandLockedBlocksForDay(
        blocks: const [],
        overrides: [surprise],
        date: CivilDate(2026, 6, 19),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );
      final occurrence = expansion.occurrences.single;

      expect(occurrence.lockedBlockId, isNull);
      expect(occurrence.overrideId, 'surprise-lock');
      expect(occurrence.name, 'Urgent errand');
      expect(occurrence.projectId, 'home');
      expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 15));
      expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 16));
    });

    test(
        'locked hour completion tracking covers task entirely inside locked time',
        () {
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.completed,
        scheduledStart: DateTime(2026, 6, 19, 13, 15),
        scheduledEnd: DateTime(2026, 6, 19, 13, 45),
      );
      final updated = trackCompletedDuringLockedHours(
        task: task,
        lockedIntervals: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
            label: 'work',
          ),
        ],
      );

      expect(updated.stats.completedDuringLockedHoursCount, 1);
      expect(updated.stats.completedDuringLockedHoursMinutes, 30);
      expect(task.stats.completedDuringLockedHoursCount, 0);
    });

    test('locked hour completion tracking covers partial overlap', () {
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.completed,
        scheduledStart: DateTime(2026, 6, 19, 13, 45),
        scheduledEnd: DateTime(2026, 6, 19, 14, 15),
      );

      final minutes = completedDuringLockedHoursMinutes(
        task: task,
        lockedIntervals: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
            label: 'work',
          ),
        ],
      );

      expect(minutes, 15);
    });

    test('locked hour completion tracking ignores no-overlap tasks', () {
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.completed,
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 14, 30),
      );

      final updated = trackCompletedDuringLockedHours(
        task: task,
        lockedIntervals: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
            label: 'work',
          ),
        ],
      );

      expect(updated, same(task));
      expect(updated.stats.completedDuringLockedHoursCount, 0);
      expect(updated.stats.completedDuringLockedHoursMinutes, 0);
    });

    test('locked hour completion tracking covers surprise task overlap', () {
      final task = Task.quickCapture(
        id: 'surprise-1',
        title: 'Unexpected call',
        createdAt: now,
      ).copyWith(
        type: TaskType.surprise,
        scheduledStart: DateTime(2026, 6, 19, 13, 45),
        scheduledEnd: DateTime(2026, 6, 19, 14, 15),
      );

      final updated = trackCompletedDuringLockedHours(
        task: task,
        lockedIntervals: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
            label: 'work',
          ),
        ],
      );

      expect(updated.stats.completedDuringLockedHoursCount, 1);
      expect(updated.stats.completedDuringLockedHoursMinutes, 15);
    });

    test('locked hour completion tracking does not double count overlaps', () {
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.completed,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 14),
      );

      final minutes = completedDuringLockedHoursMinutes(
        task: task,
        lockedIntervals: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 13, 45),
            label: 'work',
          ),
          TimeInterval(
            start: DateTime(2026, 6, 19, 13, 30),
            end: DateTime(2026, 6, 19, 14),
            label: 'appointment',
          ),
        ],
      );

      expect(minutes, 60);
    });

    test('flexible quick action done marks task completed', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );

      final result = service.apply(
        task: task,
        action: FlexibleTaskQuickAction.done,
        updatedAt: now,
      );

      expect(result.action, FlexibleTaskQuickAction.done);
      expect(result.task.status, TaskStatus.completed);
      expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13));
      expect(result.task.updatedAt, now);
      expect(result.changedTask, isTrue);
    });

    test('flexible quick action backlog clears schedule placement', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );

      final result = service.apply(
        task: task,
        action: FlexibleTaskQuickAction.backlog,
        updatedAt: now,
      );

      expect(result.action, FlexibleTaskQuickAction.backlog);
      expect(result.task.status, TaskStatus.backlog);
      expect(result.task.scheduledStart, isNull);
      expect(result.task.scheduledEnd, isNull);
      expect(result.task.stats.movedToBacklogCount, 1);
      expect(result.changedTask, isTrue);
    });

    test('flexible quick action push exposes domain destinations', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(status: TaskStatus.planned);

      final result = service.apply(
        task: task,
        action: FlexibleTaskQuickAction.push,
        updatedAt: now,
      );

      expect(result.task, same(task));
      expect(result.pushDestinations, const [
        PushDestination.nextAvailableSlot,
        PushDestination.tomorrowTopOfQueue,
        PushDestination.backlog,
      ]);
      expect(PushDestination.values, const [
        PushDestination.nextAvailableSlot,
        PushDestination.tomorrowTopOfQueue,
        PushDestination.backlog,
      ]);
      expect(result.changedTask, isFalse);
    });

    test('flexible quick action break up starts child task flow', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(status: TaskStatus.planned);

      final result = service.apply(
        task: task,
        action: FlexibleTaskQuickAction.breakUp,
        updatedAt: now,
      );

      expect(result.task, same(task));
      expect(result.startsChildTaskFlow, isTrue);
      expect(result.changedTask, isFalse);
    });

    test('push destination next available uses scheduling engine', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        durationMinutes: 15,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );

      final result = service.applyPushDestination(
        destination: PushDestination.nextAvailableSlot,
        input: SchedulingInput(
          tasks: [task],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 13, 15),
              end: DateTime(2026, 6, 19, 13, 30),
              label: 'locked-block',
            ),
          ],
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final pushed = result.schedulingResult.tasks.single;

      expect(result.destination, PushDestination.nextAvailableSlot);
      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
      expect(pushed.stats.manuallyPushedCount, 1);
      expect(result.schedulingResult.notices.single.type,
          SchedulingNoticeType.moved);
    });

    test('push destination tomorrow sets tomorrow top-of-queue metadata', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        durationMinutes: 15,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final tomorrowTask = Task.quickCapture(
        id: 'tomorrow-1',
        title: 'Fold laundry',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        durationMinutes: 30,
        scheduledStart: DateTime(2026, 6, 20, 9),
        scheduledEnd: DateTime(2026, 6, 20, 9, 30),
      );

      final result = service.applyPushDestination(
        destination: PushDestination.tomorrowTopOfQueue,
        input: SchedulingInput(
          tasks: [task, tomorrowTask],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 10),
          ),
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final pushed = result.schedulingResult.tasks.singleWhere(
        (current) => current.id == 'task-1',
      );
      final shiftedTomorrow = result.schedulingResult.tasks.singleWhere(
        (current) => current.id == 'tomorrow-1',
      );

      expect(result.destination, PushDestination.tomorrowTopOfQueue);
      expect(result.placesAtTomorrowTopOfQueue, isTrue);
      expect(pushed.scheduledStart, DateTime(2026, 6, 20, 9));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 20, 9, 15));
      expect(shiftedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15));
      expect(shiftedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45));
    });

    test('push destination backlog clears schedule placement', () {
      const service = FlexibleTaskActionService();
      final task = Task.quickCapture(
        id: 'task-1',
        title: 'Handle paperwork',
        createdAt: now,
      ).copyWith(
        status: TaskStatus.planned,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );

      final result = service.applyPushDestination(
        destination: PushDestination.backlog,
        input: SchedulingInput(
          tasks: [task],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final moved = result.schedulingResult.tasks.single;

      expect(result.destination, PushDestination.backlog);
      expect(moved.status, TaskStatus.backlog);
      expect(moved.scheduledStart, isNull);
      expect(moved.scheduledEnd, isNull);
      expect(moved.stats.movedToBacklogCount, 1);
      expect(result.schedulingResult.changes.single.nextStart, isNull);
    });
  });

  group('SchedulingEngine starter behavior', () {
    final now = DateTime(2026, 6, 19, 12);
    const utcTimeZoneId = 'UTC';
    const utcResolver = FixedOffsetTimeZoneResolver.utc();
    const engine = SchedulingEngine();

    Task flexibleTask() {
      return Task(
        id: 'task-1',
        title: 'Clean coffee maker',
        projectId: 'home',
        type: TaskType.flexible,
        status: TaskStatus.planned,
        priority: PriorityLevel.medium,
        durationMinutes: 15,
        createdAt: now,
        updatedAt: now,
      );
    }

    Task taskById(List<Task> tasks, String id) {
      return tasks.singleWhere((task) => task.id == id);
    }

    test('scheduling input classifies in-memory task groups', () {
      final flexible = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final locked = flexibleTask().copyWith(
        id: 'locked-1',
        type: TaskType.locked,
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 15),
      );
      final critical = flexibleTask().copyWith(
        id: 'critical-1',
        type: TaskType.critical,
        scheduledStart: DateTime(2026, 6, 19, 16),
        scheduledEnd: DateTime(2026, 6, 19, 16, 30),
      );

      final input = SchedulingInput(
        tasks: [flexible, locked, critical],
        window: SchedulingWindow(
          start: DateTime(2026, 6, 19, 12),
          end: DateTime(2026, 6, 19, 18),
        ),
      );

      expect(input.flexibleTasks.map((task) => task.id), ['task-1']);
      expect(input.lockedTasks.map((task) => task.id), ['locked-1']);
      expect(input.requiredVisibleTasks.map((task) => task.id), ['critical-1']);
      expect(input.blockedIntervals.map((interval) => interval.label), [
        'locked-1',
        'critical-1',
      ]);
    });

    test('analyzeSchedule reports flexible overlaps without moving tasks', () {
      final flexible = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );
      final tasks = [flexible];
      final result = engine.analyzeSchedule(
        SchedulingInput(
          tasks: tasks,
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 12),
            end: DateTime(2026, 6, 19, 18),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 13, 15),
              end: DateTime(2026, 6, 19, 14),
              label: 'locked-block',
            ),
          ],
        ),
      );

      expect(result.tasks.single, same(flexible));
      expect(() => result.tasks.add(flexible), throwsUnsupportedError);
      expect(result.changes, isEmpty);
      expect(result.overlaps.single.taskId, 'task-1');
      expect(result.overlaps.single.blockedInterval.label, 'locked-block');
      expect(result.notices.single.type, SchedulingNoticeType.overlap);
      expect(result.notices.single.taskId, 'task-1');
    });

    test('scheduling result can describe exact task movement', () {
      final moved = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 14, 15),
      );
      final result = SchedulingResult(
        tasks: [moved],
        notices: [
          SchedulingNotice(
            'Flexible task moved.',
            type: SchedulingNoticeType.moved,
            taskId: 'task-1',
          ),
        ],
        changes: [
          SchedulingChange(
            taskId: 'task-1',
            previousStart: DateTime(2026, 6, 19, 13),
            previousEnd: DateTime(2026, 6, 19, 13, 15),
            nextStart: DateTime(2026, 6, 19, 14),
            nextEnd: DateTime(2026, 6, 19, 14, 15),
          ),
        ],
      );

      expect(result.tasks.single.scheduledStart, DateTime(2026, 6, 19, 14));
      expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13));
      expect(result.changes.single.nextEnd, DateTime(2026, 6, 19, 14, 15));
      expect(result.notices.single.type, SchedulingNoticeType.moved);
    });

    test('insert backlog task into an open 20-minute slot', () {
      final backlogTask = Task.quickCapture(
        id: 'backlog-1',
        title: 'Start laundry',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlogTask],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 13, 20),
          ),
        ),
        taskId: 'backlog-1',
        updatedAt: now,
      );
      final inserted = result.tasks.single;

      expect(inserted.status, TaskStatus.planned);
      expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13));
      expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
      expect(inserted.stats.restoredFromBacklogCount, 1);
      expect(result.changes.single.taskId, 'backlog-1');
      expect(result.notices.single.type, SchedulingNoticeType.moved);
    });

    test('insert backlog task before flexible task and push later task', () {
      final backlogTask = Task.quickCapture(
        id: 'backlog-1',
        title: 'Start laundry',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final plannedFlexible = flexibleTask().copyWith(
        id: 'flexible-1',
        durationMinutes: 30,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );
      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlogTask, plannedFlexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
        ),
        taskId: 'backlog-1',
        updatedAt: now,
      );
      final inserted = taskById(result.tasks, 'backlog-1');
      final pushed = taskById(result.tasks, 'flexible-1');

      expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13));
      expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
      expect(pushed.stats.autoPushedCount, 1);
      expect(result.changes.map((change) => change.taskId), [
        'backlog-1',
        'flexible-1',
      ]);
    });

    test('insert backlog task skips locked time', () {
      final backlogTask = Task.quickCapture(
        id: 'backlog-1',
        title: 'Start laundry',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlogTask],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 13),
              end: DateTime(2026, 6, 19, 13, 20),
              label: 'appointment',
            ),
          ],
        ),
        taskId: 'backlog-1',
        updatedAt: now,
      );
      final inserted = result.tasks.single;

      expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 20));
      expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 35));
    });

    test('insert backlog task respects expanded locked blocks', () {
      final backlogTask = Task.quickCapture(
        id: 'backlog-1',
        title: 'Start laundry',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final workHours = LockedBlock(
        id: 'work-hours',
        name: 'Work',
        startTime: ClockTime(hour: 13, minute: 0),
        endTime: ClockTime(hour: 13, minute: 20),
        recurrence: LockedBlockRecurrence.weekly(
          weekdays: {LockedWeekday.friday},
        ),
        hiddenByDefault: true,
        createdAt: now,
        updatedAt: now,
      );
      final replacement = LockedBlockOverride.replace(
        id: 'longer-work',
        lockedBlockId: 'work-hours',
        date: CivilDate(2026, 6, 19),
        startTime: ClockTime(hour: 13, minute: 0),
        endTime: ClockTime(hour: 13, minute: 30),
        createdAt: now,
      );
      final lockedIntervals = lockedSchedulingIntervalsForDay(
        blocks: [workHours],
        overrides: [replacement],
        date: CivilDate(2026, 6, 19),
        timeZoneId: utcTimeZoneId,
        timeZoneResolver: utcResolver,
      );

      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlogTask],
          window: SchedulingWindow(
            start: DateTime.utc(2026, 6, 19, 13),
            end: DateTime.utc(2026, 6, 19, 14),
          ),
          lockedIntervals: lockedIntervals,
        ),
        taskId: 'backlog-1',
        updatedAt: now,
      );
      final inserted = result.tasks.single;

      expect(lockedIntervals.single.start, DateTime.utc(2026, 6, 19, 13));
      expect(lockedIntervals.single.end, DateTime.utc(2026, 6, 19, 13, 30));
      expect(inserted.scheduledStart, DateTime.utc(2026, 6, 19, 13, 30));
      expect(inserted.scheduledEnd, DateTime.utc(2026, 6, 19, 13, 45));
    });

    test('insert backlog task does not move inflexible or critical blocks', () {
      final backlogTask = Task.quickCapture(
        id: 'backlog-1',
        title: 'Start laundry',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final inflexibleTask = flexibleTask().copyWith(
        id: 'inflexible-1',
        type: TaskType.inflexible,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );
      final criticalTask = flexibleTask().copyWith(
        id: 'critical-1',
        type: TaskType.critical,
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 14, 30),
      );
      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlogTask, inflexibleTask, criticalTask],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 15),
          ),
        ),
        taskId: 'backlog-1',
        updatedAt: now,
      );
      final inserted = taskById(result.tasks, 'backlog-1');
      final inflexible = taskById(result.tasks, 'inflexible-1');
      final critical = taskById(result.tasks, 'critical-1');

      expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30));
      expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
      expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13));
      expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
      expect(critical.scheduledStart, DateTime(2026, 6, 19, 14));
      expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30));
      expect(result.changes.map((change) => change.taskId), ['backlog-1']);
    });

    test('insert backlog task returns overflow when no slot fits today', () {
      final backlogTask = Task.quickCapture(
        id: 'backlog-1',
        title: 'Start laundry',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final tasks = [backlogTask];
      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: tasks,
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 13, 30),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 13),
              end: DateTime(2026, 6, 19, 13, 30),
              label: 'appointment',
            ),
          ],
        ),
        taskId: 'backlog-1',
        updatedAt: now,
      );

      expect(result.tasks.single, same(backlogTask));
      expect(() => result.tasks.add(backlogTask), throwsUnsupportedError);
      expect(result.changes, isEmpty);
      expect(result.notices.single.type, SchedulingNoticeType.overflow);
      expect(result.notices.single.taskId, 'backlog-1');
      expect(backlogTask.status, TaskStatus.backlog);
    });

    test('push flexible task moves it after current slot', () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: [task],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final pushed = result.tasks.single;

      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
      expect(pushed.stats.manuallyPushedCount, 1);
      expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13));
      expect(result.changes.single.nextStart, DateTime(2026, 6, 19, 13, 15));
      expect(result.notices.single.type, SchedulingNoticeType.moved);
    });

    test('push flexible task can start after explicit current time', () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 18, 9),
        scheduledEnd: DateTime(2026, 6, 18, 9, 30),
        durationMinutes: 30,
      );
      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: [task],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 17),
          ),
        ),
        taskId: 'task-1',
        updatedAt: DateTime(2026, 6, 19, 12),
        earliestStart: DateTime(2026, 6, 19, 12),
      );
      final pushed = result.tasks.single;

      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 12));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 12, 30));
      expect(pushed.stats.manuallyPushedCount, 1);
      expect(result.notices.single.type, SchedulingNoticeType.moved);
    });

    test('push flexible task bumps downstream flexible tasks in order', () {
      final first = flexibleTask().copyWith(
        id: 'flexible-1',
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final second = flexibleTask().copyWith(
        id: 'flexible-2',
        durationMinutes: 30,
        scheduledStart: DateTime(2026, 6, 19, 13, 15),
        scheduledEnd: DateTime(2026, 6, 19, 13, 45),
      );
      final third = flexibleTask().copyWith(
        id: 'flexible-3',
        scheduledStart: DateTime(2026, 6, 19, 13, 45),
        scheduledEnd: DateTime(2026, 6, 19, 14),
      );
      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: [first, second, third],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14, 30),
          ),
        ),
        taskId: 'flexible-1',
        updatedAt: now,
      );
      final pushed = taskById(result.tasks, 'flexible-1');
      final bumpedSecond = taskById(result.tasks, 'flexible-2');
      final bumpedThird = taskById(result.tasks, 'flexible-3');

      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
      expect(bumpedSecond.scheduledStart, DateTime(2026, 6, 19, 13, 30));
      expect(bumpedSecond.scheduledEnd, DateTime(2026, 6, 19, 14));
      expect(bumpedThird.scheduledStart, DateTime(2026, 6, 19, 14));
      expect(bumpedThird.scheduledEnd, DateTime(2026, 6, 19, 14, 15));
      expect(bumpedSecond.stats.autoPushedCount, 1);
      expect(bumpedThird.stats.autoPushedCount, 1);
      expect(result.changes.map((change) => change.taskId), [
        'flexible-1',
        'flexible-2',
        'flexible-3',
      ]);
    });

    test('push flexible task respects locked intervals', () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: [task],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 14),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 13, 15),
              end: DateTime(2026, 6, 19, 13, 30),
              label: 'locked-block',
            ),
          ],
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final pushed = result.tasks.single;

      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
    });

    test('push flexible task leaves required items fixed and visible', () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final inflexibleTask = flexibleTask().copyWith(
        id: 'inflexible-1',
        type: TaskType.inflexible,
        scheduledStart: DateTime(2026, 6, 19, 13, 15),
        scheduledEnd: DateTime(2026, 6, 19, 13, 45),
      );
      final criticalTask = flexibleTask().copyWith(
        id: 'critical-1',
        type: TaskType.critical,
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 14, 30),
      );
      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: [task, inflexibleTask, criticalTask],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 13),
            end: DateTime(2026, 6, 19, 15),
          ),
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final pushed = taskById(result.tasks, 'task-1');
      final inflexible = taskById(result.tasks, 'inflexible-1');
      final critical = taskById(result.tasks, 'critical-1');

      expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 45));
      expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14));
      expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13, 15));
      expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
      expect(critical.scheduledStart, DateTime(2026, 6, 19, 14));
      expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30));
      expect(result.changes.map((change) => change.taskId), ['task-1']);
    });

    test('push flexible task to tomorrow top of queue', () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final tomorrowFlexible = flexibleTask().copyWith(
        id: 'tomorrow-flexible-1',
        durationMinutes: 30,
        scheduledStart: DateTime(2026, 6, 20, 9),
        scheduledEnd: DateTime(2026, 6, 20, 9, 30),
      );
      final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
        input: SchedulingInput(
          tasks: [task, tomorrowFlexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 10),
          ),
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final movedToTomorrow = taskById(result.tasks, 'task-1');
      final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');

      expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 9));
      expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 15));
      expect(movedToTomorrow.stats.manuallyPushedCount, 1);
      expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15));
      expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45));
      expect(bumpedTomorrow.stats.autoPushedCount, 1);
      expect(result.changes.map((change) => change.taskId), [
        'task-1',
        'tomorrow-flexible-1',
      ]);
      expect(
        result.notices.first.movementCode,
        SchedulingMovementCode.flexibleTaskMovedToTomorrow,
      );
    });

    test('push flexible task to tomorrow respects required and locked time',
        () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final criticalTask = flexibleTask().copyWith(
        id: 'critical-1',
        type: TaskType.critical,
        scheduledStart: DateTime(2026, 6, 20, 9, 30),
        scheduledEnd: DateTime(2026, 6, 20, 10),
      );
      final tomorrowFlexible = flexibleTask().copyWith(
        id: 'tomorrow-flexible-1',
        scheduledStart: DateTime(2026, 6, 20, 10),
        scheduledEnd: DateTime(2026, 6, 20, 10, 15),
      );
      final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
        input: SchedulingInput(
          tasks: [task, criticalTask, tomorrowFlexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 11),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 20, 9),
              end: DateTime(2026, 6, 20, 9, 30),
              label: 'locked-block',
            ),
          ],
        ),
        taskId: 'task-1',
        updatedAt: now,
      );
      final movedToTomorrow = taskById(result.tasks, 'task-1');
      final critical = taskById(result.tasks, 'critical-1');
      final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');

      expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 10));
      expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 15));
      expect(critical.scheduledStart, DateTime(2026, 6, 20, 9, 30));
      expect(critical.scheduledEnd, DateTime(2026, 6, 20, 10));
      expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 10, 15));
      expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 30));
      expect(result.changes.map((change) => change.taskId), [
        'task-1',
        'tomorrow-flexible-1',
      ]);
    });

    test('rollover moves unfinished flexible tasks to tomorrow in order', () {
      final first = flexibleTask().copyWith(
        id: 'flexible-1',
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final second = flexibleTask().copyWith(
        id: 'flexible-2',
        status: TaskStatus.active,
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 14, 30),
      );
      final tomorrowFlexible = flexibleTask().copyWith(
        id: 'tomorrow-flexible-1',
        durationMinutes: 15,
        scheduledStart: DateTime(2026, 6, 20, 9),
        scheduledEnd: DateTime(2026, 6, 20, 9, 15),
      );
      final result = engine.rollOverUnfinishedFlexibleTasks(
        input: SchedulingInput(
          tasks: [first, second, tomorrowFlexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 10, 30),
          ),
        ),
        updatedAt: now,
      );
      final rolledFirst = taskById(result.tasks, 'flexible-1');
      final rolledSecond = taskById(result.tasks, 'flexible-2');
      final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');

      expect(rolledFirst.status, TaskStatus.planned);
      expect(rolledFirst.scheduledStart, DateTime(2026, 6, 20, 9));
      expect(rolledFirst.scheduledEnd, DateTime(2026, 6, 20, 9, 15));
      expect(rolledSecond.status, TaskStatus.planned);
      expect(rolledSecond.scheduledStart, DateTime(2026, 6, 20, 9, 15));
      expect(rolledSecond.scheduledEnd, DateTime(2026, 6, 20, 9, 45));
      expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 45));
      expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10));
      expect(result.changes.map((change) => change.taskId), [
        'flexible-1',
        'flexible-2',
        'tomorrow-flexible-1',
      ]);
      expect(
        result.notices.first.movementCode,
        SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
      );
      expect(result.notices.first.parameters['rolledCount'], 2);
    });

    test('rollover excludes locked required completed and cancelled tasks', () {
      final flexible = flexibleTask().copyWith(
        id: 'flexible-1',
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 15),
      );
      final locked = flexibleTask().copyWith(
        id: 'locked-1',
        type: TaskType.locked,
        scheduledStart: DateTime(2026, 6, 19, 14),
        scheduledEnd: DateTime(2026, 6, 19, 15),
      );
      final inflexible = flexibleTask().copyWith(
        id: 'inflexible-1',
        type: TaskType.inflexible,
        scheduledStart: DateTime(2026, 6, 19, 15),
        scheduledEnd: DateTime(2026, 6, 19, 15, 30),
      );
      final critical = flexibleTask().copyWith(
        id: 'critical-1',
        type: TaskType.critical,
        scheduledStart: DateTime(2026, 6, 19, 16),
        scheduledEnd: DateTime(2026, 6, 19, 16, 30),
      );
      final completed = flexibleTask().copyWith(
        id: 'completed-1',
        status: TaskStatus.completed,
        scheduledStart: DateTime(2026, 6, 19, 17),
        scheduledEnd: DateTime(2026, 6, 19, 17, 15),
      );
      final cancelled = flexibleTask().copyWith(
        id: 'cancelled-1',
        status: TaskStatus.cancelled,
        scheduledStart: DateTime(2026, 6, 19, 18),
        scheduledEnd: DateTime(2026, 6, 19, 18, 15),
      );
      final result = engine.rollOverUnfinishedFlexibleTasks(
        input: SchedulingInput(
          tasks: [flexible, locked, inflexible, critical, completed, cancelled],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 10),
          ),
        ),
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'flexible-1').scheduledStart,
          DateTime(2026, 6, 20, 9));
      expect(taskById(result.tasks, 'locked-1').scheduledStart,
          DateTime(2026, 6, 19, 14));
      expect(taskById(result.tasks, 'inflexible-1').scheduledStart,
          DateTime(2026, 6, 19, 15));
      expect(taskById(result.tasks, 'critical-1').scheduledStart,
          DateTime(2026, 6, 19, 16));
      expect(taskById(result.tasks, 'completed-1').scheduledStart,
          DateTime(2026, 6, 19, 17));
      expect(taskById(result.tasks, 'cancelled-1').scheduledStart,
          DateTime(2026, 6, 19, 18));
      expect(result.changes.map((change) => change.taskId), ['flexible-1']);
      expect(
        result.notices.first.movementCode,
        SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
      );
      expect(result.notices.first.parameters['rolledCount'], 1);
    });

    test('moveToBacklog clears active schedule placement', () {
      final task = flexibleTask().copyWith(
        scheduledStart: DateTime(2026, 6, 19, 18),
        scheduledEnd: DateTime(2026, 6, 19, 18, 15),
      );

      final moved = engine.moveToBacklog(task, updatedAt: now);

      expect(moved.status, TaskStatus.backlog);
      expect(moved.scheduledStart, isNull);
      expect(moved.scheduledEnd, isNull);
      expect(moved.backlogEnteredAt, now);
      expect(
        moved.backlogEnteredAtProvenance,
        Task.backlogEnteredAtProvenanceMovedToBacklog,
      );
      expect(moved.stats.movedToBacklogCount, 1);
    });

    test('critical missed task goes to backlog', () {
      final task = flexibleTask().copyWith(type: TaskType.critical);

      final missed = engine.markMissed(task, updatedAt: now);

      expect(missed.status, TaskStatus.backlog);
      expect(missed.backlogEnteredAt, now);
      expect(
        missed.backlogEnteredAtProvenance,
        Task.backlogEnteredAtProvenanceMovedToBacklog,
      );
      expect(missed.stats.missedCount, 1);
      expect(missed.stats.movedToBacklogCount, 1);
    });

    test('inflexible missed task remains missed in place', () {
      final task = flexibleTask().copyWith(type: TaskType.inflexible);

      final missed = engine.markMissed(task, updatedAt: now);

      expect(missed.status, TaskStatus.missed);
      expect(missed.stats.missedCount, 1);
    });

    test('findFirstOpenInterval skips blocked time', () {
      final slot = engine.findFirstOpenInterval(
        windowStart: DateTime(2026, 6, 19, 17),
        windowEnd: DateTime(2026, 6, 19, 20),
        duration: const Duration(minutes: 30),
        blocked: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 17),
            end: DateTime(2026, 6, 19, 18),
            label: 'Work overflow',
          ),
        ],
      );

      expect(slot, isNotNull);
      expect(slot!.start, DateTime(2026, 6, 19, 18));
      expect(slot.end, DateTime(2026, 6, 19, 18, 30));
    });
  });
}
