part of '../../video_player_screen.dart';

extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
  /// Start periodic timeline heartbeats for live TV transcode session.
  void _startLiveTimelineUpdates() {
    final generation = ++_live.timelineGeneration;
    _live.timelineTimer?.cancel();
    _live.timelineTimer = Timer.periodic(const Duration(seconds: 10), (_) {
      if (generation != _live.timelineGeneration) return;
      final state = player?.state.playing == true ? 'playing' : 'paused';
      _sendLiveTimeline(state);
    });
    // Delay initial heartbeat to let the transcode session stabilize.
    // Sending time=0 immediately after player.open() causes the server
    // to spawn a duplicate transcode job with offset=-1 that 404s.
    Future.delayed(const Duration(seconds: 3), () {
      if (_live.timelineTimer != null && generation == _live.timelineGeneration) {
        final state = player?.state.playing == true ? 'playing' : 'paused';
        _sendLiveTimeline(state);
      }
    });
  }

  void _stopLiveTimelineUpdates() {
    _live.timelineGeneration++;
    _live.timelineTimer?.cancel();
    _live.timelineTimer = null;
  }

  Future<void> _sendLiveTimeline(String state) async {
    final requestSession = _live.session;
    if (requestSession == null) return;
    final requestGeneration = _live.timelineGeneration;
    // For live TV, player position/duration are unreliable (often 0). Use
    // elapsed wall-clock as the position and the program duration from tune
    // metadata; the per-backend session owns the wire mapping.
    final playbackTime = _live.playbackStartTime != null
        ? DateTime.now().difference(_live.playbackStartTime!).inMilliseconds
        : 0;

    try {
      await runLiveTimelineReport(
        requestSession: requestSession,
        requestGeneration: requestGeneration,
        state: state,
        positionMs: playbackTime,
        currentSession: () => _live.session,
        currentGeneration: () => _live.timelineGeneration,
        isMounted: () => mounted,
        commit: (updatedBuffer) {
          _setPlayerState(() {
            _live.captureBuffer = updatedBuffer;
            _live.atLiveEdge =
                (_currentPositionEpoch >=
                updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
          });
        },
      );
    } catch (e) {
      appLogger.d('Live timeline update failed', error: e);
    }
  }

  /// Fire-and-forget a stopped heartbeat for a session that started but was
  /// never adopted (unmount or superseded mid-start) so the backend tears
  /// down its tuner/transcode resources instead of waiting for a timeout.
  void _abandonLiveSession(LiveTvPlaybackSession session) {
    unawaited(() async {
      try {
        await session.reportTimeline(state: 'stopped', positionMs: 0, durationMs: session.program.durationMs ?? 0);
      } catch (e) {
        appLogger.d('Failed to stop abandoned live session', error: e);
      }
    }());
  }

  /// Resolve the owning live-TV server for [channel] and start a playback
  /// session on it — the shared resolution path for initial launch and
  /// channel zapping (Plex tunes a DVR, Jellyfin negotiates a direct URL).
  Future<LiveTvPlaybackSession?> _startLiveSession(LiveTvChannel channel) async {
    final multiServer = context.read<MultiServerProvider>();
    final serverInfo = liveTvServerInfoForChannel(multiServer, channel);
    if (serverInfo == null) {
      appLogger.w('No live TV server available for ${channel.displayName}');
      return null;
    }
    final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
    if (client == null) {
      appLogger.w('Live TV server ${serverInfo.serverId} is not connected');
      return null;
    }
    return client.liveTv.startPlayback(channel.key, dvrKey: serverInfo.dvrKey);
  }

  /// Retry the live stream with degraded direct-stream settings.
  ///
  /// The session owns the per-backend recovery: Plex re-tunes the channel
  /// for a fresh capture session (the previous one expires while MPV
  /// exhausts its reconnect attempts) applying the degradation flags;
  /// Jellyfin re-opens its session-less URL.
  Future<void> _retryLiveStream() async {
    _liveSeek.cancel();
    final currentPlayer = player;
    if (!mounted || currentPlayer == null) return;
    final generation = _playbackGeneration;
    bool isCurrent() => _isCurrentPlaybackGeneration(generation, currentPlayer);
    final session = _live.session;
    if (session == null) {
      _live.retrying = false;
      appLogger.w('Cannot retry live stream — no session');
      showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
      unawaited(_handleBackButton());
      return;
    }

    final ds = _live.fallbackLevel < 1;
    final dsa = _live.fallbackLevel < 2;
    appLogger.i('Retrying live stream: directStream=$ds directStreamAudio=$dsa');

    final result = await runLiveStreamRetry<LiveTvPlaybackSession>(
      recover: () => session.recover(directStream: ds, directStreamAudio: dsa),
      lookupStreamUrl: (recovered) => recovered.streamUrlAt(),
      applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer),
      open: (streamUrl) =>
          currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true),
      isCurrent: isCurrent,
      adoptSession: (recovered) {
        _live.adoptSession(recovered);
        _live.markStreamRestartedAtLiveEdge();
      },
      discardSession: _abandonLiveSession,
      reportFailure: (error, stackTrace) {
        appLogger.e('Failed to recover live stream', error: error, stackTrace: stackTrace);
        _live.retryFailed = true;
        showGlobalErrorSnackBar(t.messages.liveStreamInterrupted);
      },
      onFinished: () {
        if (isCurrent()) _live.retrying = false;
      },
    );
    if (result == LiveStreamRetryResult.succeeded && isCurrent()) {
      _live.retryFailed = false;
    }
  }

  /// Configure MPV options for live streaming.
  /// The official Plex Media Player does not set client-side reconnect options —
  /// reconnection is handled by the server's transcoder on the input side.
  Future<void> _setLiveStreamOptions(Player player) => player.setProperty('force-seekable', 'no');

  /// The raw live playback position as an absolute epoch second
  /// (`_live.streamStartEpoch + player position`).
  int get _rawPositionEpoch => (_live.streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();

  /// The current playback position as an absolute epoch second (for live TV time-shift).
  ///
  /// While a relative skip is pending/settling, this returns the accumulator's
  /// target rather than the raw sum. During a live re-open `_live.streamStartEpoch`
  /// is advanced to the target before the new stream's position resets to ~0,
  /// so the raw sum transiently overshoots; pinning to the pending target keeps
  /// seek accumulation and the live-edge heartbeat ([_sendLiveTimeline]) correct
  /// (close #1253).
  int get _currentPositionEpoch => _liveSeek.pendingEpoch ?? _rawPositionEpoch;

  /// Show "Watch from Start" / "Watch Live" dialog.
  /// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed.
  Future<bool?> _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) {
    final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round();
    return showOptionPickerDialog<bool>(
      context,
      title: t.liveTv.joinSession,
      options: [
        (icon: Symbols.replay_rounded, label: t.liveTv.watchFromStart(minutes: minutesAgo), value: true),
        (icon: Symbols.live_tv_rounded, label: t.liveTv.watchLive, value: false),
      ],
    );
  }

  /// Seek the live TV stream to an absolute epoch second by rebuilding the
  /// stream at the target offset. The session returns null when the backend
  /// can't time-shift (Jellyfin), and its capture buffer is null there too,
  /// so both guards cover it.
  Future<void> _seekLivePosition(int targetEpochSeconds) async {
    final currentPlayer = player;
    if (currentPlayer == null) return;
    final session = _live.session;
    final buffer = _live.captureBuffer;
    if (session == null || buffer == null) return;

    final clamped = targetEpochSeconds.clamp(buffer.seekableStartEpoch, buffer.seekableEndEpoch);
    final offsetSeconds = clamped - buffer.startedAt.round();

    final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds);
    if (streamUrl == null || !mounted || player != currentPlayer) return;

    _live.streamStartEpoch = buffer.startedAt + offsetSeconds;
    _live.atLiveEdge = (clamped >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
    _live.playbackStartTime = DateTime.now();

    await _setLiveStreamOptions(currentPlayer);
    await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
    if (mounted) _setPlayerState(() {});
  }

  /// Current seekable epoch window for [_liveSeek], or null when there is no
  /// live capture buffer.
  LiveSeekBounds? _liveSeekBounds() {
    final buffer = _live.captureBuffer;
    if (buffer == null) return null;
    return (start: buffer.seekableStartEpoch, end: buffer.seekableEndEpoch);
  }

  /// Rebuild and refresh live-edge state when [_liveSeek]'s pending target
  /// changes (a skip was accumulated, or the post-seek pin was released).
  void _onLiveSeekTargetChanged() {
    if (!mounted) return;
    final pending = _liveSeek.pendingEpoch;
    final buffer = _live.captureBuffer;
    _setPlayerState(() {
      if (pending != null && buffer != null) {
        _live.atLiveEdge = pending >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds;
      }
    });
  }

  /// Re-open the live stream at [targetEpochSeconds], logging (rather than
  /// throwing) on failure. A throw is rethrown so [_liveSeek] releases its
  /// pending pin; direct callers catch it.
  Future<void> _runLiveSeek(int targetEpochSeconds) async {
    try {
      await _seekLivePosition(targetEpochSeconds);
    } catch (e, st) {
      appLogger.w('Live time-shift seek failed', error: e, stackTrace: st);
      rethrow;
    }
  }

  /// Seek the live stream to an absolute epoch (scrubber / jump-to-live). Drops
  /// any pending relative-skip burst first so a queued seek can't override it.
  Future<void> _seekLiveToEpoch(int targetEpochSeconds) async {
    _liveSeek.cancel();
    try {
      await _runLiveSeek(targetEpochSeconds);
    } catch (_) {
      // Already logged; an absolute live seek is best-effort.
    }
  }

  /// Jump to the live edge of the capture buffer.
  Future<void> _jumpToLiveEdge() async {
    if (_live.captureBuffer == null) return;
    await _seekLiveToEpoch(_live.captureBuffer!.seekableEndEpoch);
  }

  Future<void> _switchLiveChannel(int delta) async {
    final channels = widget.live?.channels;
    if (channels == null || channels.isEmpty) return;
    final newIndex = _live.channelIndex + delta;
    if (newIndex < 0 || newIndex >= channels.length) return;
    final currentPlayer = player;
    if (currentPlayer == null) return;

    final transitionLease = _tryAcquirePlaybackTransition(_PlaybackTransition.switchingChannel);
    if (transitionLease == null) return; // debounce concurrent switches
    bool isCurrentChannelSwitch() =>
        mounted &&
        player == currentPlayer &&
        _ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingChannel);
    _liveSeek.cancel();

    final previousSession = _live.session;
    final previousHasFirstFrame = _hasFirstFrame.value;
    final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
    final channel = channels[newIndex];
    appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})');

    LiveTvPlaybackSession? session;
    var replacementOpenStarted = false;
    try {
      // Channel switch IS a fresh start: same resolution path as launch. Keep
      // the old session alive until the replacement stream is actually open so
      // a failed zap does not tell the server to reclaim the still-playing
      // tuner/transcode session.
      session = await _startLiveSession(channel);
      if (session == null) return;
      if (!isCurrentChannelSwitch()) {
        _abandonLiveSession(session);
        return;
      }

      final streamUrl = await session.streamUrlAt();
      if (streamUrl == null || !isCurrentChannelSwitch()) {
        _abandonLiveSession(session);
        return;
      }

      await _setLiveStreamOptions(currentPlayer);
      if (!isCurrentChannelSwitch()) {
        _abandonLiveSession(session);
        return;
      }

      _setPlayerState(() {
        _hasFirstFrame.value = false;
        _hasRenderedFirstFrame = false;
      });
      replacementOpenStarted = true;
      await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
      if (!isCurrentChannelSwitch()) {
        _abandonLiveSession(session);
        return;
      }

      // The new stream is now the active local playback. Stop the old heartbeat
      // and send its terminal timeline before adopting the replacement session.
      _stopLiveTimelineUpdates();
      if (previousSession != null) {
        await _sendLiveTimeline('stopped');
      }
      if (!isCurrentChannelSwitch()) {
        _abandonLiveSession(session);
        return;
      }

      _live.adoptSession(session);
      _live.fallbackLevel = 0;
      _live.markStreamRestartedAtLiveEdge();

      if (!mounted) return;
      _setPlayerState(() {
        _live.channelIndex = newIndex;
        _live.channelName = channel.displayName;
      });

      // Restart timeline heartbeats for the new session
      _startLiveTimelineUpdates();
    } catch (e) {
      // A session that tuned but was never adopted (streamUrlAt/open threw)
      // would otherwise hold its server-side tuner until the backend times out.
      final orphan = session;
      if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan);
      if (!isCurrentChannelSwitch()) return;
      if (replacementOpenStarted && mounted && _live.session == previousSession) {
        _setPlayerState(() {
          _hasFirstFrame.value = previousHasFirstFrame;
          _hasRenderedFirstFrame = previousHasRenderedFirstFrame;
        });
      }
      appLogger.e('Failed to switch channel', error: e);
      if (mounted) showErrorSnackBar(context, e.toString());
    } finally {
      _releasePlaybackTransition(transitionLease);
    }
  }

  bool get _hasNextChannel {
    final channels = widget.live?.channels;
    return channels != null && _live.channelIndex >= 0 && _live.channelIndex < channels.length - 1;
  }

  bool get _hasPreviousChannel => widget.live?.channels != null && _live.channelIndex > 0;
}
