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

/// Tests Free Slots 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('FreeSlotService', () {
    final now = DateTime(2026, 6, 19, 8);
    const service = FreeSlotService();

    test('creates and updates scheduled Free Slot records', () {
      final freeSlot = service.create(
        id: 'rest',
        title: 'Rest',
        projectId: 'health',
        start: DateTime(2026, 6, 19, 9),
        end: DateTime(2026, 6, 19, 9, 30),
        createdAt: now,
      );
      final updated = service.update(
        freeSlot: freeSlot,
        title: 'Recovery time',
        start: DateTime(2026, 6, 19, 10),
        end: DateTime(2026, 6, 19, 11),
        updatedAt: now.add(const Duration(minutes: 1)),
      );

      expect(freeSlot.type, TaskType.freeSlot);
      expect(freeSlot.status, TaskStatus.planned);
      expect(freeSlot.durationMinutes, 30);
      expect(freeSlot.scheduledStart, DateTime(2026, 6, 19, 9));
      expect(freeSlot.scheduledEnd, DateTime(2026, 6, 19, 9, 30));

      expect(updated.type, TaskType.freeSlot);
      expect(updated.title, 'Recovery time');
      expect(updated.durationMinutes, 60);
      expect(updated.scheduledStart, DateTime(2026, 6, 19, 10));
      expect(updated.scheduledEnd, DateTime(2026, 6, 19, 11));
      expect(updated.updatedAt, now.add(const Duration(minutes: 1)));
    });

    test('rejects invalid Free Slot updates', () {
      final flexible = flexibleTask(
        id: 'flexible',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );

      expect(
        () => service.update(
          freeSlot: flexible,
          start: DateTime(2026, 6, 19, 10),
          end: DateTime(2026, 6, 19, 11),
        ),
        throwsArgumentError,
      );
      expect(
        () => service.create(
          id: 'bad',
          title: 'Bad rest',
          start: DateTime(2026, 6, 19, 10),
          end: DateTime(2026, 6, 19, 10),
          createdAt: now,
        ),
        throwsA(isA<DomainValidationException>()),
      );
    });

    test('explicit required overlap returns typed conflict without moving rest',
        () {
      final freeSlot = service.create(
        id: 'rest',
        title: 'Rest',
        start: DateTime(2026, 6, 19, 9),
        end: DateTime(2026, 6, 19, 10),
        createdAt: now,
      );
      final required = requiredTask(
        id: 'appointment',
        type: TaskType.inflexible,
        createdAt: now,
      );

      final result = service.scheduleRequiredCommitment(
        input: inputFor([freeSlot, required]),
        taskId: 'appointment',
        start: DateTime(2026, 6, 19, 9, 30),
        end: DateTime(2026, 6, 19, 10, 30),
        updatedAt: now,
      );
      final scheduled = taskById(result.schedulingResult.tasks, 'appointment');
      final rest = taskById(result.schedulingResult.tasks, 'rest');

      expect(
        result.status,
        RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict,
      );
      expect(result.hasProtectedFreeSlotConflict, isTrue);
      expect(result.protectedFreeSlotConflicts.single.freeSlotTaskId, 'rest');
      expect(scheduled.scheduledStart, DateTime(2026, 6, 19, 9, 30));
      expect(scheduled.scheduledEnd, DateTime(2026, 6, 19, 10, 30));
      expect(rest.scheduledStart, freeSlot.scheduledStart);
      expect(rest.scheduledEnd, freeSlot.scheduledEnd);
      expect(result.schedulingResult.overlaps.single.taskId, 'appointment');
      expect(result.schedulingResult.changes.single.taskId, 'appointment');
    });

    test('required boundary touch does not conflict with protected rest', () {
      final freeSlot = service.create(
        id: 'rest',
        title: 'Rest',
        start: DateTime(2026, 6, 19, 9),
        end: DateTime(2026, 6, 19, 10),
        createdAt: now,
      );
      final required = requiredTask(
        id: 'meds',
        type: TaskType.critical,
        createdAt: now,
      );

      final result = service.scheduleRequiredCommitment(
        input: inputFor([freeSlot, required]),
        taskId: 'meds',
        start: DateTime(2026, 6, 19, 10),
        end: DateTime(2026, 6, 19, 10, 15),
        updatedAt: now,
      );

      expect(result.status, RequiredCommitmentScheduleStatus.scheduled);
      expect(result.protectedFreeSlotConflicts, isEmpty);
      expect(result.schedulingResult.overlaps, isEmpty);
    });
  });

  group('Free Slot automatic scheduling protection', () {
    final now = DateTime(2026, 6, 19, 8);
    const service = FreeSlotService();
    const engine = SchedulingEngine();

    test('insertion skips multiple adjacent Free Slots', () {
      final backlog = Task.quickCapture(
        id: 'backlog',
        title: 'Make call',
        createdAt: now,
      ).copyWith(durationMinutes: 15);
      final firstRest = service.create(
        id: 'rest-1',
        title: 'Rest 1',
        start: DateTime(2026, 6, 19, 9),
        end: DateTime(2026, 6, 19, 9, 30),
        createdAt: now,
      );
      final secondRest = service.create(
        id: 'rest-2',
        title: 'Rest 2',
        start: DateTime(2026, 6, 19, 9, 30),
        end: DateTime(2026, 6, 19, 10),
        createdAt: now,
      );

      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: inputFor([backlog, firstRest, secondRest]),
        taskId: 'backlog',
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'backlog').scheduledStart,
          DateTime(2026, 6, 19, 10));
      expect(taskById(result.tasks, 'rest-1').scheduledStart,
          firstRest.scheduledStart);
      expect(taskById(result.tasks, 'rest-2').scheduledStart,
          secondRest.scheduledStart);
      expect(result.changes.map((change) => change.taskId), ['backlog']);
    });

    test('push skips a Free Slot at the push boundary', () {
      final flexible = flexibleTask(
        id: 'flexible',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final rest = service.create(
        id: 'rest',
        title: 'Rest',
        start: DateTime(2026, 6, 19, 9, 30),
        end: DateTime(2026, 6, 19, 10),
        createdAt: now,
      );

      final result = engine.pushFlexibleTaskToNextAvailableSlot(
        input: inputFor([flexible, rest]),
        taskId: 'flexible',
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'flexible').scheduledStart,
          DateTime(2026, 6, 19, 10));
      expect(taskById(result.tasks, 'flexible').scheduledEnd,
          DateTime(2026, 6, 19, 10, 30));
      expect(
          taskById(result.tasks, 'rest').scheduledStart, rest.scheduledStart);
    });

    test('tomorrow placement preserves Free Slots at window edges', () {
      final task = flexibleTask(
        id: 'today',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final startRest = service.create(
        id: 'start-rest',
        title: 'Start rest',
        start: DateTime(2026, 6, 20, 9),
        end: DateTime(2026, 6, 20, 9, 30),
        createdAt: now,
      );
      final endRest = service.create(
        id: 'end-rest',
        title: 'End rest',
        start: DateTime(2026, 6, 20, 10, 30),
        end: DateTime(2026, 6, 20, 11),
        createdAt: now,
      );

      final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
        input: SchedulingInput(
          tasks: [task, startRest, endRest],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 11),
          ),
        ),
        taskId: 'today',
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'today').scheduledStart,
          DateTime(2026, 6, 20, 9, 30));
      expect(taskById(result.tasks, 'start-rest').scheduledStart,
          startRest.scheduledStart);
      expect(taskById(result.tasks, 'end-rest').scheduledStart,
          endRest.scheduledStart);
    });

    test('whole-window Free Slot leaves no fit and does not move tasks', () {
      final flexible = flexibleTask(
        id: 'flexible',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final rest = service.create(
        id: 'whole-rest',
        title: 'Whole morning rest',
        start: DateTime(2026, 6, 20, 9),
        end: DateTime(2026, 6, 20, 10),
        createdAt: now,
      );

      final result = engine.rollOverUnfinishedFlexibleTasks(
        input: SchedulingInput(
          tasks: [flexible, rest],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 20, 9),
            end: DateTime(2026, 6, 20, 10),
          ),
        ),
        sourceWindow: SchedulingWindow(
          start: DateTime(2026, 6, 19, 0),
          end: DateTime(2026, 6, 20, 0),
        ),
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'flexible'), same(flexible));
      expect(taskById(result.tasks, 'whole-rest'), same(rest));
      expect(result.changes, isEmpty);
      expect(result.notices.single.type, SchedulingNoticeType.overflow);
      expect(taskById(result.tasks, 'whole-rest').scheduledStart,
          rest.scheduledStart);
    });
  });

  group('Free Slot timeline metadata', () {
    final now = DateTime(2026, 6, 19, 8);
    const service = FreeSlotService();

    test('Free Slot read model is visible without flexible quick actions', () {
      final freeSlot = service.create(
        id: 'rest',
        title: 'Rest',
        projectId: 'health',
        start: DateTime(2026, 6, 19, 9),
        end: DateTime(2026, 6, 19, 10),
        createdAt: now,
      );
      final item = TimelineItemMapper().fromTask(freeSlot);

      expect(item.taskType, TaskType.freeSlot);
      expect(item.backgroundToken, TimelineBackgroundToken.freeSlot);
      expect(item.category, TimelineItemCategory.taskCard);
      expect(item.quickActions, isEmpty);
      expect(item.start, freeSlot.scheduledStart);
      expect(item.end, freeSlot.scheduledEnd);
    });
  });
}

/// Runs the `inputFor` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
SchedulingInput inputFor(List<Task> tasks) {
  return SchedulingInput(
    tasks: tasks,
    window: SchedulingWindow(
      start: DateTime(2026, 6, 19, 9),
      end: DateTime(2026, 6, 19, 12),
    ),
  );
}

/// Runs the `flexibleTask` 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 flexibleTask({
  required String id,
  required DateTime createdAt,
  required DateTime start,
  required int durationMinutes,
}) {
  return Task.quickCapture(
    id: id,
    title: id,
    createdAt: createdAt,
  ).copyWith(
    status: TaskStatus.planned,
    durationMinutes: durationMinutes,
    scheduledStart: start,
    scheduledEnd: start.add(Duration(minutes: durationMinutes)),
  );
}

/// Runs the `requiredTask` 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 requiredTask({
  required String id,
  required TaskType type,
  required DateTime createdAt,
}) {
  return Task(
    id: id,
    title: id,
    projectId: 'required',
    type: type,
    status: TaskStatus.planned,
    priority: PriorityLevel.high,
    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);
}
