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

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

import 'dart:math';

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('Structured scheduling outcomes', () {
    final now = DateTime(2026, 6, 19, 8);
    const engine = SchedulingEngine();

    test('not-found invalid-state no-slot overflow and no-op paths use codes',
        () {
      final window = dayWindow();
      final backlog = backlogTask(
        id: 'backlog',
        createdAt: now,
        durationMinutes: 60,
      );
      final planned = flexibleTask(
        id: 'planned',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );

      final notFound = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(tasks: [backlog], window: window),
        taskId: 'missing',
        updatedAt: now,
      );
      final invalid = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(tasks: [planned], window: window),
        taskId: 'planned',
        updatedAt: now,
      );
      final noSlot = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: SchedulingInput(
          tasks: [backlog],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 9, 30),
          ),
        ),
        taskId: 'backlog',
        updatedAt: now,
      );
      final overflow = engine.rollOverUnfinishedFlexibleTasks(
        input: SchedulingInput(
          tasks: [
            flexibleTask(
              id: 'source',
              createdAt: now,
              start: DateTime(2026, 6, 18, 9),
              durationMinutes: 60,
            ),
          ],
          window: SchedulingWindow(
            start: DateTime(2026, 6, 19, 9),
            end: DateTime(2026, 6, 19, 9, 30),
          ),
        ),
        sourceWindow: SchedulingWindow(
          start: DateTime(2026, 6, 18),
          end: DateTime(2026, 6, 19),
        ),
        updatedAt: now,
      );
      final noOp = engine.rollOverUnfinishedFlexibleTasks(
        input: SchedulingInput(tasks: const [], window: window),
        updatedAt: now,
      );

      expect(notFound.outcomeCode, SchedulingOutcomeCode.notFound);
      expect(
          notFound.notices.single.issueCode, SchedulingIssueCode.taskNotFound);
      expect(invalid.outcomeCode, SchedulingOutcomeCode.invalidState);
      expect(invalid.notices.single.issueCode,
          SchedulingIssueCode.invalidTaskState);
      expect(noSlot.outcomeCode, SchedulingOutcomeCode.noSlot);
      expect(
          noSlot.notices.single.issueCode, SchedulingIssueCode.noAvailableSlot);
      expect(overflow.outcomeCode, SchedulingOutcomeCode.overflow);
      expect(
        overflow.notices.single.issueCode,
        SchedulingIssueCode.unfinishedTasksCouldNotFit,
      );
      expect(noOp.outcomeCode, SchedulingOutcomeCode.noOp);
      expect(
        noOp.notices.single.issueCode,
        SchedulingIssueCode.noUnfinishedFlexibleTasks,
      );
    });

    test('conflict paths use conflict codes', () {
      final flexible = flexibleTask(
        id: 'flexible',
        createdAt: now,
        start: DateTime(2026, 6, 19, 9),
        durationMinutes: 30,
      );
      final analysis = engine.analyzeSchedule(
        SchedulingInput(
          tasks: [flexible],
          window: dayWindow(),
          lockedIntervals: [
            TimeInterval(
              start: DateTime(2026, 6, 19, 9, 15),
              end: DateTime(2026, 6, 19, 9, 45),
            ),
          ],
        ),
      );

      expect(analysis.operationCode, SchedulingOperationCode.analyzeSchedule);
      expect(analysis.outcomeCode, SchedulingOutcomeCode.conflict);
      expect(
        analysis.notices.single.conflictCode,
        SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
      );
    });
  });

  group('Scheduling invariants', () {
    final now = DateTime(2026, 6, 19, 8);
    const engine = SchedulingEngine();

    for (final seed in [1301, 1302, 1303, 1304, 1305, 1306]) {
      test('fixed-seed insertion invariants seed $seed', () {
        final scenario = randomInsertionScenario(seed: seed, createdAt: now);

        try {
          final result = engine.insertBacklogTaskIntoNextAvailableSlot(
            input: scenario.input,
            taskId: scenario.backlogTaskId,
            updatedAt: now,
          );

          assertTaskIdsUnique(result.tasks);
          assertIntervalsValid(result.tasks);

          if (result.outcomeCode == SchedulingOutcomeCode.success) {
            assertNoMovableTaskOverlapsBlockers(
              resultTasks: result.tasks,
              originalInput: scenario.input,
            );
            assertImmovablePlacementsUnchanged(
              before: scenario.input,
              afterTasks: result.tasks,
            );
            assertFlexibleRelativeOrderPreserved(
              beforeTasks: scenario.input.tasks,
              afterTasks: result.tasks,
            );
          } else {
            expect(result.outcomeCode, SchedulingOutcomeCode.noSlot);
            expect(result.changes, isEmpty);
            assertTaskSchedulesUnchanged(
              beforeTasks: scenario.input.tasks,
              afterTasks: result.tasks,
            );
          }
        } catch (error, stackTrace) {
          fail(
            'seed=$seed scenario=${scenario.describe()}\n$error\n$stackTrace',
          );
        }
      });
    }

    test('idempotent surprise replay does not add changes', () {
      const service = SurpriseTaskLogService();
      final first = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(tasks: const [], window: dayWindow()),
        updatedAt: now,
      );
      final replay = service.log(
        request: SurpriseTaskLogRequest(
          id: 'surprise',
          title: 'Unplanned call',
          createdAt: now,
          startedAt: DateTime(2026, 6, 19, 9),
          timeUsedMinutes: 20,
        ),
        input: SchedulingInput(
          tasks: first.schedulingResult.tasks,
          window: dayWindow(),
        ),
        updatedAt: now,
      );

      expect(replay.schedulingResult.outcomeCode, SchedulingOutcomeCode.noOp);
      expect(replay.schedulingResult.changes, isEmpty);
      expect(
        replay.schedulingResult.notices.single.issueCode,
        SchedulingIssueCode.duplicateSurpriseLog,
      );
    });

    test('large single-day insertion stays inside practical performance guard',
        () {
      final scenario = largeInsertionScenario(createdAt: now);
      final stopwatch = Stopwatch()..start();

      final result = engine.insertBacklogTaskIntoNextAvailableSlot(
        input: scenario.input,
        taskId: scenario.backlogTaskId,
        updatedAt: now,
      );

      stopwatch.stop();

      expect(result.outcomeCode, SchedulingOutcomeCode.success);
      assertTaskIdsUnique(result.tasks);
      assertNoMovableTaskOverlapsBlockers(
        resultTasks: result.tasks,
        originalInput: scenario.input,
      );
      // This is a regression guard for a large but realistic single-day queue,
      // not a microbenchmark promise.
      expect(
        stopwatch.elapsedMilliseconds,
        lessThan(2000),
        reason: 'elapsed=${stopwatch.elapsedMilliseconds}ms',
      );
    });
  });
}

/// Runs the `dayWindow` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
SchedulingWindow dayWindow() {
  return SchedulingWindow(
    start: DateTime(2026, 6, 19, 9),
    end: DateTime(2026, 6, 19, 17),
  );
}

/// Runs the `randomInsertionScenario` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
InsertionScenario randomInsertionScenario({
  required int seed,
  required DateTime createdAt,
}) {
  final random = Random(seed);
  final window = dayWindow();
  final tasks = <Task>[];
  final lockedIntervals = <TimeInterval>[];
  final backlog = backlogTask(
    id: 'backlog-$seed',
    createdAt: createdAt,
    durationMinutes: 10 + random.nextInt(16),
  );
  tasks.add(backlog);

  for (var index = 0; index < 3; index += 1) {
    final startMinute = random.nextInt(420);
    final duration = 15 + random.nextInt(30);
    final start = window.start.add(Duration(minutes: startMinute));
    final end = start.add(Duration(minutes: duration));
    if (end.isBefore(window.end) || end.isAtSameMomentAs(window.end)) {
      lockedIntervals.add(TimeInterval(start: start, end: end));
    }
  }

  var cursor = window.start.add(Duration(minutes: random.nextInt(20)));
  for (var index = 0; index < 8; index += 1) {
    cursor = cursor.add(Duration(minutes: random.nextInt(20)));
    final duration = 10 + random.nextInt(20);
    final end = cursor.add(Duration(minutes: duration));
    if (end.isAfter(window.end)) {
      break;
    }
    tasks.add(
      flexibleTask(
        id: 'flex-$seed-$index',
        createdAt: createdAt,
        start: cursor,
        durationMinutes: duration,
      ),
    );
    cursor = end;
  }

  tasks.add(
    fixedTask(
      id: 'required-$seed',
      type: random.nextBool() ? TaskType.critical : TaskType.inflexible,
      createdAt: createdAt,
      start: DateTime(2026, 6, 19, 15),
      durationMinutes: 30,
    ),
  );

  return InsertionScenario(
    seed: seed,
    backlogTaskId: backlog.id,
    input: SchedulingInput(
      tasks: tasks,
      window: window,
      lockedIntervals: lockedIntervals,
    ),
  );
}

/// Runs the `largeInsertionScenario` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
InsertionScenario largeInsertionScenario({required DateTime createdAt}) {
  final window = SchedulingWindow(
    start: DateTime(2026, 6, 19, 6),
    end: DateTime(2026, 6, 20, 6),
  );
  final backlog = backlogTask(
    id: 'large-backlog',
    createdAt: createdAt,
    durationMinutes: 1,
  );
  final tasks = <Task>[backlog];
  var cursor = window.start.add(const Duration(minutes: 30));

  for (var index = 0; index < 350; index += 1) {
    tasks.add(
      flexibleTask(
        id: 'large-flex-$index',
        createdAt: createdAt,
        start: cursor,
        durationMinutes: 2,
      ),
    );
    cursor = cursor.add(const Duration(minutes: 3));
  }

  final lockedIntervals = <TimeInterval>[
    for (var hour = 8; hour < 20; hour += 3)
      TimeInterval(
        start: DateTime(2026, 6, 19, hour),
        end: DateTime(2026, 6, 19, hour, 10),
      ),
  ];

  return InsertionScenario(
    seed: 0,
    backlogTaskId: backlog.id,
    input: SchedulingInput(
      tasks: tasks,
      window: window,
      lockedIntervals: lockedIntervals,
    ),
  );
}

/// Runs the `assertTaskIdsUnique` 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 assertTaskIdsUnique(List<Task> tasks) {
  expect(tasks.map((task) => task.id).toSet().length, tasks.length);
}

/// Runs the `assertIntervalsValid` 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 assertIntervalsValid(List<Task> tasks) {
  for (final task in tasks) {
    final scheduledStart = task.scheduledStart;
    final scheduledEnd = task.scheduledEnd;
    expect(scheduledStart == null, scheduledEnd == null, reason: task.id);
    if (scheduledStart != null && scheduledEnd != null) {
      expect(scheduledStart.isBefore(scheduledEnd), isTrue, reason: task.id);
    }

    final actualStart = task.actualStart;
    final actualEnd = task.actualEnd;
    expect(actualStart == null, actualEnd == null, reason: task.id);
    if (actualStart != null && actualEnd != null) {
      expect(actualStart.isBefore(actualEnd), isTrue, reason: task.id);
    }
  }
}

/// Runs the `assertNoMovableTaskOverlapsBlockers` 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 assertNoMovableTaskOverlapsBlockers({
  required List<Task> resultTasks,
  required SchedulingInput originalInput,
}) {
  final resultInput = SchedulingInput(
    tasks: resultTasks,
    window: originalInput.window,
    lockedIntervals: originalInput.lockedIntervals,
    requiredVisibleIntervals: originalInput.requiredVisibleIntervals,
  );

  for (final task in resultInput.movablePlannedFlexibleTasks) {
    final interval = scheduledInterval(task);
    if (interval == null || !resultInput.window.contains(interval)) {
      continue;
    }
    for (final blocker in resultInput.blockedIntervals) {
      expect(interval.overlaps(blocker), isFalse, reason: task.id);
    }
  }
}

/// Runs the `assertImmovablePlacementsUnchanged` 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 assertImmovablePlacementsUnchanged({
  required SchedulingInput before,
  required List<Task> afterTasks,
}) {
  final afterById = {for (final task in afterTasks) task.id: task};

  for (final entry in before.occupancyEntries.where((entry) {
    return entry.task != null && entry.isImmovable;
  })) {
    final beforeTask = entry.task!;
    final afterTask = afterById[beforeTask.id];
    expect(afterTask, isNotNull, reason: beforeTask.id);
    expect(afterTask!.scheduledStart, beforeTask.scheduledStart,
        reason: beforeTask.id);
    expect(afterTask.scheduledEnd, beforeTask.scheduledEnd,
        reason: beforeTask.id);
    expect(afterTask.actualStart, beforeTask.actualStart,
        reason: beforeTask.id);
    expect(afterTask.actualEnd, beforeTask.actualEnd, reason: beforeTask.id);
  }
}

/// Runs the `assertFlexibleRelativeOrderPreserved` 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 assertFlexibleRelativeOrderPreserved({
  required List<Task> beforeTasks,
  required List<Task> afterTasks,
}) {
  final beforeOrder = plannedFlexibleIdsByStart(beforeTasks);
  final afterOrder = plannedFlexibleIdsByStart(afterTasks)
      .where(beforeOrder.contains)
      .toList(growable: false);

  expect(afterOrder, beforeOrder);
}

/// Runs the `plannedFlexibleIdsByStart` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
List<String> plannedFlexibleIdsByStart(List<Task> tasks) {
  final planned = tasks.where((task) {
    return task.type == TaskType.flexible &&
        task.status == TaskStatus.planned &&
        task.scheduledStart != null;
  }).toList(growable: false)
    ..sort(
      (left, right) => left.scheduledStart!.compareTo(right.scheduledStart!),
    );

  return planned.map((task) => task.id).toList(growable: false);
}

/// Runs the `assertTaskSchedulesUnchanged` 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 assertTaskSchedulesUnchanged({
  required List<Task> beforeTasks,
  required List<Task> afterTasks,
}) {
  expect(afterTasks.map((task) => task.id), beforeTasks.map((task) => task.id));

  final afterById = {for (final task in afterTasks) task.id: task};
  for (final before in beforeTasks) {
    final after = afterById[before.id];
    expect(after, isNotNull, reason: before.id);
    expect(after!.status, before.status, reason: before.id);
    expect(after.scheduledStart, before.scheduledStart, reason: before.id);
    expect(after.scheduledEnd, before.scheduledEnd, reason: before.id);
    expect(after.actualStart, before.actualStart, reason: before.id);
    expect(after.actualEnd, before.actualEnd, reason: before.id);
  }
}

/// Runs the `scheduledInterval` top-level operation.
/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior.
TimeInterval? scheduledInterval(Task task) {
  final start = task.scheduledStart;
  final end = task.scheduledEnd;
  if (start == null || end == null) {
    return null;
  }
  return TimeInterval(start: start, end: end, label: task.id);
}

/// 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 DateTime createdAt,
  required int durationMinutes,
}) {
  return Task.quickCapture(
    id: id,
    title: id,
    createdAt: createdAt,
  ).copyWith(durationMinutes: durationMinutes);
}

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

/// Represents `InsertionScenario` within the pure scheduler domain package.
/// The type groups related data and behavior behind a named API so readers can understand this part of the scheduler without relying on tribal knowledge about the surrounding files.
class InsertionScenario {
  /// Creates a `InsertionScenario` instance with the values required by its invariants.
  /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
  const InsertionScenario({
    required this.seed,
    required this.backlogTaskId,
    required this.input,
  });

  /// Stores the `seed` value for this object.
  /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
  final int seed;

  /// Stores the `backlogTaskId` value for this object.
  /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
  final String backlogTaskId;

  /// Stores the `input` value for this object.
  /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
  final SchedulingInput input;

  /// Performs the `describe` behavior for this scheduler component.
  /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
  String describe() {
    return 'seed=$seed tasks=${input.tasks.length} '
        'locked=${input.lockedIntervals.length} backlog=$backlogTaskId';
  }
}
