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

/// Tests Edge Case Regression behavior for the Scheduler Core package.
library;

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

/// Runs the `sorts` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
void main() {
  group('Block 06.3 domain model regressions', () {
    final now = DateTime(2026, 6, 19, 12);

    test('quick capture trims titles before storing them', () {
      final task = Task.quickCapture(
        id: 'capture-1',
        title: '  Refill medication  ',
        createdAt: now,
      );

      expect(task.title, 'Refill medication');
    });

    test('copyWith can clear schedule while preserving omitted values', () {
      final scheduled = Task.quickCapture(
        id: 'task-1',
        title: 'Send invoice',
        createdAt: now,
        projectId: 'admin',
        priority: PriorityLevel.high,
        reward: RewardLevel.medium,
      ).copyWith(
        status: TaskStatus.planned,
        durationMinutes: 30,
        scheduledStart: DateTime(2026, 6, 19, 13),
        scheduledEnd: DateTime(2026, 6, 19, 13, 30),
      );

      final cleared = scheduled.copyWith(clearSchedule: true);

      expect(cleared.scheduledStart, isNull);
      expect(cleared.scheduledEnd, isNull);
      expect(cleared.id, scheduled.id);
      expect(cleared.title, scheduled.title);
      expect(cleared.projectId, 'admin');
      expect(cleared.priority, PriorityLevel.high);
      expect(cleared.reward, RewardLevel.medium);
      expect(cleared.durationMinutes, 30);
    });

    test('project profile trims and validates task titles like quick capture',
        () {
      final project = ProjectProfile(
        id: 'health',
        name: 'Health',
        colorKey: 'green',
      );

      final task = project.createTask(
        id: 'task-1',
        title: '  Book appointment  ',
        createdAt: now,
      );

      expect(task.title, 'Book appointment');
      expect(
        () => project.createTask(
          id: 'task-2',
          title: '   ',
          createdAt: now,
        ),
        throwsArgumentError,
      );
    });
  });

  group('Block 06.3 scheduling regressions', () {
    final now = DateTime(2026, 6, 19, 12);
    const engine = SchedulingEngine();

    test('next-available insertion uses earliest fitting open slot', () {
      final backlog = backlogTask(
        id: 'new-task',
        title: 'Make call',
        createdAt: now,
        durationMinutes: 15,
      );
      final laterFlexible = plannedFlexibleTask(
        id: 'later-flex',
        title: 'Fold laundry',
        createdAt: now,
        start: DateTime(2026, 6, 19, 10),
        durationMinutes: 30,
      );

      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlog, laterFlexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 9),
              end: DateTime(2026, 6, 19, 9, 20),
              label: 'locked',
            ),
          ],
        ),
        taskId: 'new-task',
        updatedAt: now,
      );
      final inserted = taskById(result.tasks, 'new-task');

      expect(inserted.status, TaskStatus.planned);
      expect(inserted.scheduledStart, DateTime(2026, 6, 19, 9, 20));
      expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 9, 35));
    });

    test('no-fit insertion leaves the task backlogged and unplaced', () {
      final backlog = backlogTask(
        id: 'new-task',
        title: 'Make call',
        createdAt: now,
        durationMinutes: 45,
      );

      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlog],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 9, 30),
          ),
        ),
        taskId: 'new-task',
        updatedAt: now,
      );
      final unchanged = taskById(result.tasks, 'new-task');

      expect(unchanged.status, TaskStatus.backlog);
      expect(unchanged.scheduledStart, isNull);
      expect(unchanged.scheduledEnd, isNull);
      expect(result.notices.single.type, SchedulingNoticeType.overflow);
    });

    test('pushing flexible tasks preserves order without moving fixed items',
        () {
      final first = plannedFlexibleTask(
        id: 'first',
        title: 'Email reply',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 15,
      );
      final second = plannedFlexibleTask(
        id: 'second',
        title: 'Laundry',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9, 15),
        durationMinutes: 15,
      );
      final inflexible = fixedTask(
        id: 'appointment',
        type: TaskType.inflexible,
        createdAt: now,
        start: DateTime(2026, 6, 19, 10),
        durationMinutes: 15,
      );
      final critical = fixedTask(
        id: 'meds',
        type: TaskType.critical,
        createdAt: now,
        start: DateTime(2026, 6, 19, 10, 15),
        durationMinutes: 15,
      );
      final locked = fixedTask(
        id: 'locked-task',
        type: TaskType.locked,
        createdAt: now,
        start: DateTime(2026, 6, 19, 9, 30),
        durationMinutes: 15,
      );

      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: [first, second, locked, inflexible, critical],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        taskId: 'first',
        updatedAt: now,
      );

      expect(
        result.changes.map((change) => change.taskId),
        ['first', 'second'],
      );
      expect(
        taskById(result.tasks, 'first').scheduledStart,
        DateTime(2026, 6, 19, 9, 15),
      );
      expect(
        taskById(result.tasks, 'second').scheduledStart,
        DateTime(2026, 6, 19, 9, 45),
      );
      expect(taskById(result.tasks, 'appointment').type, TaskType.inflexible);
      expect(taskById(result.tasks, 'appointment').scheduledStart,
          inflexible.scheduledStart);
      expect(taskById(result.tasks, 'meds').type, TaskType.critical);
      expect(taskById(result.tasks, 'meds').scheduledStart,
          critical.scheduledStart);
      expect(taskById(result.tasks, 'locked-task').type, TaskType.locked);
      expect(taskById(result.tasks, 'locked-task').scheduledStart,
          locked.scheduledStart);
    });

    test('rollover uses an explicit source window and ignores future tasks',
        () {
      final sourceWindow = SchedulingWindow(
        start: DateTime(2026, 6, 19),
        end: DateTime(2026, 6, 20),
      );
      final targetWindow = SchedulingWindow(
        start: DateTime(2026, 6, 20, 9),
        end: DateTime(2026, 6, 20, 12),
      );
      final sourcePlanned = plannedFlexibleTask(
        id: 'source-planned',
        title: 'Source planned',
        createdAt: now,
        start: DateTime(2026, 6, 19, 13),
        durationMinutes: 15,
      );
      final sourceActive = plannedFlexibleTask(
        id: 'source-active',
        title: 'Source active',
        createdAt: now,
        start: DateTime(2026, 6, 19, 14),
        durationMinutes: 15,
      ).copyWith(status: TaskStatus.active);
      final completed = plannedFlexibleTask(
        id: 'completed',
        title: 'Completed',
        createdAt: now,
        start: DateTime(2026, 6, 19, 15),
        durationMinutes: 15,
      ).copyWith(status: TaskStatus.completed);
      final cancelled = plannedFlexibleTask(
        id: 'cancelled',
        title: 'Cancelled',
        createdAt: now,
        start: DateTime(2026, 6, 19, 16),
        durationMinutes: 15,
      ).copyWith(status: TaskStatus.cancelled);
      final backlogged = backlogTask(
        id: 'backlog',
        title: 'Already backlog',
        createdAt: now,
        durationMinutes: 15,
      );
      final critical = fixedTask(
        id: 'critical',
        type: TaskType.critical,
        createdAt: now,
        start: DateTime(2026, 6, 19, 17),
        durationMinutes: 15,
      );
      final inflexible = fixedTask(
        id: 'inflexible',
        type: TaskType.inflexible,
        createdAt: now,
        start: DateTime(2026, 6, 19, 18),
        durationMinutes: 15,
      );
      final futureFlexible = plannedFlexibleTask(
        id: 'future',
        title: 'Future',
        createdAt: now,
        start: DateTime(2026, 6, 21, 9),
        durationMinutes: 15,
      );
      final tomorrowFlexible = plannedFlexibleTask(
        id: 'tomorrow',
        title: 'Tomorrow',
        createdAt: now,
        start: DateTime(2026, 6, 20, 9),
        durationMinutes: 30,
      );

      final result = engine.rollOverUnfinishedFlexibleTasks(
        input: SchedulingInput(
          tasks: [
            futureFlexible,
            sourceActive,
            sourcePlanned,
            completed,
            cancelled,
            backlogged,
            critical,
            inflexible,
            tomorrowFlexible,
          ],
          window: targetWindow,
        ),
        sourceWindow: sourceWindow,
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'source-planned').scheduledStart,
          DateTime(2026, 6, 20, 9));
      expect(taskById(result.tasks, 'source-active').scheduledStart,
          DateTime(2026, 6, 20, 9, 15));
      expect(taskById(result.tasks, 'tomorrow').scheduledStart,
          DateTime(2026, 6, 20, 9, 30));
      expect(taskById(result.tasks, 'future').scheduledStart,
          futureFlexible.scheduledStart);
      expect(taskById(result.tasks, 'completed').status, TaskStatus.completed);
      expect(taskById(result.tasks, 'cancelled').status, TaskStatus.cancelled);
      expect(taskById(result.tasks, 'backlog').status, TaskStatus.backlog);
      expect(taskById(result.tasks, 'critical').type, TaskType.critical);
      expect(taskById(result.tasks, 'inflexible').type, TaskType.inflexible);
      expect(
        result.changes.map((change) => change.taskId),
        ['source-active', 'source-planned', 'tomorrow'],
      );
    });
  });

  group('Block 06.3 backlog regressions', () {
    final now = DateTime(2026, 6, 19, 12);

    test('filters only include backlog tasks in the expected category', () {
      final inbox = backlogTask(
        id: 'inbox',
        title: 'Inbox',
        createdAt: now,
        reward: RewardLevel.low,
      );
      final pushed = backlogTask(
        id: 'pushed',
        title: 'Pushed',
        createdAt: now,
        projectId: 'home',
        reward: RewardLevel.low,
      ).copyWith(stats: const TaskStatistics(manuallyPushedCount: 1));
      final criticalMissed = backlogTask(
        id: 'critical',
        title: 'Critical',
        createdAt: now,
        type: TaskType.critical,
        projectId: 'health',
        reward: RewardLevel.low,
      ).copyWith(stats: const TaskStatistics(missedCount: 1));
      final wishlist = backlogTask(
        id: 'wishlist',
        title: 'Wishlist',
        createdAt: now,
        projectId: 'ideas',
        reward: RewardLevel.low,
        backlogTags: const {BacklogTag.wishlist},
      );
      final stale = backlogTask(
        id: 'stale',
        title: 'Stale',
        createdAt: now.subtract(const Duration(days: 40)),
        projectId: 'archive',
        reward: RewardLevel.low,
      );
      final noReward = backlogTask(
        id: 'no-reward',
        title: 'No reward',
        createdAt: now,
        projectId: 'admin',
      );
      final plannedNoReward = noReward.copyWith(
        id: 'planned-no-reward',
        status: TaskStatus.planned,
      );
      final view = BacklogView(
        tasks: [
          inbox,
          pushed,
          criticalMissed,
          wishlist,
          stale,
          noReward,
          plannedNoReward,
        ],
        now: now,
      );

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

    test('priority sorting preserves input order for equal priorities', () {
      final first = backlogTask(id: 'first', title: 'First', createdAt: now);
      final second = backlogTask(id: 'second', title: 'Second', createdAt: now);
      final third = backlogTask(id: 'third', title: 'Third', createdAt: now);
      final view = BacklogView(tasks: [second, first, third], now: now);

      expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
        'second',
        'first',
        'third',
      ]);
    });

    test('reward not-set sorts as distinct from very-low reward', () {
      final notSet = backlogTask(
        id: 'not-set',
        title: 'Unknown reward',
        createdAt: now,
        reward: RewardLevel.notSet,
        difficulty: DifficultyLevel.easy,
      );
      final veryLow = backlogTask(
        id: 'very-low',
        title: 'Very low reward',
        createdAt: now,
        reward: RewardLevel.veryLow,
        difficulty: DifficultyLevel.easy,
      );
      final view = BacklogView(tasks: [notSet, veryLow], now: now);

      expect(
          view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id), [
        'very-low',
        'not-set',
      ]);
    });

    test('age sorting preserves input order when createdAt values match', () {
      final createdAt = DateTime(2026, 6, 1);
      final first =
          backlogTask(id: 'first', title: 'First', createdAt: createdAt);
      final second =
          backlogTask(id: 'second', title: 'Second', createdAt: createdAt);
      final view = BacklogView(tasks: [second, first], now: now);

      expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [
        'second',
        'first',
      ]);
    });

    test('stale filter and marker use the same creation-age semantics', () {
      final oldButRecentlyUpdated = backlogTask(
        id: 'old',
        title: 'Old idea',
        createdAt: now.subtract(const Duration(days: 40)),
      ).copyWith(updatedAt: now);
      final newButOldUpdate = backlogTask(
        id: 'new',
        title: 'New idea',
        createdAt: now,
      ).copyWith(updatedAt: now.subtract(const Duration(days: 40)));
      final view = BacklogView(
        tasks: [oldButRecentlyUpdated, newButOldUpdate],
        now: now,
      );

      expect(view.filter(BacklogFilter.stale).map((task) => task.id), ['old']);
      expect(
        view.stalenessMarkerFor(oldButRecentlyUpdated),
        BacklogStalenessMarker.purple,
      );
      expect(
        view.stalenessMarkerFor(newButOldUpdate),
        BacklogStalenessMarker.green,
      );
    });
  });

  group('Block 06.3 quick capture regressions', () {
    final now = DateTime(2026, 6, 19, 12);
    const service = QuickCaptureService();

    test('immediate scheduling uses normal insertion and push rules', () {
      final existing = plannedFlexibleTask(
        id: 'existing',
        title: 'Existing',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 15,
      );

      final result = service.capture(
        QuickCaptureRequest(
          id: 'capture',
          title: 'Captured task',
          createdAt: now,
          addToNextAvailableSlot: true,
          durationMinutes: 15,
        ),
        schedulingInput: SchedulingInput(
          tasks: [existing],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 10),
          ),
        ),
        updatedAt: now,
      );
      final scheduledTasks = result.schedulingResult!.tasks;

      expect(result.status, QuickCaptureStatus.scheduled);
      expect(taskById(scheduledTasks, 'capture').scheduledStart,
          DateTime(2026, 6, 19, 9));
      expect(taskById(scheduledTasks, 'existing').scheduledStart,
          DateTime(2026, 6, 19, 9, 15));
    });

    test('immediate scheduling requires enough scheduling data', () {
      final result = service.capture(
        QuickCaptureRequest(
          id: 'capture',
          title: 'Captured task',
          createdAt: now,
          addToNextAvailableSlot: true,
          durationMinutes: 15,
        ),
        updatedAt: now,
      );

      expect(result.status, QuickCaptureStatus.validationError);
      expect(result.messages.single, 'Scheduling input is required.');
    });

    test('quick capture cannot create blank tasks', () {
      expect(
        () => service.capture(
          QuickCaptureRequest(
            id: 'capture',
            title: '   ',
            createdAt: now,
          ),
        ),
        throwsArgumentError,
      );
    });
  });

  group('Block 06.3 locked-time regressions', () {
    final now = DateTime(2026, 6, 19, 12);
    const utcTimeZoneId = 'UTC';
    const utcResolver = FixedOffsetTimeZoneResolver.utc();

    test('locked occurrence exposes overlay data without becoming a task', () {
      final block = LockedBlock(
        id: 'work',
        name: 'Work',
        startTime: ClockTime(hour: 9, minute: 0),
        endTime: ClockTime(hour: 17, minute: 0),
        recurrence: LockedBlockRecurrence.weekly(
          weekdays: {LockedWeekday.friday},
        ),
        createdAt: now,
        updatedAt: now,
      );

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

      expect(occurrence, isA<LockedBlockOccurrence>());
      expect(occurrence, isNot(isA<Task>()));
      expect(occurrence.hiddenByDefault, isTrue);
      expect(occurrence.schedulingInterval.label, 'Work');
      expect(expansion.schedulingIntervals.single.label, 'Work');
    });

    test('completed tasks outside locked hours do not increment counters', () {
      final task = plannedFlexibleTask(
        id: 'task',
        title: 'Task',
        createdAt: now,
        start: DateTime(2026, 6, 19, 8),
        durationMinutes: 30,
      ).copyWith(status: TaskStatus.completed);

      final tracked = trackCompletedDuringLockedHours(
        task: task,
        lockedIntervals: [
          TimeInterval(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 10),
          ),
        ],
      );

      expect(tracked.stats.completedDuringLockedHoursCount, 0);
      expect(tracked.stats.completedDuringLockedHoursMinutes, 0);
    });
  });

  group('Block 06.3 action-service regressions', () {
    final now = DateTime(2026, 6, 19, 12);
    const service = FlexibleTaskActionService();

    test('unsupported flexible quick action types fail predictably', () {
      final inflexible = fixedTask(
        id: 'appointment',
        type: TaskType.inflexible,
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );

      expect(
        () => service.apply(
          task: inflexible,
          action: FlexibleTaskQuickAction.done,
          updatedAt: now,
        ),
        throwsArgumentError,
      );
    });

    test('push backlog destination does not create duplicate task ids', () {
      final task = plannedFlexibleTask(
        id: 'task',
        title: 'Task',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );

      final result = service.applyPushDestination(
        destination: PushDestination.backlog,
        input: SchedulingInput(
          tasks: [task],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 10),
          ),
        ),
        taskId: 'task',
        updatedAt: now,
      );

      expect(result.schedulingResult.tasks.map((task) => task.id), ['task']);
      expect(result.schedulingResult.tasks.toSet().length, 1);
      expect(result.schedulingResult.changes.single.previousStart,
          DateTime(2026, 6, 19, 9));
      expect(result.schedulingResult.changes.single.nextStart, isNull);
    });

    test('public scheduling services do not reorder the input task list', () {
      final first = plannedFlexibleTask(
        id: 'first',
        title: 'First',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 15,
      );
      final second = plannedFlexibleTask(
        id: 'second',
        title: 'Second',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9, 15),
        durationMinutes: 15,
      );
      final tasks = [second, first];

      const SchedulingEngine().pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: tasks,
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 10),
          ),
        ),
        taskId: 'first',
        updatedAt: now,
      );

      expect(tasks.map((task) => task.id), ['second', 'first']);
    });

    test('invalid push destinations leave fixed task types unchanged', () {
      final critical = fixedTask(
        id: 'critical',
        type: TaskType.critical,
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );

      final result = service.applyPushDestination(
        destination: PushDestination.nextAvailableSlot,
        input: SchedulingInput(
          tasks: [critical],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 10),
          ),
        ),
        taskId: 'critical',
        updatedAt: now,
      );

      expect(result.schedulingResult.notices.single.type,
          SchedulingNoticeType.noFit);
      expect(result.schedulingResult.tasks.single.type, TaskType.critical);
      expect(result.schedulingResult.tasks.single.status, TaskStatus.planned);
    });
  });
}

/// Runs the `backlogTask` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
Task backlogTask({
  required String id,
  required String title,
  required DateTime createdAt,
  String projectId = 'inbox',
  TaskType type = TaskType.flexible,
  RewardLevel reward = RewardLevel.notSet,
  DifficultyLevel difficulty = DifficultyLevel.notSet,
  int? durationMinutes,
  Set<BacklogTag> backlogTags = const <BacklogTag>{},
}) {
  return Task.quickCapture(
    id: id,
    title: title,
    createdAt: createdAt,
    projectId: projectId,
    type: type,
    reward: reward,
    difficulty: difficulty,
    backlogTags: backlogTags,
  ).copyWith(durationMinutes: durationMinutes);
}

/// Runs the `plannedFlexibleTask` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
Task plannedFlexibleTask({
  required String id,
  required String title,
  required DateTime createdAt,
  required DateTime start,
  required int durationMinutes,
}) {
  return backlogTask(
    id: id,
    title: title,
    createdAt: createdAt,
    durationMinutes: durationMinutes,
  ).copyWith(
    status: TaskStatus.planned,
    scheduledStart: start,
    scheduledEnd: start.add(Duration(minutes: durationMinutes)),
  );
}

/// Runs the `fixedTask` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
Task fixedTask({
  required String id,
  required TaskType type,
  required DateTime createdAt,
  required DateTime start,
  required int durationMinutes,
}) {
  return Task(
    id: id,
    title: id,
    projectId: 'fixed',
    type: type,
    status: TaskStatus.planned,
    priority: PriorityLevel.medium,
    durationMinutes: durationMinutes,
    scheduledStart: start,
    scheduledEnd: start.add(Duration(minutes: durationMinutes)),
    createdAt: createdAt,
    updatedAt: createdAt,
  );
}

/// Runs the `taskById` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
Task taskById(List<Task> tasks, String id) {
  return tasks.singleWhere((task) => task.id == id);
}
