// Locked-time modeling and expansion.
//
// Locked time is anything the flexible scheduler should treat as unavailable:
// work, appointments, streams, sleep boundaries, relationship blocks, or one-off
// interruptions. This file converts human-friendly locked block definitions into
// concrete `TimeInterval`s that the scheduler can avoid.

import 'models.dart';
import 'time_contracts.dart';

/// Weekday value using DateTime's Monday-first convention.
///
/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`.
/// This enum wraps those integers so the rest of the code can talk in readable
/// names while still comparing directly to [DateTime.weekday].
enum LockedWeekday {
  monday(DateTime.monday),
  tuesday(DateTime.tuesday),
  wednesday(DateTime.wednesday),
  thursday(DateTime.thursday),
  friday(DateTime.friday),
  saturday(DateTime.saturday),
  sunday(DateTime.sunday);

  const LockedWeekday(this.dateTimeValue);

  /// Matching [DateTime.weekday] integer value.
  final int dateTimeValue;
}

/// Backwards-compatible name for explicit wall-clock time.
typedef ClockTime = WallTime;

/// Recurrence rule for locked time.
///
/// The current starter implementation only supports weekly recurrence because
/// that covers the product's fixed work/stream/relationship blocks. This object
/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence
/// rules can be added later without changing the rest of the locked-time model.
class LockedBlockRecurrence {
  LockedBlockRecurrence.weekly({
    required Set<LockedWeekday> weekdays,
  }) : weekdays = Set<LockedWeekday>.unmodifiable(weekdays) {
    if (weekdays.isEmpty) {
      throw DomainValidationException(
        code: DomainValidationCode.emptyRecurrence,
        invalidValue: weekdays,
        name: 'weekdays',
        message: 'Recurring locked blocks need at least one weekday.',
      );
    }
  }

  /// Weekdays when this recurrence should produce an occurrence.
  final Set<LockedWeekday> weekdays;

  /// Whether this recurrence applies to [date].
  bool occursOn(CivilDate date) {
    return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
  }
}

/// Scheduling constraint that reserves time without becoming a task card.
///
/// Locked blocks represent time the flexible scheduler should avoid: work hours,
/// appointments, sleep boundaries, streams, relationship blocks, or any other
/// commitment that should not be automatically rearranged. They are modeled
/// separately from normal tasks because the UI may show them as subtle overlays
/// rather than actionable task cards.
///
/// A block is either:
/// - one-off, with [date] set and [recurrence] null; or
/// - recurring, with [recurrence] set and [date] usually null.
class LockedBlock {
  LockedBlock({
    required String id,
    required String name,
    required this.startTime,
    required this.endTime,
    required this.createdAt,
    required this.updatedAt,
    this.date,
    this.recurrence,
    this.hiddenByDefault = true,
    this.projectId,
    this.archivedAt,
  })  : id =
            _requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
        name = _requiredLockedString(
            name, 'name', DomainValidationCode.blankName) {
    if (recurrence == null && date == null) {
      throw DomainValidationException(
        code: DomainValidationCode.invalidLockedBlock,
        invalidValue: null,
        name: 'date',
        message: 'One-off locked blocks need a date.',
      );
    }
    if (!_clockTimeIsBefore(startTime, endTime)) {
      throw DomainValidationException(
        code: DomainValidationCode.invalidLockedBlock,
        invalidValue: {'startTime': startTime, 'endTime': endTime},
        name: 'startTime/endTime',
        message: 'Locked block end time must be after start time.',
      );
    }
  }

  /// Stable id for persistence and one-day overrides.
  final String id;

  /// User-facing label, such as `Work`, `Stream`, or `Relationship block`.
  final String name;

  /// Start time-of-day. Combined with a date during expansion.
  final ClockTime startTime;

  /// End time-of-day. Combined with a date during expansion.
  final ClockTime endTime;

  /// Calendar date for one-off locked blocks. Recurring blocks leave this null.
  final CivilDate? date;

  /// Optional weekly recurrence. Null means this is a one-off block.
  final LockedBlockRecurrence? recurrence;

  /// Whether UI should keep this block visually quiet by default. Scheduling still
  /// treats hidden blocks as blocked time.
  final bool hiddenByDefault;

  /// Optional project/category association for UI colors or reports.
  final String? projectId;

  /// Creation timestamp for persistence/auditing.
  final DateTime createdAt;

  /// Last update timestamp for persistence/auditing.
  final DateTime updatedAt;

  /// When present, this block no longer creates future occurrences.
  ///
  /// One-day overrides are intentionally stored separately and are not deleted
  /// when a block is archived.
  final DateTime? archivedAt;

  /// Convenience check for whether this block expands through recurrence.
  bool get isRecurring => recurrence != null;

  /// Whether this block has been archived.
  bool get isArchived => archivedAt != null;

  /// Return a copy with selected locked-block details changed.
  LockedBlock copyWith({
    String? id,
    String? name,
    ClockTime? startTime,
    ClockTime? endTime,
    CivilDate? date,
    LockedBlockRecurrence? recurrence,
    bool? hiddenByDefault,
    String? projectId,
    DateTime? createdAt,
    DateTime? updatedAt,
    DateTime? archivedAt,
    bool clearArchivedAt = false,
  }) {
    return LockedBlock(
      id: id ?? this.id,
      name: name ?? this.name,
      startTime: startTime ?? this.startTime,
      endTime: endTime ?? this.endTime,
      date: date ?? this.date,
      recurrence: recurrence ?? this.recurrence,
      hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault,
      projectId: projectId ?? this.projectId,
      createdAt: createdAt ?? this.createdAt,
      updatedAt: updatedAt ?? this.updatedAt,
      archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt),
    );
  }
}

/// Concrete locked-time occurrence for one calendar day.
///
/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a
/// specific date. The scheduler only needs occurrences/time intervals, while UI
/// can use ids and visibility flags to explain where the blocked time came from.
class LockedBlockOccurrence {
  const LockedBlockOccurrence({
    required this.name,
    required this.interval,
    required this.hiddenByDefault,
    this.lockedBlockId,
    this.overrideId,
    this.projectId,
  });

  /// Display/debug label for this occurrence.
  final String name;

  /// Concrete start/end on one calendar date.
  final TimeInterval interval;

  /// Visibility hint for UI; does not affect scheduling.
  final bool hiddenByDefault;

  /// Source recurring/one-off block id, when this came from a base block.
  final String? lockedBlockId;

  /// Source override id, when this was replaced or added for one date.
  final String? overrideId;

  /// Optional project/category association for UI colors or reports.
  final String? projectId;

  /// Scheduler-facing interval. Visibility only affects future UI overlays.
  ///
  /// A fresh [TimeInterval] is returned with [name] as the label so scheduling
  /// notices/debugging can identify the blocked source without depending on the
  /// richer occurrence object.
  TimeInterval get schedulingInterval {
    return TimeInterval(
      start: interval.start,
      end: interval.end,
      label: name,
    );
  }
}

/// Type of one-day override applied to locked time.
///
/// Overrides let the app handle holidays, one-off appointments, or changed work
/// hours without editing the base recurring rule.
enum LockedBlockOverrideType {
  /// Suppress one occurrence of a recurring locked block.
  remove,

  /// Replace one occurrence with different details.
  replace,

  /// Add a one-off locked occurrence that has no base recurring block.
  add,
}

/// One-day change to locked time that leaves the base recurrence unchanged.
///
/// Overrides are applied during expansion for a single date only. This is safer
/// than modifying the recurring block because the normal schedule remains intact
/// for every other day.
///
/// Use the named factories to create valid override shapes:
/// - [remove] references a base block and suppresses that occurrence.
/// - [replace] references a base block and swaps its details for one day.
/// - [add] creates an extra locked occurrence that has no base block.
class LockedBlockOverride {
  LockedBlockOverride({
    required String id,
    required this.date,
    required this.type,
    required this.createdAt,
    required this.updatedAt,
    String? lockedBlockId,
    String? name,
    this.startTime,
    this.endTime,
    this.hiddenByDefault = true,
    this.projectId,
  })  : id =
            _requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
        lockedBlockId = _nullableLockedString(
          lockedBlockId,
          'lockedBlockId',
          DomainValidationCode.blankStableId,
        ),
        name = _nullableLockedString(
          name,
          'name',
          DomainValidationCode.blankName,
        ) {
    _validateOverrideShape(
      type: type,
      lockedBlockId: this.lockedBlockId,
      name: this.name,
      startTime: startTime,
      endTime: endTime,
    );
  }

  /// Removes a recurring occurrence for one date.
  factory LockedBlockOverride.remove({
    required String id,
    required String lockedBlockId,
    required CivilDate date,
    required DateTime createdAt,
    DateTime? updatedAt,
  }) {
    return LockedBlockOverride(
      id: id,
      lockedBlockId: lockedBlockId,
      date: date,
      type: LockedBlockOverrideType.remove,
      createdAt: createdAt,
      updatedAt: updatedAt ?? createdAt,
    );
  }

  /// Replaces a recurring occurrence with different times for one date.
  factory LockedBlockOverride.replace({
    required String id,
    required String lockedBlockId,
    required CivilDate date,
    required ClockTime startTime,
    required ClockTime endTime,
    required DateTime createdAt,
    String? name,
    bool hiddenByDefault = true,
    String? projectId,
    DateTime? updatedAt,
  }) {
    return LockedBlockOverride(
      id: id,
      lockedBlockId: lockedBlockId,
      date: date,
      type: LockedBlockOverrideType.replace,
      name: name,
      startTime: startTime,
      endTime: endTime,
      hiddenByDefault: hiddenByDefault,
      projectId: projectId,
      createdAt: createdAt,
      updatedAt: updatedAt ?? createdAt,
    );
  }

  /// Adds surprise locked time for one date.
  factory LockedBlockOverride.add({
    required String id,
    required CivilDate date,
    required String name,
    required ClockTime startTime,
    required ClockTime endTime,
    required DateTime createdAt,
    bool hiddenByDefault = true,
    String? projectId,
    DateTime? updatedAt,
  }) {
    return LockedBlockOverride(
      id: id,
      date: date,
      type: LockedBlockOverrideType.add,
      name: name,
      startTime: startTime,
      endTime: endTime,
      hiddenByDefault: hiddenByDefault,
      projectId: projectId,
      createdAt: createdAt,
      updatedAt: updatedAt ?? createdAt,
    );
  }

  /// Stable id for persistence and debugging.
  final String id;

  /// Base block id affected by remove/replace overrides. Null for add overrides.
  final String? lockedBlockId;

  /// Date the override applies to.
  final CivilDate date;

  /// Kind of override operation.
  final LockedBlockOverrideType type;

  /// Optional replacement/addition name.
  final String? name;

  /// Optional replacement/addition start time.
  final ClockTime? startTime;

  /// Optional replacement/addition end time.
  final ClockTime? endTime;

  /// Visibility hint for UI; scheduling still blocks this time.
  final bool hiddenByDefault;

  /// Optional project/category association for UI colors or reports.
  final String? projectId;

  /// Creation timestamp for persistence/auditing.
  final DateTime createdAt;

  /// Last update timestamp for persistence/auditing.
  final DateTime updatedAt;

  /// Whether this override targets [blockId] on [occurrenceDate].
  bool appliesTo({
    required String blockId,
    required CivilDate occurrenceDate,
  }) {
    return lockedBlockId == blockId && _sameDate(date, occurrenceDate);
  }

  /// Build a concrete interval for [targetDate] when this override has enough
  /// time data and applies to that date.
  ///
  /// Remove overrides intentionally return null because they do not create a new
  /// interval. Replacement/add overrides need both [startTime] and [endTime].
  TimeInterval? intervalForDate({
    required CivilDate targetDate,
    required String timeZoneId,
    required TimeZoneResolver timeZoneResolver,
    TimeZoneResolutionOptions resolutionOptions =
        const TimeZoneResolutionOptions(),
  }) {
    final start = startTime;
    final end = endTime;

    if (start == null || end == null || !_sameDate(date, targetDate)) {
      return null;
    }

    return _resolvedInterval(
      date: targetDate,
      startTime: start,
      endTime: end,
      label: name,
      timeZoneId: timeZoneId,
      timeZoneResolver: timeZoneResolver,
      resolutionOptions: resolutionOptions,
    );
  }
}

/// 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 {
  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;
}

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

bool _sameDate(CivilDate first, CivilDate second) => first == second;

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;
}

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;
}

bool _clockTimeIsBefore(ClockTime start, ClockTime end) {
  if (start.hour != end.hour) {
    return start.hour < end.hour;
  }
  return start.minute < end.minute;
}

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);
  }
}

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.',
    );
  }
}
