// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C).
//
// Subscribes to a drift watch() stream. On each emission:
//   - non-empty → map to result type T and yield
//   - empty + online → fetch via REST, populate drift, await re-emission
//   - empty + offline → yield mapped empty result (UI shows empty state)
//
// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh
// in the background after the first non-empty emission. Use for
// aggregate lists (playlists, etc.) where the server may have rows
// the local sync didn't pick up — yields cache immediately, refreshes
// silently, and drift watch() re-emits with whatever new rows landed.
//
// The pattern lets every read provider trust drift as the source of
// truth. SyncController keeps drift fresh in the background; widget
// rebuilds happen automatically as drift writes propagate via watch().

import 'dart:async';

import 'package:flutter/foundation.dart' show debugPrint;

// `tag` parameter is preserved for future ad-hoc instrumentation.
// Normal operation only logs failure paths so the per-screen log
// noise stays low.

/// Wraps the watch + cold-cache fallback pattern. Generic over:
///   D — the drift row type (or TypedResult for joins)
///   T — the result type the caller wants (e.g. `List<ArtistRef>`)
///
/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()`
/// returns true. It must populate drift via its own side-effect; the
/// drift watch() stream will re-emit and this helper yields the
/// populated rows on the next iteration.
///
/// REST failures are swallowed — the helper falls through to yielding
/// the empty result. Caller is responsible for surfacing errors via
/// toast etc.
Stream<T> cacheFirst<D, T>({
  required Stream<List<D>> driftStream,
  required Future<void> Function() fetchAndPopulate,
  required T Function(List<D>) toResult,
  required Future<bool> Function() isOnline,
  bool alwaysRefresh = false,
  String? tag,
}) async* {
  // Tracks whether we've already kicked off a stale-while-revalidate
  // refresh for this stream subscription, so we don't fire one on every
  // drift re-emission (otherwise the populate cycles forever).
  var revalidated = false;

  // Tracks whether we've already tried a cold-cache fetch on this
  // subscription. Without this, providers can spin forever in the
  // rows-empty branch when fetchAndPopulate writes rows that don't
  // match THIS filter — e.g. three liked-tab streams sharing one
  // populate: the track populate writes track-typed rows, drift
  // watch fires for the cached_likes table, the album stream
  // re-emits with rows-empty (no album likes), the rows-empty
  // branch re-fires populate, repeats forever. Setting this guard
  // makes the first fetch attempt also the last for any given
  // subscription.
  var coldFetchAttempted = false;

  await for (final rows in driftStream) {
    if (rows.isNotEmpty) {
      yield toResult(rows);
      coldFetchAttempted = true;
      // Stale-while-revalidate: yield cache immediately, then kick off
      // a REST refresh in the background. Drift watch() picks up the
      // resulting writes and re-emits via this same stream loop.
      // Useful for aggregate lists (e.g. playlists) where the server
      // may have rows the local sync didn't pick up.
      if (alwaysRefresh && !revalidated && await isOnline()) {
        revalidated = true;
        unawaited(_safeFetch(fetchAndPopulate));
      }
      continue;
    }
    // rows is empty. If we've already attempted a cold fetch and
    // drift is still empty for this filter, yield empty so the UI
    // shows the no-content state instead of spinning forever.
    if (coldFetchAttempted) {
      yield toResult(rows);
      continue;
    }
    coldFetchAttempted = true;
    if (await isOnline()) {
      try {
        await fetchAndPopulate();
        // Yield the current (still-empty) rows so the UI moves past
        // loading even if populate was a no-op for this filter
        // (server returned nothing matching, or all rows were
        // already in drift via sync). If populate DID write rows
        // matching this filter, the drift watch's next emission
        // yields them via the rows.isNotEmpty branch — UI briefly
        // shows empty then populates, instead of spinning.
        yield toResult(rows);
      } catch (e, st) {
        if (tag != null) {
          debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
        }
        yield toResult(rows); // empty result; caller surfaces error
      }
    } else {
      yield toResult(rows); // empty result; offline
    }
  }
}

Future<void> _safeFetch(Future<void> Function() fn) async {
  try {
    await fn();
  } catch (_) {
    // Background revalidate — swallow; UI already showed cached state.
  }
}
