import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/locked_hub_controller.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/providers/multi_server_provider.dart';
import 'package:plezy/services/device_performance.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/animated_dim_scrim.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/rasterized_gradient.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:plezy/widgets/tv_browse_rail.dart';
import 'package:provider/provider.dart';

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

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  group('TvBrowseRailLayout', () {
    test('density changes card width', () {
      final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
      final hub = MediaHub(id: 'hub_1', title: 'Movies', type: 'movie', items: [item], size: 1);

      final compact = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.min,
        episodePosterMode: EpisodePosterMode.seriesPoster,
        scale: 0.85,
      );
      final comfortable = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.seriesPoster,
        scale: 0.85,
      );

      expect(comfortable.cardWidth, greaterThan(compact.cardWidth));
      expect(comfortable.posterWidth, greaterThan(compact.posterWidth));
    });

    test('detail episode hubs can force episode thumbnails', () {
      final episode = testMediaItem(
        id: 'episode_1',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode 1',
        thumbPath: '/episode-thumb',
        grandparentThumbPath: '/show-poster',
      );
      final hub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1);

      final defaultLayout = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.seriesPoster,
        scale: 0.85,
      );
      final forcedLayout = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: 0.85,
      );
      final compactForcedLayout = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: 0.85,
        widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
      );

      expect(defaultLayout.useWideLayout, isFalse);
      expect(forcedLayout.useWideLayout, isTrue);
      expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight));
      expect(compactForcedLayout.useWideLayout, isTrue);
      expect(compactForcedLayout.cardWidth, lessThan(forcedLayout.cardWidth));
      expect(compactForcedLayout.posterHeight, lessThan(forcedLayout.posterHeight));
    });

    test('estimated rail height is stable across mixed hub heights', () {
      final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
      final episode = testMediaItem(
        id: 'episode_1',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode 1',
        thumbPath: '/episode-thumb',
      );
      final posterHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
      final wideHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1);

      const size = Size(1280, 720);
      final scale = TvBrowseRailLayout.scaleForSize(size);
      final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);
      final posterMetrics = TvBrowseRailLayout.metricsForHub(
        hub: posterHub,
        availableWidth: availableWidth,
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: scale,
      );
      final wideMetrics = TvBrowseRailLayout.metricsForHub(
        hub: wideHub,
        availableWidth: availableWidth,
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: scale,
      );

      final maxHeight = TvBrowseRailLayout.maxActiveRailHeight(
        hubs: [wideHub, posterHub],
        availableWidth: availableWidth,
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: scale,
      );

      final estimate = TvBrowseRailLayout.estimateHeight(
        size: size,
        hubs: [wideHub, posterHub],
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
      );
      final posterSectionHeight = TvBrowseRailLayout.hubSectionHeightFor(
        scale: scale,
        activeRailHeight: posterMetrics.height,
      );
      final expectedPosterEstimate =
          TvBrowseRailLayout.railTopPaddingForScale(scale) +
          TvBrowseRailLayout.viewportHeightFor(hubCount: 2, scale: scale, sectionHeight: posterSectionHeight) +
          TvBrowseRailLayout.railBottomPaddingForScale(scale);

      expect(posterMetrics.height, greaterThan(wideMetrics.height));
      expect(maxHeight, posterMetrics.height);
      expect(estimate, closeTo(expectedPosterEstimate, 0.001));
      expect(
        estimate,
        TvBrowseRailLayout.estimateHeight(
          size: size,
          hubs: [posterHub, wideHub],
          density: LibraryDensity.max,
          episodePosterMode: EpisodePosterMode.episodeThumbnail,
        ),
      );
    });

    test('compact tall poster scale reduces browse rail height', () {
      final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
      final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);

      const size = Size(1280, 720);
      final defaultHeight = TvBrowseRailLayout.estimateHeight(
        size: size,
        hubs: [hub],
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.seriesPoster,
      );
      final compactHeight = TvBrowseRailLayout.estimateHeight(
        size: size,
        hubs: [hub],
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.seriesPoster,
        tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
      );

      expect(compactHeight, lessThan(defaultHeight));
    });

    test('empty episode thumbnail hubs reserve thumbnail row height', () {
      final episode = testMediaItem(
        id: 'episode_1',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode 1',
        thumbPath: '/episode-thumb',
      );
      const emptyHub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: <MediaItem>[]);
      final loadedHub = MediaHub(id: emptyHub.id, title: emptyHub.title, type: emptyHub.type, items: [episode]);
      const size = Size(1280, 720);
      final scale = TvBrowseRailLayout.scaleForSize(size);
      final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);

      final emptyMetrics = TvBrowseRailLayout.metricsForHub(
        hub: emptyHub,
        availableWidth: availableWidth,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: scale,
        tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
        widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
      );
      final loadedMetrics = TvBrowseRailLayout.metricsForHub(
        hub: loadedHub,
        availableWidth: availableWidth,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: scale,
        tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
        widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
      );

      expect(emptyMetrics.useWideLayout, isTrue);
      expect(emptyMetrics.posterHeight, closeTo(loadedMetrics.posterHeight, 0.001));
      expect(emptyMetrics.height, closeTo(loadedMetrics.height, 0.001));
    });

    test('full card layout removes label reserve and preserves episode poster mode', () {
      final episode = testMediaItem(
        id: 'episode_1',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode 1',
        thumbPath: '/episode-thumb',
        grandparentThumbPath: '/show-poster',
      );
      final hub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1);

      final detailed = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: 0.85,
      );
      final full = TvBrowseRailLayout.metricsForHub(
        hub: hub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: 0.85,
        fullCardLayout: true,
      );

      expect(full.height, lessThan(detailed.height));
      expect(full.useWideLayout, isTrue);
      expect(detailed.itemGap, 0);
      expect(full.itemGap, closeTo(12 * 0.85, 0.001));
      expect(full.posterHeight, closeTo(full.posterWidth * 9 / 16, 0.001));
    });

    test('compact wide poster scale makes clips match compact episode thumbnails', () {
      final episode = testMediaItem(
        id: 'episode_1',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode 1',
        thumbPath: '/episode-thumb',
      );
      final clip = testMediaItem(
        id: 'clip_1',
        backend: MediaBackend.plex,
        kind: MediaKind.clip,
        title: 'Trailer',
        thumbPath: '/trailer-thumb',
      );
      final episodeHub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1);
      final clipHub = MediaHub(id: 'detail_extras', title: 'Extras', type: 'clip', items: [clip], size: 1);

      final episodeMetrics = TvBrowseRailLayout.metricsForHub(
        hub: episodeHub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.episodeThumbnail,
        scale: 0.85,
        widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
      );
      final clipMetrics = TvBrowseRailLayout.metricsForHub(
        hub: clipHub,
        availableWidth: 1040,
        density: LibraryDensity.defaultValue,
        episodePosterMode: EpisodePosterMode.seriesPoster,
        scale: 0.85,
        widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
      );

      expect(clipMetrics.useWideLayout, isTrue);
      expect(clipMetrics.cardWidth, closeTo(episodeMetrics.cardWidth, 0.001));
      expect(clipMetrics.posterHeight, closeTo(episodeMetrics.posterHeight, 0.001));
    });

    test('multi-hub estimate reserves next hub peek height', () {
      final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
      final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
      final showHub = MediaHub(id: 'shows', title: 'Shows', type: 'show', items: [movie], size: 1);

      const size = Size(1280, 720);
      final scale = TvBrowseRailLayout.scaleForSize(size);
      final singleHubHeight = TvBrowseRailLayout.estimateHeight(
        size: size,
        hubs: [movieHub],
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.seriesPoster,
      );
      final multiHubHeight = TvBrowseRailLayout.estimateHeight(
        size: size,
        hubs: [movieHub, showHub],
        density: LibraryDensity.max,
        episodePosterMode: EpisodePosterMode.seriesPoster,
      );

      expect(multiHubHeight - singleHubHeight, closeTo(TvBrowseRailLayout.nextHubPeekHeightForScale(scale), 0.001));
    });
  });

  late HubFocusMemory focusMemory;

  setUp(() async {
    resetSharedPreferencesForTest();
    SettingsService.resetForTesting();
    focusMemory = HubFocusMemory();
    await SettingsService.getInstance();
  });

  testWidgets('semantic selection proxy stays fixed while animated rail selection changes', (tester) async {
    final semantics = tester.ensureSemantics();
    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(tester.view.resetDevicePixelRatio);
    addTearDown(tester.view.resetPhysicalSize);

    final serverManager = MultiServerManager();
    final items = [
      testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'First Movie'),
      testMediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Second Movie'),
    ];
    final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: items, size: items.length);
    String? activatedItemId;

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  hubs: [hub],
                  autofocus: true,
                  iconForHub: (_, _) => Icons.movie_rounded,
                  onActivateItem: (_, item) {
                    activatedItemId = item.id;
                    return true;
                  },
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    final proxy = find.bySemanticsIdentifier('tv_browse_rail_selection');
    expect(proxy, findsOneWidget);
    final initialRect = tester.getRect(proxy);
    final initialNode = tester.getSemantics(proxy);
    expect(initialNode.label, contains('Movies'));
    expect(initialNode.label, contains('First Movie'));
    expect(initialNode.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
    expect(tester.widget<Semantics>(proxy).properties.onTap, isNotNull);

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
    await tester.pump();

    final movedNode = tester.getSemantics(proxy);
    expect(movedNode.label, contains('Second Movie'));
    expect(tester.getRect(proxy), initialRect);
    expect(tester.hasRunningAnimations, isTrue);

    tester.widget<Semantics>(find.byKey(const ValueKey('tv_browse_rail_semantic_proxy'))).properties.onTap!();
    await tester.pump();
    expect(activatedItemId, 'movie_2');

    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
    semantics.dispose();
  });

  testWidgets('low-end snapshot optimization preserves vertical scroll animation', (tester) async {
    DevicePerformance.debugReset(autoReduced: true);
    addTearDown(DevicePerformance.debugReset);
    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(tester.view.resetDevicePixelRatio);
    addTearDown(tester.view.resetPhysicalSize);

    final serverManager = MultiServerManager();
    final hubs = List.generate(6, (hubIndex) {
      final item = testMediaItem(
        id: 'movie_$hubIndex',
        backend: MediaBackend.plex,
        kind: MediaKind.movie,
        title: 'Movie $hubIndex',
      );
      return MediaHub(id: 'hub_$hubIndex', title: 'Hub $hubIndex', type: 'movie', items: [item], size: 1);
    });

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  hubs: hubs,
                  autofocus: true,
                  iconForHub: (_, _) => Icons.movie_rounded,
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    final position = _verticalRailPosition(tester);
    final initialOffset = position.pixels;
    tester.widget<Semantics>(find.byKey(const ValueKey('tv_browse_rail_semantic_proxy'))).properties.onScrollDown!();
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 80));
    await tester.pump(const Duration(milliseconds: 80));
    final animatedOffset = position.pixels;
    expect(animatedOffset, greaterThan(initialOffset));
    expect(tester.hasRunningAnimations, isTrue);

    final snapshots = tester.widgetList<SnapshotWidget>(find.byType(SnapshotWidget));
    expect(snapshots, isNotEmpty);
    expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isTrue);
    await tester.pumpAndSettle();
    expect(position.pixels, greaterThan(animatedOffset));
    expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isFalse);
  });

  testWidgets('active hub header uses theme foreground in light mode', (tester) async {
    final serverManager = MultiServerManager();
    final theme = monoTheme(dark: false);
    final episode = testMediaItem(
      id: 'episode_1',
      backend: MediaBackend.plex,
      kind: MediaKind.episode,
      title: 'Episode 1',
    );
    final hub = MediaHub(id: 'season_1', title: 'Season 1', type: 'episode', items: [episode], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: theme,
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(focusMemory: focusMemory, hubs: [hub], iconForHub: (_, _) => Icons.tv_rounded),
            ),
          ),
        ),
      ),
    );

    await tester.pump();

    final headerText = tester.widget<Text>(find.text('Season 1'));
    expect(headerText.style?.color, theme.colorScheme.onSurface);
  });

  testWidgets('two hubs sharing a backend id across servers do not collide on card GlobalKeys', (tester) async {
    final serverManager = MultiServerManager();
    // Multi-server aggregation: two servers return a hub with the SAME backend
    // id ('recently_added'). Per-card GlobalKeys must be qualified by serverId,
    // or both rows hand the same GlobalKey<MediaCardState> to different cards
    // and finalizeTree throws "Duplicate GlobalKey".
    MediaHub hubFor(String serverId) => MediaHub(
      id: 'recently_added',
      title: 'Recently Added ($serverId)',
      type: 'movie',
      serverId: serverId,
      size: 2,
      items: [
        testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'A', serverId: serverId),
        testMediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'B', serverId: serverId),
      ],
    );

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [hubFor('serverA'), hubFor('serverB')],
                iconForHub: (_, _) => Icons.movie_rounded,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    expect(tester.takeException(), isNull);
  });

  testWidgets('full card layout hides media text and overlays actor text when enabled', (tester) async {
    await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);

    final serverManager = MultiServerManager();
    final movie = testMediaItem(
      id: 'movie_1',
      backend: MediaBackend.plex,
      kind: MediaKind.movie,
      title: 'Hidden Movie',
    );
    final actor = testMediaItem(
      id: 'actor_1',
      backend: MediaBackend.plex,
      kind: MediaKind.unknown,
      title: 'Actor Name',
      parentTitle: 'Character Name',
    );
    final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
    final actorHub = MediaHub(id: 'actors', title: 'Cast', type: 'person', items: [actor], size: 1);

    Widget rail(MediaHub hub) {
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(focusMemory: focusMemory, hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(rail(movieHub));
    await tester.pump();
    expect(find.text('Hidden Movie'), findsNothing);

    await tester.pumpWidget(rail(actorHub));
    await tester.pump();
    expect(find.text('Actor Name'), findsOneWidget);
    expect(find.text('Character Name'), findsOneWidget);
  });

  testWidgets('full card focus adds outside ring, local glow, and card image scale', (tester) async {
    await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);

    TvDetectionService.debugSetAppleTVOverride(true);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      TvDetectionService.debugSetAppleTVOverride(null);
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  hubs: [hub],
                  autofocus: true,
                  iconForHub: (_, _) => Icons.movie_rounded,
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
    final metrics = TvBrowseRailLayout.metricsForHub(
      hub: hub,
      availableWidth: 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale),
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.seriesPoster,
      scale: scale,
      fullCardLayout: true,
    );

    // The focused card mounts a leader (CompositedTransformTarget); scope to it
    // so we measure the focused card's in-card border container.
    final cardFinder = find
        .descendant(of: find.byType(CompositedTransformTarget), matching: find.byType(AnimatedContainer))
        .first;
    final borderContainer = tester.widget<AnimatedContainer>(cardFinder);
    final border = (borderContainer.foregroundDecoration as BoxDecoration).border as Border;
    final cardSize = tester.getSize(cardFinder);
    final focusScale = tester.widget<AnimatedScale>(
      find.ancestor(of: cardFinder, matching: find.byType(AnimatedScale)).first,
    );

    // The border stays in-card; the glow now renders in an overlay that follows
    // the focused card so it paints above siblings on all sides.
    expect(borderContainer.decoration, isNull);
    expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
    expect(find.byType(ShaderMask), findsNothing);
    expect(find.byType(CompositedTransformTarget), findsOneWidget);
    expect(find.byType(CompositedTransformFollower), findsOneWidget);
    expect(
      find.descendant(of: find.byType(CompositedTransformFollower), matching: find.byType(CustomPaint)),
      findsOneWidget,
    );
    expect(focusScale.scale, closeTo(1.03, 0.0001));
    expect(cardSize.width, closeTo(metrics.cardWidth, 0.001));
    expect(cardSize.height, closeTo(metrics.posterHeight, 0.001));
  });

  testWidgets('vertical hub viewport keeps top clipping while switching hubs', (tester) async {
    await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);

    TvDetectionService.debugSetAppleTVOverride(true);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      TvDetectionService.debugSetAppleTVOverride(null);
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final firstMovie = testMediaItem(
      id: 'movie_1',
      backend: MediaBackend.plex,
      kind: MediaKind.movie,
      title: 'Movie 1',
    );
    final secondMovie = testMediaItem(
      id: 'movie_2',
      backend: MediaBackend.plex,
      kind: MediaKind.movie,
      title: 'Movie 2',
    );
    final firstHub = MediaHub(id: 'movies_1', title: 'Movies 1', type: 'movie', items: [firstMovie], size: 1);
    final secondHub = MediaHub(id: 'movies_2', title: 'Movies 2', type: 'movie', items: [secondMovie], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  hubs: [firstHub, secondHub],
                  autofocus: true,
                  iconForHub: (_, _) => Icons.movie_rounded,
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 16));

    final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
    final expectedLeftOverflow = TvBrowseRailLayout.horizontalInsetForScale(scale);
    final verticalViewportClip = tester
        .widgetList<ClipRect>(
          find.ancestor(of: find.byKey(const ValueKey('tv_browse_rail_vertical')), matching: find.byType(ClipRect)),
        )
        .singleWhere((widget) => widget.clipper != null);
    final clipRectSize = tester.getSize(find.byWidget(verticalViewportClip));
    final clip = verticalViewportClip.clipper!.getClip(clipRectSize);

    // The vertical viewport keeps a tight top/bottom clip (the glow is no longer
    // clipped here — it renders in the overlay); only the left background bleed
    // extends beyond the viewport.
    expect(clip.left, closeTo(-expectedLeftOverflow, 0.001));
    expect(clip.top, 0);
    expect(clip.bottom, closeTo(clipRectSize.height, 0.001));

    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
    await tester.pumpAndSettle();

    // The newly focused hub's card still carries the overlay glow.
    expect(find.byType(CompositedTransformFollower), findsOneWidget);
  });

  testWidgets('detailed card focus border hugs the poster, captions outside', (tester) async {
    await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
    TvDetectionService.debugSetAppleTVOverride(true);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      TvDetectionService.debugSetAppleTVOverride(null);
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final movie = testMediaItem(
      id: 'movie_1',
      backend: MediaBackend.plex,
      kind: MediaKind.movie,
      title: 'Visible Movie',
      year: 2024,
    );
    final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  hubs: [hub],
                  autofocus: true,
                  iconForHub: (_, _) => Icons.movie_rounded,
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    // The border is drawn inside MediaCard around the poster only (#1278);
    // the title/year captions render below it, outside the focus rect.
    final focusDecoration = find.descendant(
      of: find.ancestor(of: find.text('Visible Movie'), matching: find.byType(MediaCard)),
      matching: find.byWidgetPredicate((widget) {
        if (widget is! AnimatedContainer || widget.foregroundDecoration is! BoxDecoration) return false;
        return (widget.foregroundDecoration as BoxDecoration).border is Border;
      }),
    );

    expect(focusDecoration, findsOneWidget);
    expect(find.text('2024'), findsOneWidget);

    final focusRect = tester.getRect(focusDecoration);
    final titleRect = tester.getRect(find.text('Visible Movie'));
    final subtitleRect = tester.getRect(find.text('2024'));
    expect(focusRect.bottom, lessThanOrEqualTo(titleRect.top));
    expect(focusRect.bottom, lessThanOrEqualTo(subtitleRect.top));
    // Still a tight ring: the poster fills the card width above the captions.
    expect(focusRect.top, lessThan(titleRect.top));
  });

  testWidgets('view all item uses compact pill focus style', (tester) async {
    TvDetectionService.debugSetAppleTVOverride(true);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      TvDetectionService.debugSetAppleTVOverride(null);
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2, more: true);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  hubs: [hub],
                  autofocus: true,
                  iconForHub: (_, _) => Icons.movie_rounded,
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
    await tester.pump();
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
    await tester.pumpAndSettle();

    final viewAllText = find.text('View All');
    final pill = find.ancestor(of: viewAllText, matching: find.byType(AnimatedContainer));
    final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);

    expect(viewAllText, findsOneWidget);
    expect(pill, findsOneWidget);
    final pillWidget = tester.widget<AnimatedContainer>(pill);
    final decoration = pillWidget.decoration as BoxDecoration;
    final pillSize = tester.getSize(pill);

    expect(decoration.border, isNull);
    expect(decoration.boxShadow, isNotNull);
    expect(pillSize.width, closeTo(TvBrowseRailLayout.viewAllItemWidthForScale(scale), 0.001));
    expect(pillSize.width, lessThan(132 * scale));
    expect(pillSize.height, closeTo(TvBrowseRailLayout.viewAllPillHeightForScale(scale), 0.001));
  });

  testWidgets('loading trailing item keeps visible focus style', (tester) async {
    TvDetectionService.debugSetAppleTVOverride(true);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      TvDetectionService.debugSetAppleTVOverride(null);
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [hub],
                autofocus: true,
                iconForHub: (_, _) => Icons.movie_rounded,
                trailingForHub: (_) => TvRailTrailing.loading,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
    await tester.pump(const Duration(milliseconds: 150));
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
    await tester.pump();

    final spinner = find.byType(CircularProgressIndicator);
    final pill = find.ancestor(of: spinner, matching: find.byType(AnimatedContainer));
    final decoration = tester.widget<AnimatedContainer>(pill).decoration as BoxDecoration;

    expect(spinner, findsOneWidget);
    expect(decoration.boxShadow, isNotNull);
  });

  testWidgets('clamps focused trailing item when trailing state disappears', (tester) async {
    TvDetectionService.debugSetAppleTVOverride(true);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      TvDetectionService.debugSetAppleTVOverride(null);
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
    var trailing = TvRailTrailing.loading;
    var activations = 0;
    late StateSetter setParentState;

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: StatefulBuilder(
              builder: (context, setState) {
                setParentState = setState;
                return SizedBox(
                  width: 1280,
                  height: 720,
                  child: TvBrowseRail(
                    focusMemory: focusMemory,
                    hubs: [hub],
                    autofocus: true,
                    iconForHub: (_, _) => Icons.movie_rounded,
                    trailingForHub: (_) => trailing,
                    onActivateItem: (_, _) {
                      activations++;
                      return Future.value(true);
                    },
                  ),
                );
              },
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
    await tester.pump(const Duration(milliseconds: 150));
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
    await tester.pump();

    setParentState(() => trailing = TvRailTrailing.none);
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
    await tester.pump();
    await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
    await tester.pump();

    expect(activations, 1);
  });

  testWidgets('focused rail dims only inactive hub artwork', (tester) async {
    final serverManager = MultiServerManager();
    final firstItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
    final secondItem = testMediaItem(
      id: 'movie_2',
      backend: MediaBackend.plex,
      kind: MediaKind.movie,
      title: 'Movie 2',
    );
    final firstHub = MediaHub(id: 'hub_1', title: 'First Hub', type: 'movie', items: [firstItem], size: 1);
    final secondHub = MediaHub(id: 'hub_2', title: 'Second Hub', type: 'movie', items: [secondItem], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [firstHub, secondHub],
                iconForHub: (_, _) => Icons.movie_rounded,
              ),
            ),
          ),
        ),
      ),
    );

    await tester.pump();

    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pumpAndSettle();

    expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');

    final scrims = tester.widgetList<AnimatedDimScrim>(find.byType(AnimatedDimScrim)).toList();
    expect(scrims, hasLength(1));
    expect(scrims.single.alpha, 0.4);
    expect(scrims.single.dimmed, isFalse);

    List<MediaCard> cards() => tester.widgetList<MediaCard>(find.byType(MediaCard)).toList();
    MediaCard cardFor(MediaItem item) => cards().singleWhere((card) => card.item == item);

    final firstArtworkDim = cardFor(firstItem).artworkDim!;
    final secondArtworkDim = cardFor(secondItem).artworkDim!;
    expect(firstArtworkDim.value, 0);
    expect(secondArtworkDim.value, closeTo(0.3, 0.001));

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
    await tester.pumpAndSettle();
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);

    expect(firstArtworkDim.value, closeTo(0.3, 0.001));
    expect(secondArtworkDim.value, 0);
  });

  testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async {
    final activeHubIds = <String>[];

    Widget buildRail(List<MediaHub> hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) {
      final serverManager = MultiServerManager();
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                key: const ValueKey('rail'),
                hubs: hubs,
                initialHubId: initialHubId,
                initialItemId: initialItemId,
                autofocus: autofocus,
                iconForHub: (_, _) => Icons.tv_rounded,
                onActiveHubChanged: (hub, _) => activeHubIds.add(hub.id),
              ),
            ),
          ),
        ),
      );
    }

    const castHub = MediaHub(id: 'detail_actors', title: 'Cast', type: 'person', items: <MediaItem>[]);
    const preferredSeason = MediaHub(id: 'detail_season_1', title: 'Season 2', type: 'episode', items: <MediaItem>[]);

    await tester.pumpWidget(buildRail(const [castHub]));
    await tester.pump();

    await tester.pumpWidget(buildRail(const [preferredSeason, castHub], initialHubId: preferredSeason.id));
    await tester.pump();

    expect(activeHubIds, containsAllInOrder(['detail_actors', 'detail_season_1']));
    expect(activeHubIds.last, 'detail_season_1');
  });

  testWidgets('selects preferred hub after an earlier update could not find it', (tester) async {
    final activeHubIds = <String>[];

    Widget buildRail(List<MediaHub> hubs, {String? initialHubId}) {
      final serverManager = MultiServerManager();
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                key: const ValueKey('rail'),
                hubs: hubs,
                initialHubId: initialHubId,
                iconForHub: (_, _) => Icons.tv_rounded,
                onActiveHubChanged: (hub, _) => activeHubIds.add(hub.id),
              ),
            ),
          ),
        ),
      );
    }

    const castHub = MediaHub(id: 'detail_actors', title: 'Cast', type: 'person', items: <MediaItem>[]);
    const episodesHub = MediaHub(id: 'detail_episodes', title: 'Episodes', type: 'episode', items: <MediaItem>[]);

    await tester.pumpWidget(buildRail(const [castHub]));
    await tester.pump();

    await tester.pumpWidget(buildRail(const [castHub], initialHubId: episodesHub.id));
    await tester.pump();

    await tester.pumpWidget(buildRail(const [episodesHub, castHub], initialHubId: episodesHub.id));
    await tester.pump();

    expect(activeHubIds, containsAllInOrder(['detail_actors', 'detail_episodes']));
    expect(activeHubIds.last, 'detail_episodes');
  });

  testWidgets('keeps the user selection when a preferred hub arrives after item navigation', (tester) async {
    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(tester.view.resetDevicePixelRatio);
    addTearDown(tester.view.resetPhysicalSize);

    final serverManager = MultiServerManager();
    final multiServerProvider = testMultiServerProvider(serverManager);
    addTearDown(multiServerProvider.dispose);

    final recentItems = [
      testMediaItem(id: 'recent_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recently Added 1'),
      testMediaItem(id: 'recent_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recently Added 2'),
    ];
    final continueItem = testMediaItem(
      id: 'continue_1',
      backend: MediaBackend.plex,
      kind: MediaKind.movie,
      title: 'Continue Watching',
    );
    final recentHub = MediaHub(
      id: 'recently_added',
      title: 'Recently Added',
      type: 'movie',
      items: recentItems,
      size: recentItems.length,
    );
    final continueHub = MediaHub(
      id: 'continue_watching',
      title: 'Continue Watching',
      type: 'mixed',
      items: [continueItem],
      size: 1,
    );
    final focusedSelections = <(String, String)>[];

    Widget buildRail(List<MediaHub> hubs) {
      return ChangeNotifierProvider<MultiServerProvider>.value(
        value: multiServerProvider,
        child: InputModeTracker(
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 1280,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: focusMemory,
                  key: const ValueKey('rail'),
                  hubs: hubs,
                  initialHubId: continueHub.id,
                  autofocus: true,
                  iconForHub: (_, _) => Icons.tv_rounded,
                  onFocusedHubItemChanged: (hub, item) => focusedSelections.add((hub.id, item.id)),
                ),
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildRail([recentHub]));
    await tester.pump();
    expect(focusedSelections.last, (recentHub.id, recentItems.first.id));

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
    await tester.pumpAndSettle();
    expect(focusedSelections.last, (recentHub.id, recentItems.last.id));

    await tester.pumpWidget(buildRail([continueHub, recentHub]));
    await tester.pumpAndSettle();

    expect(focusedSelections.last, (recentHub.id, recentItems.last.id));
  });

  testWidgets('selects preferred item when active hub items are populated asynchronously', (tester) async {
    final focusedItemIds = <String>[];

    Widget buildRail(List<MediaHub> hubs, {String? initialItemId}) {
      final serverManager = MultiServerManager();
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                key: const ValueKey('rail'),
                hubs: hubs,
                initialItemId: initialItemId,
                iconForHub: (_, _) => Icons.tv_rounded,
                onFocusedItemChanged: (item) => focusedItemIds.add(item.id),
              ),
            ),
          ),
        ),
      );
    }

    final episode1 = testMediaItem(
      id: 'episode_1',
      backend: MediaBackend.plex,
      kind: MediaKind.episode,
      title: 'Episode 1',
    );
    final episode2 = testMediaItem(
      id: 'episode_2',
      backend: MediaBackend.plex,
      kind: MediaKind.episode,
      title: 'Episode 2',
    );
    const emptySeason = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: <MediaItem>[]);
    final loadedSeason = MediaHub(
      id: emptySeason.id,
      title: emptySeason.title,
      type: emptySeason.type,
      items: [episode1, episode2],
      size: 2,
    );

    await tester.pumpWidget(buildRail(const [emptySeason], initialItemId: episode2.id));
    await tester.pump();

    await tester.pumpWidget(buildRail([loadedSeason], initialItemId: episode2.id));
    await tester.pump();

    expect(focusedItemIds.last, episode2.id);
  });

  testWidgets('scrolls remembered item after switching hubs', (tester) async {
    List<MediaItem> movieItems() => List.generate(
      12,
      (index) =>
          testMediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'),
    );
    List<MediaItem> episodeItems() => List.generate(
      12,
      (index) => testMediaItem(
        id: 'episode_$index',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode $index',
        thumbPath: '/episode_$index',
      ),
    );

    final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 12);
    final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 12);
    final serverManager = MultiServerManager();
    final activeHubIds = <String>[];
    var parentRebuilds = 0;
    // Seed remembered focus under the rail's server-qualified hub key (mirrors
    // _TvBrowseRailState._hubKey: '<serverId>:<id>'), so the multi-server keying
    // resolves it the same way the rail does.
    focusMemory.setForHub('${episodeHub.serverId ?? ''}:${episodeHub.id}', 5);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: StatefulBuilder(
              builder: (context, setParentState) {
                return SizedBox(
                  width: 700,
                  height: 720,
                  child: TvBrowseRail(
                    focusMemory: focusMemory,
                    hubs: [movieHub, episodeHub],
                    autofocus: true,
                    iconForHub: (_, _) => Icons.tv_rounded,
                    onActiveHubChanged: (hub, _) {
                      activeHubIds.add(hub.id);
                      setParentState(() => parentRebuilds++);
                    },
                    episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
                  ),
                );
              },
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
    final fullCardLayout = SettingsService.instanceOrNull!.read(SettingsService.tvFullCardLayout);
    final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale);
    final movieMetrics = TvBrowseRailLayout.metricsForHub(
      hub: movieHub,
      availableWidth: availableWidth,
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.episodeThumbnail,
      scale: scale,
      fullCardLayout: fullCardLayout,
    );
    final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
      scale: scale,
      activeRailHeight: movieMetrics.height,
    );

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 16));
    await tester.pump(const Duration(milliseconds: 100));

    final midAnimationPosition = _verticalRailPosition(tester).pixels;
    expect(midAnimationPosition, greaterThan(0));
    expect(midAnimationPosition, lessThan(expectedVerticalOffset));

    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
    await tester.pumpAndSettle();

    final position = _activeRailPosition(tester);
    final verticalPosition = _verticalRailPosition(tester);
    final metrics = TvBrowseRailLayout.metricsForHub(
      hub: episodeHub,
      availableWidth: position.viewportDimension,
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.episodeThumbnail,
      scale: scale,
      fullCardLayout: fullCardLayout,
    );
    final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
      hub: episodeHub,
      index: 5,
      metrics: metrics,
      viewportWidth: position.viewportDimension,
      maxScrollExtent: position.maxScrollExtent,
      scale: scale,
    );

    expect(activeHubIds.last, episodeHub.id);
    expect(parentRebuilds, greaterThan(0));
    expect(position.pixels, closeTo(expectedOffset, 0.1));
    expect(verticalPosition.pixels, closeTo(expectedVerticalOffset, 0.1));
  });

  testWidgets('realigns active hub after preceding hub height changes', (tester) async {
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final tallItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
    final wideItem = testMediaItem(
      id: 'episode_1',
      backend: MediaBackend.plex,
      kind: MediaKind.episode,
      title: 'Episode 1',
      thumbPath: '/episode_1',
    );
    final activeItem = testMediaItem(
      id: 'episode_2',
      backend: MediaBackend.plex,
      kind: MediaKind.episode,
      title: 'Episode 2',
      thumbPath: '/episode_2',
    );
    final firstHubTall = MediaHub(id: 'dynamic', title: 'Dynamic', type: 'movie', items: [tallItem], size: 1);
    final firstHubWide = MediaHub(id: 'dynamic', title: 'Dynamic', type: 'episode', items: [wideItem], size: 1);
    final activeHub = MediaHub(id: 'active', title: 'Active', type: 'episode', items: [activeItem], size: 1);

    Widget buildRail(List<MediaHub> hubs) {
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                key: const ValueKey('rail'),
                hubs: hubs,
                autofocus: true,
                iconForHub: (_, _) => Icons.tv_rounded,
                episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildRail([firstHubTall, activeHub]));
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
    await tester.pumpAndSettle();
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
    await tester.pump();

    await tester.pumpWidget(buildRail([firstHubWide, activeHub]));
    await tester.pumpAndSettle();

    final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
    final availableWidth = 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale);
    final firstWideMetrics = TvBrowseRailLayout.metricsForHub(
      hub: firstHubWide,
      availableWidth: availableWidth,
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.episodeThumbnail,
      scale: scale,
    );
    final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
      scale: scale,
      activeRailHeight: firstWideMetrics.height,
    );

    expect(_verticalRailPosition(tester).pixels, closeTo(expectedVerticalOffset, 0.1));
  });

  testWidgets('does not realign active hub when a background hub updates', (tester) async {
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    MediaItem episode(String id) {
      return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id');
    }

    final serverManager = MultiServerManager();
    final firstHub = MediaHub(id: 'first', title: 'First', type: 'episode', items: [episode('episode_1')], size: 1);
    final activeHub = MediaHub(id: 'active', title: 'Active', type: 'episode', items: [episode('episode_2')], size: 1);
    final backgroundInitialHub = MediaHub(
      id: 'background',
      title: 'Background',
      type: 'episode',
      items: [episode('episode_3')],
      size: 1,
    );
    final backgroundUpdatedHub = MediaHub(
      id: backgroundInitialHub.id,
      title: backgroundInitialHub.title,
      type: backgroundInitialHub.type,
      items: [episode('episode_3'), episode('episode_4')],
      size: 2,
    );

    Widget buildRail({required bool backgroundLoaded}) {
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                key: const ValueKey('rail'),
                hubs: [firstHub, activeHub, backgroundLoaded ? backgroundUpdatedHub : backgroundInitialHub],
                autofocus: true,
                iconForHub: (_, _) => Icons.tv_rounded,
                episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildRail(backgroundLoaded: false));
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
    await tester.pumpAndSettle();
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
    await tester.pump();
    expect(_verticalRailPosition(tester).pixels, greaterThan(0));

    _verticalRailPosition(tester).jumpTo(0);
    await tester.pump();

    await tester.pumpWidget(buildRail(backgroundLoaded: true));
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 80));

    expect(_verticalRailPosition(tester).pixels, 0);
  });

  testWidgets('keeps vertical navigation smooth when active hub updates during scroll', (tester) async {
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    MediaItem episode(String id) {
      return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id');
    }

    final serverManager = MultiServerManager();
    final firstHub = MediaHub(id: 'first', title: 'First', type: 'episode', items: [episode('episode_1')], size: 1);
    final middleInitialHub = MediaHub(
      id: 'middle',
      title: 'Middle',
      type: 'episode',
      items: [episode('episode_2')],
      size: 1,
    );
    final middleUpdatedHub = MediaHub(
      id: middleInitialHub.id,
      title: middleInitialHub.title,
      type: middleInitialHub.type,
      items: [episode('episode_2'), episode('episode_3')],
      size: 2,
    );
    final lastHub = MediaHub(id: 'last', title: 'Last', type: 'episode', items: [episode('episode_4')], size: 1);
    var updateMiddleOnFocus = false;
    var middleLoaded = false;

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: StatefulBuilder(
              builder: (context, setParentState) {
                return SizedBox(
                  width: 1280,
                  height: 720,
                  child: TvBrowseRail(
                    focusMemory: focusMemory,
                    hubs: [firstHub, middleLoaded ? middleUpdatedHub : middleInitialHub, lastHub],
                    autofocus: true,
                    iconForHub: (_, _) => Icons.tv_rounded,
                    episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
                    onActiveHubChanged: (hub, _) {
                      if (updateMiddleOnFocus && hub.id == middleInitialHub.id && !middleLoaded) {
                        setParentState(() => middleLoaded = true);
                      }
                    },
                  ),
                );
              },
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    for (var i = 0; i < 2; i++) {
      await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
      await tester.pumpAndSettle();
      await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
      await tester.pump();
    }

    final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
    final availableWidth = 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale);
    final firstMetrics = TvBrowseRailLayout.metricsForHub(
      hub: firstHub,
      availableWidth: availableWidth,
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.episodeThumbnail,
      scale: scale,
    );
    final middleTargetOffset = TvBrowseRailLayout.hubSectionHeightFor(
      scale: scale,
      activeRailHeight: firstMetrics.height,
    );
    final startOffset = _verticalRailPosition(tester).pixels;
    expect(startOffset, greaterThan(middleTargetOffset));

    updateMiddleOnFocus = true;
    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
    await tester.pump();
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 80));
    await tester.pump(const Duration(milliseconds: 80));

    expect(middleLoaded, isTrue);
    final midAnimationOffset = _verticalRailPosition(tester).pixels;
    expect(midAnimationOffset, greaterThan(middleTargetOffset + 0.5));
    expect(midAnimationOffset, lessThan(startOffset - 0.5));

    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp);
    await tester.pumpAndSettle();
    expect(_verticalRailPosition(tester).pixels, closeTo(middleTargetOffset, 0.1));
  });

  testWidgets('uses per-hub item focus instead of the owner last-column hint', (tester) async {
    List<MediaItem> movieItems() => List.generate(
      8,
      (index) =>
          testMediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'),
    );
    List<MediaItem> episodeItems() => List.generate(
      8,
      (index) => testMediaItem(
        id: 'episode_$index',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode $index',
        thumbPath: '/episode_$index',
      ),
    );
    final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 8);
    final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 8);
    final focused = <String>[];
    final serverManager = MultiServerManager();

    Future<void> press(LogicalKeyboardKey key) async {
      await tester.sendKeyDownEvent(key);
      await tester.pump();
      await tester.sendKeyUpEvent(key);
      await tester.pump();
    }

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: StatefulBuilder(
              builder: (context, setParentState) {
                return SizedBox(
                  width: 700,
                  height: 720,
                  child: TvBrowseRail(
                    focusMemory: focusMemory,
                    hubs: [movieHub, episodeHub],
                    autofocus: true,
                    iconForHub: (_, _) => Icons.tv_rounded,
                    onActiveHubChanged: (_, _) => setParentState(() {}),
                    onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'),
                    episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
                  ),
                );
              },
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    for (var i = 0; i < 5; i++) {
      await press(LogicalKeyboardKey.arrowRight);
    }
    expect(focused.last, 'movies:movie_5');

    await press(LogicalKeyboardKey.arrowDown);
    expect(focused.last, 'episodes:episode_0');

    await press(LogicalKeyboardKey.arrowUp);
    expect(focused.last, 'movies:movie_5');
  });

  testWidgets('repeated detail hub ids restore only within their browse owner', (tester) async {
    final episodeItems = [
      for (var index = 0; index < 12; index++)
        testMediaItem(
          id: 'episode_$index',
          backend: MediaBackend.plex,
          kind: MediaKind.episode,
          title: 'Episode $index',
          thumbPath: '/episode_$index',
        ),
    ];
    final extraItems = [
      for (var index = 0; index < 3; index++)
        testMediaItem(id: 'extra_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Extra $index'),
    ];
    final episodeHub = MediaHub(
      id: 'detail_episodes',
      title: 'Episodes',
      type: 'episode',
      items: episodeItems,
      size: episodeItems.length,
    );
    final extrasHub = MediaHub(
      id: 'detail_extras',
      title: 'Extras',
      type: 'movie',
      items: extraItems,
      size: extraItems.length,
    );
    final focused = <String>[];

    Future<void> mount(HubFocusMemory owner, List<MediaHub> hubs) async {
      final serverManager = MultiServerManager();
      await tester.pumpWidget(
        ChangeNotifierProvider<MultiServerProvider>(
          create: (_) => testMultiServerProvider(serverManager),
          child: MaterialApp(
            theme: monoTheme(dark: true),
            home: Scaffold(
              body: SizedBox(
                width: 700,
                height: 720,
                child: TvBrowseRail(
                  focusMemory: owner,
                  hubs: hubs,
                  autofocus: true,
                  iconForHub: (_, _) => Icons.tv_rounded,
                  onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'),
                  episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
                ),
              ),
            ),
          ),
        ),
      );
      await tester.pump();
      tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
      await tester.pump();
    }

    Future<void> press(LogicalKeyboardKey key) async {
      await tester.sendKeyDownEvent(key);
      await tester.pump();
      await tester.sendKeyUpEvent(key);
      await tester.pumpAndSettle();
    }

    await mount(focusMemory, [episodeHub, extrasHub]);
    for (var index = 0; index < 5; index++) {
      await press(LogicalKeyboardKey.arrowRight);
    }
    expect(focused.last, 'detail_episodes:episode_5');
    expect(_activeRailPosition(tester).pixels, greaterThan(0));

    await press(LogicalKeyboardKey.arrowDown);
    expect(focused.last, 'detail_extras:extra_0');
    await press(LogicalKeyboardKey.arrowUp);
    expect(focused.last, 'detail_episodes:episode_5');
    expect(_activeRailPosition(tester).pixels, greaterThan(0));

    await tester.pumpWidget(const SizedBox.shrink());
    await tester.pump();

    final freshOwner = HubFocusMemory();
    await mount(freshOwner, [extrasHub, episodeHub]);
    await press(LogicalKeyboardKey.arrowDown);

    expect(focused.last, 'detail_episodes:episode_0');
    expect(_activeRailPosition(tester).pixels, 0);
  });

  testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async {
    await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    Future<void> pressRight() async {
      await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
      await tester.pump();
      await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
      await tester.pump(const Duration(milliseconds: 16));
    }

    final episodes = List.generate(
      153,
      (index) => testMediaItem(
        id: 'episode_${index + 1}',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode ${index + 1}',
        parentIndex: 11,
        index: index + 1,
        thumbPath: '/episode_${index + 1}',
      ),
    );
    final hub = MediaHub(id: 'detail_season_11', title: 'Season 11', type: 'episode', items: episodes, size: 153);
    final serverManager = MultiServerManager();

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [hub],
                autofocus: true,
                iconForHub: (_, _) => Icons.tv_rounded,
                episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    for (var i = 0; i < 117; i++) {
      await pressRight();
    }
    await tester.pumpAndSettle();

    final targetTitle = find.text('Episode 118');
    expect(targetTitle, findsOneWidget);
    final railRect = tester.getRect(find.byType(TvBrowseRail));
    final targetRect = tester.getRect(targetTitle);
    expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5));
    expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5));

    final position = _activeRailPosition(tester);
    final size = tester.view.physicalSize / tester.view.devicePixelRatio;
    final scale = TvBrowseRailLayout.scaleForSize(size);
    final metrics = TvBrowseRailLayout.metricsForHub(
      hub: hub,
      availableWidth: size.width - TvBrowseRailLayout.horizontalInsetForScale(scale),
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.episodeThumbnail,
      scale: scale,
    );
    final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
      hub: hub,
      index: 117,
      metrics: metrics,
      viewportWidth: position.viewportDimension,
      maxScrollExtent: position.maxScrollExtent,
      scale: scale,
    );
    expect(position.pixels, closeTo(expectedOffset, 0.1));
  });

  testWidgets('keeps late episode thumbnails visible during rapid key repeat', (tester) async {
    await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final focusedItemIds = <String>[];
    const episodeCount = 500;
    const targetIndex = 419;
    final episodes = List.generate(
      episodeCount,
      (index) => testMediaItem(
        id: 'episode_${index + 1}',
        backend: MediaBackend.plex,
        kind: MediaKind.episode,
        title: 'Episode ${index + 1}',
        parentIndex: 11,
        index: index + 1,
        thumbPath: '/episode_${index + 1}',
      ),
    );
    final hub = MediaHub(
      id: 'detail_season_11',
      title: 'Season 11',
      type: 'episode',
      items: episodes,
      size: episodeCount,
    );
    final serverManager = MultiServerManager();

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [hub],
                autofocus: true,
                iconForHub: (_, _) => Icons.tv_rounded,
                onFocusedItemChanged: (item) => focusedItemIds.add(item.id),
                episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();
    tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
    for (var i = 0; i < targetIndex - 1; i++) {
      await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight);
    }
    await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
    await tester.pumpAndSettle();

    expect(focusedItemIds.last, 'episode_${targetIndex + 1}');
    final targetTitle = find.text('Episode ${targetIndex + 1}');
    expect(targetTitle, findsOneWidget);
    final railRect = tester.getRect(find.byType(TvBrowseRail));
    final targetRect = tester.getRect(targetTitle);
    expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5));
    expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5));

    final position = _activeRailPosition(tester);
    final size = tester.view.physicalSize / tester.view.devicePixelRatio;
    final scale = TvBrowseRailLayout.scaleForSize(size);
    final metrics = TvBrowseRailLayout.metricsForHub(
      hub: hub,
      availableWidth: size.width - TvBrowseRailLayout.horizontalInsetForScale(scale),
      density: LibraryDensity.defaultValue,
      episodePosterMode: EpisodePosterMode.episodeThumbnail,
      scale: scale,
    );
    final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
      hub: hub,
      index: targetIndex,
      metrics: metrics,
      viewportWidth: position.viewportDimension,
      maxScrollExtent: position.maxScrollExtent,
      scale: scale,
    );
    expect(position.pixels, closeTo(expectedOffset, 0.1));
  });

  testWidgets('resets long-press state when context menu focus receives select key up', (tester) async {
    final menuFocusNode = FocusNode(debugLabel: 'context_menu_probe');
    addTearDown(menuFocusNode.dispose);
    addTearDown(SelectKeyUpSuppressor.clearSuppression);

    var activations = 0;
    final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
    final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
    final serverManager = MultiServerManager();

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: Stack(
              children: [
                SizedBox(
                  width: 1280,
                  height: 720,
                  child: TvBrowseRail(
                    focusMemory: focusMemory,
                    hubs: [hub],
                    iconForHub: (_, _) => Icons.person_rounded,
                    onActivateItem: (_, _) {
                      activations++;
                      return Future.value(true);
                    },
                  ),
                ),
                Focus(
                  focusNode: menuFocusNode,
                  onKeyEvent: (_, event) {
                    if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled;
                    return KeyEventResult.ignored;
                  },
                  child: const SizedBox.shrink(),
                ),
              ],
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
    railState.requestFocus();
    await tester.pump();

    await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
    await tester.pump(const Duration(milliseconds: 501));

    menuFocusNode.requestFocus();
    await tester.pump();
    await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
    await tester.pump();

    railState.requestFocus();
    await tester.pump();
    await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
    await tester.pump(const Duration(milliseconds: 100));
    await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
    await tester.pump();

    expect(activations, 1);
  });

  testWidgets('does not autofocus unless requested', (tester) async {
    FocusManager.instance.primaryFocus?.unfocus();

    Widget buildRail({required bool autofocus}) {
      final serverManager = MultiServerManager();
      final item = testMediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
      final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
      return ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [hub],
                autofocus: autofocus,
                iconForHub: (_, _) => Icons.tv_rounded,
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildRail(autofocus: false));
    await tester.pump();
    expect(FocusManager.instance.primaryFocus?.debugLabel, isNot('tv_browse_rail'));

    await tester.pumpWidget(buildRail(autofocus: true));
    await tester.pump();
    expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
  });

  testWidgets('lays out when bottom-positioned in a stack', (tester) async {
    final serverManager = MultiServerManager();
    final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 896,
              height: 540,
              child: Stack(
                children: [
                  Positioned(
                    left: 0,
                    right: 0,
                    bottom: 0,
                    child: TvBrowseRail(
                      focusMemory: focusMemory,
                      hubs: [hub],
                      iconForHub: (_, _) => Icons.movie_rounded,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    expect(tester.takeException(), isNull);
  });

  testWidgets('background gradient stays full bleed beside pushed foreground', (tester) async {
    tester.view.devicePixelRatio = 1.0;
    tester.view.physicalSize = const Size(1280, 720);
    addTearDown(() {
      tester.view.resetDevicePixelRatio();
      tester.view.resetPhysicalSize();
    });

    final serverManager = MultiServerManager();
    final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);

    await tester.pumpWidget(
      ChangeNotifierProvider<MultiServerProvider>(
        create: (_) => testMultiServerProvider(serverManager),
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1060,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                hubs: [hub],
                iconForHub: (_, _) => Icons.movie_rounded,
                backgroundBleedLeft: SideNavigationRailState.expandedWidth,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    final gradient = find.byType(RasterizedGradient);
    final backgroundPosition = tester.widget<Positioned>(
      find.ancestor(of: gradient.first, matching: find.byType(Positioned)).first,
    );

    expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
    expect(backgroundPosition.width, 1280);
  });

  testWidgets('background bleed updates do not renotify rail focus', (tester) async {
    final serverManager = MultiServerManager();
    final multiServerProvider = testMultiServerProvider(serverManager);
    addTearDown(multiServerProvider.dispose);

    final focusedItemIds = <String>[];
    final activeHubIds = <String>[];
    final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
    final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);

    Widget buildRail(double backgroundBleedLeft) {
      return ChangeNotifierProvider<MultiServerProvider>.value(
        value: multiServerProvider,
        child: MaterialApp(
          theme: monoTheme(dark: true),
          home: Scaffold(
            body: SizedBox(
              width: 1280,
              height: 720,
              child: TvBrowseRail(
                focusMemory: focusMemory,
                key: const ValueKey('rail'),
                hubs: [hub],
                iconForHub: (_, _) => Icons.movie_rounded,
                backgroundBleedLeft: backgroundBleedLeft,
                onFocusedItemChanged: (focused) => focusedItemIds.add(focused.id),
                onActiveHubChanged: (active, _) => activeHubIds.add(active.id),
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildRail(0));
    await tester.pump();
    focusedItemIds.clear();
    activeHubIds.clear();

    await tester.pumpWidget(buildRail(SideNavigationRailState.expandedWidth));
    await tester.pump();

    expect(focusedItemIds, isEmpty);
    expect(activeHubIds, isEmpty);
  });
}

ScrollPosition _activeRailPosition(WidgetTester tester) {
  return tester
      .stateList<ScrollableState>(find.byType(Scrollable))
      .map((state) => state.position)
      .where((position) => axisDirectionToAxis(position.axisDirection) == Axis.horizontal)
      .where((position) => position.maxScrollExtent > 0)
      .reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b);
}

ScrollPosition _verticalRailPosition(WidgetTester tester) {
  final scrollable = find.descendant(
    of: find.byKey(const ValueKey('tv_browse_rail_vertical')),
    matching: find.byType(Scrollable),
  );
  return tester
      .stateList<ScrollableState>(scrollable)
      .map((state) => state.position)
      .singleWhere((position) => axisDirectionToAxis(position.axisDirection) == Axis.vertical);
}
