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

/// Tests Time Contracts 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('Civil date and wall-time contracts', () {
    test('civil dates preserve date-only boundaries', () {
      expect(CivilDate(2024, 2, 29).addDays(1), CivilDate(2024, 3, 1));
      expect(CivilDate(2026, 12, 31).addDays(1), CivilDate(2027, 1, 1));
      expect(CivilDate.fromIsoString('2026-01-02'), CivilDate(2026, 1, 2));
      expect(CivilDate(2026, 6, 24).toIsoString(), '2026-06-24');
    });

    test('civil date and wall-time parsing reject loose timestamp shapes', () {
      expectValidationCode(
        () => CivilDate.fromIsoString('2026-2-29'),
        DomainValidationCode.invalidCivilDate,
      );
      expectValidationCode(
        () => CivilDate.fromIsoString('2026-02-29'),
        DomainValidationCode.invalidCivilDate,
      );
      expectValidationCode(
        () => WallTime.fromIsoString('9:05'),
        DomainValidationCode.invalidClockTime,
      );
      expectValidationCode(
        () => WallTime.fromIsoString('24:00'),
        DomainValidationCode.invalidClockTime,
      );
    });

    test('wall times preserve midnight and end-of-day values', () {
      expect(WallTime(hour: 0, minute: 0).toIsoString(), '00:00');
      expect(WallTime(hour: 23, minute: 59).toIsoString(), '23:59');
      expect(WallTime.fromIsoString('09:05').minutesSinceMidnight, 545);
    });

    test('overnight locked rules are rejected instead of normalized', () {
      final now = DateTime.utc(2026, 6, 24, 12);

      expectValidationCode(
        () => LockedBlock(
          id: 'overnight',
          name: 'Overnight work',
          startTime: WallTime(hour: 23, minute: 0),
          endTime: WallTime(hour: 1, minute: 0),
          date: CivilDate(2026, 6, 24),
          createdAt: now,
          updatedAt: now,
        ),
        DomainValidationCode.invalidLockedBlock,
      );
    });
  });

  group('Clock and id boundaries', () {
    test('scheduling engine uses injected clock when updatedAt is omitted', () {
      final stamp = DateTime.utc(2026, 6, 24, 18);
      final engine = SchedulingEngine(clock: FixedClock(stamp));
      final task = Task.quickCapture(
        id: 'task',
        title: 'Inbox item',
        createdAt: DateTime.utc(2026, 6, 24, 9),
      );

      final moved = engine.moveToBacklog(task);

      expect(moved.updatedAt, stamp);
    });

    test('quick capture convenience wrapper uses injected id and clock', () {
      final stamp = DateTime.utc(2026, 6, 24, 18);
      final service = QuickCaptureService(
        clock: FixedClock(stamp),
        idGenerator: SequentialIdGenerator(prefix: 'capture', start: 7),
      );

      final result = service.captureNew(title: '  Pay bill  ');

      expect(result.status, QuickCaptureStatus.addedToBacklog);
      expect(result.task.id, 'capture-7');
      expect(result.task.title, 'Pay bill');
      expect(result.task.createdAt, stamp);
      expect(result.task.updatedAt, stamp);
    });

    test('surprise log convenience wrapper uses injected id and clock', () {
      final stamp = DateTime.utc(2026, 6, 24, 18);
      final service = SurpriseTaskLogService(
        clock: FixedClock(stamp),
        idGenerator: SequentialIdGenerator(prefix: 'surprise'),
      );
      final input = SchedulingInput(
        tasks: const [],
        window: SchedulingWindow(
          start: DateTime.utc(2026, 6, 24, 8),
          end: DateTime.utc(2026, 6, 24, 20),
        ),
      );

      final result = service.logNew(
        title: 'Unexpected call',
        input: input,
      );

      expect(result.surpriseTask.id, 'surprise-1');
      expect(result.surpriseTask.createdAt, stamp);
      expect(result.surpriseTask.updatedAt, stamp);
      expect(result.surpriseTask.status, TaskStatus.completed);
    });
  });

  group('Time-zone resolution boundary', () {
    test('fixed offset resolver converts local wall time to UTC instant', () {
      const resolver = FixedOffsetTimeZoneResolver(
        offset: Duration(hours: 5, minutes: 30),
      );

      final resolved = resolver.resolve(
        date: CivilDate(2026, 6, 24),
        wallTime: WallTime(hour: 9, minute: 0),
        timeZoneId: 'Asia/Kolkata',
      );

      expect(resolved.instant, DateTime.utc(2026, 6, 24, 3, 30));
      expect(resolved.resolution, LocalTimeResolution.exact);
      expect(resolved.utcOffset, const Duration(hours: 5, minutes: 30));
    });

    test('blank time-zone ids are rejected', () {
      const resolver = FixedOffsetTimeZoneResolver.utc();

      expectValidationCode(
        () => resolver.resolve(
          date: CivilDate(2026, 6, 24),
          wallTime: WallTime(hour: 9, minute: 0),
          timeZoneId: '   ',
        ),
        DomainValidationCode.blankTimeZoneId,
      );
    });

    test('DST gap policy shifts nonexistent local time forward by default', () {
      final resolver = _LosAngelesDstFixtureResolver();
      final block = LockedBlock(
        id: 'sleep-boundary',
        name: 'Sleep boundary',
        startTime: WallTime(hour: 2, minute: 30),
        endTime: WallTime(hour: 3, minute: 30),
        recurrence: LockedBlockRecurrence.weekly(
          weekdays: {LockedWeekday.sunday},
        ),
        createdAt: DateTime.utc(2026, 3, 1),
        updatedAt: DateTime.utc(2026, 3, 1),
      );

      final expansion = expandLockedBlocksForDay(
        blocks: [block],
        overrides: const [],
        date: CivilDate(2026, 3, 8),
        timeZoneId: 'America/Los_Angeles',
        timeZoneResolver: resolver,
      );
      final directResolution = resolver.resolve(
        date: CivilDate(2026, 3, 8),
        wallTime: WallTime(hour: 2, minute: 30),
        timeZoneId: 'America/Los_Angeles',
      );

      expect(directResolution.resolution, LocalTimeResolution.shiftedForward);
      expect(expansion.occurrences.single.interval.start,
          DateTime.utc(2026, 3, 8, 10));
      expect(expansion.occurrences.single.interval.end,
          DateTime.utc(2026, 3, 8, 10, 30));
    });

    test('DST repeated local time uses earlier instant by default', () {
      final resolver = _LosAngelesDstFixtureResolver();
      final block = LockedBlock(
        id: 'quiet-hour',
        name: 'Quiet hour',
        startTime: WallTime(hour: 1, minute: 30),
        endTime: WallTime(hour: 2, minute: 30),
        date: CivilDate(2026, 11, 1),
        createdAt: DateTime.utc(2026, 11, 1),
        updatedAt: DateTime.utc(2026, 11, 1),
      );

      final expansion = expandLockedBlocksForDay(
        blocks: [block],
        overrides: const [],
        date: CivilDate(2026, 11, 1),
        timeZoneId: 'America/Los_Angeles',
        timeZoneResolver: resolver,
      );

      expect(expansion.occurrences.single.interval.start,
          DateTime.utc(2026, 11, 1, 8, 30));
      expect(expansion.occurrences.single.interval.end,
          DateTime.utc(2026, 11, 1, 10, 30));
    });

    test('DST repeated local time can choose the later instant', () {
      final resolver = _LosAngelesDstFixtureResolver();
      final block = LockedBlock(
        id: 'quiet-hour',
        name: 'Quiet hour',
        startTime: WallTime(hour: 1, minute: 30),
        endTime: WallTime(hour: 2, minute: 30),
        date: CivilDate(2026, 11, 1),
        createdAt: DateTime.utc(2026, 11, 1),
        updatedAt: DateTime.utc(2026, 11, 1),
      );

      final expansion = expandLockedBlocksForDay(
        blocks: [block],
        overrides: const [],
        date: CivilDate(2026, 11, 1),
        timeZoneId: 'America/Los_Angeles',
        timeZoneResolver: resolver,
        resolutionOptions: const TimeZoneResolutionOptions(
          repeatedLocalTimePolicy: RepeatedLocalTimePolicy.later,
        ),
      );

      expect(expansion.occurrences.single.interval.start,
          DateTime.utc(2026, 11, 1, 9, 30));
      expect(expansion.occurrences.single.interval.end,
          DateTime.utc(2026, 11, 1, 10, 30));
    });

    test('DST reject policies return typed validation codes', () {
      final resolver = _LosAngelesDstFixtureResolver();

      expectValidationCode(
        () => resolver.resolve(
          date: CivilDate(2026, 3, 8),
          wallTime: WallTime(hour: 2, minute: 30),
          timeZoneId: 'America/Los_Angeles',
          options: const TimeZoneResolutionOptions(
            nonexistentLocalTimePolicy: NonexistentLocalTimePolicy.reject,
          ),
        ),
        DomainValidationCode.nonexistentLocalTime,
      );
      expectValidationCode(
        () => resolver.resolve(
          date: CivilDate(2026, 11, 1),
          wallTime: WallTime(hour: 1, minute: 30),
          timeZoneId: 'America/Los_Angeles',
          options: const TimeZoneResolutionOptions(
            repeatedLocalTimePolicy: RepeatedLocalTimePolicy.reject,
          ),
        ),
        DomainValidationCode.ambiguousLocalTime,
      );
    });
  });
}

/// Runs the `expectValidationCode` 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 expectValidationCode(
  Object? Function() body,
  DomainValidationCode expectedCode,
) {
  expect(
    body,
    throwsA(
      isA<DomainValidationException>().having(
        (error) => error.code,
        'code',
        expectedCode,
      ),
    ),
  );
}

/// Private implementation type for `_LosAngelesDstFixtureResolver` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _LosAngelesDstFixtureResolver implements TimeZoneResolver {
  /// Performs the `resolve` 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.
  @override
  ResolvedLocalDateTime resolve({
    required CivilDate date,
    required WallTime wallTime,
    required String timeZoneId,
    TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(),
  }) {
    if (timeZoneId.trim() != 'America/Los_Angeles') {
      throw DomainValidationException(
        code: DomainValidationCode.blankTimeZoneId,
        invalidValue: timeZoneId,
        name: 'timeZoneId',
        message: 'Fixture resolver only supports America/Los_Angeles.',
      );
    }

    if (date == CivilDate(2026, 3, 8) && wallTime.hour == 2) {
      if (options.nonexistentLocalTimePolicy ==
          NonexistentLocalTimePolicy.reject) {
        throw DomainValidationException(
          code: DomainValidationCode.nonexistentLocalTime,
          invalidValue: {'date': date, 'wallTime': wallTime},
          name: 'wallTime',
          message: 'Local time does not exist in this time-zone.',
        );
      }

      return _resolved(
        date: date,
        wallTime: WallTime(hour: 3, minute: 0),
        timeZoneId: timeZoneId,
        offset: const Duration(hours: -7),
        resolution: LocalTimeResolution.shiftedForward,
      );
    }

    if (date == CivilDate(2026, 11, 1) && wallTime.hour == 1) {
      if (options.repeatedLocalTimePolicy == RepeatedLocalTimePolicy.reject) {
        throw DomainValidationException(
          code: DomainValidationCode.ambiguousLocalTime,
          invalidValue: {'date': date, 'wallTime': wallTime},
          name: 'wallTime',
          message: 'Local time is repeated in this time-zone.',
        );
      }

      final useLater =
          options.repeatedLocalTimePolicy == RepeatedLocalTimePolicy.later;
      return _resolved(
        date: date,
        wallTime: wallTime,
        timeZoneId: timeZoneId,
        offset: Duration(hours: useLater ? -8 : -7),
        resolution: useLater
            ? LocalTimeResolution.repeatedLater
            : LocalTimeResolution.repeatedEarlier,
      );
    }

    final offset = _offsetFor(date, wallTime);
    return _resolved(
      date: date,
      wallTime: wallTime,
      timeZoneId: timeZoneId,
      offset: offset,
      resolution: LocalTimeResolution.exact,
    );
  }

  /// Runs the `_offsetFor` helper used inside this library.
  /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
  Duration _offsetFor(CivilDate date, WallTime wallTime) {
    if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) {
      return const Duration(hours: -7);
    }
    if (date == CivilDate(2026, 11, 1) && wallTime.hour >= 2) {
      return const Duration(hours: -8);
    }
    return const Duration(hours: -8);
  }

  /// Runs the `_resolved` helper used inside this library.
  /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
  ResolvedLocalDateTime _resolved({
    required CivilDate date,
    required WallTime wallTime,
    required String timeZoneId,
    required Duration offset,
    required LocalTimeResolution resolution,
  }) {
    final localAsUtc = DateTime.utc(
      date.year,
      date.month,
      date.day,
      wallTime.hour,
      wallTime.minute,
    );

    return ResolvedLocalDateTime(
      date: date,
      wallTime: wallTime,
      timeZoneId: timeZoneId,
      instant: localAsUtc.subtract(offset),
      resolution: resolution,
      utcOffset: offset,
    );
  }
}
