import 'dart:async';

import 'package:flutter/widgets.dart';

import '../media/library_query.dart';
import '../media/media_item.dart';
import '../utils/media_server_http_client.dart';
import '../exceptions/media_server_exceptions.dart';

/// Sparse-loading state + fetch orchestration for paginated item grids/lists.
///
/// State lives in [loadedItems] (index → item) and [totalSize]. Subclasses
/// provide [fetchPage] to hit an endpoint; the mixin handles dedupe, retry
/// with backoff, abort propagation, and request-generation invalidation so
/// reloads don't collide with in-flight fetches.
///
/// Typical lifecycle:
/// 1. Subclass's `loadItems` calls [resetPaginationState] inside `setState`,
///    then awaits [loadInitialPage].
/// 2. On scroll, subclass calls [ensureRangeLoaded] with the visible index
///    range. Eager prefetch ahead of the viewport via [prefetchAhead].
/// 3. On dispose, subclass calls [disposePagination].
mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
  /// Sparse map of loaded items, keyed by position.
  final Map<int, MediaItem> loadedItems = {};

  /// Total items on the server. 0 until the first page completes.
  int totalSize = 0;

  final Set<int> _loadingRanges = {};
  AbortController? _cancelToken;

  /// Monotonic generation — bumped on reset/dispose so stale fetches are
  /// discarded instead of mutating state from a prior load.
  int _requestId = 0;

  int _retryCount = 0;
  Timer? _retryTimer;
  bool _visibleRangeLoading = false;
  DateTime? _lastEagerPrefetch;

  /// Re-invoked by the retry timer. Most recent range-load args.
  VoidCallback? _scheduledRetry;

  /// Fetch a page of items. Subclass implements this — typically delegating
  /// to a paginated client method that returns a [LibraryPage] of [MediaItem].
  Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort);

  /// Hook fired after each successful page merge. Default: no-op.
  /// Override for image prefetch, syncing a base-class `items` list, etc.
  void onPageLoaded(int start, List<MediaItem> items) {}

  /// Synchronously clear pagination state and bump the generation counter.
  /// Call from inside the subclass's `setState` before awaiting
  /// [loadInitialPage]. Aborts any in-flight fetches from the previous load.
  void resetPaginationState() {
    _requestId++;
    _cancelToken?.abort();
    _cancelToken = AbortController();
    _retryTimer?.cancel();
    _retryCount = 0;
    _visibleRangeLoading = false;
    _lastEagerPrefetch = null;
    _scheduledRetry = null;
    loadedItems.clear();
    _loadingRanges.clear();
    totalSize = 0;
  }

  /// Fetch the first page. Await from outside `setState`. Mutates
  /// [loadedItems] and [totalSize] on success; throws on failure.
  Future<LibraryPage<MediaItem>> loadInitialPage(int pageSize) async {
    final generation = _requestId;
    final result = await fetchPage(0, pageSize, _cancelToken);
    if (generation != _requestId || !mounted) return result;

    for (var i = 0; i < result.items.length; i++) {
      loadedItems[i] = result.items[i];
    }
    totalSize = result.totalCount;
    onPageLoaded(0, result.items);
    return result;
  }

  /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
  /// with [buffer] extra indices on each side. Serialized — only one
  /// range-fetch runs at a time — and re-checks after each success so a
  /// single call can backfill multiple gaps.
  Future<void> ensureRangeLoaded(int firstIndex, int visibleCount, {int buffer = 100}) async {
    if (_visibleRangeLoading || totalSize == 0) return;

    final rangeStart = (firstIndex - buffer).clamp(0, totalSize);
    final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, totalSize);

    int? fetchStart;
    int? fetchEnd;
    for (var i = rangeStart; i < rangeEnd; i++) {
      if (!loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
        fetchStart ??= i;
        fetchEnd = i + 1;
      }
    }
    if (fetchStart == null || fetchEnd == null) return;

    _retryTimer?.cancel();
    _scheduledRetry = () => ensureRangeLoaded(firstIndex, visibleCount, buffer: buffer);
    _visibleRangeLoading = true;
    try {
      final success = await _fetchRange(fetchStart, fetchEnd - fetchStart);
      if (success && mounted) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (mounted) ensureRangeLoaded(firstIndex, visibleCount, buffer: buffer);
        });
      }
    } finally {
      _visibleRangeLoading = false;
    }
  }

  /// Throttled eager prefetch: if anything immediately outside the viewport
  /// is unloaded, fetch a page. Runs at most once per 100ms.
  void prefetchAhead(int firstIndex, int visibleCount, {int pageSize = 200}) {
    if (totalSize == 0) return;

    final now = DateTime.now();
    if (_lastEagerPrefetch != null && now.difference(_lastEagerPrefetch!) < const Duration(milliseconds: 100)) {
      return;
    }

    final lookAheadStart = (firstIndex + visibleCount).clamp(0, totalSize);
    final lookAheadEnd = (lookAheadStart + visibleCount).clamp(0, totalSize);
    for (var i = lookAheadStart; i < lookAheadEnd; i++) {
      if (!loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
        _lastEagerPrefetch = now;
        _fetchRange(i, pageSize);
        return;
      }
    }

    final lookBehindStart = (firstIndex - visibleCount).clamp(0, totalSize);
    for (var i = firstIndex - 1; i >= lookBehindStart; i--) {
      if (!loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
        _lastEagerPrefetch = now;
        _fetchRange(i, pageSize);
        return;
      }
    }
  }

  /// Evict entries far from [centerIndex] once [loadedItems] exceeds
  /// [threshold], keeping [maxKeep] entries centered on [centerIndex].
  void evictDistantItems(int centerIndex, {int maxKeep = 500, int threshold = 600}) {
    if (loadedItems.length <= threshold) return;
    final halfKeep = maxKeep ~/ 2;
    loadedItems.removeWhere((index, _) => index < centerIndex - halfKeep || index > centerIndex + halfKeep);
  }

  /// Remove the item at [index] and shift higher indices down by one.
  /// Mirrors the "one item deleted on the server" invariant: decrements
  /// [totalSize] even if [index] wasn't in the sparse map (evicted).
  void removeLoadedItemAndShift(int index) {
    loadedItems.remove(index);
    final shifted = <int, MediaItem>{};
    for (final entry in loadedItems.entries) {
      if (entry.key > index) {
        shifted[entry.key - 1] = entry.value;
      } else {
        shifted[entry.key] = entry.value;
      }
    }
    loadedItems
      ..clear()
      ..addAll(shifted);
    totalSize = (totalSize - 1).clamp(0, totalSize);
  }

  /// Discard the "fetch in flight" markers. In-flight network requests keep
  /// running but are no longer considered for dedupe — the next
  /// [ensureRangeLoaded] / [prefetchAhead] will re-scan the visible range.
  /// Used by scroll-idle handlers after a fast scroll where earlier eager
  /// prefetches are aimed at a now-irrelevant region.
  void clearPendingRanges() {
    _loadingRanges.clear();
  }

  /// Ensure the page containing [index] is fetched (or already fetched).
  /// For callers that don't track viewport geometry — trigger this when a
  /// skeleton for [index] is built, and the containing page will backfill.
  /// Dedupes so multiple skeletons in the same page share one fetch.
  void ensureIndexLoaded(int index, {int pageSize = 200}) {
    if (totalSize == 0 || index >= totalSize || index < 0) return;
    if (loadedItems.containsKey(index) || _loadingRanges.contains(index)) return;
    final pageStart = (index ~/ pageSize) * pageSize;
    // Wire up the backoff retry: if this fetch fails, the retry timer in
    // _fetchRange needs something to call. Without this, a failed fetch on a
    // skeleton-only screen leaves the skeleton stuck until something else
    // triggers a rebuild.
    _scheduledRetry = () => ensureIndexLoaded(index, pageSize: pageSize);
    _fetchRange(pageStart, pageSize);
  }

  /// Aborts in-flight requests and cancels timers. Call from `dispose()`.
  void disposePagination() {
    _requestId++;
    _cancelToken?.abort();
    _cancelToken = null;
    _retryTimer?.cancel();
    _retryTimer = null;
    _loadingRanges.clear();
    _scheduledRetry = null;
  }

  Future<bool> _fetchRange(int start, int size) async {
    if (start >= totalSize) return false;
    final clampedSize = size.clamp(0, totalSize - start);
    if (clampedSize == 0) return false;

    final indices = List.generate(clampedSize, (i) => start + i);
    if (indices.every((i) => _loadingRanges.contains(i) || loadedItems.containsKey(i))) return true;
    _loadingRanges.addAll(indices);

    final generation = _requestId;

    try {
      final result = await fetchPage(start, clampedSize, _cancelToken);
      if (generation != _requestId || !mounted) return false;

      setState(() {
        for (var i = 0; i < result.items.length; i++) {
          loadedItems[start + i] = result.items[i];
        }
        if (result.totalCount != totalSize) totalSize = result.totalCount;
      });

      _retryCount = 0;
      onPageLoaded(start, result.items);
      return true;
    } catch (e) {
      if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.cancelled) return false;
      _retryCount++;
      final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
      _retryTimer?.cancel();
      _retryTimer = Timer(delay, () {
        if (mounted && generation == _requestId) _scheduledRetry?.call();
      });
      return false;
    } finally {
      _loadingRanges.removeAll(indices);
    }
  }
}
