import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart';
import 'package:marianum_mobile/view/pages/timetable/data/calendar_logic.dart';
import 'package:marianum_mobile/view/pages/timetable/data/lesson_period_schedule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';

DateTime _at(int year, int month, int day, [int hour = 0, int minute = 0]) =>
    DateTime(year, month, day, hour, minute);

Appointment _appt({
  required DateTime start,
  required DateTime end,
  String subject = 'Test',
  bool isAllDay = false,
  Object? id,
  String? rrule,
}) => Appointment(
  id: id,
  startTime: start,
  endTime: end,
  subject: subject,
  color: Colors.blue,
  isAllDay: isAllDay,
  recurrenceRule: rrule,
);

McTimetableEntry _lesson({String status = 'REGULAR'}) => McTimetableEntry(
  id: 0,
  date: DateTime(2026, 5, 8),
  startTime: DateTime(1970, 1, 1, 8),
  endTime: DateTime(1970, 1, 1, 9),
  subjects: const [],
  teachers: const [],
  rooms: const [],
  classNames: const [],
  lessonType: 'LESSON',
  status: status,
  substitutionText: null,
  lessonText: null,
  infoText: null,
);

CustomTimetableEvent _customEvent() => CustomTimetableEvent(
  id: 'x',
  title: '',
  description: '',
  startDate: DateTime(2026),
  endDate: DateTime(2026),
  color: null,
  rrule: '',
  createdAt: DateTime(2026),
  updatedAt: DateTime(2026),
);

void main() {
  group('isAllDayLike', () {
    test('explicit isAllDay flag wins', () {
      final a = _appt(
        start: _at(2026, 5, 8, 9),
        end: _at(2026, 5, 8, 10),
        isAllDay: true,
      );
      expect(isAllDayLike(a), isTrue);
    });

    test('events under 8 hours are not all-day-like', () {
      final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 15, 59));
      expect(isAllDayLike(a), isFalse);
    });

    test('events of exactly 8 hours count as all-day-like', () {
      final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 16));
      expect(isAllDayLike(a), isTrue);
    });

    test(
      'Duration.inHours truncation does not let a 9h 30min event escape',
      () {
        final a = _appt(
          start: _at(2026, 5, 8, 8),
          end: _at(2026, 5, 8, 17, 30),
        );
        expect(
          isAllDayLike(a),
          isTrue,
          reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)',
        );
      },
    );
  });

  group('isOutsideSchoolHours', () {
    // School hours run 7:30 → 17:15 (kCalendarStartHour = 7.5, kCalendarEndHour = 17.25).

    test('lessons fully inside the grid are inside', () {
      final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9));
      expect(isOutsideSchoolHours(a), isFalse);
    });

    test('all-day-like events are always outside', () {
      final a = _appt(
        start: _at(2026, 5, 8, 9),
        end: _at(2026, 5, 8, 10),
        isAllDay: true,
      );
      expect(isOutsideSchoolHours(a), isTrue);
    });

    test('events ending at or before grid start are outside', () {
      final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 7, 30));
      expect(isOutsideSchoolHours(a), isTrue);
    });

    test('events starting at or after grid end are outside', () {
      final a = _appt(start: _at(2026, 5, 8, 17, 15), end: _at(2026, 5, 8, 18));
      expect(isOutsideSchoolHours(a), isTrue);
    });

    test('events that engulf the entire grid are outside', () {
      final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 18));
      expect(isOutsideSchoolHours(a), isTrue);
    });

    test('events that cross only the start boundary are inside', () {
      final a = _appt(start: _at(2026, 5, 8, 7), end: _at(2026, 5, 8, 8));
      expect(isOutsideSchoolHours(a), isFalse);
    });

    test('events that cross only the end boundary are inside', () {
      final a = _appt(start: _at(2026, 5, 8, 17), end: _at(2026, 5, 8, 18));
      expect(isOutsideSchoolHours(a), isFalse);
    });
  });

  group('partitionAppointmentsForWeek', () {
    final monday = _at(2026, 5, 4); // a Monday

    test('single non-recurring lesson lands in the right day bucket', () {
      final wednesday9 = _appt(
        start: _at(2026, 5, 6, 9),
        end: _at(2026, 5, 6, 10),
      );
      final result = partitionAppointmentsForWeek([wednesday9], monday);
      expect(result.inside[0], isEmpty);
      expect(result.inside[1], isEmpty);
      expect(result.inside[2], hasLength(1));
      expect(result.inside[3], isEmpty);
      expect(result.outside.expand((e) => e), isEmpty);
    });

    test('all-day events go to the outside bucket on their day', () {
      final tuesdayAllDay = _appt(
        start: _at(2026, 5, 5),
        end: _at(2026, 5, 6),
        isAllDay: true,
      );
      final result = partitionAppointmentsForWeek([tuesdayAllDay], monday);
      expect(result.inside.expand((e) => e), isEmpty);
      expect(result.outside[1], hasLength(1));
    });

    test('events outside the visible week are dropped', () {
      final lastWeek = _appt(
        start: _at(2026, 4, 27, 9),
        end: _at(2026, 4, 27, 10),
      );
      final nextWeek = _appt(
        start: _at(2026, 5, 11, 9),
        end: _at(2026, 5, 11, 10),
      );
      final result = partitionAppointmentsForWeek([lastWeek, nextWeek], monday);
      expect(result.inside.expand((e) => e), isEmpty);
      expect(result.outside.expand((e) => e), isEmpty);
    });

    test('weekend events (Sat/Sun) are dropped, only Mon–Fri counted', () {
      final saturday = _appt(
        start: _at(2026, 5, 9, 9),
        end: _at(2026, 5, 9, 10),
      );
      final result = partitionAppointmentsForWeek([saturday], monday);
      expect(result.inside.expand((e) => e), isEmpty);
    });

    test('weekly RRULE expands to one occurrence per matching week', () {
      // Anchor on the Monday before our visible week, repeating weekly.
      // The visible week's Monday should produce one occurrence.
      final anchor = _appt(
        start: _at(2026, 4, 27, 9),
        end: _at(2026, 4, 27, 10),
        rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO',
      );
      final result = partitionAppointmentsForWeek([anchor], monday);
      expect(
        result.inside[0],
        hasLength(1),
        reason: 'Monday of the visible week should get one expansion',
      );
      expect(result.inside[0].first.startTime, _at(2026, 5, 4, 9));
    });

    test('malformed RRULE falls back to placing the anchor', () {
      final broken = _appt(
        start: _at(2026, 5, 6, 9),
        end: _at(2026, 5, 6, 10),
        rrule: 'this is not a valid rrule',
      );
      final result = partitionAppointmentsForWeek([broken], monday);
      expect(result.inside[2], hasLength(1));
    });
  });

  group('PeriodLayout', () {
    final p1 = const LessonPeriod(
      name: '1',
      start: TimeOfDay(hour: 8, minute: 0),
      end: TimeOfDay(hour: 9, minute: 0),
    );
    final brk = const LessonPeriod(
      name: 'Pause',
      start: TimeOfDay(hour: 9, minute: 0),
      end: TimeOfDay(hour: 9, minute: 15),
      isBreak: true,
    );
    final p2 = const LessonPeriod(
      name: '2',
      start: TimeOfDay(hour: 9, minute: 15),
      end: TimeOfDay(hour: 10, minute: 15),
    );

    final layout = PeriodLayout(
      periods: [p1, brk, p2],
      lessonHeight: 60, // 60px per lesson
      breakHeight: 20,
    );

    test('totalHeight sums lessons and breaks', () {
      expect(layout.totalHeight, 60 + 20 + 60);
    });

    test('topOf returns cumulative height of preceding periods', () {
      expect(layout.topOf(p1), 0);
      expect(layout.topOf(brk), 60);
      expect(layout.topOf(p2), 80);
    });

    test('heightOf returns the period-type-specific height', () {
      expect(layout.heightOf(p1), 60);
      expect(layout.heightOf(brk), 20);
    });

    test('yOfDateTime maps proportionally inside a period', () {
      // 8:30 = halfway through the 1st lesson → y = 30
      expect(layout.yOfDateTime(_at(2026, 5, 8, 8, 30)), 30);
    });

    test('yOfDateTime clips to 0 before the first period', () {
      expect(layout.yOfDateTime(_at(2026, 5, 8, 6)), 0);
    });

    test('yOfDateTime clips to totalHeight after the last period', () {
      expect(layout.yOfDateTime(_at(2026, 5, 8, 18)), layout.totalHeight);
    });

    test('periodAtY returns the lesson under the cursor', () {
      expect(layout.periodAtY(0), p1);
      expect(layout.periodAtY(59), p1);
    });

    test('periodAtY skips a break to the next non-break lesson', () {
      // y=70 falls in the break range; periodAtY should jump to p2.
      expect(layout.periodAtY(70), p2);
    });

    test('periodAtY returns null past the last period', () {
      expect(layout.periodAtY(layout.totalHeight + 10), isNull);
    });
  });

  group('assignLanes', () {
    test('non-overlapping appointments stay on lane 0', () {
      final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9));
      final b = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11));
      final result = assignLanes([a, b], maxLanes: 2);
      expect(result, hasLength(2));
      for (final cell in result) {
        expect(cell.lane, 0);
        expect(
          cell.laneCount,
          1,
          reason: 'separate clusters → laneCount=1 each',
        );
      }
    });

    test('two overlapping appointments split into 2 lanes', () {
      final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10));
      final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11));
      final result = assignLanes([a, b], maxLanes: 2);
      expect(result, hasLength(2));
      expect(result.map((c) => c.lane).toSet(), {0, 1});
      expect(result.every((c) => c.laneCount == 2), isTrue);
    });

    test(
      'three overlapping appointments with maxLanes=2 collapse the third into overflow',
      () {
        final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11));
        final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11));
        final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11));
        final result = assignLanes([a, b, c], maxLanes: 2);

        final visible = result.whereType<LaidOutAppointment>().toList();
        final overflow = result.whereType<LaidOutOverflow>().toList();
        expect(
          visible,
          hasLength(1),
          reason: 'maxLanes-1 = 1 visible appointment',
        );
        expect(overflow, hasLength(1));
        expect(overflow.first.appointments, hasLength(2));
        expect(overflow.first.lane, 1);
        expect(overflow.first.laneCount, 2);
      },
    );

    test('CustomAppointment beats a regular lesson on lane priority', () {
      final custom = _appt(
        id: CustomAppointment(_customEvent()),
        start: _at(2026, 5, 8, 8),
        end: _at(2026, 5, 8, 10),
      );
      final regular = _appt(
        id: LessonAppointment(_lesson()),
        start: _at(2026, 5, 8, 8),
        end: _at(2026, 5, 8, 10),
      );

      final result = assignLanes([
        regular,
        custom,
      ], maxLanes: 2).whereType<LaidOutAppointment>().toList();
      // Same startTime → priority decides: custom (0) goes left of regular (2).
      final customCell = result.firstWhere(
        (c) => c.appointment.id is CustomAppointment,
      );
      final regularCell = result.firstWhere(
        (c) => c.appointment.id is LessonAppointment,
      );
      expect(customCell.lane, lessThan(regularCell.lane));
    });

    test('cancelled lesson lands left of a non-cancelled one on tie', () {
      final cancelled = _appt(
        id: LessonAppointment(_lesson(status: 'CANCELLED')),
        start: _at(2026, 5, 8, 8),
        end: _at(2026, 5, 8, 10),
      );
      final regular = _appt(
        id: LessonAppointment(_lesson()),
        start: _at(2026, 5, 8, 8),
        end: _at(2026, 5, 8, 10),
      );

      final result = assignLanes([
        regular,
        cancelled,
      ], maxLanes: 2).whereType<LaidOutAppointment>().toList();
      String? statusOf(LaidOutAppointment c) {
        final id = c.appointment.id;
        return id is LessonAppointment ? id.entry.status : null;
      }

      final cancelledCell = result.firstWhere((c) => statusOf(c) == 'CANCELLED');
      final regularCell = result.firstWhere((c) => statusOf(c) == 'REGULAR');
      expect(cancelledCell.lane, lessThan(regularCell.lane));
    });

    test(
      'overflow time-range spans earliest start to latest end of collapsed appointments',
      () {
        // 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3.
        final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12));
        final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10));
        final c = _appt(
          start: _at(2026, 5, 8, 9, 30),
          end: _at(2026, 5, 8, 14),
        );
        final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11));

        final overflow = assignLanes([
          a,
          b,
          c,
          d,
        ], maxLanes: 2).whereType<LaidOutOverflow>().single;
        expect(overflow.appointments, hasLength(3));
        expect(
          overflow.startTime,
          _at(2026, 5, 8, 9),
          reason: 'earliest non-visible start time',
        );
        expect(
          overflow.endTime,
          _at(2026, 5, 8, 14),
          reason: 'latest non-visible end time',
        );
      },
    );

    test('empty input returns an empty list', () {
      expect(assignLanes(const [], maxLanes: 2), isEmpty);
    });

    test('asserts maxLanes >= 2', () {
      expect(
        () => assignLanes(const [], maxLanes: 1),
        throwsA(isA<AssertionError>()),
      );
    });
  });
}
