part of '../../jellyfin_client.dart';

bool _canUseJellyfinStaticStreamFallback(Object error) {
  if (error is MediaServerAuthException) return false;
  if (error is MediaServerHttpException) {
    final status = error.statusCode;
    return !error.isCancellation && status != 401 && status != 403;
  }
  return true;
}

mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
  /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters
  /// at the item level (`raw['Chapters']`) and native skip segments through a
  /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort
  /// so older servers still use chapter title fallback.
  @override
  Future<PlaybackExtras> fetchPlaybackExtras(
    String itemId, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
    bool forceRefresh = false,
  }) async {
    final item = await fetchItem(itemId);
    final markers = item == null ? const <MediaMarker>[] : await _fetchMediaSegmentMarkers(itemId);
    return jellyfinPlaybackExtrasFromRaw(
      item?.raw,
      itemId,
      introPattern: introPattern,
      creditsPattern: creditsPattern,
      forceChapterFallback: forceChapterFallback,
      markers: markers,
    );
  }

  @override
  Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
    String itemId, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
  }) async {
    final item = await cache.getMetadata(ServerId(cacheServerId), itemId);
    if (item == null) return null;
    final markers = await _fetchCachedMediaSegmentMarkers(itemId);
    return jellyfinPlaybackExtrasFromRaw(
      item.raw,
      itemId,
      introPattern: introPattern,
      creditsPattern: creditsPattern,
      forceChapterFallback: forceChapterFallback,
      markers: markers,
    );
  }

  @override
  Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
    final item = await cache.getMetadata(ServerId(cacheServerId), itemId);
    final raw = item?.raw;
    if (raw is! Map<String, dynamic>) return null;
    final sources = raw['MediaSources'];
    if (sources is! List || sources.isEmpty) return null;
    final first = sources.first;
    if (first is! Map<String, dynamic>) return null;
    return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']);
  }

  @override
  Future<ScrubPreviewSource?> createScrubPreviewSource({
    required MediaItem item,
    required MediaSourceInfo mediaSource,
  }) async {
    if (!capabilities.scrubThumbnails) return null;
    final manifest = mediaSource.trickplayByWidth;
    if (manifest == null || manifest.isEmpty) return null;
    return JellyfinTrickplayService.create(
      client: this as JellyfinClient,
      itemId: item.id,
      mediaSourceId: mediaSource.mediaSourceId,
      manifest: manifest,
    );
  }

  Future<List<MediaMarker>> _fetchMediaSegmentMarkers(String itemId) async {
    final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId);
    try {
      return await fetchWithCacheFallback<List<MediaMarker>>(
            cacheKey: endpoint,
            networkCall: () async {
              final response = await _http.get(endpoint);
              if (response.statusCode == 404) {
                return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri);
              }
              throwIfHttpError(response);
              return response;
            },
            parseCache: jellyfinMediaSegmentsToMarkers,
            parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data),
          ) ??
          const [];
    } on MediaServerHttpException catch (e) {
      if (e.statusCode != 404) {
        appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
      }
      return const [];
    } catch (e) {
      appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
      return const [];
    }
  }

  Future<List<MediaMarker>> _fetchCachedMediaSegmentMarkers(String itemId) async {
    try {
      final data = await cache.get(ServerId(cacheServerId), JellyfinApiCache.mediaSegmentsEndpoint(itemId));
      return jellyfinMediaSegmentsToMarkers(data);
    } catch (e) {
      appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e);
      return const [];
    }
  }

  String _withApiKey(String urlOrPath) {
    final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath);
    final params = Map<String, String>.from(uri.queryParameters)..['api_key'] = connection.accessToken;
    return uri.replace(queryParameters: params).toString();
  }

  /// Jellyfin playback URL resolution.
  ///
  /// Always POSTs `/Items/{id}/PlaybackInfo` so Jellyfin can resolve external
  /// audio/subtitle streams server-side. Uses the returned `TranscodingUrl` or
  /// `DirectStreamUrl` when present, otherwise falls back to a static direct
  /// stream URL (`/Videos/{id}/stream?Static=true&api_key=...`).
  ///
  /// The returned `MediaSourceInfo` is what the player uses for track-picker
  /// labels and auto-track selection by language.
  ///
  /// Throws [PlaybackException] when the item is missing or has no
  /// `MediaSources`.
  @override
  Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
    final metadata = options.metadata;
    final bundle = await fetchPlaybackBundle(
      metadata.id,
      sourceIndex: options.selectedMediaIndex,
      sourceId: options.selectedMediaSourceId,
      preferredSignature: options.preferredVersionSignature,
    );
    if (bundle == null) {
      throw PlaybackException('Item ${metadata.id} returned no MediaSources');
    }
    var mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
      bundle.selectedSource,
      chapters: bundle.chapters,
      trickplay: bundle.trickplay,
    );
    var effectiveSourceId = bundle.selectedSourceId;
    var effectiveContainer = bundle.container;
    var includeExternalSubtitleDelivery = false;

    String? videoUrl;
    String? playSessionId;
    var playMethod = 'DirectPlay';
    var isTranscoding = false;
    TranscodeFallbackReason? fallbackReason;

    // Tracks negotiate with the audio device profile and ignore the
    // (video-shaped) [PlaybackInitializationOptions.qualityPreset]; capping
    // comes from [PlaybackInitializationOptions.audioQualityPreset] instead.
    // Original / null keeps the unlimited default so high-bitrate lossless
    // files direct-play uncapped.
    final isTrack = metadata.kind == MediaKind.track;
    final preset = options.qualityPreset;
    final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
    final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
    final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
    final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
    final int? maxStreamingBitrate = wantsOriginal
        ? null
        : isTrack
        // Non-original audio presets always carry a bitrate by construction.
        ? audioPreset.bitrateKbps! * 1000
        : (preset.videoBitrateKbps ?? 100_000) * 1000;
    final resumeOffsetMs = metadata.viewOffsetMs;
    final int? transcodeStartTimeTicks = !wantsOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
        ? msToJellyfinTicks(resumeOffsetMs)
        : null;
    Map<String, dynamic>? negotiation;
    Map<String, dynamic>? chosenSource;
    try {
      negotiation = await getPlaybackInfo(
        metadata.id,
        maxStreamingBitrate: maxStreamingBitrate,
        mediaSourceId: bundle.selectedSourceId,
        startTimeTicks: transcodeStartTimeTicks,
        audioStreamIndex: requestedAudioStreamId,
        subtitleStreamIndex: requestedSubtitleStreamId,
        audioProfile: isTrack,
      );
      chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
    } catch (error, stackTrace) {
      if (!_canUseJellyfinStaticStreamFallback(error)) {
        Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace);
      }
      appLogger.w(
        'Jellyfin playback negotiation unavailable; using the static stream',
        error: error,
        stackTrace: stackTrace,
      );
    }

    if (chosenSource == null) {
      fallbackReason = TranscodeFallbackReason.decisionFailed;
      appLogger.w('Jellyfin playback negotiation returned no usable source; using the static stream');
    } else {
      final negotiatedSourceId = chosenSource['Id'];
      final negotiatedContainer = chosenSource['Container'];
      if (negotiatedSourceId is String) effectiveSourceId = negotiatedSourceId;
      if (negotiatedContainer is String) effectiveContainer = negotiatedContainer;
      if (chosenSource['MediaStreams'] is List) {
        mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
          chosenSource,
          chapters: bundle.chapters,
          trickplay: bundle.trickplay,
        );
      }

      final negotiatedPlaySessionId = negotiation!['PlaySessionId'];
      void capturePlaySessionId(String urlOrPath) {
        playSessionId = Uri.tryParse(urlOrPath)?.queryParameters['PlaySessionId'];
        if ((playSessionId == null || playSessionId!.isEmpty) && negotiatedPlaySessionId is String) {
          playSessionId = negotiatedPlaySessionId;
        }
      }

      final transcodingUrl = chosenSource['TranscodingUrl'];
      final directStreamUrl = chosenSource['DirectStreamUrl'];
      if (!wantsOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) {
        // TranscodingUrl is server-relative and already encodes container,
        // codecs, MediaSourceId, and PlaySessionId; we just append the
        // api_key for auth.
        capturePlaySessionId(transcodingUrl);
        videoUrl = _withApiKey(transcodingUrl);
        playMethod = 'Transcode';
        isTranscoding = true;
        includeExternalSubtitleDelivery = true;
      } else if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
        capturePlaySessionId(directStreamUrl);
        videoUrl = _withApiKey(directStreamUrl);
        playMethod = 'DirectStream';
        // DirectStream remuxes the selected streams into a new container.
        // Subtitle streams marked for external delivery are not present in
        // that container, so expose their server URLs as sidecars just as we
        // do for transcoded playback. True DirectPlay keeps using the
        // embedded native tracks and does not incur a sidecar fetch.
        includeExternalSubtitleDelivery = true;
      } else if (!wantsOriginal) {
        fallbackReason = TranscodeFallbackReason.directPlayOnly;
      }
    }

    final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
    mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
    // Tracks have no subtitle streams to assemble (a `Lyric` stream may be
    // present, but lyrics flow through fetchLyrics, not the subtitle path).
    final subtitleSidecars = isTrack
        ? const <PlaybackSubtitleSidecar>[]
        : _buildExternalSubtitles(
            metadata.id,
            effectiveSourceId,
            mediaInfo,
            includeExternalDelivery: includeExternalSubtitleDelivery,
          );
    final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id);
    videoUrl ??= isTrack
        ? buildAudioDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId)
        : buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId);

    return PlaybackInitializationResult(
      availableVersions: bundle.availableVersions,
      videoUrl: videoUrl,
      mediaInfo: mediaInfo,
      subtitleSidecars: subtitleSidecars,
      isOffline: false,
      isTranscoding: isTranscoding,
      fallbackReason: fallbackReason,
      activeAudioStreamId: requestedAudioStreamId,
      playSessionId: playSessionId,
      playMethod: playMethod,
      selectedMediaIndex: bundle.selectedSourceIndex,
    );
  }

  int? _validJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
    if (explicit == null) return null;
    return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null;
  }

  int? _validJellyfinSubtitleStreamId(SubtitleTrack? preferred, MediaSourceInfo mediaInfo) {
    if (preferred == null) return null;
    if (preferred.id == SubtitleTrack.off.id) return -1;

    var semanticPreference = preferred;
    const sourcePrefix = 'source:';
    if (preferred.id.startsWith(sourcePrefix)) {
      semanticPreference = SubtitleTrack(
        id: 'navigation',
        title: preferred.title,
        language: preferred.language,
        codec: preferred.codec,
        isDefault: preferred.isDefault,
        isForced: preferred.isForced,
        isExternal: preferred.isExternal,
        isContainer: preferred.isContainer,
      );
      final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length));
      if (explicit != null) {
        final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull;
        if (exactSource != null) {
          // A source id is authoritative only within one item. When semantic
          // metadata is available, prefer the best current-source row so a
          // reused stream index cannot override a better title/codec match.
          final semanticMatch = findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks);
          final hasLanguage = preferred.language?.isNotEmpty ?? false;
          if (!hasLanguage || (semanticMatch?.id == explicit && preferred.isForced == exactSource.forced)) {
            return explicit;
          }
          return semanticMatch?.id;
        }
      }
    }

    return findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks)?.id;
  }

  Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
    if (sources is! List || sources.isEmpty) return null;
    final requestedSourceId = selectedSourceId?.trim();
    if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
      for (final source in sources) {
        if (source is! Map<String, dynamic>) {
          throw const FormatException('Malformed Jellyfin PlaybackInfo media source');
        }
        final sourceId = source['Id'];
        if (sourceId is String && sourceId.toLowerCase() == requestedSourceId.toLowerCase()) {
          return source;
        }
      }
      return null;
    }
    final first = sources.first;
    if (first is! Map<String, dynamic>) {
      throw const FormatException('Malformed Jellyfin PlaybackInfo media source');
    }
    final firstId = first['Id'];
    if (firstId != null && firstId is! String) {
      throw const FormatException('Malformed Jellyfin PlaybackInfo media source id');
    }
    return first;
  }

  int? _resolveJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
    final validExplicit = _validJellyfinAudioStreamId(explicit, mediaInfo);
    if (validExplicit != null) return validExplicit;
    final defaultStreamIndex = mediaInfo.defaultAudioStreamIndex;
    if (defaultStreamIndex != null) return defaultStreamIndex;
    for (final track in mediaInfo.audioTracks) {
      if (track.selected) return track.id;
    }
    return null;
  }

  MediaSourceInfo _withSelectedJellyfinAudioStream(MediaSourceInfo mediaInfo, int? selectedStreamId) {
    if (selectedStreamId == null || !mediaInfo.audioTracks.any((track) => track.id == selectedStreamId)) {
      return mediaInfo;
    }
    return MediaSourceInfo(
      videoUrl: mediaInfo.videoUrl,
      audioTracks: [
        for (final track in mediaInfo.audioTracks)
          MediaAudioTrack(
            id: track.id,
            index: track.index,
            codec: track.codec,
            language: track.language,
            languageCode: track.languageCode,
            title: track.title,
            displayTitle: track.displayTitle,
            channels: track.channels,
            selected: track.id == selectedStreamId,
            external: track.external,
          ),
      ],
      subtitleTracks: mediaInfo.subtitleTracks,
      chapters: mediaInfo.chapters,
      partId: mediaInfo.partId,
      displayCriteria: mediaInfo.displayCriteria,
      mediaSourceId: mediaInfo.mediaSourceId,
      defaultAudioStreamIndex: mediaInfo.defaultAudioStreamIndex,
      defaultSubtitleStreamIndex: mediaInfo.defaultSubtitleStreamIndex,
      trickplayByWidth: mediaInfo.trickplayByWidth,
    );
  }

  String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) {
    final sourceId = mediaSourceId;
    final streamIndex = track.index ?? track.id;
    final codec = track.codec;
    if (sourceId == null || codec == null || codec.isEmpty) return null;
    final path = Uri(
      pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
    ).path;
    return path.startsWith('/') ? path : '/$path';
  }

  List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
    String itemId,
    String? mediaSourceId,
    MediaSourceInfo mediaInfo, {
    bool includeExternalDelivery = false,
  }) {
    final externalSubtitles = <PlaybackSubtitleSidecar>[];
    for (final track in mediaInfo.subtitleTracks) {
      if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
        continue;
      }
      final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
      if (path == null) continue;
      // Jellyfin's subtitle URL is a path relative to baseUrl; build the
      // absolute URL with the api_key query param.
      final url = _withApiKey(path);
      externalSubtitles.add(
        PlaybackSubtitleSidecar(
          sourceStreamId: track.id,
          track: SubtitleTrack.uri(
            url,
            title:
                cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
                cleanTrackMetadataValue(track.language),
            language: cleanTrackMetadataValue(track.languageCode),
            codec: track.codec,
            isDefault: track.selected,
            isForced: track.forced,
          ),
        ),
      );
    }
    return externalSubtitles;
  }

  /// Internal accessor for [PlaybackInitializationService]. Returns the
  /// chosen `MediaSource` JSON, every available source's [MediaVersion],
  /// and the item's `Chapters` array. One round-trip vs. fetchItem + raw
  /// extraction at the call site.
  ///
  /// Returns `null` when the item doesn't exist or has no `MediaSources`.
  /// [sourceId] wins when present because Jellyfin plugins may reorder merged
  /// `MediaSources` between requests. [sourceIndex] is clamped to the valid
  /// range as a fallback to mirror Plex's `parseVideoPlaybackDataFromJson`.
  Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
    String itemId, {
    int sourceIndex = 0,
    String? sourceId,
    String? preferredSignature,
  }) async {
    final item = await fetchItem(itemId);
    final raw = item?.raw;
    if (raw is! Map<String, dynamic>) return null;
    final sources = raw['MediaSources'];
    if (sources is! List || sources.isEmpty) return null;
    final availableVersions = jellyfinSourcesToVersions(sources);
    var index = sourceIndex;
    final requestedSourceId = sourceId?.trim();
    var resolvedBySourceId = false;
    if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
      final byId = sources.indexWhere((source) => source is Map<String, dynamic> && source['Id'] == requestedSourceId);
      if (byId >= 0) {
        index = byId;
        resolvedBySourceId = true;
      }
    }
    // Saved-preference signature: only meaningful when the id didn't pin a
    // source (Resume rows omit MediaSources, so launch passes a signature and
    // a stored index that may not fit this item's source ordering).
    if (!resolvedBySourceId && preferredSignature != null && preferredSignature.isNotEmpty) {
      final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredSignature});
      if (bySignature != null) index = bySignature;
    }
    if (index < 0 || index >= sources.length) index = 0;
    final source = sources[index];
    if (source is! Map<String, dynamic>) return null;
    final chapters = raw['Chapters'];
    return JellyfinPlaybackBundle(
      availableVersions: availableVersions,
      selectedSource: source,
      chapters: chapters is List ? chapters : const [],
      container: source['Container'] as String?,
      selectedSourceId: source['Id'] as String?,
      selectedSourceIndex: index,
      trickplay: raw['Trickplay'],
    );
  }

  /// Direct-stream URL for [itemId]. Best for files the device can play
  /// natively. Adds `?Static=true` to skip the transcoder and
  /// `&api_key=...` so the request authenticates without a header.
  ///
  /// Pass [mediaSourceId] to stream a non-default alternate version. When the
  /// item only has a single MediaSource, [mediaSourceId] equals [itemId] and
  /// can be omitted; for items with multiple versions Jellyfin uses the
  /// param to pick which file to serve.
  String buildDirectStreamUrl(
    String itemId, {
    String? container,
    String? mediaSourceId,
    String? playSessionId,
    String? liveStreamId,
    int? audioStreamIndex,
  }) {
    return buildJellyfinDirectStreamUrl(
      baseUrl: connection.baseUrl,
      accessToken: connection.accessToken,
      deviceId: connection.deviceId,
      itemId: itemId,
      container: container,
      mediaSourceId: mediaSourceId,
      playSessionId: playSessionId,
      liveStreamId: liveStreamId,
      audioStreamIndex: audioStreamIndex,
    );
  }

  /// Audio sibling of [buildDirectStreamUrl]: `/Audio/{id}/stream` with the
  /// same `Static=true` + `api_key` + `DeviceId` self-authentication. Used
  /// for track direct-play fallback, downloads, and external players.
  String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
    return buildJellyfinDirectStreamUrl(
      baseUrl: connection.baseUrl,
      accessToken: connection.accessToken,
      deviceId: connection.deviceId,
      itemId: itemId,
      mediaSegment: 'Audio',
      container: container,
      mediaSourceId: mediaSourceId,
    );
  }

  /// Trickplay sprite-sheet URL. [width] picks one of the resolutions
  /// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based
  /// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails).
  /// Pass [mediaSourceId] when the item has more than one source so the
  /// server returns the matching version's tiles.
  String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) {
    return buildJellyfinTrickplayTileUrl(
      baseUrl: connection.baseUrl,
      accessToken: connection.accessToken,
      deviceId: connection.deviceId,
      itemId: itemId,
      width: width,
      sheetIndex: sheetIndex,
      mediaSourceId: mediaSourceId,
    );
  }

  /// Negotiate playback and return a structurally valid successful response.
  /// Typed request/decode/cancellation failures propagate unchanged. A
  /// successful response must be a map with a list-valued `MediaSources`;
  /// the list may be empty for consumer-specific unavailable-stream policy.
  ///
  /// When non-null, [maxStreamingBitrate] is forwarded as both the top-level
  /// field and inside the `DeviceProfile` so the server caps direct-stream and
  /// transcode bitrate against the same ceiling. Original playback passes null
  /// to avoid capping high-bitrate files. [mediaSourceId] pins the negotiation
  /// to a specific version when the item has multiple sources.
  /// [startTimeTicks] is forwarded to Jellyfin's playback negotiation for
  /// resume-aware stream metadata. Our video transcode profile is HLS, and
  /// Jellyfin omits `StartTimeTicks` from the returned HLS URL, so the player
  /// still performs the initial seek.
  /// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
  /// to pick for the transcode profile (Jellyfin's negotiation factors them in
  /// when picking codec compatibility).
  /// [audioProfile] extends the DeviceProfile with music direct-play and
  /// audio→mp3 transcode entries for track playback; the video profiles (and
  /// the request body when false) are untouched either way.
  Future<Map<String, dynamic>> getPlaybackInfo(
    String itemId, {
    int? maxStreamingBitrate = 100_000_000,
    String? mediaSourceId,
    String? liveStreamId,
    int? startTimeTicks,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
    bool? autoOpenLiveStream,
    bool? enableDirectPlay,
    bool? enableDirectStream,
    bool? enableTranscoding,
    bool? allowVideoStreamCopy,
    bool? allowAudioStreamCopy,
    bool audioProfile = false,
  }) async {
    final query = <String, String>{
      'userId': connection.userId,
      'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(),
      'MediaSourceId': ?mediaSourceId,
      'LiveStreamId': ?liveStreamId,
      'StartTimeTicks': ?startTimeTicks?.toString(),
      'AudioStreamIndex': ?audioStreamIndex?.toString(),
      'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
      'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(),
      'EnableDirectPlay': ?enableDirectPlay?.toString(),
      'EnableDirectStream': ?enableDirectStream?.toString(),
      'EnableTranscoding': ?enableTranscoding?.toString(),
      'AllowVideoStreamCopy': ?allowVideoStreamCopy?.toString(),
      'AllowAudioStreamCopy': ?allowAudioStreamCopy?.toString(),
    };
    final response = await _http.post(
      '/Items/${_segment(itemId)}/PlaybackInfo',
      queryParameters: query,
      body: {
        'UserId': connection.userId,
        'MaxStreamingBitrate': ?maxStreamingBitrate,
        'MediaSourceId': ?mediaSourceId,
        'LiveStreamId': ?liveStreamId,
        'StartTimeTicks': ?startTimeTicks,
        'AudioStreamIndex': ?audioStreamIndex,
        'SubtitleStreamIndex': ?subtitleStreamIndex,
        'AutoOpenLiveStream': ?autoOpenLiveStream,
        'EnableDirectPlay': ?enableDirectPlay,
        'EnableDirectStream': ?enableDirectStream,
        'EnableTranscoding': ?enableTranscoding,
        'AllowVideoStreamCopy': ?allowVideoStreamCopy,
        'AllowAudioStreamCopy': ?allowAudioStreamCopy,
        'DeviceProfile': <String, Object?>{
          'Name': 'Plezy',
          'MaxStreamingBitrate': ?maxStreamingBitrate,
          'CodecProfiles': const <Map<String, Object?>>[],
          // Comma-separated codec lists are order-sensitive — first entry
          // wins when the server picks an output codec. HEVC is listed
          // ahead of H.264 so a server that has "Allow encoding in HEVC
          // format" enabled will actually emit HEVC instead of falling
          // back to H.264.
          'TranscodingProfiles': <Map<String, Object?>>[
            const {
              'Type': 'Video',
              'Container': 'ts',
              'Protocol': 'hls',
              'VideoCodec': 'hevc,h264',
              'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus',
            },
            // Track playback transcode target: stereo mp3 over plain http.
            // Appended after the video profile so the first-entry-wins
            // ordering for video output codecs is untouched.
            if (audioProfile)
              const {
                'Type': 'Audio',
                'Container': 'mp3',
                'AudioCodec': 'mp3',
                'Protocol': 'http',
                'Context': 'Streaming',
                'MaxAudioChannels': '2',
              },
          ],
          // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server
          // from forcing a transcode for HEVC sources whose container we
          // already accept — mpv decodes HEVC natively on every platform
          // we ship.
          'DirectPlayProfiles': <Map<String, Object?>>[
            const {
              'Type': 'Video',
              'Container': 'mp4,mkv,m4v,webm,mov,ts',
              'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video',
              'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts',
            },
            // Music containers/codecs mpv plays natively everywhere.
            if (audioProfile)
              const {
                'Type': 'Audio',
                'Container': 'flac,mp3,ogg,oga,opus,m4a,m4b,aac,alac,wav,aiff,wma,webma',
                'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
              },
          ],
          'SubtitleProfiles': const <Map<String, Object?>>[
            {'Format': 'srt', 'Method': 'External'},
            {'Format': 'ass', 'Method': 'External'},
            {'Format': 'ssa', 'Method': 'External'},
            {'Format': 'vtt', 'Method': 'External'},
            {'Format': 'pgssub', 'Method': 'External'},
            {'Format': 'dvdsub', 'Method': 'External'},
            {'Format': 'dvbsub', 'Method': 'External'},
          ],
        },
      },
    );
    throwIfHttpError(response);
    final data = response.data;
    if (data is! Map<String, dynamic> || data['MediaSources'] is! List) {
      throw MediaServerHttpException(
        type: MediaServerHttpErrorType.unknown,
        statusCode: response.statusCode,
        message: 'Malformed Jellyfin PlaybackInfo response',
      );
    }
    return data;
  }

  @override
  Future<ExternalIds> fetchExternalIds(String itemId) async {
    final item = await fetchItem(itemId);
    final raw = item?.raw;
    final providerIds = raw is Map<String, dynamic> ? raw['ProviderIds'] : null;
    if (providerIds is Map<String, dynamic>) {
      return ExternalIds.fromJellyfinProviderIds(providerIds);
    }
    return const ExternalIds();
  }

  /// Jellyfin embeds the access token in the URL query string (`api_key=...`)
  /// rather than relying on headers, so the player needs no extra headers
  /// for direct streams.
  @override
  Map<String, String> get streamHeaders => const {};

  /// Shared body for the `/Sessions/Playing[/Progress]` pair — only [path] and
  /// [isPaused] differ between start and progress. Shape mirrors the Jellyfin
  /// SDK's `PlaybackStartInfo`/`PlaybackProgressInfo`: Findroid sends the same
  /// fields, and Jellyfin's session tracker drops events that omit `PlayMethod`
  /// because it has no way to associate progress with an active session row.
  Future<void> _postPlayingState(
    String path, {
    required String itemId,
    required Duration position,
    required bool isPaused,
    String? playSessionId,
    String? playMethod,
    String? liveStreamId,
    String? mediaSourceId,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
  }) async {
    final response = await _http.post(
      path,
      body: {
        'ItemId': itemId,
        'MediaSourceId': ?mediaSourceId,
        'AudioStreamIndex': ?audioStreamIndex,
        'SubtitleStreamIndex': ?subtitleStreamIndex,
        'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
        'CanSeek': true,
        'IsPaused': isPaused,
        'IsMuted': false,
        'PlayMethod': playMethod ?? 'DirectPlay',
        'RepeatMode': 'RepeatNone',
        'PlaybackOrder': 'Default',
        'PlaySessionId': ?playSessionId,
        'LiveStreamId': ?liveStreamId,
      },
    );
    throwIfHttpError(response);
  }

  /// Tell the server the user has started playing [itemId].
  ///
  /// [duration] is accepted for interface symmetry with Plex but ignored —
  /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes
  /// are still sent so the active session reflects the chosen tracks.
  @override
  Future<void> reportPlaybackStarted({
    required String itemId,
    required Duration position,
    Duration? duration,
    String? playSessionId,
    String? playMethod,
    String? liveStreamId,
    String? mediaSourceId,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
  }) => _postPlayingState(
    '/Sessions/Playing',
    itemId: itemId,
    position: position,
    isPaused: false,
    playSessionId: playSessionId,
    playMethod: playMethod,
    liveStreamId: liveStreamId,
    mediaSourceId: mediaSourceId,
    audioStreamIndex: audioStreamIndex,
    subtitleStreamIndex: subtitleStreamIndex,
  );

  /// Periodic progress ping (5–10s cadence is typical). Server uses this to
  /// drive the resume position, detect idle sessions, and save remembered
  /// audio/subtitle stream indexes when enabled in Jellyfin user settings.
  @override
  Future<void> reportPlaybackProgress({
    required String itemId,
    required Duration position,
    required Duration duration,
    bool isPaused = false,
    String? playSessionId,
    String? playMethod,
    String? liveStreamId,
    String? mediaSourceId,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
  }) => _postPlayingState(
    '/Sessions/Playing/Progress',
    itemId: itemId,
    position: position,
    isPaused: isPaused,
    playSessionId: playSessionId,
    playMethod: playMethod,
    liveStreamId: liveStreamId,
    mediaSourceId: mediaSourceId,
    audioStreamIndex: audioStreamIndex,
    subtitleStreamIndex: subtitleStreamIndex,
  );

  /// End-of-playback signal. Final position becomes the resume bookmark.
  /// [duration] is accepted for interface symmetry with Plex but ignored.
  @override
  Future<void> reportPlaybackStopped({
    required String itemId,
    required Duration position,
    Duration? duration,
    String? playSessionId,
    String? liveStreamId,
    String? mediaSourceId,
    PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
  }) async {
    final response = await _http.post(
      '/Sessions/Playing/Stopped',
      body: {
        'ItemId': itemId,
        'MediaSourceId': ?mediaSourceId,
        'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
        'Failed': false,
        'PlaySessionId': ?playSessionId,
        'LiveStreamId': ?liveStreamId,
      },
    );
    throwIfHttpError(response);
  }
}
