import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';

import '../../../../extensions/date_time.dart';
import 'arbitrary_appointment.dart';
import 'calendar_layout.dart';
import 'lesson_period_schedule.dart';

/// Either explicitly marked as all-day, or so long it's effectively a full
/// day from the user's perspective. We compare in minutes (not hours) because
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
bool isAllDayLike(Appointment a) =>
    a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;

/// True when the appointment doesn't fit into the school-hours grid:
/// all-day, fully before the grid start, fully after the grid end, engulfing
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
/// event the source system happens to represent with explicit times).
bool isOutsideSchoolHours(Appointment a) {
  if (isAllDayLike(a)) return true;
  final schoolStart = (kCalendarStartHour * 60).round();
  final schoolEnd = (kCalendarEndHour * 60).round();
  final startMin = a.startTime.hour * 60 + a.startTime.minute;
  final endMin = a.endTime.hour * 60 + a.endTime.minute;
  if (endMin <= schoolStart) return true;
  if (startMin >= schoolEnd) return true;
  if (startMin <= schoolStart && endMin >= schoolEnd) return true;
  return false;
}

int dayIndex(DateTime t, DateTime weekStart) =>
    DateTime(t.year, t.month, t.day).difference(weekStart).inDays;

class BoundRegion {
  final TimeRegion region;
  final DateTime start;
  final DateTime end;

  BoundRegion({required this.region, required this.start, required this.end});
}

List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
  final result = <BoundRegion>[];
  final dayStart = DateTime(day.year, day.month, day.day);
  for (final region in regions) {
    final isRecurringDaily =
        region.recurrenceRule != null &&
        region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
    if (isRecurringDaily) {
      final start = dayStart.add(
        Duration(
          hours: region.startTime.hour,
          minutes: region.startTime.minute,
        ),
      );
      final end = dayStart.add(
        Duration(hours: region.endTime.hour, minutes: region.endTime.minute),
      );
      result.add(BoundRegion(region: region, start: start, end: end));
    } else if (region.startTime.isSameDay(day)) {
      result.add(
        BoundRegion(
          region: region,
          start: region.startTime,
          end: region.endTime,
        ),
      );
    }
  }
  return result;
}

/// Expands the given list of appointments across the visible 5-day work week
/// (resolving RRULE recurrences) and splits each day's events into two
/// buckets: those that fit within the school-hours grid (`inside`) and those
/// that don't (`outside` — all-day events and events that start before
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
/// is rendered as chips above the grid.
({List<List<Appointment>> inside, List<List<Appointment>> outside})
partitionAppointmentsForWeek(
  List<Appointment> appointments,
  DateTime weekStart,
) {
  final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
  final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
  final weekEnd = weekStart.addDays(5);
  final weekStartUtc = weekStart.toUtc();
  final weekEndUtc = weekEnd.toUtc();

  void place(int idx, Appointment a) {
    if (isOutsideSchoolHours(a)) {
      outside[idx].add(a);
    } else {
      inside[idx].add(a);
    }
  }

  for (final a in appointments) {
    final rule = a.recurrenceRule;
    if (rule == null || rule.isEmpty) {
      final idx = dayIndex(a.startTime, weekStart);
      if (idx >= 0 && idx < 5) place(idx, a);
      continue;
    }
    try {
      final parsed = RecurrenceRule.fromString(rule);
      final anchorUtc = a.startTime.toUtc();
      final duration = a.endTime.difference(a.startTime);
      // Day-keyed set of exception dates so occurrences scheduled for one
      // of them get skipped. Syncfusion's own recurrenceExceptionDates
      // handling never runs because we expand the rule manually here.
      final exceptionDayKeys = (a.recurrenceExceptionDates ?? const <DateTime>[])
          .map((d) => '${d.year}-${d.month}-${d.day}')
          .toSet();
      for (final occUtc in parsed.getInstances(start: anchorUtc)) {
        if (!occUtc.isBefore(weekEndUtc)) break;
        if (occUtc.isBefore(weekStartUtc)) continue;
        final occLocal = occUtc.toLocal();
        if (exceptionDayKeys.contains(
          '${occLocal.year}-${occLocal.month}-${occLocal.day}',
        )) {
          continue;
        }
        final idx = DateTime(
          occLocal.year,
          occLocal.month,
          occLocal.day,
        ).difference(weekStart).inDays;
        if (idx < 0 || idx >= 5) continue;
        final newStart = DateTime(
          occLocal.year,
          occLocal.month,
          occLocal.day,
          a.startTime.hour,
          a.startTime.minute,
        );
        place(
          idx,
          Appointment(
            id: a.id,
            startTime: newStart,
            endTime: newStart.add(duration),
            subject: a.subject,
            color: a.color,
            location: a.location,
            notes: a.notes,
            isAllDay: a.isAllDay,
          ),
        );
      }
    } catch (_) {
      final idx = dayIndex(a.startTime, weekStart);
      if (idx >= 0 && idx < 5) place(idx, a);
    }
  }
  return (inside: inside, outside: outside);
}

/// Maps lesson periods to vertical screen positions. Every non-break period
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
/// Short transition gaps (Wechselzeiten) between periods are not represented
/// at all — periods are rendered back-to-back, so a 5-minute gap simply
/// disappears visually.
class PeriodLayout {
  final List<LessonPeriod> periods;
  final double lessonHeight;
  final double breakHeight;

  const PeriodLayout({
    required this.periods,
    required this.lessonHeight,
    required this.breakHeight,
  });

  double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight;

  double get totalHeight => periods.fold<double>(0, (sum, p) => sum + _h(p));

  double topOf(LessonPeriod period) {
    var y = 0.0;
    for (final p in periods) {
      if (identical(p, period)) return y;
      y += _h(p);
    }
    return y;
  }

  double heightOf(LessonPeriod period) => _h(period);

  /// Vertical offset for a given time of day. Times inside a period are mapped
  /// proportionally; times that fall into a transition gap are clipped to the
  /// end of the preceding period. Times before the first / after the last
  /// period clip to 0 / [totalHeight].
  double yOfDateTime(DateTime t) {
    final tMin = t.hour * 60 + t.minute + t.second / 60.0;
    var y = 0.0;
    for (final p in periods) {
      final pStart = p.start.hour * 60 + p.start.minute;
      final pEnd = p.end.hour * 60 + p.end.minute;
      final h = _h(p);
      if (tMin < pStart) return y;
      if (tMin <= pEnd) {
        final span = pEnd - pStart;
        final ratio = span > 0 ? (tMin - pStart) / span : 0.0;
        return y + ratio * h;
      }
      y += h;
    }
    return y;
  }

  /// Period at a given y-offset. If y falls into a break, returns the next
  /// non-break period. Returns null when y is past the last period.
  LessonPeriod? periodAtY(double y) {
    var cursor = 0.0;
    for (var i = 0; i < periods.length; i++) {
      final p = periods[i];
      final h = _h(p);
      if (y >= cursor && y < cursor + h) {
        if (p.isBreak) {
          for (var j = i + 1; j < periods.length; j++) {
            if (!periods[j].isBreak) return periods[j];
          }
          return null;
        }
        return p;
      }
      cursor += h;
    }
    return null;
  }
}

/// One cell rendered in the day column — either a regular appointment or an
/// overflow placeholder representing several hidden appointments.
sealed class LaidOutCell {
  int get lane;
  int get laneCount;
  DateTime get startTime;
  DateTime get endTime;
}

class LaidOutAppointment extends LaidOutCell {
  final Appointment appointment;
  @override
  final int lane;
  @override
  final int laneCount;
  LaidOutAppointment(this.appointment, this.lane, this.laneCount);

  @override
  DateTime get startTime => appointment.startTime;
  @override
  DateTime get endTime => appointment.endTime;
}

class LaidOutOverflow extends LaidOutCell {
  final List<Appointment> appointments;
  @override
  final int lane;
  @override
  final int laneCount;
  @override
  final DateTime startTime;
  @override
  final DateTime endTime;
  LaidOutOverflow(
    this.appointments,
    this.lane,
    this.laneCount,
    this.startTime,
    this.endTime,
  );
}

/// Horizontal ordering rank for parallel appointments. Lower = further left.
/// User-owned custom events sit on the leftmost lane, cancelled lessons after
/// them, every other lesson last. Only used as a tiebreaker — the greedy lane
/// assignment still has to honor actual time-overlap constraints, so events
/// that start later can't jump left of events that started earlier and are
/// still occupying that lane.
int _appointmentPriority(Appointment a) {
  final id = a.id;
  if (id is CustomAppointment) return 0;
  if (id is LessonAppointment && id.entry.status == 'CANCELLED') return 1;
  return 2;
}

/// Assigns each appointment a lane index using a greedy sweep, then collapses
/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments
/// + one trailing overflow cell.
///
/// Greedy sweep:
/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom →
///    cancelled → other) so parallel events land in the requested left-to-
///    right order, then `endTime` descending as a final tiebreaker.
/// 2. Walk the list, placing each appointment in the lowest-index lane that
///    is free at its `startTime`. When no lane is free, open a new one.
/// 3. A cluster ends as soon as every active lane's end is at or before the
///    next appointment's start.
List<LaidOutCell> assignLanes(
  List<Appointment> appts, {
  required int maxLanes,
}) {
  assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow');
  if (appts.isEmpty) return const <LaidOutCell>[];

  final sorted = [...appts]
    ..sort((a, b) {
      final c = a.startTime.compareTo(b.startTime);
      if (c != 0) return c;
      final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
      if (p != 0) return p;
      return b.endTime.compareTo(a.endTime);
    });

  // Phase 1: greedy lane assignment, grouped by cluster.
  final clusters = <List<({Appointment apt, int lane})>>[];
  var current = <({Appointment apt, int lane})>[];
  var laneEnds = <DateTime>[];

  for (final apt in sorted) {
    final allFree =
        laneEnds.isNotEmpty &&
        laneEnds.every((end) => !end.isAfter(apt.startTime));
    if (allFree) {
      clusters.add(current);
      current = <({Appointment apt, int lane})>[];
      laneEnds = <DateTime>[];
    }

    var laneIdx = -1;
    for (var i = 0; i < laneEnds.length; i++) {
      if (!laneEnds[i].isAfter(apt.startTime)) {
        laneIdx = i;
        break;
      }
    }
    if (laneIdx == -1) {
      laneIdx = laneEnds.length;
      laneEnds.add(apt.endTime);
    } else {
      laneEnds[laneIdx] = apt.endTime;
    }

    current.add((apt: apt, lane: laneIdx));
  }
  if (current.isNotEmpty) clusters.add(current);

  // Phase 2: emit cells per cluster, collapsing if too wide.
  final result = <LaidOutCell>[];
  for (final cluster in clusters) {
    final laneCount = cluster.fold<int>(
      0,
      (m, e) => e.lane + 1 > m ? e.lane + 1 : m,
    );

    if (laneCount <= maxLanes) {
      for (final entry in cluster) {
        result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount));
      }
    } else {
      // Too many parallel appointments: keep the highest-priority
      // (maxLanes - 1) and collapse the rest into a single overflow cell in
      // the trailing lane. Sorting by priority first means custom and
      // cancelled lessons stay visible when the cluster has to be trimmed,
      // matching the requested left-to-right order in the visible lanes.
      final visibleCount = maxLanes - 1;
      final byPriority = [...cluster.map((e) => e.apt)]
        ..sort((a, b) {
          final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
          if (p != 0) return p;
          return a.startTime.compareTo(b.startTime);
        });

      for (var i = 0; i < visibleCount; i++) {
        result.add(LaidOutAppointment(byPriority[i], i, maxLanes));
      }

      final overflow = byPriority.sublist(visibleCount);
      var earliest = overflow.first.startTime;
      var latest = overflow.first.endTime;
      for (final a in overflow.skip(1)) {
        if (a.startTime.isBefore(earliest)) earliest = a.startTime;
        if (a.endTime.isAfter(latest)) latest = a.endTime;
      }
      result.add(
        LaidOutOverflow(overflow, maxLanes - 1, maxLanes, earliest, latest),
      );
    }
  }
  return result;
}
