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

part of '../../task_actions.dart';

/// Logs surprise completed work and repairs affected flexible tasks.
///
/// Surprise work is already done, so the new task is created as completed. When
/// it has a concrete interval, overlapping planned flexible tasks are moved by
/// the existing push scheduler with the surprise interval treated as blocked
/// time. Required visible and locked time is never moved.
class SurpriseTaskLogService {
  /// Creates a `SurpriseTaskLogService` 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 SurpriseTaskLogService({
    this.schedulingEngine = const SchedulingEngine(),
    this.accountingService = const TaskActivityAccountingService(),
    this.clock = const SystemClock(),
    this.idGenerator,
  });

  /// Scheduling dependency used to move overlapping flexible tasks.
  final SchedulingEngine schedulingEngine;

  /// Activity-derived statistics updater.
  final TaskActivityAccountingService accountingService;

  /// Clock boundary used when a log timestamp is not supplied.
  final Clock clock;

  /// Optional ID boundary for outer convenience entry points.
  final IdGenerator? idGenerator;

  /// Log one surprise task against a scheduling snapshot.
  SurpriseTaskLogResult log({
    required SurpriseTaskLogRequest request,
    required SchedulingInput input,
    DateTime? updatedAt,
    bool revealHiddenLockedDetails = false,
  }) {
    final now = updatedAt ?? clock.now();
    logger.debug(() => 'Logging surprise task. taskId=${request.id} '
        'taskCount=${input.tasks.length} hasStartedAt=${request.startedAt != null} '
        'hasDuration=${request.timeUsedMinutes != null} '
        'revealHiddenLockedDetails=$revealHiddenLockedDetails');
    final existingTask = _taskById(input.tasks, request.id);
    if (existingTask != null) {
      if (existingTask.type != TaskType.surprise) {
        logger.error(() =>
            'Surprise task log failed: id already used by another task type. '
            'taskId=${request.id} taskType=${existingTask.type.name}');
        throw ArgumentError.value(
          request.id,
          'request.id',
          'Surprise task id is already used by another task.',
        );
      }

      logger.warn(
          () => 'Duplicate surprise task log ignored. taskId=${request.id}');
      return SurpriseTaskLogResult(
        surpriseTask: existingTask,
        schedulingResult: SchedulingResult(
          tasks: input.tasks,
          operationCode: SchedulingOperationCode.logSurpriseTask,
          outcomeCode: SchedulingOutcomeCode.noOp,
          notices: [
            SchedulingNotice(
              'Surprise task already logged.',
              issueCode: SchedulingIssueCode.duplicateSurpriseLog,
              taskId: existingTask.id,
            ),
          ],
        ),
      );
    }

    final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now);
    final completedActivity = _surpriseCompletedActivity(
      task: rawSurpriseTask,
      operationId: request.id,
      occurredAt: now,
    );
    final accountedSurprise = accountingService.apply(
      task: rawSurpriseTask,
      activities: [completedActivity],
      lockedIntervals: _lockedIntervalsForAccounting(input),
    );
    final surpriseTask = accountedSurprise.task;
    final surpriseInterval = _occupyingIntervalForTask(surpriseTask);
    var currentTasks = <Task>[surpriseTask, ...input.tasks];

    if (surpriseInterval == null) {
      logger.debug(() => 'Surprise task logged without occupying interval. '
          'taskId=${surpriseTask.id} activityCount=1');
      return SurpriseTaskLogResult(
        surpriseTask: surpriseTask,
        schedulingResult: SchedulingResult(
          tasks: List<Task>.unmodifiable(currentTasks),
          operationCode: SchedulingOperationCode.logSurpriseTask,
          outcomeCode: SchedulingOutcomeCode.success,
          notices: [
            SchedulingNotice('Surprise task logged.'),
          ],
        ),
        activities: [completedActivity],
      );
    }

    final requiredOverlaps = _requiredOverlaps(
      input: input,
      surpriseInterval: surpriseInterval,
    );
    final lockedOverlaps = _lockedOverlaps(
      input: input,
      surpriseInterval: surpriseInterval,
      revealHiddenLockedDetails: revealHiddenLockedDetails,
    );
    final flexibleTaskIds = _overlappingFlexibleTaskIds(
      input.movablePlannedFlexibleTasks,
      surpriseInterval,
    );
    final changes = <SchedulingChange>[];
    final movementNotices = <SchedulingNotice>[];
    var usedNormalPushRules = false;

    logger.debug(() =>
        'Surprise task repair scanning overlaps. taskId=${surpriseTask.id} '
        'requiredOverlapCount=${requiredOverlaps.length} '
        'lockedOverlapCount=${lockedOverlaps.length} '
        'flexibleOverlapCount=${flexibleTaskIds.length}');

    for (final taskId in flexibleTaskIds) {
      final currentTask = _taskById(currentTasks, taskId);
      final currentInterval =
          currentTask == null ? null : _scheduledIntervalForTask(currentTask);
      if (currentTask == null ||
          currentInterval == null ||
          !currentInterval.overlaps(surpriseInterval)) {
        logger.finer(() => 'Surprise task repair skipped flexible candidate. '
            'surpriseTaskId=${surpriseTask.id} taskId=$taskId '
            'hasCurrentTask=${currentTask != null} hasInterval=${currentInterval != null}');
        continue;
      }

      logger.finer(() => 'Surprise task repair pushing flexible overlap. '
          'surpriseTaskId=${surpriseTask.id} taskId=$taskId');
      final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
        input: SchedulingInput(
          tasks: currentTasks,
          window: input.window,
          lockedIntervals: input.lockedIntervals,
          requiredVisibleIntervals: input.requiredVisibleIntervals,
        ),
        taskId: taskId,
        updatedAt: now,
        countAsManualPush: false,
      );

      usedNormalPushRules = true;
      currentTasks = pushResult.tasks;
      changes.addAll(pushResult.changes);
      movementNotices.addAll(pushResult.notices);
    }

    final overlapNotices = requiredOverlaps
        .map(
          (overlap) => SchedulingNotice(
            'Surprise task overlaps required visible time.',
            type: SchedulingNoticeType.overlap,
            taskId: overlap.taskId,
            conflictCode:
                SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime,
            parameters: {
              'blockedStart': overlap.blockedInterval.start,
              'blockedEnd': overlap.blockedInterval.end,
            },
          ),
        )
        .toList(growable: false);

    logger.debug(() => 'Surprise task log finished. taskId=${surpriseTask.id} '
        'outcome=${requiredOverlaps.isEmpty ? SchedulingOutcomeCode.success.name : SchedulingOutcomeCode.conflict.name} '
        'changeCount=${changes.length} noticeCount=${movementNotices.length + overlapNotices.length + 1} '
        'requiredOverlapCount=${requiredOverlaps.length} lockedOverlapCount=${lockedOverlaps.length}');

    return SurpriseTaskLogResult(
      surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
      schedulingResult: SchedulingResult(
        tasks: List<Task>.unmodifiable(currentTasks),
        operationCode: SchedulingOperationCode.logSurpriseTask,
        outcomeCode: requiredOverlaps.isEmpty
            ? SchedulingOutcomeCode.success
            : SchedulingOutcomeCode.conflict,
        notices: List<SchedulingNotice>.unmodifiable([
          SchedulingNotice('Surprise task logged.'),
          ...movementNotices,
          ...overlapNotices,
        ]),
        changes: List<SchedulingChange>.unmodifiable(changes),
        overlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
      ),
      requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
      lockedOverlaps: List<TimeInterval>.unmodifiable(lockedOverlaps),
      activities: [completedActivity],
      usedNormalPushRules: usedNormalPushRules,
    );
  }

  /// Convenience outer-boundary wrapper that supplies id and creation time.
  SurpriseTaskLogResult logNew({
    required String title,
    required SchedulingInput input,
    DateTime? startedAt,
    int? timeUsedMinutes,
    String projectId = 'inbox',
    PriorityLevel? priority,
    RewardLevel reward = RewardLevel.notSet,
    DifficultyLevel difficulty = DifficultyLevel.notSet,
    DateTime? createdAt,
    DateTime? updatedAt,
    bool revealHiddenLockedDetails = false,
  }) {
    final generator = idGenerator;
    if (generator == null) {
      logger.error(() => 'Surprise task logNew failed: missing id generator.');
      throw StateError('Surprise task logging needs an id generator.');
    }

    final now = createdAt ?? updatedAt ?? clock.now();
    logger.debug(() =>
        'Logging new surprise task. hasTitle=${title.trim().isNotEmpty} '
        'hasStartedAt=${startedAt != null} hasDuration=${timeUsedMinutes != null} '
        'projectId=$projectId');

    return log(
      request: SurpriseTaskLogRequest(
        id: generator.nextId(),
        title: title,
        createdAt: now,
        startedAt: startedAt,
        timeUsedMinutes: timeUsedMinutes,
        projectId: projectId,
        priority: priority,
        reward: reward,
        difficulty: difficulty,
      ),
      input: input,
      updatedAt: updatedAt ?? now,
      revealHiddenLockedDetails: revealHiddenLockedDetails,
    );
  }

  /// Runs the `_createSurpriseTask` helper used inside this library.
  /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
  Task _createSurpriseTask(
    SurpriseTaskLogRequest request, {
    required DateTime updatedAt,
  }) {
    final trimmedTitle = request.title.trim();
    if (trimmedTitle.isEmpty) {
      logger.warn(() => 'Surprise task creation failed: blank title. '
          'taskId=${request.id}');
      throw ArgumentError.value(request.title, 'title', 'Title is required.');
    }

    final duration = request.timeUsedMinutes;
    final start = request.startedAt;
    final hasScheduledTime = start != null && duration != null && duration > 0;

    return Task(
      id: request.id,
      title: trimmedTitle,
      projectId: request.projectId,
      type: TaskType.surprise,
      status: TaskStatus.completed,
      priority: request.priority,
      reward: request.reward,
      difficulty: request.difficulty,
      durationMinutes: duration != null && duration > 0 ? duration : null,
      scheduledStart: hasScheduledTime ? start : null,
      scheduledEnd:
          hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
      actualStart: hasScheduledTime ? start : null,
      actualEnd:
          hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
      completedAt: updatedAt,
      createdAt: request.createdAt,
      updatedAt: updatedAt,
    );
  }
}

/// Top-level helper that performs the `_activitiesForPushDestination` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
List<TaskActivity> _activitiesForPushDestination({
  required PushDestination destination,
  required SchedulingInput input,
  required SchedulingResult result,
  required String taskId,
  required String operationId,
  required DateTime occurredAt,
  required Iterable<TaskActivity> existingActivities,
}) {
  if (result.outcomeCode != SchedulingOutcomeCode.success) {
    return const <TaskActivity>[];
  }

  final activities = <TaskActivity>[];
  for (final change in result.changes) {
    if (_hasDuplicateActivity(
      existingActivities: existingActivities,
      operationId: operationId,
      taskId: change.taskId,
    )) {
      continue;
    }

    final task = _taskById(input.tasks, change.taskId);
    if (task == null) {
      continue;
    }

    final code = _activityCodeForPushChange(
      destination: destination,
      isSelectedTask: change.taskId == taskId,
    );
    activities.add(
      TaskActivity(
        id: _activityId(operationId, change.taskId, code),
        operationId: operationId,
        code: code,
        taskId: change.taskId,
        projectId: task.projectId,
        occurredAt: occurredAt,
        metadata: {
          'previousStatus': task.status.name,
          'nextStatus': code == TaskActivityCode.movedToBacklog
              ? TaskStatus.backlog.name
              : task.status.name,
          'previousStart': change.previousStart,
          'previousEnd': change.previousEnd,
          'nextStart': change.nextStart,
          'nextEnd': change.nextEnd,
          'destination': destination.name,
        },
      ),
    );
  }

  return List<TaskActivity>.unmodifiable(activities);
}

/// Top-level helper that performs the `_activityCodeForPushChange` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
TaskActivityCode _activityCodeForPushChange({
  required PushDestination destination,
  required bool isSelectedTask,
}) {
  if (destination == PushDestination.backlog) {
    return TaskActivityCode.movedToBacklog;
  }

  return isSelectedTask
      ? TaskActivityCode.manuallyPushed
      : TaskActivityCode.automaticallyPushed;
}

/// Top-level helper that performs the `_operationCodeForPushDestination` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
SchedulingOperationCode _operationCodeForPushDestination(
  PushDestination destination,
) {
  return switch (destination) {
    PushDestination.nextAvailableSlot =>
      SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
    PushDestination.tomorrowTopOfQueue =>
      SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
    PushDestination.backlog =>
      SchedulingOperationCode.moveFlexibleTaskToBacklog,
  };
}

/// Top-level helper that performs the `_surpriseCompletedActivity` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
TaskActivity _surpriseCompletedActivity({
  required Task task,
  required String operationId,
  required DateTime occurredAt,
}) {
  return TaskActivity(
    id: _activityId(operationId, task.id, TaskActivityCode.completed),
    operationId: operationId,
    code: TaskActivityCode.completed,
    taskId: task.id,
    projectId: task.projectId,
    occurredAt: occurredAt,
    metadata: {
      'nextStatus': TaskStatus.completed.name,
      'completedAt': occurredAt,
      'actualStart': task.actualStart,
      'actualEnd': task.actualEnd,
      'durationMinutes': task.durationMinutes,
      'reward': task.reward.name,
      'difficulty': task.difficulty.name,
      if (task.reminderOverride != null)
        'reminderProfile': task.reminderOverride!.name,
      'pushesBeforeCompletion': 0,
      'source': 'surpriseLog',
    },
  );
}

/// Top-level helper that performs the `_lockedIntervalsForAccounting` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
List<TimeInterval> _lockedIntervalsForAccounting(SchedulingInput input) {
  return input.occupancyEntries
      .where(
        (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint,
      )
      .map((entry) => entry.interval)
      .whereType<TimeInterval>()
      .toList(growable: false);
}

/// Top-level helper that performs the `_hasDuplicateActivity` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
bool _hasDuplicateActivity({
  required Iterable<TaskActivity> existingActivities,
  required String operationId,
  required String taskId,
}) {
  for (final activity in existingActivities) {
    if (activity.operationId == operationId && activity.taskId == taskId) {
      return true;
    }
  }

  return false;
}

/// Top-level helper that performs the `_defaultOperationId` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _defaultOperationId(
    String actionName, String taskId, DateTime occurredAt) {
  return '$actionName:$taskId:${occurredAt.toIso8601String()}';
}

/// Top-level helper that performs the `_activityId` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _activityId(
  String operationId,
  String taskId,
  TaskActivityCode activityCode,
) {
  return '$operationId:$taskId:${activityCode.name}';
}

/// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) {
  for (final task in tasks) {
    if (task.id == taskId) {
      return task;
    }
  }

  return null;
}

/// Build an interval for a task, or null when it has no usable placement.
TimeInterval? _scheduledIntervalForTask(Task task) {
  final start = task.scheduledStart;
  final end = task.scheduledEnd;

  if (start == null || end == null || !start.isBefore(end)) {
    return null;
  }

  return TimeInterval(start: start, end: end, label: task.id);
}

/// Build an actual-first occupancy interval for completed/active task facts.
TimeInterval? _occupyingIntervalForTask(Task task) {
  final actualStart = task.actualStart;
  final actualEnd = task.actualEnd;

  if (actualStart != null &&
      actualEnd != null &&
      actualStart.isBefore(actualEnd)) {
    return TimeInterval(start: actualStart, end: actualEnd, label: task.id);
  }

  return _scheduledIntervalForTask(task);
}

/// Required visible overlaps with a surprise interval.
List<SchedulingOverlap> _requiredOverlaps({
  required SchedulingInput input,
  required TimeInterval surpriseInterval,
}) {
  final overlaps = <SchedulingOverlap>[];

  for (final occupancy in input.occupancyEntries) {
    final task = occupancy.task;
    final interval = occupancy.interval;
    final isRequiredOccupancy =
        occupancy.category == OccupancyCategory.requiredVisibleCommitment ||
            (occupancy.category == OccupancyCategory.activeWork &&
                task != null &&
                task.isRequiredVisible);

    if (task == null ||
        !isRequiredOccupancy ||
        interval == null ||
        !interval.overlaps(surpriseInterval)) {
      continue;
    }

    overlaps.add(
      SchedulingOverlap(
        taskId: task.id,
        taskInterval: interval,
        blockedInterval: surpriseInterval,
      ),
    );
  }

  return overlaps;
}

/// Locked overlaps with a surprise interval, kept hidden from normal notices.
List<TimeInterval> _lockedOverlaps({
  required SchedulingInput input,
  required TimeInterval surpriseInterval,
  required bool revealHiddenLockedDetails,
}) {
  return input.occupancyEntries
      .where(
        (occupancy) =>
            occupancy.category == OccupancyCategory.hiddenLockedConstraint,
      )
      .map((occupancy) => occupancy.interval)
      .whereType<TimeInterval>()
      .where((interval) => interval.overlaps(surpriseInterval))
      .map((interval) {
    if (revealHiddenLockedDetails) {
      return interval;
    }
    return TimeInterval(start: interval.start, end: interval.end);
  }).toList(growable: false);
}

/// Planned flexible task ids overlapping a surprise interval in timeline order.
List<String> _overlappingFlexibleTaskIds(
  List<Task> tasks,
  TimeInterval surpriseInterval,
) {
  final overlappingTasks = tasks.where((task) {
    if (task.status != TaskStatus.planned) {
      return false;
    }

    final interval = _scheduledIntervalForTask(task);
    return interval != null && interval.overlaps(surpriseInterval);
  }).toList(growable: false)
    ..sort((a, b) {
      final aStart = a.scheduledStart ?? surpriseInterval.end;
      final bStart = b.scheduledStart ?? surpriseInterval.end;

      return aStart.compareTo(bStart);
    });

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