import 'dart:async';
import '../../../media/ids.dart';

import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';

import '../../../focus/dpad_navigator.dart';
import '../../../focus/dpad_select_long_press_controller.dart';
import '../../../focus/focus_theme.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../focus/key_event_utils.dart';
import '../../../i18n/app_locale_utils.dart';
import '../../../i18n/strings.g.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart';
import '../../../models/media_grab_operation.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../media/media_server_client.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/app_logger.dart';
import '../live_tv_actions_mixin.dart';
import '../live_tv_refresh_lifecycle.dart';
import '../../../utils/formatters.dart';
import '../../../utils/live_tv_grouping.dart';
import '../../../utils/live_tv_matching.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/app_menu.dart';
import '../../../widgets/clickable_cursor.dart';
import '../../../widgets/optimized_media_image.dart';
import '../livetv_styles.dart';

class GuideTab extends StatefulWidget {
  final List<LiveTvChannel> channels;
  final bool Function(LiveTvChannel channel)? isFavoriteChannel;
  final void Function(LiveTvChannel)? onToggleFavorite;
  final VoidCallback? onNavigateUp;
  final VoidCallback? onBack;

  const GuideTab({
    super.key,
    required this.channels,
    this.isFavoriteChannel,
    this.onToggleFavorite,
    this.onNavigateUp,
    this.onBack,
  });

  @override
  State<GuideTab> createState() => GuideTabState();
}

@visibleForTesting
({String channelScopeKey, ({String kind, String value})? programId, int? beginsAt, int? endsAt}) guideAiringIdentity(
  LiveTvChannel channel,
  LiveTvProgram program,
) {
  ({String kind, String value})? programId;
  if (program.ratingKey case final ratingKey? when ratingKey.isNotEmpty) {
    programId = (kind: 'ratingKey', value: ratingKey);
  } else if (program.guid case final guid? when guid.isNotEmpty) {
    programId = (kind: 'guid', value: guid);
  } else if (program.key case final key? when key.isNotEmpty) {
    programId = (kind: 'key', value: key);
  }

  // Keep the slot even with an ID so repeated airings cannot inherit each
  // other's hold; with no ID, channel scope plus timing is the fallback.
  return (
    channelScopeKey: liveTvChannelScopeKey(channel),
    programId: programId,
    beginsAt: program.beginsAt,
    endsAt: program.endsAt,
  );
}

enum _GuideZone { timeNav, grid }

typedef _GuideFocusSnapshot = ({
  bool hasFocus,
  _GuideZone zone,
  int timeNavIndex,
  int channelIndex,
  int gridColumn,
  LiveTvProgram? program,
});

sealed class _GuideRow {
  const _GuideRow();
}

final class _GuideSourceHeaderRow extends _GuideRow {
  final String label;

  const _GuideSourceHeaderRow({required this.label});
}

final class _GuideChannelRow extends _GuideRow {
  final LiveTvChannel channel;
  final int channelIndex;

  const _GuideChannelRow({required this.channel, required this.channelIndex});
}

class GuideTabState extends State<GuideTab>
    with LiveTvActionsMixin<GuideTab>, MountedSetStateMixin, WidgetsBindingObserver {
  static const _slotWidth = 180.0;
  static const _channelColumnWidth = 132.0;
  static const _rowHeight = 64.0;
  static const _sourceHeaderRowHeight = 40.0;
  static const _timeHeaderHeight = 40.0;
  static const _minutesPerSlot = 30;

  /// Minimum time away (backgrounded or on another section) before the
  /// viewport is realigned to the live line on return.
  static const _realignAfterAway = Duration(minutes: 30);

  List<LiveTvProgram> _programs = [];
  Map<String, List<LiveTvProgram>> _programsByChannelScope = const {};
  Set<String> _scheduledRecordingKeys = const {};
  bool _isLoading = true;
  int _programLoadGeneration = 0;

  late DateTime _gridStart;
  late DateTime _gridEnd;

  final ScrollController _headerHorizontalController = ScrollController();
  final ScrollController _gridHorizontalController = ScrollController();
  final ScrollController _channelVerticalController = ScrollController();
  final ScrollController _gridVerticalController = ScrollController();
  bool _syncingScroll = false;

  Timer? _timeIndicatorTimer;
  final _programSelectController = DpadSelectLongPressController();
  final _dayPickerKey = GlobalKey();

  // Stale-window catch-up state (#1297). The grid window is only auto
  // re-anchored when it was live-anchored and has drifted fully into the
  // past — deliberately picked day/time windows are never yanked.
  bool _isGuideVisible = true;
  DateTime? _hiddenSince;
  bool _nowWasInWindow = true;

  // Focus state
  final FocusNode _guideFocusNode = FocusNode(debugLabel: 'guide_tab');
  _GuideZone _focusZone = _GuideZone.timeNav;
  int _timeNavIndex = 1; // 0=left arrow, 1=day picker, 2=right arrow
  int _gridChannelIndex = 0;
  int _gridColumn = 0; // 0=channel, 1=program
  bool _hasFocus = false;
  final ValueNotifier<_GuideFocusSnapshot> _focusSnapshot = ValueNotifier((
    hasFocus: false,
    zone: _GuideZone.timeNav,
    timeNavIndex: 1,
    channelIndex: 0,
    gridColumn: 0,
    program: null,
  ));
  LiveTvProgram? _focusedProgram;
  bool _pendingFocus = false;

  @override
  List<LiveTvChannel> get liveTvChannels => widget.channels;

  /// Focus into the guide content (called from tab bar navigation or initial load).
  void focusContent() {
    if (!InputModeTracker.isKeyboardMode(context)) return;
    // If still loading programs, defer until the Focus widget is in the tree.
    if (_isLoading) {
      _pendingFocus = true;
      return;
    }
    _pendingFocus = false;
    _guideFocusNode.requestFocus();
    _updateFocus(() {
      if (widget.channels.isNotEmpty) {
        _focusZone = _GuideZone.grid;
        _gridColumn = 0;
        _gridChannelIndex = 0;
        _focusedProgram = null;
      } else {
        _focusZone = _GuideZone.timeNav;
        _timeNavIndex = 1;
      }
    });
  }

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _initTimeRange();
    _loadPrograms();

    _gridHorizontalController.addListener(_syncGridToHeader);
    _headerHorizontalController.addListener(_syncHeaderToGrid);

    _startTimeIndicatorTimer();
  }

  void _startTimeIndicatorTimer() {
    _timeIndicatorTimer?.cancel();
    _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
      _checkWindowDrift();
      // ignore: no-empty-block - setState triggers rebuild to update time indicator
      setStateIfMounted(() {});
    });
  }

  // Not the gated data-refresh timer the other tabs run: pause/resume drive the
  // per-minute UI ticker, and pause has to stamp _hiddenSince on both a section
  // hide and an app background so _catchUpIfStale can measure the absence.
  void pauseRefresh() {
    _hiddenSince ??= DateTime.now();
    _timeIndicatorTimer?.cancel();
  }

  void resumeRefresh() {
    _startTimeIndicatorTimer();
    unawaited(_refreshScheduledRecordingKeys());
    // Post-frame: resumeRefresh is invoked during tab transitions/build and
    // the catch-up may setState (reload or scroll).
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _catchUpIfStale();
    });
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Subscribes to TickerMode, so this fires whenever the guide is shown or
    // hidden (main-screen IndexedStack section switch, opaque route push/pop).
    final visible = TickerMode.valuesOf(context).enabled;
    if (visible == _isGuideVisible) return;
    _isGuideVisible = visible;
    visible ? resumeRefresh() : pauseRefresh();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (liveTvRefreshTransition(state)) {
      case LiveTvRefreshLifecycleTransition.pause:
        pauseRefresh();
      case LiveTvRefreshLifecycleTransition.resume:
        if (_isGuideVisible) resumeRefresh();
      case LiveTvRefreshLifecycleTransition.ignore:
        break;
    }
  }

  @override
  void didUpdateWidget(GuideTab oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (!identical(oldWidget.channels, widget.channels)) {
      _programsByChannelScope = _indexProgramsByChannel(_programs, widget.channels);
    }
    if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) {
      _gridChannelIndex = widget.channels.length - 1;
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted) _publishFocusSnapshot();
      });
    }
  }

  @override
  void dispose() {
    _programLoadGeneration++;
    WidgetsBinding.instance.removeObserver(this);
    _programSelectController.dispose();
    _guideFocusNode.dispose();
    _gridVerticalController.dispose();
    _gridHorizontalController.removeListener(_syncGridToHeader);
    _headerHorizontalController.removeListener(_syncHeaderToGrid);
    _headerHorizontalController.dispose();
    _gridHorizontalController.dispose();
    _channelVerticalController.dispose();
    _timeIndicatorTimer?.cancel();
    _focusSnapshot.dispose();
    super.dispose();
  }

  void _handleGuideFocusChange(bool hasFocus) {
    if (_hasFocus == hasFocus) return;
    if (!hasFocus) _resetProgramSelectLongPressState();
    _hasFocus = hasFocus;
    _publishFocusSnapshot();
  }

  void _updateFocus(VoidCallback update) {
    update();
    _publishFocusSnapshot();
  }

  void _publishFocusSnapshot() {
    _focusSnapshot.value = (
      hasFocus: _hasFocus,
      zone: _focusZone,
      timeNavIndex: _timeNavIndex,
      channelIndex: _gridChannelIndex,
      gridColumn: _gridColumn,
      program: _focusedProgram,
    );
  }

  void _resetProgramSelectLongPressState() => _programSelectController.reset();

  void _syncGridToHeader() {
    if (_syncingScroll) return;
    _syncingScroll = true;
    if (_headerHorizontalController.hasClients) {
      _headerHorizontalController.jumpTo(_gridHorizontalController.offset);
    }
    _syncingScroll = false;
  }

  void _syncHeaderToGrid() {
    if (_syncingScroll) return;
    _syncingScroll = true;
    if (_gridHorizontalController.hasClients) {
      _gridHorizontalController.jumpTo(_headerHorizontalController.offset);
    }
    _syncingScroll = false;
  }

  void _initTimeRange() {
    final now = DateTime.now();
    _gridStart = DateTime(now.year, now.month, now.day, now.hour);
    if (now.minute >= 30) {
      _gridStart = _gridStart.add(const Duration(minutes: 30));
    }
    _gridStart = _gridStart.subtract(const Duration(hours: 1));
    _gridEnd = _gridStart.add(const Duration(hours: 6));
    _nowWasInWindow = true;
  }

  void _shiftTimeRange(int hours) {
    setState(() {
      _gridStart = _gridStart.add(Duration(hours: hours));
      _gridEnd = _gridStart.add(const Duration(hours: 6));
      _nowWasInWindow = _nowInWindow(DateTime.now());
    });
    _loadPrograms();
  }

  void _jumpToNow() {
    _initTimeRange();
    _loadPrograms();
  }

  bool _nowInWindow(DateTime now) => !now.isBefore(_gridStart) && now.isBefore(_gridEnd);

  /// Timer path: re-anchor only when a live-anchored window drifted fully past.
  void _checkWindowDrift() {
    if (!_isGuideVisible || _isLoading) return;
    if (_nowWasInWindow && !_nowInWindow(DateTime.now())) _jumpToNow();
  }

  /// Active path (app resume / guide became visible): drift-jump, else
  /// realign the viewport to the live line after a meaningful absence (#1297).
  void _catchUpIfStale() {
    if (!_isGuideVisible) return; // still hidden — keep _hiddenSince
    final hiddenSince = _hiddenSince;
    _hiddenSince = null; // evaluated while visible — consume it
    if (_isLoading) return; // in-flight load already ends in _scrollToNow()
    final now = DateTime.now();
    if (_nowWasInWindow && !_nowInWindow(now)) {
      _jumpToNow();
    } else if (_nowInWindow(now) && hiddenSince != null && now.difference(hiddenSince) >= _realignAfterAway) {
      _scrollToNow();
    }
  }

  bool _isCurrentProgramLoad(int generation) => mounted && generation == _programLoadGeneration;

  Future<void> _loadPrograms() async {
    if (!mounted) return;
    final loadGeneration = ++_programLoadGeneration;
    final requestGridStart = _gridStart;
    final requestGridEnd = _gridEnd;
    final startEpoch = requestGridStart.millisecondsSinceEpoch ~/ 1000;
    final endEpoch = requestGridEnd.millisecondsSinceEpoch ~/ 1000;
    final from = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true);
    final to = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true);
    setState(() => _isLoading = true);

    try {
      final allPrograms = <LiveTvProgram>[];
      final scheduledRecordingKeys = <String>{};
      final multiServer = context.read<MultiServerProvider>();
      final liveTvServers = List<LiveTvServerInfo>.of(multiServer.liveTvServers);
      final queriedServers = <String>{};

      for (final serverInfo in liveTvServers) {
        if (!queriedServers.add(serverInfo.serverId)) continue;
        try {
          final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId));
          if (genericClient == null) continue;

          final programs = await genericClient.liveTv.fetchSchedule(from: from, to: to);
          if (!_isCurrentProgramLoad(loadGeneration)) return;
          allPrograms.addAll(programs);
          await _addScheduledRecordingKeysForServer(
            client: genericClient,
            serverId: ServerId(serverInfo.serverId),
            keys: scheduledRecordingKeys,
            isCurrent: () => _isCurrentProgramLoad(loadGeneration),
          );
          if (!_isCurrentProgramLoad(loadGeneration)) return;
        } catch (e) {
          if (!_isCurrentProgramLoad(loadGeneration)) return;
          appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
        }
      }

      if (!_isCurrentProgramLoad(loadGeneration)) return;
      final shouldFocus = _pendingFocus;
      final programsByChannelScope = _indexProgramsByChannel(allPrograms, widget.channels);

      setState(() {
        _programs = allPrograms;
        _programsByChannelScope = programsByChannelScope;
        _scheduledRecordingKeys = scheduledRecordingKeys;
        _isLoading = false;
        // Focus tracking compares by identity, so a reload orphans the
        // focused program — re-resolve it against the fresh list.
        if (_focusZone == _GuideZone.grid && _gridColumn == 1 && _focusedProgram != null) {
          final focused = _focusedProgram;
          if (!_programs.any((p) => identical(p, focused))) {
            _focusedProgram = _findCurrentProgram(_gridChannelIndex);
          }
        }
      });
      _publishFocusSnapshot();

      _scrollToNow(loadGeneration: loadGeneration);

      if (shouldFocus) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (_isCurrentProgramLoad(loadGeneration)) focusContent();
        });
      }
    } catch (e) {
      if (!_isCurrentProgramLoad(loadGeneration)) return;
      appLogger.e('Failed to load guide programs', error: e);
      setState(() => _isLoading = false);
    }
  }

  Future<void> _refreshScheduledRecordingKeys() async {
    if (!mounted) return;
    final multiServer = context.read<MultiServerProvider>();
    final scheduledRecordingKeys = <String>{};
    final queriedServers = <String>{};

    for (final serverInfo in multiServer.liveTvServers) {
      if (!queriedServers.add(serverInfo.serverId)) continue;
      final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
      if (client == null) continue;
      await _addScheduledRecordingKeysForServer(
        client: client,
        serverId: ServerId(serverInfo.serverId),
        keys: scheduledRecordingKeys,
      );
    }

    if (!mounted) return;
    setState(() => _scheduledRecordingKeys = scheduledRecordingKeys);
  }

  Future<void> _addScheduledRecordingKeysForServer({
    required MediaServerClient client,
    required ServerId serverId,
    required Set<String> keys,
    bool Function()? isCurrent,
  }) async {
    final dvr = client.liveTvDvr;
    if (dvr == null) return;
    try {
      final grabs = await dvr.fetchScheduledRecordings();
      if (isCurrent?.call() == false) return;
      for (final grab in grabs) {
        _addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
      }
    } catch (e) {
      if (isCurrent?.call() == false) return;
      appLogger.d('Failed to load scheduled recordings for $serverId', error: e);
    }

    try {
      final rules = await dvr.fetchRecordingRules(includeGrabs: true, includeStorage: false);
      if (isCurrent?.call() == false) return;
      for (final rule in rules) {
        for (final grab in rule.grabOperations) {
          _addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
        }
      }
    } catch (e) {
      if (isCurrent?.call() == false) return;
      appLogger.d('Failed to load active recording grabs for $serverId', error: e);
    }
  }

  void _addRecordingKeysForGrab(MediaGrabOperation grab, {required ServerId serverId, required Set<String> keys}) {
    if (!_isActiveScheduledGrab(grab)) return;
    final program = grab.program;
    if (program == null) return;
    keys.addAll(_recordingKeysForProgram(program, fallbackServerId: serverId));
  }

  bool _isActiveScheduledGrab(MediaGrabOperation grab) {
    final status = grab.status?.trim().toLowerCase();
    return status == null || status.isEmpty || status == 'scheduled' || status == 'grabbing' || status == 'recording';
  }

  bool _isRecordingScheduled(LiveTvProgram program) {
    final keys = _recordingKeysForProgram(program);
    return keys.any(_scheduledRecordingKeys.contains);
  }

  void _handleRecordingStateChanged(LiveTvProgram program, bool isScheduled) {
    final keys = _recordingKeysForProgram(program);
    if (keys.isNotEmpty) {
      setState(() {
        final next = {..._scheduledRecordingKeys};
        if (isScheduled) {
          next.addAll(keys);
        } else {
          next.removeAll(keys);
        }
        _scheduledRecordingKeys = next;
      });
    }
    unawaited(_refreshScheduledRecordingKeys());
  }

  Set<String> _recordingKeysForProgram(LiveTvProgram program, {String? fallbackServerId}) {
    final serverId = liveTvNonEmpty(program.serverId) ?? liveTvNonEmpty(fallbackServerId);
    if (serverId == null) return const <String>{};

    final keys = <String>{};
    void addMediaId(String? value) {
      final normalized = liveTvNonEmpty(value);
      if (normalized != null) keys.add(_recordingKey(ServerId(serverId), 'media', normalized));
    }

    addMediaId(program.ratingKey);
    addMediaId(program.guid);
    addMediaId(program.key);

    final channelIdentifier = liveTvNonEmpty(program.channelIdentifier);
    final beginsAt = program.beginsAt;
    if (channelIdentifier != null && beginsAt != null) {
      keys.add(_recordingKey(ServerId(serverId), 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}'));
    }

    return keys;
  }

  String _recordingKey(ServerId serverId, String type, String value) => '$serverId\u0000$type\u0000$value';

  List<_GuideRow> get _guideRows {
    final groups = groupLiveTvChannelsBySource(widget.channels);
    if (groups.length <= 1) {
      return [
        for (var i = 0; i < widget.channels.length; i++) _GuideChannelRow(channel: widget.channels[i], channelIndex: i),
      ];
    }

    final channelIndexes = <LiveTvChannel, int>{};
    for (var i = 0; i < widget.channels.length; i++) {
      channelIndexes[widget.channels[i]] = i;
    }

    return [
      for (final group in groups) ...[
        _GuideSourceHeaderRow(label: group.label),
        for (final channel in group.channels)
          _GuideChannelRow(channel: channel, channelIndex: channelIndexes[channel] ?? 0),
      ],
    ];
  }

  double _guideRowHeight(_GuideRow row) {
    return switch (row) {
      _GuideSourceHeaderRow() => _sourceHeaderRowHeight,
      _GuideChannelRow() => _rowHeight,
    };
  }

  double _guideContentHeight(List<_GuideRow> rows) {
    var height = 0.0;
    for (final row in rows) {
      height += _guideRowHeight(row);
    }
    return height;
  }

  double _rowTopForChannelIndex(int channelIndex) {
    var top = 0.0;
    for (final row in _guideRows) {
      if (row is _GuideChannelRow && row.channelIndex == channelIndex) return top;
      top += _guideRowHeight(row);
    }
    return channelIndex * _rowHeight;
  }

  void _scrollToNow({int? loadGeneration}) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || (loadGeneration != null && !_isCurrentProgramLoad(loadGeneration))) return;
      final now = DateTime.now();
      final minutesSinceStart = now.difference(_gridStart).inMinutes;
      final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
      if (_gridHorizontalController.hasClients) {
        _gridHorizontalController.jumpTo(
          (offset - MediaQuery.sizeOf(context).width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent),
        );
      }
    });
  }

  Map<String, List<LiveTvProgram>> _indexProgramsByChannel(List<LiveTvProgram> programs, List<LiveTvChannel> channels) {
    final programsByIdentifier = <String, List<LiveTvProgram>>{};
    for (final program in programs) {
      final identifier = program.channelIdentifier?.trim();
      if (identifier == null || identifier.isEmpty) continue;
      (programsByIdentifier[identifier] ??= []).add(program);
    }

    final indexed = <String, List<LiveTvProgram>>{};
    for (final channel in channels) {
      final candidates = <LiveTvProgram>{};
      candidates.addAll(programsByIdentifier[channel.key] ?? const []);
      final identifier = channel.identifier;
      if (identifier != null && identifier != channel.key) {
        candidates.addAll(programsByIdentifier[identifier] ?? const []);
      }
      final matching = candidates.where((program) => liveTvProgramMatchesChannel(program, channel)).toList()
        ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
      indexed[liveTvChannelScopeKey(channel)] = matching;
    }
    return indexed;
  }

  List<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
    return _programsByChannelScope[liveTvChannelScopeKey(channel)] ?? const [];
  }

  double _totalGridWidth() {
    final totalMinutes = _gridEnd.difference(_gridStart).inMinutes;
    return (totalMinutes / _minutesPerSlot) * _slotWidth;
  }

  void _activateProgram(LiveTvChannel channel, LiveTvProgram program) {
    if (PlatformDetector.isTV() && program.isCurrentlyAiring) {
      tuneChannel(channel);
      return;
    }

    _showProgramDetails(channel, program);
  }

  ({LiveTvChannel channel, LiveTvProgram program})? _focusedProgramTarget() {
    if (_focusZone != _GuideZone.grid || _gridColumn != 1) return null;
    final program = _focusedProgram;
    if (program == null) return null;
    if (_gridChannelIndex < 0 || _gridChannelIndex >= widget.channels.length) return null;

    return (channel: widget.channels[_gridChannelIndex], program: program);
  }

  KeyEventResult _handleFocusedProgramSelectKey(KeyEvent event) {
    final target = _focusedProgramTarget();
    if (target == null) return KeyEventResult.ignored;

    final ownerChannelIndex = _gridChannelIndex;
    final targetIdentity = guideAiringIdentity(target.channel, target.program);
    return _programSelectController.handleKeyEvent(
      event,
      isOwnerActive: () {
        if (!mounted || _focusZone != _GuideZone.grid || _gridColumn != 1 || _gridChannelIndex != ownerChannelIndex) {
          return false;
        }

        final activeTarget = _focusedProgramTarget();
        return activeTarget != null &&
            guideAiringIdentity(activeTarget.channel, activeTarget.program) == targetIdentity;
      },
      onShortPress: () => _activateProgram(target.channel, target.program),
      onLongPress: () {
        _programSelectController.reset();
        _showProgramDetails(target.channel, target.program);
      },
    );
  }

  KeyEventResult _handleFocusedProgramContextMenuKey(KeyEvent event) {
    if (!event.logicalKey.isContextMenuKey || !event.isActionable) return KeyEventResult.ignored;
    final target = _focusedProgramTarget();
    if (target == null) return KeyEventResult.ignored;

    _resetProgramSelectLongPressState();
    _showProgramDetails(target.channel, target.program);
    return KeyEventResult.handled;
  }

  KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
    final key = event.logicalKey;

    if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
      if (event is KeyUpEvent && key.isSelectKey) {
        _resetProgramSelectLongPressState();
      }
      return KeyEventResult.handled;
    }

    // Back key
    if (key.isBackKey) {
      if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
        return KeyEventResult.handled;
      }
      if (_focusZone == _GuideZone.grid) {
        if (event is KeyUpEvent) {
          _updateFocus(() {
            _focusZone = _GuideZone.timeNav;
            _timeNavIndex = 1;
          });
        }
        return KeyEventResult.handled;
      }
      return handleBackKeyAction(event, () => widget.onBack?.call());
    }

    if (PlatformDetector.isTV()) {
      final selectResult = _handleFocusedProgramSelectKey(event);
      if (selectResult != KeyEventResult.ignored) return selectResult;
    }

    final contextMenuResult = _handleFocusedProgramContextMenuKey(event);
    if (contextMenuResult != KeyEventResult.ignored) return contextMenuResult;

    if (!event.isActionable) return KeyEventResult.ignored;

    return _focusZone == _GuideZone.timeNav ? _handleTimeNavKey(key) : _handleGridKey(key);
  }

  KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) {
    if (key.isLeftKey) {
      if (_timeNavIndex > 0) {
        _updateFocus(() => _timeNavIndex--);
      } else {
        widget.onBack?.call();
      }
      return KeyEventResult.handled;
    }
    if (key.isRightKey) {
      if (_timeNavIndex < 2) _updateFocus(() => _timeNavIndex++);
      return KeyEventResult.handled;
    }
    if (key.isDownKey) {
      if (widget.channels.isNotEmpty) {
        _updateFocus(() {
          _focusZone = _GuideZone.grid;
          _gridColumn = 0;
          _focusedProgram = null;
        });
        _scrollToChannel(_gridChannelIndex);
      }
      return KeyEventResult.handled;
    }
    if (key.isUpKey) {
      widget.onNavigateUp?.call();
      return KeyEventResult.handled;
    }
    if (key.isSelectKey) {
      switch (_timeNavIndex) {
        case 0:
          _shiftTimeRange(-2);
        case 1:
          _showDayPicker();
        case 2:
          _shiftTimeRange(2);
      }
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  }

  KeyEventResult _handleGridKey(LogicalKeyboardKey key) {
    if (key.isUpKey) {
      if (_gridChannelIndex > 0) {
        _updateFocus(() {
          _gridChannelIndex--;
          if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex);
        });
        _scrollToChannel(_gridChannelIndex);
      } else {
        _updateFocus(() {
          _focusZone = _GuideZone.timeNav;
          _timeNavIndex = 1;
        });
      }
      return KeyEventResult.handled;
    }
    if (key.isDownKey) {
      if (_gridChannelIndex < widget.channels.length - 1) {
        _updateFocus(() {
          _gridChannelIndex++;
          if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex);
        });
        _scrollToChannel(_gridChannelIndex);
      }
      return KeyEventResult.handled;
    }
    if (key.isRightKey) {
      if (_gridColumn == 0) {
        final program = _findCurrentProgram(_gridChannelIndex);
        if (program != null) {
          _updateFocus(() {
            _gridColumn = 1;
            _focusedProgram = program;
          });
          _scrollToProgramTime(program);
        }
      } else {
        // Already in program column — move to next program
        _navigateToAdjacentProgram(_gridChannelIndex, forward: true);
      }
      return KeyEventResult.handled;
    }
    if (key.isLeftKey) {
      if (_gridColumn == 1) {
        // Try moving to previous program; if at first program, go back to channel column
        if (!_navigateToAdjacentProgram(_gridChannelIndex, forward: false)) {
          _updateFocus(() {
            _gridColumn = 0;
            _focusedProgram = null;
          });
        }
      } else {
        widget.onBack?.call();
      }
      return KeyEventResult.handled;
    }
    if (key.isSelectKey) {
      if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) {
        final channel = widget.channels[_gridChannelIndex];
        if (_gridColumn == 0) {
          tuneChannel(channel);
        } else if (_focusedProgram != null) {
          _activateProgram(channel, _focusedProgram!);
        }
      }
      return KeyEventResult.handled;
    }
    // 'F' key toggles favorite on focused channel
    if (key == LogicalKeyboardKey.keyF && _gridColumn == 0) {
      if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) {
        widget.onToggleFavorite?.call(widget.channels[_gridChannelIndex]);
      }
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  }

  LiveTvProgram? _findCurrentProgram(int channelIndex) {
    if (channelIndex < 0 || channelIndex >= widget.channels.length) return null;
    final channel = widget.channels[channelIndex];
    final programs = _getProgramsForChannel(channel);
    final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;

    // Currently airing
    for (final p in programs) {
      if ((p.beginsAt ?? 0) <= now && (p.endsAt ?? 0) > now) return p;
    }
    // First future program
    for (final p in programs) {
      if ((p.endsAt ?? 0) > now) return p;
    }
    return programs.firstOrNull;
  }

  /// Navigate to the next or previous program on the same channel.
  /// Returns true if navigation succeeded, false if at the boundary.
  bool _navigateToAdjacentProgram(int channelIndex, {required bool forward}) {
    if (channelIndex < 0 || channelIndex >= widget.channels.length) return false;
    final channel = widget.channels[channelIndex];
    final programs = _getProgramsForChannel(channel);
    if (programs.isEmpty || _focusedProgram == null) return false;

    final currentIndex = programs.indexWhere((p) => identical(p, _focusedProgram));
    if (currentIndex < 0) return false;

    final nextIndex = forward ? currentIndex + 1 : currentIndex - 1;
    if (nextIndex < 0 || nextIndex >= programs.length) return false;

    final nextProgram = programs[nextIndex];
    _updateFocus(() => _focusedProgram = nextProgram);
    _scrollToProgramTime(nextProgram);
    return true;
  }

  void _scrollToChannel(int index) {
    if (!_gridVerticalController.hasClients) return;
    final targetTop = _rowTopForChannelIndex(index);
    final targetBottom = targetTop + _rowHeight;
    final viewportTop = _gridVerticalController.offset;
    final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension;

    double? newOffset;
    if (targetTop < viewportTop) {
      newOffset = targetTop;
    } else if (targetBottom > viewportBottom) {
      newOffset = targetBottom - _gridVerticalController.position.viewportDimension;
    }

    if (newOffset != null) {
      final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent);
      _gridVerticalController.animateTo(clamped, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
      if (_channelVerticalController.hasClients) {
        _channelVerticalController.animateTo(
          clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent),
          duration: const Duration(milliseconds: 150),
          curve: Curves.easeOut,
        );
      }
    }
  }

  void _scrollToProgramTime(LiveTvProgram? program) {
    if (program == null || !_gridHorizontalController.hasClients) return;

    final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
    final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
    final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
    final startOffset = progStart - gridStartEpoch;
    final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth;

    final viewportWidth = _gridHorizontalController.position.viewportDimension;
    final currentOffset = _gridHorizontalController.offset;

    if (left < currentOffset || left > currentOffset + viewportWidth - 100) {
      final maxScroll = _gridHorizontalController.position.maxScrollExtent;
      _gridHorizontalController.jumpTo((left - 50).clamp(0.0, maxScroll));
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    return Focus(
      focusNode: _guideFocusNode,
      onFocusChange: _handleGuideFocusChange,
      onKeyEvent: _handleKeyEvent,
      child: _buildGuideGrid(theme),
    );
  }

  Widget _buildGuideGrid(ThemeData theme) {
    final rows = _guideRows;
    return Column(
      children: [
        _buildTimeNavigation(theme),
        Expanded(
          child: ListenableBuilder(
            listenable: _gridHorizontalController,
            builder: (context, child) {
              return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]);
            },
            child: Column(
              children: [
                Row(
                  children: [
                    const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
                    Expanded(
                      child: SingleChildScrollView(
                        controller: _headerHorizontalController,
                        scrollDirection: Axis.horizontal,
                        physics: const ClampingScrollPhysics(),
                        child: SizedBox(
                          width: _totalGridWidth(),
                          height: _timeHeaderHeight,
                          child: _buildTimeHeader(theme),
                        ),
                      ),
                    ),
                  ],
                ),
                Expanded(
                  child: Row(
                    children: [
                      SizedBox(
                        width: _channelColumnWidth,
                        child: CustomScrollView(
                          controller: _channelVerticalController,
                          physics: const NeverScrollableScrollPhysics(),
                          slivers: [
                            SliverVariedExtentList.builder(
                              itemCount: rows.length,
                              itemExtentBuilder: (index, _) => _guideRowHeight(rows[index]),
                              itemBuilder: (context, index) {
                                final row = rows[index];
                                return switch (row) {
                                  _GuideSourceHeaderRow(:final label) => _buildSourceHeaderCell(label, theme),
                                  _GuideChannelRow(:final channel, :final channelIndex) => _buildChannelCell(
                                    channel,
                                    theme,
                                    index: channelIndex,
                                  ),
                                };
                              },
                            ),
                          ],
                        ),
                      ),
                      Expanded(
                        child: NotificationListener<ScrollNotification>(
                          onNotification: (notification) {
                            if (notification is ScrollUpdateNotification &&
                                notification.metrics.axis == Axis.vertical) {
                              if (_channelVerticalController.hasClients) {
                                _channelVerticalController.jumpTo(notification.metrics.pixels);
                              }
                            }
                            return false;
                          },
                          child: SingleChildScrollView(
                            controller: _gridHorizontalController,
                            scrollDirection: Axis.horizontal,
                            physics: const ClampingScrollPhysics(),
                            child: SizedBox(
                              width: _totalGridWidth(),
                              child: CustomScrollView(
                                controller: _gridVerticalController,
                                slivers: [
                                  SliverVariedExtentList.builder(
                                    itemCount: rows.length,
                                    itemExtentBuilder: (index, _) => _guideRowHeight(rows[index]),
                                    itemBuilder: (context, index) {
                                      final row = rows[index];
                                      return switch (row) {
                                        _GuideSourceHeaderRow(:final label) => _buildSourceHeaderGridRow(label, theme),
                                        _GuideChannelRow(:final channel, :final channelIndex) => _buildProgramRow(
                                          channel,
                                          _getProgramsForChannel(channel),
                                          theme,
                                          channelIndex: channelIndex,
                                        ),
                                      };
                                    },
                                  ),
                                ],
                              ),
                            ),
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildNowIndicatorOverlay(ThemeData _) {
    final now = DateTime.now();
    if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) {
      return const SizedBox.shrink();
    }
    final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble();
    final nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
    final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
    final left = _channelColumnWidth + nowOffset - scrollOffset;

    // Hide when scrolled behind the channel column
    if (left < _channelColumnWidth) return const SizedBox.shrink();

    final gridHeight = _timeHeaderHeight + _guideContentHeight(_guideRows);

    return Positioned(
      left: left,
      top: 0,
      height: gridHeight,
      child: IgnorePointer(child: Container(width: 2, color: Colors.red)),
    );
  }

  String _dayLabel(DateTime day) {
    final now = DateTime.now();
    final today = DateTime(now.year, now.month, now.day);
    final target = DateTime(day.year, day.month, day.day);

    if (target == today) return t.liveTv.today;

    return DateFormat('EEEE', LocaleSettings.currentLocale.intlLocaleName).format(target);
  }

  List<(String, int)> get _timeSlots => [
    (t.liveTv.midnight, 0),
    (t.liveTv.overnight, 2),
    (t.liveTv.morning, 6),
    (t.liveTv.daytime, 12),
    (t.liveTv.evening, 18),
    (t.liveTv.lateNight, 22),
  ];

  Rect? _menuAnchorRect() {
    final renderBox = _dayPickerKey.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox == null) return null;

    final buttonPos = renderBox.localToGlobal(Offset.zero);
    final buttonSize = renderBox.size;
    return Rect.fromLTWH(buttonPos.dx, buttonPos.dy, buttonSize.width, buttonSize.height);
  }

  Future<void> _showDayPicker() async {
    final anchorRect = _menuAnchorRect();
    if (anchorRect == null) return;

    final now = DateTime.now();
    final today = DateTime(now.year, now.month, now.day);
    final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day);

    final days = <DateTime>[];
    for (var i = 0; i < 8; i++) {
      days.add(today.add(Duration(days: i)));
    }

    final value = await showAppMenu<Object>(
      context,
      anchorRect: anchorRect,
      focusFirstItem: InputModeTracker.isKeyboardMode(context),
      entries: [
        AppMenuItem<Object>(value: 'now', label: t.liveTv.now),
        ...days.map((day) {
          final isSelected = day == gridDay;
          final label = _dayLabel(day);
          return AppMenuItem<Object>(value: day, label: label, selected: isSelected);
        }),
      ],
    );
    if (!mounted) return;
    if (value == null) {
      _guideFocusNode.requestFocus();
      return;
    }
    if (value is String && value == 'now') {
      _jumpToNow();
      _guideFocusNode.requestFocus();
    } else if (value is DateTime) {
      await _showTimeSlotPicker(value);
    }
  }

  Future<void> _showTimeSlotPicker(DateTime day) async {
    final anchorRect = _menuAnchorRect();
    if (anchorRect == null) return;

    final label = _dayLabel(day).toUpperCase();

    final value = await showAppMenu<int>(
      context,
      anchorRect: anchorRect,
      focusFirstItem: InputModeTracker.isKeyboardMode(context),
      entries: [
        AppMenuItem<int>(
          value: -1,
          icon: Symbols.chevron_left_rounded,
          child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: .bold)),
        ),
        const AppMenuDivider<int>(),
        ..._timeSlots.map((slot) {
          return AppMenuItem<int>(value: slot.$2, label: slot.$1);
        }),
      ],
    );
    if (!mounted) return;
    if (value == null) {
      _guideFocusNode.requestFocus();
      return;
    }
    if (value == -1) {
      await _showDayPicker();
      return;
    }
    setState(() {
      _gridStart = DateTime(day.year, day.month, day.day, value);
      _gridEnd = _gridStart.add(const Duration(hours: 6));
      _nowWasInWindow = _nowInWindow(DateTime.now());
    });
    unawaited(_loadPrograms());
    _guideFocusNode.requestFocus();
  }

  Widget _timeNavFocusWrap({required Widget child, required int index}) {
    return _GuideFocusSelector(
      valueListenable: _focusSnapshot,
      isSelected: (focus) => focus.hasFocus && focus.zone == _GuideZone.timeNav && focus.timeNavIndex == index,
      builder: (context, isFocused) {
        if (!isFocused) return child;
        return Container(
          decoration: FocusTheme.textFillFocusDecoration(context, isFocused: true, borderRadius: MonoTokens.radiusFull),
          child: child,
        );
      },
    );
  }

  Widget _buildTimeNavigation(ThemeData theme) {
    final timeLabel = formatClockTime(_gridStart, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
    final dayLabel = _dayLabel(_gridStart);

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
      child: Row(
        children: [
          _timeNavFocusWrap(
            index: 0,
            child: IconButton(
              icon: const AppIcon(Symbols.chevron_left_rounded),
              onPressed: () => _shiftTimeRange(-2),
              iconSize: 20,
              visualDensity: VisualDensity.compact,
            ),
          ),
          Expanded(
            child: Row(
              mainAxisAlignment: .center,
              children: [
                _timeNavFocusWrap(
                  index: 1,
                  child: ClickableCursor(
                    child: GestureDetector(
                      key: _dayPickerKey,
                      onTap: _showDayPicker,
                      child: Container(
                        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                        decoration: BoxDecoration(
                          color: tokens(context).text.withValues(alpha: 0.08),
                          borderRadius: const BorderRadius.all(Radius.circular(MonoTokens.radiusFull)),
                        ),
                        child: Row(
                          mainAxisSize: .min,
                          children: [
                            Text(dayLabel, style: theme.textTheme.labelLarge),
                            const SizedBox(width: 2),
                            AppIcon(Symbols.arrow_drop_down_rounded, size: 18, color: theme.colorScheme.onSurface),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Text(timeLabel, style: theme.textTheme.labelLarge),
              ],
            ),
          ),
          _timeNavFocusWrap(
            index: 2,
            child: IconButton(
              icon: const AppIcon(Symbols.chevron_right_rounded),
              onPressed: () => _shiftTimeRange(2),
              iconSize: 20,
              visualDensity: VisualDensity.compact,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTimeHeader(ThemeData theme) {
    final is24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
    final slots = <Widget>[];
    var current = _gridStart;

    while (current.isBefore(_gridEnd)) {
      final timeStr = formatClockTime(current, is24Hour: is24Hour);
      slots.add(
        SizedBox(
          width: _slotWidth,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Align(
              alignment: .centerLeft,
              child: Text(timeStr, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)),
            ),
          ),
        ),
      );
      current = current.add(const Duration(minutes: _minutesPerSlot));
    }

    return Row(children: slots);
  }

  Widget _buildSourceHeaderCell(String label, ThemeData theme) {
    return Container(
      height: _sourceHeaderRowHeight,
      padding: const EdgeInsets.symmetric(horizontal: 8),
      alignment: .centerLeft,
      child: Text(
        label,
        style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted, fontWeight: .w700),
        maxLines: 2,
        overflow: .ellipsis,
      ),
    );
  }

  Widget _buildSourceHeaderGridRow(String label, ThemeData theme) {
    return SizedBox(
      height: _sourceHeaderRowHeight,
      child: ClipRect(
        child: ListenableBuilder(
          listenable: _gridHorizontalController,
          builder: (context, child) {
            final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
            return Transform.translate(offset: Offset(scrollOffset, 0), child: child);
          },
          child: Align(
            alignment: .centerLeft,
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 12),
              child: Text(
                label,
                style: theme.textTheme.labelSmall?.copyWith(
                  color: tokens(context).textMuted,
                  fontWeight: .w700,
                  letterSpacing: 0.3,
                ),
                maxLines: 1,
                overflow: .ellipsis,
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
    final multiServer = context.read<MultiServerProvider>();
    final serverId = serverIdOrNull(channel.serverId);
    final client = serverId == null ? null : multiServer.getClientForServer(serverId);

    return _GuideFocusSelector(
      valueListenable: _focusSnapshot,
      isSelected: (focus) =>
          focus.hasFocus && focus.zone == _GuideZone.grid && focus.gridColumn == 0 && focus.channelIndex == index,
      builder: (context, isFocused) {
        return _ChannelCell(
          rowHeight: _rowHeight,
          channelColumnWidth: _channelColumnWidth,
          channelThumb: channel.thumb,
          client: client,
          channel: channel,
          theme: theme,
          onTap: () => tuneChannel(channel),
          onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null,
          isFocused: isFocused,
          isFavorite: widget.isFavoriteChannel?.call(channel) ?? false,
          fallbackBuilder: () => _buildChannelNameFallback(channel, theme),
        );
      },
    );
  }

  Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) {
    return Column(
      mainAxisAlignment: .center,
      children: [
        if (channel.number != null)
          Text(
            channel.number!,
            style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted),
            maxLines: 1,
          ),
        Text(
          channel.displayName,
          style: theme.textTheme.bodySmall?.copyWith(fontWeight: .w500),
          maxLines: 1,
          overflow: .ellipsis,
          textAlign: TextAlign.center,
        ),
      ],
    );
  }

  Widget _buildProgramRow(
    LiveTvChannel channel,
    List<LiveTvProgram> programs,
    ThemeData theme, {
    required int channelIndex,
  }) {
    if (programs.isEmpty) {
      final tk = tokens(context);
      return SizedBox(
        height: _rowHeight,
        child: Padding(
          padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap),
          child: Material(
            color: Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg),
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(tk.radiusXs)),
            child: Center(
              child: Text(t.liveTv.noPrograms, style: theme.textTheme.bodySmall?.copyWith(color: tk.textMuted)),
            ),
          ),
        ),
      );
    }

    final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
    final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;

    return SizedBox(
      height: _rowHeight,
      child: ListenableBuilder(
        listenable: _gridHorizontalController,
        builder: (context, _) {
          final scrollOffset = _gridHorizontalController.hasClients ? _gridHorizontalController.offset : 0.0;
          // Keep one slot of overscan so the next D-pad target exists before the horizontal jump.
          final visibleStart = scrollOffset > _slotWidth ? scrollOffset - _slotWidth : 0.0;
          final visibleEnd = scrollOffset + MediaQuery.sizeOf(context).width + _slotWidth;
          final blocks = <Widget>[];

          for (final program in programs) {
            final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
            final progEnd = (program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch);
            if (progEnd <= progStart) continue;

            final startOffset = progStart - gridStartEpoch;
            final duration = progEnd - progStart;
            final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth;
            final width = (duration / (_minutesPerSlot * 60)) * _slotWidth;
            final clampedWidth = width.clamp(6.0, double.infinity);
            if (left + clampedWidth < visibleStart || left > visibleEnd) continue;

            blocks.add(
              Positioned(
                key: ObjectKey(program),
                left: left,
                width: clampedWidth,
                top: 0,
                bottom: 0,
                child: _buildProgramBlock(
                  channel,
                  program,
                  theme,
                  channelIndex: channelIndex,
                  tileLeft: left,
                  tileWidth: clampedWidth,
                  scrollOffset: scrollOffset,
                ),
              ),
            );
          }

          return Stack(children: blocks);
        },
      ),
    );
  }

  Widget _buildProgramBlock(
    LiveTvChannel channel,
    LiveTvProgram program,
    ThemeData theme, {
    required int channelIndex,
    required double tileLeft,
    required double tileWidth,
    required double scrollOffset,
  }) {
    return _GuideFocusSelector(
      valueListenable: _focusSnapshot,
      isSelected: (focus) =>
          focus.hasFocus &&
          focus.zone == _GuideZone.grid &&
          focus.gridColumn == 1 &&
          focus.channelIndex == channelIndex &&
          identical(focus.program, program),
      builder: (context, isFocused) {
        final tk = tokens(context);
        final isCurrentlyAiring = program.isCurrentlyAiring;
        final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
        final isRecordingScheduled = _isRecordingScheduled(program);

        final Color fillColor;
        final Color titleColor;
        final Color subtitleColor;
        if (isFocused) {
          // Inverted focus card: primary == text in the mono theme, so the cursor
          // reads as a solid inverted cell (white card, dark text in dark mode).
          fillColor = theme.colorScheme.primary;
          titleColor = theme.colorScheme.onPrimary;
          subtitleColor = theme.colorScheme.onPrimary.withValues(alpha: 0.7);
        } else if (isPast) {
          fillColor = Color.alphaBlend(tk.surface.withValues(alpha: 0.5), tk.bg);
          titleColor = tk.text.withValues(alpha: 0.5);
          subtitleColor = tk.text.withValues(alpha: 0.3);
        } else if (isCurrentlyAiring) {
          fillColor = airingFill(context);
          titleColor = tk.text;
          subtitleColor = tk.textMuted;
        } else {
          fillColor = tk.surface;
          titleColor = tk.text;
          subtitleColor = tk.textMuted;
        }
        final radius = BorderRadius.circular(isFocused ? tk.radiusSm : tk.radiusXs);

        return Padding(
          padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap),
          child: Material(
            color: fillColor,
            shape: RoundedRectangleBorder(borderRadius: radius),
            child: InkWell(
              borderRadius: radius,
              mouseCursor: SystemMouseCursors.click,
              canRequestFocus: false,
              onTap: () => _activateProgram(channel, program),
              onLongPress: () => _showProgramDetails(channel, program),
              onSecondaryTap: () => _showProgramDetails(channel, program),
              child: Padding(
                padding: .fromLTRB(
                  6 + (scrollOffset - tileLeft).clamp(0.0, (tileWidth - tk.groupGap - 32).clamp(0.0, double.infinity)),
                  4,
                  6,
                  4,
                ),
                child: Column(
                  crossAxisAlignment: .start,
                  mainAxisAlignment: .center,
                  children: [
                    Row(
                      children: [
                        if (isRecordingScheduled) ...[
                          _RecordingDot(color: Colors.red, tooltip: t.liveTv.recordingScheduled),
                          const SizedBox(width: 5),
                        ],
                        Expanded(
                          child: Text(
                            program.grandparentTitle ?? program.title,
                            style: theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600, color: titleColor),
                            maxLines: 1,
                            overflow: .ellipsis,
                          ),
                        ),
                      ],
                    ),
                    if (program.grandparentTitle != null)
                      Text(
                        '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}',
                        style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
                        maxLines: 1,
                        overflow: .ellipsis,
                      ),
                    if (program.startTime != null)
                      Text(
                        '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60_000)}',
                        style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor),
                        maxLines: 1,
                        overflow: .ellipsis,
                      ),
                  ],
                ),
              ),
            ),
          ),
        );
      },
    );
  }

  void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
    showProgramDetails(
      program: program,
      channel: channel,
      posterThumb: program.thumb,
      posterServerId: channel.serverId,
      onRecordingStateChanged: (isScheduled) => _handleRecordingStateChanged(program, isScheduled),
    );
  }
}

class _GuideFocusSelector extends StatefulWidget {
  const _GuideFocusSelector({required this.valueListenable, required this.isSelected, required this.builder});

  final ValueListenable<_GuideFocusSnapshot> valueListenable;
  final bool Function(_GuideFocusSnapshot focus) isSelected;
  final Widget Function(BuildContext context, bool isSelected) builder;

  @override
  State<_GuideFocusSelector> createState() => _GuideFocusSelectorState();
}

class _GuideFocusSelectorState extends State<_GuideFocusSelector> {
  late bool _isSelected;

  @override
  void initState() {
    super.initState();
    _isSelected = widget.isSelected(widget.valueListenable.value);
    widget.valueListenable.addListener(_handleValueChanged);
  }

  @override
  void didUpdateWidget(_GuideFocusSelector oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.valueListenable != widget.valueListenable) {
      oldWidget.valueListenable.removeListener(_handleValueChanged);
      widget.valueListenable.addListener(_handleValueChanged);
    }
    _isSelected = widget.isSelected(widget.valueListenable.value);
  }

  @override
  void dispose() {
    widget.valueListenable.removeListener(_handleValueChanged);
    super.dispose();
  }

  void _handleValueChanged() {
    final isSelected = widget.isSelected(widget.valueListenable.value);
    if (isSelected == _isSelected) return;
    setState(() => _isSelected = isSelected);
  }

  @override
  Widget build(BuildContext context) => widget.builder(context, _isSelected);
}

class _RecordingDot extends StatelessWidget {
  final Color color;
  final String tooltip;

  const _RecordingDot({required this.color, required this.tooltip});

  @override
  Widget build(BuildContext context) {
    return Tooltip(
      message: tooltip,
      child: Semantics(
        label: tooltip,
        child: Container(
          width: 8,
          height: 8,
          decoration: BoxDecoration(color: color, shape: BoxShape.circle),
        ),
      ),
    );
  }
}

class _ChannelCell extends StatefulWidget {
  final double rowHeight;
  final double channelColumnWidth;
  final String? channelThumb;
  final MediaServerClient? client;
  final LiveTvChannel channel;
  final ThemeData theme;
  final VoidCallback onTap;
  final VoidCallback? onLongPress;
  final bool isFocused;
  final bool isFavorite;
  final Widget Function() fallbackBuilder;

  const _ChannelCell({
    required this.rowHeight,
    required this.channelColumnWidth,
    required this.channelThumb,
    required this.client,
    required this.channel,
    required this.theme,
    required this.onTap,
    this.onLongPress,
    required this.isFocused,
    this.isFavorite = false,
    required this.fallbackBuilder,
  });

  @override
  State<_ChannelCell> createState() => _ChannelCellState();
}

class _ChannelCellState extends State<_ChannelCell> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final theme = widget.theme;
    final tk = tokens(context);
    final showAction = _hovered || widget.isFocused;
    final radius = BorderRadius.circular(widget.isFocused ? tk.radiusSm : tk.radiusXs);
    // Inverted focus card, matching the program-block cursor.
    final contentColor = widget.isFocused ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface;

    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: GestureDetector(
        onSecondaryTap: widget.onLongPress,
        child: SizedBox(
          height: widget.rowHeight,
          child: Padding(
            padding: EdgeInsets.only(right: tk.groupGap, bottom: tk.groupGap),
            child: Material(
              color: widget.isFocused ? theme.colorScheme.primary : tk.surface,
              shape: RoundedRectangleBorder(borderRadius: radius),
              child: InkWell(
                borderRadius: radius,
                canRequestFocus: false,
                onTap: widget.onTap,
                onLongPress: widget.onLongPress,
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 8),
                  child: Stack(
                    alignment: .center,
                    children: [
                      AnimatedOpacity(
                        opacity: showAction ? 0.3 : 1.0,
                        duration: FocusTheme.getAnimationDuration(context),
                        child: widget.channelThumb != null && widget.client != null
                            ? OptimizedMediaImage.thumb(
                                client: widget.client!,
                                imagePath: widget.channelThumb,
                                width: widget.channelColumnWidth - 16,
                                height: widget.rowHeight - 16,
                                fit: BoxFit.contain,
                              )
                            : widget.fallbackBuilder(),
                      ),
                      if (showAction) AppIcon(Symbols.play_arrow_rounded, size: 32, color: contentColor),
                      if (widget.isFavorite)
                        Positioned(
                          top: 2,
                          right: 0,
                          child: AppIcon(
                            Symbols.star_rounded,
                            size: 14,
                            color: widget.isFocused ? theme.colorScheme.onPrimary : theme.colorScheme.primary,
                          ),
                        ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
