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

part of '../../locked_time.dart';

/// Expands locked blocks and one-day overrides into concrete intervals.
///
/// This object is the boundary between human-friendly locked block definitions
/// and scheduler-friendly intervals. It retains the occurrence details for UI
/// while exposing [schedulingIntervals] for placement algorithms.
class LockedScheduleExpansion {
  /// Creates a `LockedScheduleExpansion` 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 LockedScheduleExpansion({
    required this.date,
    required this.occurrences,
  });

  /// Calendar date represented by this expansion, normalized to year/month/day.
  final CivilDate date;

  /// Concrete locked occurrences active on [date].
  final List<LockedBlockOccurrence> occurrences;

  /// Just the intervals the scheduler needs to avoid.
  List<TimeInterval> get schedulingIntervals {
    return List<TimeInterval>.unmodifiable(
      occurrences.map((occurrence) => occurrence.schedulingInterval),
    );
  }
}

/// Returns concrete locked-time occurrences for [date].
///
/// Expansion order:
/// 1. Sort overrides deterministically by creation time, then id.
/// 2. For each base block that occurs on the date, collect its overrides.
/// 3. Skip the occurrence if any remove override applies.
/// 4. Use the latest replacement override if one exists.
/// 5. Add one-off `add` overrides for the date.
/// 6. Sort occurrences by start time for predictable UI/scheduler behavior.
LockedScheduleExpansion expandLockedBlocksForDay({
  required List<LockedBlock> blocks,
  required List<LockedBlockOverride> overrides,
  required CivilDate date,
  required String timeZoneId,
  required TimeZoneResolver timeZoneResolver,
  TimeZoneResolutionOptions resolutionOptions =
      const TimeZoneResolutionOptions(),
}) {
  final occurrences = <LockedBlockOccurrence>[];
  final sortedOverrides = [...overrides]..sort((a, b) {
      final createdComparison = a.createdAt.compareTo(b.createdAt);
      if (createdComparison != 0) {
        return createdComparison;
      }

      return a.id.compareTo(b.id);
    });

  for (final block in blocks) {
    if (block.isArchived) {
      continue;
    }
    if (!_blockOccursOn(block, date)) {
      continue;
    }

    final blockOverrides = sortedOverrides
        .where(
          (override) =>
              override.appliesTo(blockId: block.id, occurrenceDate: date),
        )
        .toList(growable: false);

    if (blockOverrides.any(
      (override) => override.type == LockedBlockOverrideType.remove,
    )) {
      continue;
    }

    final replacement = _lastReplacementOverride(blockOverrides);

    occurrences.add(
      replacement == null
          ? _occurrenceFromBlock(
              block,
              date,
              timeZoneId: timeZoneId,
              timeZoneResolver: timeZoneResolver,
              resolutionOptions: resolutionOptions,
            )
          : _occurrenceFromReplacement(
              block: block,
              override: replacement,
              date: date,
              timeZoneId: timeZoneId,
              timeZoneResolver: timeZoneResolver,
              resolutionOptions: resolutionOptions,
            ),
    );
  }

  for (final override in sortedOverrides) {
    if (override.type != LockedBlockOverrideType.add ||
        !_sameDate(override.date, date)) {
      continue;
    }

    final occurrence = _occurrenceFromAddedOverride(
      override,
      date,
      timeZoneId: timeZoneId,
      timeZoneResolver: timeZoneResolver,
      resolutionOptions: resolutionOptions,
    );
    if (occurrence != null) {
      occurrences.add(occurrence);
    }
  }

  occurrences.sort((a, b) {
    final startComparison = a.interval.start.compareTo(b.interval.start);
    if (startComparison != 0) {
      return startComparison;
    }

    return a.name.compareTo(b.name);
  });

  return LockedScheduleExpansion(
    date: date,
    occurrences: List<LockedBlockOccurrence>.unmodifiable(occurrences),
  );
}

/// Return the last replacement override from an already sorted override list.
///
/// Later-created replacements win, which lets a user correct a one-day override
/// without deleting older history first.
LockedBlockOverride? _lastReplacementOverride(
  List<LockedBlockOverride> overrides,
) {
  for (final override in overrides.reversed) {
    if (override.type == LockedBlockOverrideType.replace) {
      return override;
    }
  }

  return null;
}

/// Convenience wrapper when callers only need scheduler intervals.
List<TimeInterval> lockedSchedulingIntervalsForDay({
  required List<LockedBlock> blocks,
  required List<LockedBlockOverride> overrides,
  required CivilDate date,
  required String timeZoneId,
  required TimeZoneResolver timeZoneResolver,
  TimeZoneResolutionOptions resolutionOptions =
      const TimeZoneResolutionOptions(),
}) {
  return expandLockedBlocksForDay(
    blocks: blocks,
    overrides: overrides,
    date: date,
    timeZoneId: timeZoneId,
    timeZoneResolver: timeZoneResolver,
    resolutionOptions: resolutionOptions,
  ).schedulingIntervals;
}

/// Returns [task] with locked-hour completion statistics applied when relevant.
///
/// This does not decide whether completion is allowed. It only records that a
/// completion overlapped locked time, which future reports can surface as a
/// boundary-leak signal.
Task trackCompletedDuringLockedHours({
  required Task task,
  required List<TimeInterval> lockedIntervals,
}) {
  final overlapMinutes = completedDuringLockedHoursMinutes(
    task: task,
    lockedIntervals: lockedIntervals,
  );

  if (overlapMinutes == 0) {
    return task;
  }

  return task.copyWith(
    stats: task.stats.incrementCompletedDuringLockedHours(overlapMinutes),
  );
}

/// Calculates how many scheduled task minutes overlap locked intervals.
///
/// Multiple locked intervals may overlap each other. To avoid double-counting,
/// intersections are merged before minutes are summed.
int completedDuringLockedHoursMinutes({
  required Task task,
  required List<TimeInterval> lockedIntervals,
}) {
  if (!_shouldTrackLockedHourCompletion(task)) {
    return 0;
  }

  final taskInterval = _scheduledIntervalForTask(task);
  if (taskInterval == null) {
    return 0;
  }

  final intersections = <TimeInterval>[];
  for (final lockedInterval in lockedIntervals) {
    if (!taskInterval.overlaps(lockedInterval)) {
      continue;
    }

    intersections.add(
      TimeInterval(
        start: _latest(taskInterval.start, lockedInterval.start),
        end: _earliest(taskInterval.end, lockedInterval.end),
      ),
    );
  }

  if (intersections.isEmpty) {
    return 0;
  }

  intersections.sort((a, b) => a.start.compareTo(b.start));

  var total = Duration.zero;
  var current = intersections.first;
  for (final interval in intersections.skip(1)) {
    if (interval.start.isAfter(current.end)) {
      total += current.duration;
      current = interval;
      continue;
    }

    current = TimeInterval(
      start: current.start,
      end: _latest(current.end, interval.end),
    );
  }

  total += current.duration;

  return total.inMinutes;
}

/// Whether a base block should produce an occurrence on [date].
bool _blockOccursOn(LockedBlock block, CivilDate date) {
  final recurrence = block.recurrence;
  if (recurrence != null) {
    return recurrence.occursOn(date);
  }

  return _sameDate(block.date!, date);
}

/// Convert a base block rule into a concrete occurrence for [date].
LockedBlockOccurrence _occurrenceFromBlock(
  LockedBlock block,
  CivilDate date, {
  required String timeZoneId,
  required TimeZoneResolver timeZoneResolver,
  TimeZoneResolutionOptions resolutionOptions =
      const TimeZoneResolutionOptions(),
}) {
  return LockedBlockOccurrence(
    lockedBlockId: block.id,
    name: block.name,
    interval: _resolvedInterval(
      date: date,
      startTime: block.startTime,
      endTime: block.endTime,
      label: block.name,
      timeZoneId: timeZoneId,
      timeZoneResolver: timeZoneResolver,
      resolutionOptions: resolutionOptions,
    ),
    hiddenByDefault: block.hiddenByDefault,
    projectId: block.projectId,
  );
}

/// Convert a replacement override into a concrete occurrence.
///
/// Missing override fields fall back to the original block, so a one-day change
/// can replace only the time, only the name, or only the project association.
LockedBlockOccurrence _occurrenceFromReplacement({
  required LockedBlock block,
  required LockedBlockOverride override,
  required CivilDate date,
  required String timeZoneId,
  required TimeZoneResolver timeZoneResolver,
  TimeZoneResolutionOptions resolutionOptions =
      const TimeZoneResolutionOptions(),
}) {
  final startTime = override.startTime ?? block.startTime;
  final endTime = override.endTime ?? block.endTime;
  final name = override.name ?? block.name;

  return LockedBlockOccurrence(
    lockedBlockId: block.id,
    overrideId: override.id,
    name: name,
    interval: _resolvedInterval(
      date: date,
      startTime: startTime,
      endTime: endTime,
      label: name,
      timeZoneId: timeZoneId,
      timeZoneResolver: timeZoneResolver,
      resolutionOptions: resolutionOptions,
    ),
    hiddenByDefault: override.hiddenByDefault,
    projectId: override.projectId ?? block.projectId,
  );
}

/// Convert an add override into a concrete occurrence when it is complete.
LockedBlockOccurrence? _occurrenceFromAddedOverride(
  LockedBlockOverride override,
  CivilDate date, {
  required String timeZoneId,
  required TimeZoneResolver timeZoneResolver,
  TimeZoneResolutionOptions resolutionOptions =
      const TimeZoneResolutionOptions(),
}) {
  final interval = override.intervalForDate(
    targetDate: date,
    timeZoneId: timeZoneId,
    timeZoneResolver: timeZoneResolver,
    resolutionOptions: resolutionOptions,
  );
  final name = override.name;

  if (interval == null || name == null) {
    return null;
  }

  return LockedBlockOccurrence(
    overrideId: override.id,
    name: name,
    interval: TimeInterval(
      start: interval.start,
      end: interval.end,
      label: name,
    ),
    hiddenByDefault: override.hiddenByDefault,
    projectId: override.projectId,
  );
}

/// Whether this task state/type should be checked for locked-hour completion.
bool _shouldTrackLockedHourCompletion(Task task) {
  return task.status == TaskStatus.completed || task.type == TaskType.surprise;
}

/// Build a valid scheduled interval for a task, or null if the task is unplaced.
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);
}

/// Return whichever timestamp is earlier.
DateTime _earliest(DateTime first, DateTime second) {
  return first.isBefore(second) ? first : second;
}

/// Return whichever timestamp is later.
DateTime _latest(DateTime first, DateTime second) {
  return first.isAfter(second) ? first : second;
}

/// Top-level helper that performs the `_resolvedInterval` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
TimeInterval _resolvedInterval({
  required CivilDate date,
  required ClockTime startTime,
  required ClockTime endTime,
  required String? label,
  required String timeZoneId,
  required TimeZoneResolver timeZoneResolver,
  required TimeZoneResolutionOptions resolutionOptions,
}) {
  final start = timeZoneResolver.resolve(
    date: date,
    wallTime: startTime,
    timeZoneId: timeZoneId,
    options: resolutionOptions,
  );
  final end = timeZoneResolver.resolve(
    date: date,
    wallTime: endTime,
    timeZoneId: timeZoneId,
    options: resolutionOptions,
  );

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

/// Top-level helper that performs the `_sameDate` 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 _sameDate(CivilDate first, CivilDate second) => first == second;

/// Top-level helper that performs the `_requiredLockedString` 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 _requiredLockedString(
  String value,
  String name,
  DomainValidationCode code,
) {
  final trimmed = value.trim();
  if (trimmed.isEmpty) {
    throw DomainValidationException(
      code: code,
      invalidValue: value,
      name: name,
      message: '$name cannot be blank.',
    );
  }
  return trimmed;
}

/// Top-level helper that performs the `_nullableLockedString` 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? _nullableLockedString(
  String? value,
  String name,
  DomainValidationCode code,
) {
  if (value == null) {
    return null;
  }
  final trimmed = value.trim();
  if (trimmed.isEmpty) {
    throw DomainValidationException(
      code: code,
      invalidValue: value,
      name: name,
      message: '$name cannot be blank.',
    );
  }
  return trimmed;
}

/// Top-level helper that performs the `_clockTimeIsBefore` 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 _clockTimeIsBefore(ClockTime start, ClockTime end) {
  if (start.hour != end.hour) {
    return start.hour < end.hour;
  }
  return start.minute < end.minute;
}

/// Top-level helper that performs the `_validateOverrideShape` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
void _validateOverrideShape({
  required LockedBlockOverrideType type,
  required String? lockedBlockId,
  required String? name,
  required ClockTime? startTime,
  required ClockTime? endTime,
}) {
  switch (type) {
    case LockedBlockOverrideType.remove:
      if (lockedBlockId == null ||
          name != null ||
          startTime != null ||
          endTime != null) {
        throw DomainValidationException(
          code: DomainValidationCode.invalidLockedOverride,
          invalidValue: type,
          name: 'LockedBlockOverride.remove',
          message: 'Remove overrides only reference a locked block and date.',
        );
      }
    case LockedBlockOverrideType.replace:
      if (lockedBlockId == null) {
        throw DomainValidationException(
          code: DomainValidationCode.invalidLockedOverride,
          invalidValue: type,
          name: 'lockedBlockId',
          message: 'Replace overrides must reference a locked block.',
        );
      }
      _validateOverrideInterval(startTime: startTime, endTime: endTime);
    case LockedBlockOverrideType.add:
      if (lockedBlockId != null || name == null) {
        throw DomainValidationException(
          code: DomainValidationCode.invalidLockedOverride,
          invalidValue: type,
          name: 'LockedBlockOverride.add',
          message:
              'Add overrides need a name and cannot reference a base block.',
        );
      }
      if (startTime == null || endTime == null) {
        throw DomainValidationException(
          code: DomainValidationCode.invalidLockedOverride,
          invalidValue: type,
          name: 'startTime/endTime',
          message: 'Add overrides need both start and end time.',
        );
      }
      _validateOverrideInterval(startTime: startTime, endTime: endTime);
  }
}

/// Top-level helper that performs the `_validateOverrideInterval` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
void _validateOverrideInterval({
  required ClockTime? startTime,
  required ClockTime? endTime,
}) {
  if ((startTime == null) != (endTime == null)) {
    throw DomainValidationException(
      code: DomainValidationCode.invalidLockedOverride,
      invalidValue: {'startTime': startTime, 'endTime': endTime},
      name: 'startTime/endTime',
      message: 'Override start and end time must both be present or absent.',
    );
  }
  if (startTime != null &&
      endTime != null &&
      !_clockTimeIsBefore(startTime, endTime)) {
    throw DomainValidationException(
      code: DomainValidationCode.invalidLockedOverride,
      invalidValue: {'startTime': startTime, 'endTime': endTime},
      name: 'startTime/endTime',
      message: 'Override end time must be after start time.',
    );
  }
}
