import 'dart:async';
import 'dart:developer';
import 'dart:io';

import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart';

import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../model/account_data.dart';
import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart';
import '../widget_data/widget_sync.dart';

/// Periodic widget refresh in a background Dart isolate. The Marianum-Connect
/// dio singleton + bearer interceptor handle login/refresh transparently —
/// we only need to pin the endpoint to whatever the user picked in the
/// in-app settings before issuing calls.
class WidgetBackgroundTask {
  static const String periodicTaskName = 'eu.mhsl.marianum.widget.refresh';
  static const String oneOffTaskName = 'eu.mhsl.marianum.widget.refresh.once';

  static const Duration periodicFrequency = Duration(minutes: 30);

  /// A snapshot younger than this is considered fresh enough — a second
  /// trigger within the window (push + periodic slot coinciding) is skipped.
  static const Duration refreshDebounce = Duration(minutes: 10);

  static Future<void> initialize() async {
    await Workmanager().initialize(_callbackDispatcher);
    await Workmanager().registerPeriodicTask(
      periodicTaskName,
      periodicTaskName,
      frequency: periodicFrequency,
      // iOS ignores `frequency:` and instead uses initialDelay as the
      // BGAppRefresh earliestBeginDate on every (auto-)resubmission —
      // without it each completed run is immediately eligible again.
      initialDelay: Platform.isIOS ? periodicFrequency : Duration.zero,
      constraints: Constraints(networkType: NetworkType.connected),
      existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
      backoffPolicy: BackoffPolicy.linear,
      backoffPolicyDelay: const Duration(minutes: 5),
    );
  }

  /// Single owner of the platform strategy for "refresh soon": Android
  /// enqueues a WorkManager one-off (retry + network constraint included),
  /// iOS runs inline — one-off Workmanager tasks there only execute
  /// in-process anyway, so the direct call is equivalent and skips the extra
  /// background engine. [inlineTimeout] bounds the inline path for callers
  /// with a hard budget (FCM handler).
  static Future<void> requestImmediateRefresh({
    bool force = true,
    Duration? inlineTimeout,
  }) async {
    if (Platform.isIOS) {
      final refresh = runRefreshNow(force: force);
      await (inlineTimeout == null ? refresh : refresh.timeout(inlineTimeout));
      return;
    }
    await Workmanager().registerOneOffTask(
      '$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
      oneOffTaskName,
      constraints: Constraints(networkType: NetworkType.connected),
      existingWorkPolicy: ExistingWorkPolicy.append,
    );
  }

  /// Shared refresh entry for the periodic worker, push triggers, and login.
  /// Throws on fetch failure so the worker path can signal a retry.
  static Future<void> runRefreshNow({bool force = false}) async {
    await WidgetSync.ensureInitialized();
    bool populated;
    try {
      // Bounded: a hanging keystore read must not stall the caller's budget
      // (FCM handler ~25s on iOS) forever.
      populated = await AccountData().waitForPopulation().timeout(
        const Duration(seconds: 10),
      );
    } on TimeoutException {
      populated = false;
    }
    if (!populated) {
      // Deliberately does NOT flip the widget to logged-out: a failed or slow
      // keychain read (locked iOS device during the 06:00 silent push) is
      // indistinguishable from "never logged in" here, and blanking the
      // widget on a transient failure is worse than keeping the snapshot.
      // Logout/login manage the flag explicitly (WidgetSync.clear / login).
      log('[widget-refresh] credentials unavailable, skipping refresh');
      return;
    }
    final fetchedAt = await WidgetSync.getFetchedAt();
    if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) {
      log('[widget-refresh] snapshot is fresh, skipping refresh');
      return;
    }
    await _refresh();
  }

  static Future<void> cancelAll() async {
    await Workmanager().cancelAll();
  }
}

/// Pure debounce decision so it stays unit-testable. A `fetchedAt` in the
/// future (clock change, debug time shift) never skips — refreshing is the
/// safe direction.
bool shouldSkipRefresh({
  required DateTime? fetchedAt,
  required DateTime now,
  required bool force,
}) {
  if (force || fetchedAt == null) return false;
  final age = now.difference(fetchedAt);
  return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
}

@pragma('vm:entry-point')
void _callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    try {
      WidgetsFlutterBinding.ensureInitialized();
      await WidgetBackgroundTask.runRefreshNow();
      return true;
    } on Exception catch (e, s) {
      log('[widget-bg] refresh failed: $e', stackTrace: s);
      // false → Workmanager retries with backoff. Native side keeps the
      // last good snapshot so the user still sees something.
      return false;
    }
  });
}

Future<void> _refresh() async {
  await WidgetSync.ensureInitialized();
  // The background isolate doesn't go through main.dart's BlocBuilder, so we
  // re-apply the endpoint the foreground last persisted. Without this the
  // dio singleton would fall back to its hardcoded live default even when
  // the user picked beta/custom in the in-app settings.
  final mcBaseUrl = await WidgetSync.getMarianumConnectBaseUrl();
  if (mcBaseUrl != null && mcBaseUrl.isNotEmpty) {
    MarianumConnectEndpoint.update(mcBaseUrl);
  }

  final now = WidgetPublisher.widgetNow();
  // Fetch window matches the week payload's window so the widget can roll
  // forward into next week's lessons without fresh data.
  final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
  final weekEndExclusive = weekStart.add(
    const Duration(days: WidgetDataMapper.weekWindowDays),
  );

  // All six requests are independent — run them concurrently so the total
  // latency is the slowest request, not the sum (matters for the push path's
  // hard time budget). Reference-data failures fall through to null in the
  // mapper rather than aborting the whole refresh.
  final timetableFuture = TimetableGetWeek().run(
    from: weekStart,
    until: weekEndExclusive.subtract(const Duration(days: 1)),
  );
  final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
    () => TimetableGetSubjects().run(),
  );
  final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
    () => TimetableGetRooms().run(),
  );
  final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
    () => TimetableGetHolidays().run(),
  );
  final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
    () => TimetableGetTimegrid().run(),
  );
  final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
    () => GetCustomTimetableEvent(
      GetCustomTimetableEventParams(AccountData().getUserSecret()),
    ).run(),
  );
  final timetable = await timetableFuture;
  final subjects = await subjectsFuture;
  final rooms = await roomsFuture;
  final holidays = await holidaysFuture;
  final timegrid = await timegridFuture;
  final customEvents = await customEventsFuture;

  final lessons = timetable.entries;

  final [connectDouble, isTeacher] = await Future.wait([
    WidgetSync.getConnectDoubleLessons(),
    WidgetSync.getIsTeacher(),
  ]);
  final dayData = WidgetDataMapper.buildDayData(
    now: now,
    lessons: lessons,
    subjects: subjects,
    rooms: rooms,
    holidays: holidays,
    timegrid: timegrid,
    customEvents: customEvents,
    connectDoubleLessons: connectDouble,
    showClassInsteadOfTeacher: isTeacher,
  );
  final weekData = WidgetDataMapper.buildWeekData(
    now: now,
    lessons: lessons,
    subjects: subjects,
    rooms: rooms,
    holidays: holidays,
    timegrid: timegrid,
    customEvents: customEvents,
    connectDoubleLessons: connectDouble,
    showClassInsteadOfTeacher: isTeacher,
  );

  await WidgetSync.writeDayData(dayData);
  await WidgetSync.writeWeekData(weekData);
  await WidgetSync.setLoggedIn(true);
  await WidgetSync.triggerUpdate();
  log(
    '[widget-bg] refreshed: day=${dayData.lessons.length} '
    'week=${weekData.lessons.length}',
  );
}

Future<T?> _runOrNull<T>(Future<T> Function() task) async {
  try {
    return await task();
  } on Exception catch (e) {
    log('[widget-bg] reference fetch failed: $e');
    return null;
  }
}
