import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/discover_provider.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/deletion_notifier.dart';
import 'package:plezy/utils/watch_state_notifier.dart';

import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';

MediaItem _item(
  String id, {
  String? parentId,
  String? grandparentId,
  MediaKind kind = MediaKind.episode,
  String serverId = 'server_1',
}) => testMediaItem(
  id: id,
  backend: MediaBackend.plex,
  kind: kind,
  title: id,
  serverId: serverId,
  serverName: 'Server',
  parentId: parentId,
  grandparentId: grandparentId,
);

MediaHub _hub(
  String id, {
  String? identifier,
  String? libraryId,
  List<MediaItem>? items,
  String serverId = 'server_1',
}) => MediaHub(
  id: id,
  title: id,
  type: 'movie',
  identifier: identifier,
  items: items ?? [_item('$id-item', serverId: serverId)],
  size: 1,
  libraryId: libraryId,
  serverId: serverId,
);

/// Counting fake — the provider's fetch-cost policy is the contract under
/// test: a durable watch event must cost exactly one on-deck call and zero hub
/// refetches, progress zero calls, an order change zero calls, and a hidden-set
/// change one full pass.
class _FakeAggregationService extends DataAggregationService {
  _FakeAggregationService(super.serverManager);

  int onDeckCalls = 0;
  int hubCalls = 0;
  Set<String>? lastOnDeckServerIds;
  Set<String>? lastHubsServerIds;
  Set<String>? onDeckSucceededServerIds;
  Set<String>? hubSucceededServerIds;
  Set<String> onDeckCancelledServerIds = const {};
  Set<String> hubCancelledServerIds = const {};
  List<MediaItem> Function() onDeckResult = () => const [];
  List<MediaHub> Function() hubsResult = () => const [];
  Future<void>? onDeckGate;
  Future<void>? hubGate;
  Completer<void>? onDeckStarted;
  Completer<void>? hubStarted;

  @override
  Future<OnDeckAggregationResult> getOnDeckFromAllServers({
    int? limit,
    Set<String>? hiddenLibraryKeys,
    Set<String>? serverIds,
  }) async {
    onDeckCalls++;
    lastOnDeckServerIds = serverIds;
    final started = onDeckStarted;
    if (started != null && !started.isCompleted) started.complete();
    final gate = onDeckGate;
    if (gate != null) await gate;
    final items = onDeckResult();
    return (
      items: limit != null && items.length > limit ? items.sublist(0, limit) : items,
      succeededServerIds: onDeckSucceededServerIds ?? serverIds ?? const {'server_1'},
      cancelledServerIds: onDeckCancelledServerIds,
    );
  }

  @override
  Future<HubAggregationResult> getHubsFromAllServers({
    int? limit,
    Set<String>? hiddenLibraryKeys,
    bool useGlobalHubs = true,
    bool includePlaybackHubs = true,
    Set<String>? serverIds,
  }) async {
    hubCalls++;
    lastHubsServerIds = serverIds;
    final started = hubStarted;
    if (started != null && !started.isCompleted) started.complete();
    final gate = hubGate;
    if (gate != null) await gate;
    return (
      hubs: hubsResult(),
      succeededServerIds: hubSucceededServerIds ?? serverIds ?? const {'server_1'},
      cancelledServerIds: hubCancelledServerIds,
    );
  }
}

class _FakeClient implements MediaServerClient {
  final String serverIdValue;
  final String serverNameValue;
  final List<String> fetchedItemIds = [];
  MediaItem? itemResult;

  _FakeClient({this.serverIdValue = 'server_1', this.serverNameValue = 'Server'});

  @override
  ServerId get serverId => ServerId(serverIdValue);

  @override
  String? get serverName => serverNameValue;

  @override
  MediaBackend get backend => MediaBackend.plex;

  @override
  ServerCapabilities get capabilities => ServerCapabilities.plex;

  @override
  Future<MediaItem?> fetchItem(String id, {bool useCache = true}) async {
    fetchedItemIds.add(id);
    return itemResult;
  }

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  late _FakeClient client;
  late MultiServerManager manager;
  late _FakeAggregationService aggregation;
  late MultiServerProvider multiServer;
  late HiddenLibrariesProvider hiddenLibraries;
  late LibrariesProvider libraries;
  late DiscoverProvider provider;
  late List<(String, List<MediaItem>)> shelfSyncs;
  bool isBinding = false;

  setUp(() async {
    resetSharedPreferencesForTest();
    SettingsService.resetForTesting();
    await SettingsService.getInstance();
    isBinding = false;
    shelfSyncs = [];

    client = _FakeClient();
    manager = MultiServerManager()..debugRegisterClientForTesting(client);
    aggregation = _FakeAggregationService(manager);
    multiServer = MultiServerProvider(manager, aggregation);
    hiddenLibraries = HiddenLibrariesProvider();
    libraries = LibrariesProvider();
    provider = DiscoverProvider(
      multiServer,
      hiddenLibraries,
      libraries,
      profileId: 'profile-a',
      isProfileBinding: () => isBinding,
      syncSystemShelf: (owner, items) async => shelfSyncs.add((owner, List<MediaItem>.of(items))),
    );
  });

  tearDown(() {
    provider.dispose();
    libraries.dispose();
    hiddenLibraries.dispose();
    multiServer.dispose();
  });

  test('load publishes on-deck and hubs; concurrent calls coalesce', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];

    // Three synchronous calls: one in-flight pass plus at most one trailing
    // pass (a request arriving mid-load must observe its own fresh fetch).
    await Future.wait([provider.load(), provider.load(), provider.load()]);

    expect(provider.onDeck.map((i) => i.id), ['a']);
    expect(provider.hubs.map((h) => h.id), ['hub-1']);
    expect(provider.isLoading, isFalse);
    expect(provider.areHubsLoading, isFalse);
    expect(provider.errorMessage, isNull);
    expect(aggregation.onDeckCalls, 2);
    expect(aggregation.hubCalls, 2);
  });

  test('a failed in-flight pass still runs one coalesced trailing pass', () async {
    final gate = Completer<void>();
    aggregation.onDeckGate = gate.future;
    aggregation.onDeckStarted = Completer<void>();
    aggregation.onDeckResult = () {
      if (aggregation.onDeckCalls == 1) throw Exception('first pass failed');
      return [_item('recovered')];
    };
    aggregation.hubsResult = () => [_hub('hub-1')];

    final first = provider.load();
    await aggregation.onDeckStarted!.future;
    final coalesced = provider.load();
    gate.complete();
    await Future.wait([first, coalesced]);

    expect(aggregation.onDeckCalls, 2);
    expect(aggregation.hubCalls, 2);
    expect(provider.onDeck.map((item) => item.id), ['recovered']);
    expect(provider.errorMessage, isNull);
  });

  test('dispose during an in-flight coalesced load prevents trailing work and commits', () async {
    final scoped = DiscoverProvider(
      multiServer,
      hiddenLibraries,
      libraries,
      profileId: 'profile-a',
      isProfileBinding: () => isBinding,
    );
    final gate = Completer<void>();
    aggregation.onDeckGate = gate.future;
    aggregation.hubGate = gate.future;
    aggregation.onDeckStarted = Completer<void>();
    aggregation.hubStarted = Completer<void>();
    aggregation.onDeckResult = () => [_item('late')];
    aggregation.hubsResult = () => [_hub('late-hub')];

    final first = scoped.load();
    await Future.wait([aggregation.onDeckStarted!.future, aggregation.hubStarted!.future]);
    final coalesced = scoped.load();
    scoped.dispose();
    gate.complete();
    await Future.wait([first, coalesced]);

    expect(aggregation.onDeckCalls, 1);
    expect(aggregation.hubCalls, 1);
    expect(scoped.onDeck, isEmpty);
    expect(scoped.hubs, isEmpty);
    expect(scoped.loadGeneration, 0);

    await scoped.load();
    await scoped.syncToOnlineServers({'server_1'});
    expect(aggregation.onDeckCalls, 1);
    expect(aggregation.hubCalls, 1);
  });

  test('limits the preview row and probes for more', () async {
    aggregation.onDeckResult = () => [for (var i = 0; i < 30; i++) _item('item-$i')];

    await provider.load();

    expect(provider.onDeck, hasLength(DiscoverProvider.continueWatchingPreviewLimit));
    expect(provider.hasMoreContinueWatching, isTrue);
  });

  test('filters playback-progress hubs that duplicate the continue watching row', () async {
    aggregation.hubsResult = () => [
      _hub('keep'),
      _hub('cw', identifier: 'home.continue'),
      _hub('od', identifier: 'home.ondeck'),
      _hub('nu', identifier: 'home.nextup'),
    ];

    await provider.load();

    expect(provider.hubs.map((h) => h.id), ['keep']);
  });

  test('watched and unwatched events refresh continue watching only', () async {
    aggregation.onDeckResult = () => [_item('ep-1', parentId: 'season-1')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    WatchStateNotifier().notifyWatched(item: _item('ep-1', parentId: 'season-1'));
    await pumpEventQueue();

    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
    expect(aggregation.hubCalls, hubCallsBefore);

    WatchStateNotifier().notifyWatched(item: _item('ep-1', parentId: 'season-1'), isNowWatched: false);
    await pumpEventQueue();

    expect(aggregation.onDeckCalls, onDeckCallsBefore + 2);
    expect(aggregation.hubCalls, hubCallsBefore);
  });

  test('full, background, and delta publication forward the profile owner', () async {
    aggregation.onDeckResult = () => [_item('full')];
    aggregation.hubsResult = () => [_hub('hub')];
    await provider.load();
    await pumpEventQueue();
    expect(shelfSyncs, isNotEmpty);
    expect(shelfSyncs.every((sync) => sync.$1 == 'profile-a'), isTrue);

    shelfSyncs.clear();
    aggregation.onDeckResult = () => [_item('refresh')];
    await provider.refreshContinueWatching();
    await pumpEventQueue();
    expect(shelfSyncs.map((sync) => sync.$1), ['profile-a']);

    shelfSyncs.clear();
    aggregation.onDeckSucceededServerIds = {'server_2'};
    aggregation.hubSucceededServerIds = {'server_2'};
    aggregation.onDeckResult = () => [_item('delta', serverId: 'server_2')];
    aggregation.hubsResult = () => [_hub('delta-hub', serverId: 'server_2')];
    await provider.syncToOnlineServers({'server_1', 'server_2'});
    await pumpEventQueue();
    expect(shelfSyncs.map((sync) => sync.$1), ['profile-a']);
  });

  test('null profile owner never publishes to the system shelf', () async {
    final calls = <String>[];
    final ownerless = DiscoverProvider(
      multiServer,
      hiddenLibraries,
      libraries,
      profileId: null,
      isProfileBinding: () => isBinding,
      syncSystemShelf: (owner, items) async => calls.add(owner),
    );
    addTearDown(ownerless.dispose);
    aggregation.onDeckResult = () => [_item('private')];
    aggregation.hubsResult = () => [_hub('hub')];

    await ownerless.load();
    await pumpEventQueue();

    expect(calls, isEmpty);
  });

  test('sub-threshold progress patches the row without refetching', () async {
    final playing = _item('ep-1').copyWith(durationMs: 100000, viewOffsetMs: 10000, viewCount: 0);
    aggregation.onDeckResult = () => [playing, for (var i = 2; i <= 21; i++) _item('ep-$i')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    await pumpEventQueue();
    shelfSyncs.clear();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    WatchStateNotifier().notifyProgress(item: playing, viewOffset: 30000, duration: 100000);
    await pumpEventQueue();

    expect(provider.onDeck.first.viewOffsetMs, 30000);
    expect(provider.onDeck.first.isWatched, isFalse);
    expect(provider.onDeck, hasLength(DiscoverProvider.continueWatchingPreviewLimit));
    expect(provider.hasMoreContinueWatching, isTrue);
    expect(aggregation.onDeckCalls, onDeckCallsBefore);
    expect(aggregation.hubCalls, hubCallsBefore);
    expect(shelfSyncs, hasLength(1));
    expect(shelfSyncs.single.$1, 'profile-a');
    expect(shelfSyncs.single.$2.first.viewOffsetMs, 30000);
  });

  test('watched-threshold progress refreshes continue watching only', () async {
    final playing = _item('ep-1').copyWith(durationMs: 100000, viewOffsetMs: 80000);
    aggregation.onDeckResult = () => [playing];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    WatchStateNotifier().notifyProgress(item: playing, viewOffset: 95000, duration: 100000);
    await pumpEventQueue();

    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
    expect(aggregation.hubCalls, hubCallsBefore);
  });

  test('removal event drops the row immediately, then refreshes in background', () async {
    aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    var sawImmediateRemoval = false;
    provider.addListener(() {
      if (provider.onDeck.length == 1 && provider.onDeck.single.id == 'ep-2') {
        sawImmediateRemoval = true;
      }
    });
    aggregation.onDeckResult = () => [_item('ep-2')];

    WatchStateNotifier().notifyRemovedFromContinueWatching(item: _item('ep-1'));
    await pumpEventQueue();

    expect(sawImmediateRemoval, isTrue);
    expect(provider.onDeck.map((i) => i.id), ['ep-2']);
    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
    expect(aggregation.hubCalls, hubCallsBefore);
  });

  test('deletion drops the item from on-deck and hubs, then refreshes continue watching only', () async {
    aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
    aggregation.hubsResult = () => [
      _hub('hub-1', items: [_item('ep-1'), _item('other')]),
    ];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    var sawImmediateRemoval = false;
    provider.addListener(() {
      if (provider.onDeck.length == 1 && provider.onDeck.single.id == 'ep-2') {
        sawImmediateRemoval = true;
      }
    });
    aggregation.onDeckResult = () => [_item('ep-2')];

    DeletionNotifier().notifyDeletedItem(item: _item('ep-1'));
    await pumpEventQueue();

    expect(sawImmediateRemoval, isTrue);
    expect(provider.onDeck.map((i) => i.id), ['ep-2']);
    expect(provider.hubs.single.items.map((i) => i.id), ['other']);
    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
    expect(aggregation.hubCalls, hubCallsBefore);
  });

  test('deleting an ancestor removes its episodes from continue watching', () async {
    aggregation.onDeckResult = () => [_item('ep-1', grandparentId: 'show-1'), _item('ep-2')];
    await provider.load();
    aggregation.onDeckResult = () => [_item('ep-2')];

    DeletionNotifier().notifyDeletedItem(item: _item('show-1', kind: MediaKind.show));
    await pumpEventQueue();

    expect(provider.onDeck.map((i) => i.id), ['ep-2']);
  });

  test('download-only deletion leaves lists untouched and triggers no refetch', () async {
    aggregation.onDeckResult = () => [_item('ep-1')];
    aggregation.hubsResult = () => [
      _hub('hub-1', items: [_item('ep-1')]),
    ];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    DeletionNotifier().notifyDeletedItem(item: _item('ep-1'), isDownloadOnly: true);
    await pumpEventQueue();

    expect(provider.onDeck.map((i) => i.id), ['ep-1']);
    expect(provider.hubs.single.items.map((i) => i.id), ['ep-1']);
    expect(aggregation.onDeckCalls, onDeckCallsBefore);
    expect(aggregation.hubCalls, hubCallsBefore);
  });

  test('library order change re-sorts hubs without any refetch', () async {
    aggregation.hubsResult = () => [_hub('hub-lib2', libraryId: 'lib-2'), _hub('hub-lib1', libraryId: 'lib-1')];
    await provider.load();
    expect(provider.hubs.map((h) => h.id), ['hub-lib2', 'hub-lib1']);
    final hubCallsBefore = aggregation.hubCalls;

    MediaLibrary lib(String id) => MediaLibrary(id: id, backend: MediaBackend.plex, title: id, serverId: 'server_1');
    await libraries.updateLibraryOrder([lib('lib-1'), lib('lib-2')]);
    await pumpEventQueue();

    expect(provider.hubs.map((h) => h.id), ['hub-lib1', 'hub-lib2']);
    expect(aggregation.hubCalls, hubCallsBefore);
  });

  test('hidden-library change triggers exactly one full reload', () async {
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    await hiddenLibraries.hideLibrary('server_1:lib-1');
    await pumpEventQueue();

    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
    expect(aggregation.hubCalls, hubCallsBefore + 1);
  });

  test('refreshContinueWatching never flips states or surfaces errors', () async {
    aggregation.onDeckResult = () => [_item('a')];
    await provider.load();

    aggregation.onDeckResult = () => throw Exception('server down');
    await provider.refreshContinueWatching();

    expect(provider.onDeck.map((i) => i.id), ['a']);
    expect(provider.errorMessage, isNull);
    expect(provider.isLoading, isFalse);
  });

  test('load failure surfaces the error and ends both loading states', () async {
    aggregation.onDeckResult = () => throw Exception('boom');

    await provider.load();

    expect(provider.errorMessage, contains('boom'));
    expect(provider.isLoading, isFalse);
    expect(provider.areHubsLoading, isFalse);
  });

  test('no servers while the profile binder runs stays loading instead of erroring', () async {
    final emptyManager = MultiServerManager();
    final emptyAggregation = _FakeAggregationService(emptyManager);
    final emptyMultiServer = MultiServerProvider(emptyManager, emptyAggregation);
    addTearDown(emptyMultiServer.dispose);
    final binderProvider = DiscoverProvider(
      emptyMultiServer,
      hiddenLibraries,
      libraries,
      profileId: 'profile-a',
      isProfileBinding: () => isBinding,
    );
    addTearDown(binderProvider.dispose);

    isBinding = true;
    await binderProvider.load();
    expect(binderProvider.isLoading, isTrue);
    expect(binderProvider.errorMessage, isNull);

    isBinding = false;
    await binderProvider.load();
    expect(binderProvider.isLoading, isFalse);
    expect(binderProvider.errorMessage, isNotNull);
  });

  // A pass in which zero servers succeeded is never authoritative: it must
  // not wipe existing content, and it may only commit "loaded, empty" when
  // the failure is settled (no cancellations, binder not running). The
  // sign-in empty-flash regression: a rebind tore down the client mid-fetch,
  // the aborted pass committed loaded-empty, and the screen flashed
  // "no content available" until the follow-up load landed.

  test('zero-success pass with cancellations stays loading instead of committing empty', () async {
    aggregation.onDeckSucceededServerIds = const {};
    aggregation.hubSucceededServerIds = const {};
    aggregation.onDeckCancelledServerIds = const {'server_1'};
    aggregation.hubCancelledServerIds = const {'server_1'};

    await provider.load();

    expect(provider.isLoading, isTrue);
    expect(provider.areHubsLoading, isTrue);
    expect(provider.errorMessage, isNull);
    expect(provider.loadGeneration, 0);

    // The guaranteed follow-up load lands the real content.
    aggregation.onDeckSucceededServerIds = null;
    aggregation.hubSucceededServerIds = null;
    aggregation.onDeckCancelledServerIds = const {};
    aggregation.hubCancelledServerIds = const {};
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();

    expect(provider.onDeck.map((i) => i.id), ['a']);
    expect(provider.hubs.map((h) => h.id), ['hub-1']);
    expect(provider.isLoading, isFalse);
    expect(provider.areHubsLoading, isFalse);
  });

  test('zero-success pass during profile binding stays loading (no cancellations)', () async {
    // Covers the timeout-during-bind window: every fetch failed while the
    // binder was still wiring servers, with no cancellation marker.
    isBinding = true;
    aggregation.onDeckSucceededServerIds = const {};
    aggregation.hubSucceededServerIds = const {};

    await provider.load();

    expect(provider.isLoading, isTrue);
    expect(provider.areHubsLoading, isTrue);
    expect(provider.errorMessage, isNull);
  });

  test('settled zero-success pass with no prior content commits loaded-empty', () async {
    // Locks the no-eternal-spinner constraint: a genuinely dead server
    // outside any disruption window keeps today's empty state.
    aggregation.onDeckSucceededServerIds = const {};
    aggregation.hubSucceededServerIds = const {};

    await provider.load();

    expect(provider.isLoading, isFalse);
    expect(provider.areHubsLoading, isFalse);
    expect(provider.onDeck, isEmpty);
    expect(provider.hubs, isEmpty);
    expect(provider.errorMessage, isNull);
  });

  test('totally failed refresh keeps previous content instead of wiping it', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    final generationBefore = provider.loadGeneration;

    aggregation.onDeckSucceededServerIds = const {};
    aggregation.hubSucceededServerIds = const {};
    aggregation.onDeckResult = () => const [];
    aggregation.hubsResult = () => const [];
    await provider.load();

    expect(provider.onDeck.map((i) => i.id), ['a']);
    expect(provider.hubs.map((h) => h.id), ['hub-1']);
    expect(provider.isLoading, isFalse);
    expect(provider.areHubsLoading, isFalse);
    // No new data: a failed pass must not reset the hero carousel.
    expect(provider.loadGeneration, generationBefore);

    // The kept content does not count as covering the failed servers — the
    // next status emission refetches them.
    aggregation.onDeckSucceededServerIds = null;
    aggregation.hubSucceededServerIds = null;
    aggregation.onDeckResult = () => [_item('b')];
    final callsBefore = aggregation.onDeckCalls;
    await provider.syncToOnlineServers({'server_1'});
    expect(aggregation.onDeckCalls, greaterThan(callsBefore));
  });

  test('a disrupted half is independent: on-deck commits while hubs stay loading', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubSucceededServerIds = const {};
    aggregation.hubCancelledServerIds = const {'server_1'};

    await provider.load();

    expect(provider.isLoading, isFalse);
    expect(provider.onDeck.map((i) => i.id), ['a']);
    expect(provider.areHubsLoading, isTrue);
    expect(provider.hubs, isEmpty);
  });

  test('updateItem qualifies same bare ids by source server without fan-out', () async {
    final serverOneItem = _item('shared-id', serverId: 'server_1').copyWith(title: 'Server One Original');
    final serverTwoItem = _item('shared-id', serverId: 'server_2').copyWith(title: 'Server Two Original');
    final serverTwoClient = _FakeClient(serverIdValue: 'server_2', serverNameValue: 'Server Two');
    manager.debugRegisterClientForTesting(serverTwoClient);

    aggregation.onDeckResult = () => [serverOneItem, serverTwoItem];
    aggregation.hubsResult = () => [
      _hub('hub-1', serverId: 'server_1', items: [serverOneItem]),
      _hub('hub-2', serverId: 'server_2', items: [serverTwoItem]),
    ];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;
    final source = provider.hubs
        .expand((hub) => hub.items)
        .singleWhere((item) => item.globalKey == serverTwoItem.globalKey);
    final updated = serverTwoItem.copyWith(title: 'Server Two Refreshed');
    serverTwoClient.itemResult = updated;

    await provider.updateItem(source);

    // The old bare-id aggregation path fetched every server. The source item
    // must instead select one client and leave both full-list fetch surfaces
    // untouched.
    expect(client.fetchedItemIds, isEmpty);
    expect(serverTwoClient.fetchedItemIds, ['shared-id']);
    expect(aggregation.onDeckCalls, onDeckCallsBefore);
    expect(aggregation.hubCalls, hubCallsBefore);

    final onDeckByKey = {for (final item in provider.onDeck) item.globalKey: item};
    final hubItemsByKey = {for (final item in provider.hubs.expand((hub) => hub.items)) item.globalKey: item};
    expect(onDeckByKey[serverOneItem.globalKey], same(serverOneItem));
    expect(onDeckByKey[serverTwoItem.globalKey], same(updated));
    expect(hubItemsByKey[serverOneItem.globalKey], same(serverOneItem));
    expect(hubItemsByKey[serverTwoItem.globalKey], same(updated));
  });

  test('syncToOnlineServers reloads for mid-session connects only', () async {
    aggregation.onDeckResult = () => [_item('a')];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;

    // Same server set → already covered, no fetch.
    await provider.syncToOnlineServers({'server_1'});
    expect(aggregation.onDeckCalls, onDeckCallsBefore);

    // New server mid-session → one delta fetch scoped to it.
    await provider.syncToOnlineServers({'server_1', 'server_2'});
    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);

    // During profile binding the startup priming owns loading — waves are
    // ignored so the hub fan-out doesn't run once per wave.
    isBinding = true;
    await provider.syncToOnlineServers({'server_1', 'server_2', 'server_3'});
    expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
  });

  test('mid-session connect delta-fetches only the new server and merges', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    final generationBefore = provider.loadGeneration;

    aggregation.onDeckResult = () => [_item('b', serverId: 'server_2')];
    aggregation.hubsResult = () => [_hub('hub-2', serverId: 'server_2')];
    await provider.syncToOnlineServers({'server_1', 'server_2'});

    // The fetch fanned out to the new server only…
    expect(aggregation.lastOnDeckServerIds, {'server_2'});
    expect(aggregation.lastHubsServerIds, {'server_2'});
    // …and merged into the loaded state instead of replacing it.
    expect(provider.onDeck.map((i) => i.id), containsAll(['a', 'b']));
    expect(provider.hubs.map((h) => h.id), containsAll(['hub-1', 'hub-2']));
    // A delta behaves like a background refresh: no hero carousel reset.
    expect(provider.loadGeneration, generationBefore);

    // Already merged → the next emission with the same set is a no-op.
    final callsAfterDelta = aggregation.onDeckCalls;
    await provider.syncToOnlineServers({'server_1', 'server_2'});
    expect(aggregation.onDeckCalls, callsAfterDelta);
  });

  test('online-server deltas arriving mid-pass are unioned into one trailing pass', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    final gate = Completer<void>();
    aggregation.onDeckGate = gate.future;
    aggregation.hubGate = gate.future;
    aggregation.onDeckStarted = Completer<void>();
    aggregation.hubStarted = Completer<void>();
    aggregation.onDeckResult = () => [_item('new', serverId: 'server_2')];
    aggregation.hubsResult = () => [_hub('new-hub', serverId: 'server_2')];

    final firstDelta = provider.syncToOnlineServers({'server_1', 'server_2'});
    await Future.wait([aggregation.onDeckStarted!.future, aggregation.hubStarted!.future]);
    final trailingDelta = provider.syncToOnlineServers({'server_1', 'server_2', 'server_3'});

    gate.complete();
    await Future.wait([firstDelta, trailingDelta]);

    expect(aggregation.onDeckCalls, onDeckCallsBefore + 2);
    expect(aggregation.hubCalls, hubCallsBefore + 2);
    expect(aggregation.lastOnDeckServerIds, {'server_3'});
    expect(aggregation.lastHubsServerIds, {'server_3'});
  });

  test('full load partial hub failure retries hubs without refetching continue watching', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => const [];
    aggregation.hubSucceededServerIds = const {};
    await provider.load();
    final onDeckCallsBefore = aggregation.onDeckCalls;
    final hubCallsBefore = aggregation.hubCalls;

    aggregation.hubsResult = () => [_hub('hub-1')];
    aggregation.hubSucceededServerIds = const {'server_1'};
    await provider.syncToOnlineServers({'server_1'});

    expect(aggregation.onDeckCalls, onDeckCallsBefore);
    expect(aggregation.hubCalls, hubCallsBefore + 1);
    expect(aggregation.lastHubsServerIds, {'server_1'});
    expect(provider.hubs.map((h) => h.id), ['hub-1']);
  });

  test('delta partial hub failure retries only the missing surface', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();

    aggregation.onDeckResult = () => [_item('b', serverId: 'server_2')];
    aggregation.hubsResult = () => const [];
    aggregation.hubSucceededServerIds = const {};
    await provider.syncToOnlineServers({'server_1', 'server_2'});
    final onDeckCallsAfterPartial = aggregation.onDeckCalls;
    final hubCallsAfterPartial = aggregation.hubCalls;

    aggregation.hubsResult = () => [_hub('hub-2', serverId: 'server_2')];
    aggregation.hubSucceededServerIds = const {'server_2'};
    await provider.syncToOnlineServers({'server_1', 'server_2'});

    expect(aggregation.onDeckCalls, onDeckCallsAfterPartial);
    expect(aggregation.hubCalls, hubCallsAfterPartial + 1);
    expect(aggregation.lastHubsServerIds, {'server_2'});
    expect(provider.onDeck.map((i) => i.id), containsAll(['a', 'b']));
    expect(provider.hubs.map((h) => h.id), containsAll(['hub-1', 'hub-2']));
  });

  test('delta failure keeps the loaded state and retries on the next emission', () async {
    aggregation.onDeckResult = () => [_item('a')];
    aggregation.hubsResult = () => [_hub('hub-1')];
    await provider.load();
    final callsBefore = aggregation.onDeckCalls;

    aggregation.onDeckResult = () => throw Exception('flaky connect');
    await provider.syncToOnlineServers({'server_1', 'server_2'});
    expect(provider.onDeck.map((i) => i.id), ['a']);
    expect(provider.errorMessage, isNull);
    expect(provider.isLoading, isFalse);

    // The failed id was not marked loaded, so the next emission retries it.
    aggregation.onDeckResult = () => [_item('b', serverId: 'server_2')];
    await provider.syncToOnlineServers({'server_1', 'server_2'});
    expect(aggregation.onDeckCalls, callsBefore + 2);
    expect(provider.onDeck.map((i) => i.id), containsAll(['a', 'b']));
  });

  test('dispose unregisters the online-servers listener', () {
    final before = multiServer.onlineServersListenerCount;
    final extra = DiscoverProvider(
      multiServer,
      hiddenLibraries,
      libraries,
      profileId: 'profile-a',
      isProfileBinding: () => isBinding,
    );
    expect(multiServer.onlineServersListenerCount, before + 1);

    extra.dispose();
    expect(multiServer.onlineServersListenerCount, before);
  });

  test('loadGeneration bumps on full loads only', () async {
    aggregation.onDeckResult = () => [_item('a')];
    final initial = provider.loadGeneration;

    await provider.load();
    expect(provider.loadGeneration, initial + 1);

    await provider.refreshContinueWatching();
    expect(provider.loadGeneration, initial + 1);
  });
}
