import 'dart:developer';

import 'package:rrule/rrule.dart';

import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../extensions/date_time.dart';
import '../view/pages/timetable/data/lesson_labels.dart';
import '../view/pages/timetable/data/lesson_merger.dart';
import '../view/pages/timetable/data/lesson_period_schedule.dart';
import '../view/pages/timetable/data/lesson_status.dart';
import 'widget_data.dart';

class WidgetDataMapper {
  /// After 17:00 the user's question shifts from "what's left today" to
  /// "what's tomorrow", so the day-widget rolls forward.
  static const int _dayWidgetCutoffHour = 17;

  static const _weekend = {DateTime.saturday, DateTime.sunday};

  static DateTime resolveDayAnchor(DateTime now) {
    var candidate = DateTime(now.year, now.month, now.day);
    final shiftToTomorrow =
        now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday);
    if (shiftToTomorrow) {
      candidate = candidate.add(const Duration(days: 1));
    }
    while (_weekend.contains(candidate.weekday)) {
      candidate = candidate.add(const Duration(days: 1));
    }
    return candidate;
  }

  static DateTime resolveWeekAnchor(DateTime now) =>
      startOfCalendarWeek(resolveDayAnchor(now));

  /// Monday of the calendar week containing [reference] — no roll-forward,
  /// unlike [resolveWeekAnchor]. Start of the week payload's 14-day window.
  static DateTime startOfCalendarWeek(DateTime reference) {
    final monday = reference.subtract(Duration(days: reference.weekday - 1));
    return DateTime(monday.year, monday.month, monday.day);
  }

  /// Days covered by the week payload: current calendar week + the next, so
  /// native renderers can roll the view forward without fresh data.
  static const int weekWindowDays = 14;

  static WidgetTimetableData buildDayData({
    required DateTime now,
    required Iterable<McTimetableEntry> lessons,
    required TimetableGetSubjectsResponse? subjects,
    required TimetableGetRoomsResponse? rooms,
    required TimetableGetHolidaysResponse? holidays,
    TimetableGetTimegridResponse? timegrid,
    GetCustomTimetableEventResponse? customEvents,
    bool connectDoubleLessons = true,
    bool showClassInsteadOfTeacher = false,
  }) {
    final anchor = resolveDayAnchor(now);
    final holiday = _findHoliday(anchor, holidays);
    final dayStart = anchor;
    final dayEnd = anchor.add(const Duration(days: 1));
    final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList();
    final source = connectDoubleLessons
        ? LessonMerger.merge(dayLessons)
        : dayLessons;
    final mapped = <WidgetLesson>[
      ..._mapAll(
        source,
        now,
        subjects,
        rooms,
        showClassInsteadOfTeacher: showClassInsteadOfTeacher,
      ),
      ..._expandCustomEvents(customEvents, dayStart, dayEnd),
    ]..sort((a, b) => a.start.compareTo(b.start));
    return WidgetTimetableData(
      fetchedAt: now,
      anchorDate: anchor,
      lessons: _resolveCollisions(mapped),
      periods: _resolvePeriods(timegrid),
      isHoliday: holiday != null,
      holidayName: holiday?.longName,
    );
  }

  static WidgetTimetableData buildWeekData({
    required DateTime now,
    required Iterable<McTimetableEntry> lessons,
    required TimetableGetSubjectsResponse? subjects,
    required TimetableGetRoomsResponse? rooms,
    required TimetableGetHolidaysResponse? holidays,
    TimetableGetTimegridResponse? timegrid,
    GetCustomTimetableEventResponse? customEvents,
    bool connectDoubleLessons = true,
    bool showClassInsteadOfTeacher = false,
  }) {
    final anchor = resolveWeekAnchor(now);
    // The window is anchored at the *current* calendar week, not the
    // (possibly rolled-forward) week anchor: on Friday evening the payload
    // must still contain today for renderers that derive day slices.
    final windowStart = startOfCalendarWeek(now);
    final endExclusive = windowStart.add(const Duration(days: weekWindowDays));
    final weekLessons = lessons.where((l) {
      final dt = l.startDateTime;
      return !dt.isBefore(windowStart) && dt.isBefore(endExclusive);
    }).toList();
    // Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
    // a 1st-period lesson on Tue if subject/teacher match.
    final source = connectDoubleLessons
        ? _mergePerDay(weekLessons)
        : weekLessons;
    final mapped = <WidgetLesson>[
      ..._mapAll(
        source,
        now,
        subjects,
        rooms,
        showClassInsteadOfTeacher: showClassInsteadOfTeacher,
      ),
      ..._expandCustomEvents(customEvents, windowStart, endExclusive),
    ]..sort((a, b) => a.start.compareTo(b.start));
    final days = [
      for (var i = 0; i < weekWindowDays; i++)
        _dayInfo(windowStart.addDays(i), holidays),
    ];
    // The anchor always lies inside the window; the orElse only guards the
    // impossible.
    final anchorInfo = days.firstWhere(
      (d) => d.date == anchor,
      orElse: () => _dayInfo(anchor, holidays),
    );
    return WidgetTimetableData(
      fetchedAt: now,
      anchorDate: anchor,
      lessons: _resolveCollisions(mapped),
      periods: _resolvePeriods(timegrid),
      isHoliday: anchorInfo.isHoliday,
      holidayName: anchorInfo.holidayName,
      days: days,
    );
  }

  static WidgetDayInfo _dayInfo(
    DateTime day,
    TimetableGetHolidaysResponse? holidays,
  ) {
    final holiday = _findHoliday(day, holidays);
    return WidgetDayInfo(
      date: day,
      isHoliday: holiday != null,
      holidayName: holiday?.longName,
    );
  }

  /// cancelled (0) < event (1) < regular (2) — events replace cancelled
  /// lessons but lose to real ones, leaving a `+1` hint on the survivor.
  static int _priority(WidgetLessonStatus status) => switch (status) {
        WidgetLessonStatus.cancelled => 0,
        WidgetLessonStatus.event => 1,
        _ => 2,
      };

  static List<WidgetLesson> _resolveCollisions(List<WidgetLesson> lessons) {
    if (lessons.length <= 1) return lessons;

    bool overlaps(WidgetLesson l, WidgetLesson other) =>
        l != other && l.start.isBefore(other.end) && l.end.isAfter(other.start);

    // Index-based: a long event covering several regulars must bump *every*
    // covered lesson, not just the first overlap.
    final dropped = List<bool>.filled(lessons.length, false);
    final bumps = List<int>.filled(lessons.length, 0);
    for (var i = 0; i < lessons.length; i++) {
      final l = lessons[i];
      final myPrio = _priority(l.status);
      final overrideIdxs = <int>[];
      for (var j = 0; j < lessons.length; j++) {
        if (i == j) continue;
        if (_priority(lessons[j].status) <= myPrio) continue;
        if (!overlaps(l, lessons[j])) continue;
        overrideIdxs.add(j);
      }
      if (overrideIdxs.isNotEmpty) {
        dropped[i] = true;
        if (l.status == WidgetLessonStatus.event) {
          for (final idx in overrideIdxs) {
            bumps[idx] += 1;
          }
        }
      }
    }
    final filtered = <WidgetLesson>[];
    for (var i = 0; i < lessons.length; i++) {
      if (dropped[i]) continue;
      final l = lessons[i];
      filtered.add(
        bumps[i] > 0
            ? l.copyWith(siblingCount: l.siblingCount + bumps[i])
            : l,
      );
    }
    if (filtered.length <= 1) return filtered;

    final groups = <String, List<WidgetLesson>>{};
    for (final l in filtered) {
      final key =
          '${l.start.year}-${l.start.month}-${l.start.day}-${l.start.hour}-${l.start.minute}';
      groups.putIfAbsent(key, () => []).add(l);
    }
    final result = <WidgetLesson>[];
    for (final group in groups.values) {
      if (group.length == 1) {
        result.add(group.first);
        continue;
      }
      final active = group
          .where((l) => l.status != WidgetLessonStatus.cancelled)
          .toList();
      if (active.isEmpty) {
        result.addAll(group);
        continue;
      }
      active.sort((a, b) => a.subjectShort.compareTo(b.subjectShort));
      // Additive — preserves the event-bump from the priority pass, otherwise
      // a slot with another regular lesson AND a hidden event would show +1
      // instead of +2.
      final keeper = active.first;
      result.add(
        keeper.copyWith(
          siblingCount: keeper.siblingCount + active.length - 1,
        ),
      );
    }
    return result..sort((a, b) => a.start.compareTo(b.start));
  }

  /// Gaps below this collapse to zero on the virtual axis so 45-min slots
  /// stack flush; bigger gaps survive as visible Pause-blocks.
  static const int _smallBreakThresholdMinutes = 5;

  static List<WidgetPeriod> _resolvePeriods(
    TimetableGetTimegridResponse? timegrid,
  ) {
    final schedule =
        (timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ??
            LessonPeriodSchedule.fallback();
    final raw = schedule.periods
        .map(
          (p) => (
            name: p.name,
            start: p.start.hour * 60 + p.start.minute,
            end: p.end.hour * 60 + p.end.minute,
          ),
        )
        .toList()
      ..sort((a, b) => a.start.compareTo(b.start));

    final result = <WidgetPeriod>[];
    var virtualOffset = 0;
    int? prevEnd;
    for (final p in raw) {
      if (prevEnd != null) {
        final gap = p.start - prevEnd;
        if (gap > _smallBreakThresholdMinutes) virtualOffset += gap;
      }
      final duration = p.end - p.start;
      result.add(
        WidgetPeriod(
          name: p.name,
          startMinutes: p.start,
          endMinutes: p.end,
          virtualStartMinutes: virtualOffset,
          virtualEndMinutes: virtualOffset + duration,
        ),
      );
      virtualOffset += duration;
      prevEnd = p.end;
    }
    return result;
  }

  // Per-Tag-Merge: ohne diese Gruppierung würde eine letzte Stunde am Montag
  // mit der ersten Stunde am Dienstag verschmelzen, wenn Fach + Lehrer
  // identisch sind.
  static List<McTimetableEntry> _mergePerDay(List<McTimetableEntry> lessons) {
    final byDay = <String, List<McTimetableEntry>>{};
    for (final l in lessons) {
      final key = '${l.date.year}-${l.date.month}-${l.date.day}';
      byDay.putIfAbsent(key, () => []).add(l);
    }
    return [for (final group in byDay.values) ...LessonMerger.merge(group)];
  }

  static Iterable<WidgetLesson> _mapAll(
    Iterable<McTimetableEntry> source,
    DateTime now,
    TimetableGetSubjectsResponse? subjects,
    TimetableGetRoomsResponse? rooms, {
    required bool showClassInsteadOfTeacher,
  }) => source.map(
    (l) => _mapLesson(
      l,
      now,
      subjects,
      rooms,
      showClassInsteadOfTeacher: showClassInsteadOfTeacher,
    ),
  );

  static WidgetLesson _mapLesson(
    McTimetableEntry lesson,
    DateTime now,
    TimetableGetSubjectsResponse? subjects,
    TimetableGetRoomsResponse? rooms, {
    bool showClassInsteadOfTeacher = false,
  }) {
    final start = lesson.startDateTime;
    final end = lesson.endDateTime;
    final status = _mapStatus(
      LessonStatusClassifier.classify(lesson, start, end, now),
    );
    final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? '';
    // Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall
    // back to "Event" so the tile isn't just a dash.
    final subjectShort = subjectShortRaw.isEmpty ? 'Event' : subjectShortRaw;
    String? subjectLong;
    if (subjects != null && subjectShortRaw.isNotEmpty) {
      subjectLong = subjects.result
          .where((s) => s.shortName == subjectShortRaw)
          .firstOrNull
          ?.longName;
    }
    final roomShort = lesson.rooms.firstOrNull;
    var roomName = roomShort;
    if (rooms != null && roomShort != null) {
      roomName = rooms.result
              .where((r) => r.shortName == roomShort)
              .firstOrNull
              ?.shortName ??
          roomName;
    }
    final teacher = lesson.teachers.firstOrNull;
    // Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
    // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten
    // den Lehrer als Fallback.
    final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null;
    final teacherName = classLabel ?? teacher?.shortName;
    final originalTeacher = classLabel != null
        ? null
        : teacher?.originalShortName;
    return WidgetLesson(
      start: start,
      end: end,
      subjectShort: subjectShort,
      subjectLong: subjectLong,
      room: roomName,
      teacher: teacherName,
      originalTeacher: originalTeacher,
      status: status,
    );
  }

  static WidgetLessonStatus _mapStatus(LessonStatus status) {
    switch (status) {
      case LessonStatus.cancelled:
        return WidgetLessonStatus.cancelled;
      case LessonStatus.event:
        return WidgetLessonStatus.event;
      case LessonStatus.irregular:
        return WidgetLessonStatus.irregular;
      case LessonStatus.teacherChanged:
        return WidgetLessonStatus.teacherChanged;
      case LessonStatus.duty:
        return WidgetLessonStatus.duty;
      case LessonStatus.past:
        return WidgetLessonStatus.past;
      case LessonStatus.ongoing:
        return WidgetLessonStatus.ongoing;
      case LessonStatus.regular:
        return WidgetLessonStatus.regular;
    }
  }

  static bool _onSameDay(McTimetableEntry lesson, DateTime day) {
    return lesson.date.year == day.year &&
        lesson.date.month == day.month &&
        lesson.date.day == day.day;
  }

  static McHoliday? _findHoliday(
    DateTime day,
    TimetableGetHolidaysResponse? holidays,
  ) {
    if (holidays == null) return null;
    final asDay = DateTime(day.year, day.month, day.day);
    for (final h in holidays.result) {
      final start = DateTime(h.startDate.year, h.startDate.month, h.startDate.day);
      final end = DateTime(h.endDate.year, h.endDate.month, h.endDate.day);
      if (!asDay.isBefore(start) && !asDay.isAfter(end)) return h;
    }
    return null;
  }

  static Iterable<WidgetLesson> _expandCustomEvents(
    GetCustomTimetableEventResponse? customEvents,
    DateTime rangeStart,
    DateTime rangeEndExclusive,
  ) sync* {
    if (customEvents == null) return;
    final rangeStartUtc = rangeStart.toUtc();
    final rangeEndUtc = rangeEndExclusive.toUtc();
    for (final event in customEvents.events) {
      yield* _expandSingleEvent(event, rangeStartUtc, rangeEndUtc);
    }
  }

  static Iterable<WidgetLesson> _expandSingleEvent(
    CustomTimetableEvent event,
    DateTime rangeStartUtc,
    DateTime rangeEndUtc,
  ) sync* {
    final rule = event.rrule;
    final duration = event.endDate.difference(event.startDate);

    if (rule.isEmpty) {
      final startUtc = event.startDate.toUtc();
      if (startUtc.isBefore(rangeStartUtc) ||
          !startUtc.isBefore(rangeEndUtc)) {
        return;
      }
      yield* _customEventToWidgetLessons(event, event.startDate, duration);
      return;
    }

    try {
      final parsed = RecurrenceRule.fromString(rule);
      final anchorUtc = event.startDate.toUtc();
      for (final occUtc in parsed.getInstances(start: anchorUtc)) {
        if (!occUtc.isBefore(rangeEndUtc)) break;
        if (occUtc.isBefore(rangeStartUtc)) continue;
        final occLocal = occUtc.toLocal();
        final occStart = DateTime(
          occLocal.year,
          occLocal.month,
          occLocal.day,
          event.startDate.hour,
          event.startDate.minute,
        );
        yield* _customEventToWidgetLessons(event, occStart, duration);
      }
    } on Exception catch (e) {
      log('Widget mapper: invalid rrule "$rule" on event ${event.id}: $e');
    }
  }

  /// Splits multi-day events into one block per local calendar day, so each
  /// affected day on the week-widget shows the event. All-day events
  /// (start = end = midnight) collapse to a single 00:00–23:59 block.
  static Iterable<WidgetLesson> _customEventToWidgetLessons(
    CustomTimetableEvent event,
    DateTime occurrenceStart,
    Duration duration,
  ) sync* {
    final title = event.title.trim();
    WidgetLesson buildBlock(DateTime start, DateTime end) => WidgetLesson(
          start: start,
          end: end,
          subjectShort: title.isEmpty ? 'Termin' : title,
          subjectLong: title.isEmpty ? null : title,
          status: WidgetLessonStatus.event,
          customColor: event.color,
        );

    final isAllDay = duration == Duration.zero && _isMidnight(event.startDate);
    if (isAllDay) {
      yield buildBlock(
        occurrenceStart,
        DateTime(
          occurrenceStart.year,
          occurrenceStart.month,
          occurrenceStart.day,
          23,
          59,
        ),
      );
      return;
    }

    final actualEnd = occurrenceStart.add(duration);
    var segmentStart = occurrenceStart;
    while (segmentStart.isBefore(actualEnd)) {
      final nextMidnight = DateTime(
        segmentStart.year,
        segmentStart.month,
        segmentStart.day,
      ).add(const Duration(days: 1));
      final segmentEnd = actualEnd.isBefore(nextMidnight)
          ? actualEnd
          : nextMidnight.subtract(const Duration(minutes: 1));
      yield buildBlock(segmentStart, segmentEnd);
      segmentStart = nextMidnight;
    }
  }

  static bool _isMidnight(DateTime d) =>
      d.hour == 0 && d.minute == 0 && d.second == 0;
}
