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

/// Tests Surprise Task Logging 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('Surprise task logging', () {
    final now = DateTime(2026, 6, 19, 12);
    const service = SurpriseTaskLogService();

    test('creates a completed surprise task that occupies the logged time', () {
      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Answered urgent text',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9),
          timeUsedMinutes: 20,
          projectId: 'relationships',
          priority: PriorityLevel.high,
          reward: RewardLevel.medium,
        ),
        input: emptyInput(),
        updatedAt: now,
      );

      expect(result.surpriseTask.type, TaskType.surprise);
      expect(result.surpriseTask.status, TaskStatus.completed);
      expect(result.surpriseTask.completedAt, now);
      expect(result.surpriseTask.title, 'Answered urgent text');
      expect(result.surpriseTask.projectId, 'relationships');
      expect(result.surpriseTask.priority, PriorityLevel.high);
      expect(result.surpriseTask.reward, RewardLevel.medium);
      expect(result.surpriseTask.durationMinutes, 20);
      expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9));
      expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20));
      expect(result.surpriseTask.actualStart, DateTime(2026, 6, 19, 9));
      expect(result.surpriseTask.actualEnd, DateTime(2026, 6, 19, 9, 20));
      expect(
        result.schedulingResult.tasks.map((task) => task.id),
        ['surprise-1'],
      );
    });

    test('pushes overlapping flexible tasks using normal push rules', () {
      final flexible = plannedFlexibleTask(
        id: 'flexible',
        title: 'Write notes',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );

      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: [flexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );
      final moved = taskById(result.schedulingResult.tasks, 'flexible');

      expect(moved.scheduledStart, DateTime(2026, 6, 19, 9, 30));
      expect(moved.scheduledEnd, DateTime(2026, 6, 19, 10));
      expect(moved.stats.autoPushedCount, 1);
      expect(moved.stats.manuallyPushedCount, 0);
      expect(result.schedulingResult.changes.single.taskId, 'flexible');
      expect(result.usedNormalPushRules, isTrue);
    });

    test('does not move inflexible or critical overlaps and reports them', () {
      final inflexible = fixedTask(
        id: 'appointment',
        type: TaskType.inflexible,
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final critical = fixedTask(
        id: 'meds',
        type: TaskType.critical,
        createdAt: now,
        start: DateTime(2026, 6, 19, 9, 20),
        durationMinutes: 20,
      );

      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: [inflexible, critical],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );

      expect(taskById(result.schedulingResult.tasks, 'appointment'),
          sameScheduleAs(inflexible));
      expect(taskById(result.schedulingResult.tasks, 'meds'),
          sameScheduleAs(critical));
      expect(
        result.requiredOverlaps.map((overlap) => overlap.taskId),
        ['appointment', 'meds'],
      );
      expect(
        result.schedulingResult.overlaps.map((overlap) => overlap.taskId),
        ['appointment', 'meds'],
      );
    });

    test('tracks locked overlap without leaking hidden details by default', () {
      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: const [],
          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, 10),
              label: 'hidden-work',
            ),
          ],
        ),
        updatedAt: now,
      );

      expect(result.lockedOverlaps.single.label, isNull);
      expect(result.lockedOverlaps.single.start, DateTime(2026, 6, 19, 9));
      expect(result.lockedOverlaps.single.end, DateTime(2026, 6, 19, 10));
      expect(result.schedulingResult.tasks.map((task) => task.type), [
        TaskType.surprise,
      ]);
      expect(result.schedulingResult.overlaps, isEmpty);
    });

    test('can reveal hidden locked overlap details when explicitly requested',
        () {
      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: const [],
          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, 10),
              label: 'hidden-work',
            ),
          ],
        ),
        updatedAt: now,
        revealHiddenLockedDetails: true,
      );

      expect(result.lockedOverlaps.single.label, 'hidden-work');
    });

    test('optional fields can be omitted without blocking save', () {
      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: '  Took a break  ',
          createdAt: now,
        ),
        input: emptyInput(),
        updatedAt: now,
      );

      expect(result.surpriseTask.title, 'Took a break');
      expect(result.surpriseTask.projectId, 'inbox');
      expect(result.surpriseTask.priority, isNull);
      expect(result.surpriseTask.reward, RewardLevel.notSet);
      expect(result.surpriseTask.durationMinutes, isNull);
      expect(result.surpriseTask.scheduledStart, isNull);
      expect(result.surpriseTask.scheduledEnd, isNull);
      expect(result.surpriseTask.actualStart, isNull);
      expect(result.surpriseTask.actualEnd, isNull);
      expect(result.schedulingResult.tasks.single.id, 'surprise-1');
    });

    test('does not push the same flexible task twice after cascade movement',
        () {
      final first = plannedFlexibleTask(
        id: 'first',
        title: 'First',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final second = plannedFlexibleTask(
        id: 'second',
        title: 'Second',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9, 30),
        durationMinutes: 30,
      );

      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 40,
        ),
        input: SchedulingInput(
          tasks: [first, second],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 12),
          ),
        ),
        updatedAt: now,
      );

      expect(taskById(result.schedulingResult.tasks, 'first').scheduledStart,
          DateTime(2026, 6, 19, 9, 50));
      expect(taskById(result.schedulingResult.tasks, 'second').scheduledStart,
          DateTime(2026, 6, 19, 10, 20));
      expect(
        result.schedulingResult.changes.map((change) => change.taskId),
        ['first', 'second'],
      );
    });

    test('retrying the same surprise id is a no-op', () {
      final flexible = plannedFlexibleTask(
        id: 'flexible',
        title: 'Write notes',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final first = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: [flexible],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );

      final retry = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 10),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: first.schedulingResult.tasks,
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );

      expect(
        retry.schedulingResult.tasks
            .where((task) => task.id == 'surprise-1')
            .length,
        1,
      );
      expect(retry.schedulingResult.changes, isEmpty);
      expect(
        taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart,
        taskById(first.schedulingResult.tasks, 'flexible').scheduledStart,
      );
      expect(
        retry.schedulingResult.notices.single.issueCode,
        SchedulingIssueCode.duplicateSurpriseLog,
      );
    });

    test('later insertion avoids persisted surprise actual occupancy', () {
      final surpriseResult = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9),
          timeUsedMinutes: 30,
        ),
        input: SchedulingInput(
          tasks: const [],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );
      final backlog = Task.quickCapture(
        id: 'backlog',
        title: 'Make call',
        createdAt: now,
      ).copyWith(durationMinutes: 15);

      final later =
          const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlog, ...surpriseResult.schedulingResult.tasks],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        taskId: 'backlog',
        updatedAt: now,
      );

      expect(taskById(later.tasks, 'backlog').scheduledStart,
          DateTime(2026, 6, 19, 9, 30));
    });

    test('active and completed actual intervals block later insertion', () {
      final active = plannedFlexibleTask(
        id: 'active',
        title: 'Active',
        createdAt: now,
        start: DateTime(2026, 6, 19, 8),
        durationMinutes: 30,
      ).copyWith(
        status: TaskStatus.active,
        actualStart: DateTime(2026, 6, 19, 9),
        actualEnd: DateTime(2026, 6, 19, 9, 20),
      );
      final completed = plannedFlexibleTask(
        id: 'completed',
        title: 'Completed',
        createdAt: now,
        start: DateTime(2026, 6, 19, 8, 30),
        durationMinutes: 30,
      ).copyWith(
        status: TaskStatus.completed,
        actualStart: DateTime(2026, 6, 19, 9, 20),
        actualEnd: DateTime(2026, 6, 19, 9, 40),
      );
      final backlog = Task.quickCapture(
        id: 'backlog',
        title: 'Make call',
        createdAt: now,
      ).copyWith(durationMinutes: 15);

      final result =
          const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlog, active, completed],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        taskId: 'backlog',
        updatedAt: now,
      );

      expect(taskById(result.tasks, 'backlog').scheduledStart,
          DateTime(2026, 6, 19, 9, 40));
      expect(taskById(result.tasks, 'active').scheduledStart,
          active.scheduledStart);
      expect(taskById(result.tasks, 'completed').actualStart,
          completed.actualStart);
    });

    test('exact surprise boundaries do not move adjacent flexible tasks', () {
      final before = plannedFlexibleTask(
        id: 'before',
        title: 'Before',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final after = plannedFlexibleTask(
        id: 'after',
        title: 'After',
        createdAt: now,
        start: DateTime(2026, 6, 19, 10),
        durationMinutes: 30,
      );

      final result = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 30),
          timeUsedMinutes: 30,
        ),
        input: SchedulingInput(
          tasks: [before, after],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );

      expect(result.schedulingResult.changes, isEmpty);
      expect(taskById(result.schedulingResult.tasks, 'before').scheduledStart,
          before.scheduledStart);
      expect(taskById(result.schedulingResult.tasks, 'after').scheduledStart,
          after.scheduledStart);
    });

    test('multiple surprise entries remain occupied for later scheduling', () {
      final first = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-1',
          title: 'First',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9),
          timeUsedMinutes: 15,
        ),
        input: SchedulingInput(
          tasks: const [],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );
      final second = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise-2',
          title: 'Second',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9, 15),
          timeUsedMinutes: 15,
        ),
        input: SchedulingInput(
          tasks: first.schedulingResult.tasks,
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        updatedAt: now,
      );
      final backlog = Task.quickCapture(
        id: 'backlog',
        title: 'Make call',
        createdAt: now,
      ).copyWith(durationMinutes: 15);

      final later =
          const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlog, ...second.schedulingResult.tasks],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 11),
          ),
        ),
        taskId: 'backlog',
        updatedAt: now,
      );

      expect(later.tasks.where((task) => task.type == TaskType.surprise).length,
          2);
      expect(taskById(later.tasks, 'backlog').scheduledStart,
          DateTime(2026, 6, 19, 9, 30));
    });
  });
}

/// Runs the `emptyInput` 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 emptyInput() {
  return SchedulingInput(
    tasks: const [],
    window: SchedulingWindow(
      start: DateTime(2026, 6, 19, 9),
      end: DateTime(2026, 6, 19, 17),
    ),
  );
}

/// 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 Task.quickCapture(
    id: id,
    title: title,
    createdAt: createdAt,
  ).copyWith(
    status: TaskStatus.planned,
    durationMinutes: durationMinutes,
    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.high,
    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);
}

/// Runs the `sameScheduleAs` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
Matcher sameScheduleAs(Task expected) {
  return predicate<Task>((actual) {
    return actual.scheduledStart == expected.scheduledStart &&
        actual.scheduledEnd == expected.scheduledEnd &&
        actual.status == expected.status &&
        actual.type == expected.type;
  }, 'same schedule and state as ${expected.id}');
}
