import 'package:collection/collection.dart';

import '../media/media_display_criteria.dart';
import '../media/media_version.dart';
import '../media/media_source_info.dart';
import '../utils/jellyfin_time.dart';
import '../utils/json_utils.dart';
import 'file_info_parser.dart';
import 'jellyfin_mappers.dart';

/// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the
/// existing Plex-shaped track picker can render readable labels and the
/// auto-track-selection has a `selected` flag to honour. Exposed as a
/// top-level function for unit testing the field mapping without spinning
/// up a [PlaybackInitializationService] or a [JellyfinClient].
///
/// [chapters] is Jellyfin's item-level `Chapters` array (the JSON list).
/// Each entry has `{Name, StartPositionTicks, ImageDateModified}`. Pass it
/// in here — Jellyfin doesn't nest chapters inside MediaSource so the
/// caller has to thread the field through.
///
/// [trickplay] is `BaseItemDto.Trickplay` — the item-level trickplay manifest.
/// Parsed defensively because two shapes appear in the wild: a flat
/// `Map<resolutionKey, info>` (per Jellyfin OpenAPI) and a nested
/// `Map<sourceId, Map<resolutionKey, info>>` (Streamyfin reports this).
MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
  Map<String, dynamic> source, {
  Object? chapters,
  Object? trickplay,
}) {
  final rawStreams = source['MediaStreams'];
  final parsedStreams = walkStreams(rawStreams is List ? rawStreams : null, const JellyfinFileInfoStreamReader());
  // partId stays null for Jellyfin because Plex's `/library/parts/{id}`
  // select-stream endpoint has no Jellyfin equivalent. Jellyfin track
  // persistence is driven by `/Sessions/Playing/Progress` stream indexes.
  const int? partId = null;
  final defaultAudioStreamIndex = flexibleInt(source['DefaultAudioStreamIndex']);
  final defaultSubtitleStreamIndex = flexibleInt(source['DefaultSubtitleStreamIndex']);
  final audioTracks = _withDefaultAudioSelection(parsedStreams.audioTracks, defaultAudioStreamIndex);
  final subtitleTracks = _withDefaultSubtitleSelection(parsedStreams.subtitleTracks, defaultSubtitleStreamIndex);

  final mappedChapters = <MediaChapter>[];
  if (chapters is List) {
    for (var i = 0; i < chapters.length; i++) {
      final entry = chapters[i];
      if (entry is! Map<String, dynamic>) continue;
      final startMs = jellyfinTicksToMs(entry['StartPositionTicks']) ?? 0;
      mappedChapters.add(MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name'] as String?));
    }
    MediaChapter.backfillEndOffsets(mappedChapters);
  }

  final mediaSourceId = source['Id'] as String?;
  final trickplayByWidth = _parseTrickplayManifest(trickplay, mediaSourceId);

  return MediaSourceInfo(
    videoUrl: '',
    audioTracks: audioTracks,
    subtitleTracks: subtitleTracks,
    chapters: mappedChapters,
    partId: partId,
    displayCriteria: _jellyfinDisplayCriteria(source, parsedStreams.videoStream),
    mediaSourceId: mediaSourceId,
    defaultAudioStreamIndex: defaultAudioStreamIndex,
    defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
    trickplayByWidth: trickplayByWidth,
  );
}

MediaDisplayCriteria? _jellyfinDisplayCriteria(Map<String, dynamic> source, Map<String, dynamic>? videoStream) {
  if (videoStream == null) return null;

  final doviProfile = flexibleInt(videoStream['DvProfile']);
  final doviCompatibilityId = flexibleInt(videoStream['DvBlSignalCompatibilityId']);
  final videoRangeType = videoStream['VideoRangeType']?.toString().toLowerCase();
  final videoRange = videoStream['VideoRange']?.toString().toLowerCase();
  final transfer = _stringOrNull(videoStream['ColorTransfer']);
  final primaries = _stringOrNull(videoStream['ColorPrimaries']);
  final matrix = _stringOrNull(videoStream['ColorSpace']);
  final defaults = _jellyfinDefaultDisplayColorTags(
    videoRangeType: videoRangeType,
    videoRange: videoRange,
    doviCompatibilityId: doviCompatibilityId,
    transfer: transfer,
    primaries: primaries,
    matrix: matrix,
  );
  final criteria = MediaDisplayCriteria.fromRaw(
    fps: videoStream['RealFrameRate'] ?? videoStream['AverageFrameRate'],
    width: videoStream['Width'] ?? source['Width'],
    height: videoStream['Height'] ?? source['Height'],
    doviProfile: doviProfile,
    doviLevel: videoStream['DvLevel'],
    doviCompatibilityId: doviCompatibilityId,
    transfer: transfer ?? defaults.transfer,
    primaries: primaries ?? defaults.primaries,
    matrix: matrix ?? defaults.matrix,
  );
  return criteria.isUsable ? criteria : null;
}

({String? transfer, String? primaries, String? matrix}) _jellyfinDefaultDisplayColorTags({
  required String? videoRangeType,
  required String? videoRange,
  int? doviCompatibilityId,
  String? transfer,
  String? primaries,
  String? matrix,
}) {
  final range = '${videoRangeType ?? ''} ${videoRange ?? ''}';
  final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix);
  if (doviCompatibilityId == 4 || range.contains('hlg') || colorTags.contains('hlg') || colorTags.contains('arib')) {
    return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc');
  }
  if (doviCompatibilityId == 1 ||
      doviCompatibilityId == 6 ||
      range.contains('hdr') ||
      colorTags.contains('smpte2084') ||
      colorTags.contains('st2084') ||
      colorTags.contains('pq') ||
      colorTags.contains('bt2020')) {
    return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc');
  }
  if (doviCompatibilityId == 2 || range.trim().isEmpty || range.contains('sdr')) {
    return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709');
  }
  return (transfer: null, primaries: null, matrix: null);
}

String? _stringOrNull(Object? value) {
  final string = value?.toString().trim();
  return string == null || string.isEmpty ? null : string;
}

String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) =>
    [transfer, primaries, matrix].whereType<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');

List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
  if (defaultStreamIndex == null) return tracks;
  return [
    for (final track in tracks)
      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.index == defaultStreamIndex,
      ),
  ];
}

List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
  return [
    for (final track in tracks)
      MediaSubtitleTrack(
        id: track.id,
        index: track.index,
        codec: track.codec,
        language: track.language,
        languageCode: track.languageCode,
        title: track.title,
        displayTitle: track.displayTitle,
        selected: defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
        forced: track.forced,
        key: track.key,
        external: track.external,
      ),
  ];
}

/// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral
/// playback extras. Native Jellyfin media segments are passed in separately;
/// chapter title fallback uses the same intro/credits patterns as Plex.
PlaybackExtras jellyfinPlaybackExtrasFromRaw(
  dynamic raw,
  String itemId, {
  String? introPattern,
  String? creditsPattern,
  bool forceChapterFallback = false,
  List<MediaMarker> markers = const [],
}) {
  String segment(String value) => Uri.encodeComponent(value);
  String query(String value) => Uri.encodeComponent(value);

  final chapters = raw is Map<String, dynamic> ? raw['Chapters'] : null;
  final runtimeMs = raw is Map<String, dynamic> ? jellyfinTicksToMs(raw['RunTimeTicks']) : null;
  final mapped = <MediaChapter>[];
  if (chapters is List) {
    for (var i = 0; i < chapters.length; i++) {
      final entry = chapters[i];
      if (entry is! Map<String, dynamic>) continue;
      final startMs = jellyfinTicksToMs(entry['StartPositionTicks']) ?? 0;
      // Jellyfin chapter image path: `/Items/{itemId}/Images/Chapter/{i}?tag=...`.
      final imageTag = entry['ImageTag'];
      final thumb = imageTag is String && imageTag.isNotEmpty
          ? '/Items/${segment(itemId)}/Images/Chapter/$i?tag=${query(imageTag)}'
          : null;
      mapped.add(
        MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name']?.toString(), thumb: thumb),
      );
    }
    MediaChapter.backfillEndOffsets(mapped, runtimeMs: runtimeMs);
  }
  return PlaybackExtras.withChapterFallback(
    chapters: mapped,
    markers: markers,
    introPatternStr: introPattern,
    creditsPatternStr: creditsPattern,
    forceChapterFallback: forceChapterFallback,
  );
}

List<MediaMarker> jellyfinMediaSegmentsToMarkers(dynamic raw) {
  final items = raw is Map ? raw['Items'] : raw;
  if (items is! List) return const [];

  final markers = <MediaMarker>[];
  for (var i = 0; i < items.length; i++) {
    final entry = items[i];
    if (entry is! Map) continue;
    final type = _jellyfinSegmentMarkerType(entry['Type']?.toString());
    final start = jellyfinTicksToMs(entry['StartTicks']);
    final end = jellyfinTicksToMs(entry['EndTicks']);
    if (type == null || start == null || end == null || end <= start) continue;
    markers.add(MediaMarker(id: i, type: type, startTimeOffset: start, endTimeOffset: end));
  }
  return markers;
}

String? _jellyfinSegmentMarkerType(String? value) {
  switch (value?.toLowerCase()) {
    case 'intro':
      return 'intro';
    case 'outro':
    case 'credits':
      return 'credits';
  }
  return null;
}

/// Coerce a Jellyfin trickplay manifest to `Map<int width, TrickplayInfo>`,
/// tolerating both the flat OpenAPI shape (`{ "320": {...} }`) and the nested
/// Streamyfin shape (`{ "<sourceId>": { "320": {...} } }`).
///
/// Returns `null` when [raw] is missing, malformed, or contains no usable
/// entries — callers treat that as "no scrub thumbnails".
Map<int, TrickplayInfo>? _parseTrickplayManifest(Object? raw, String? sourceId) {
  if (raw is! Map) return null;
  if (raw.isEmpty) return null;

  // Discriminate FLAT vs NESTED by inspecting the values:
  //   FLAT  → at least one value is itself a TrickplayInfoDto-like map
  //           (has both `Width` and `Height` keys).
  //   NESTED → values are themselves resolution-keyed maps; the inner
  //           values are the TrickplayInfoDto-like ones.
  final Map resolutionMap;
  if (raw.values.any(_looksLikeTrickplayInfo)) {
    resolutionMap = raw;
  } else {
    final byId = sourceId != null ? raw[sourceId] : null;
    if (byId is Map) {
      resolutionMap = byId;
    } else {
      // Source id not in the manifest — fall back to the first nested
      // entry so the user still gets *something*. The caller already
      // chose the right source; this is best-effort recovery.
      final first = raw.values.firstWhereOrNull((v) => v is Map);
      if (first is! Map) return null;
      resolutionMap = first;
    }
  }

  final result = <int, TrickplayInfo>{};
  resolutionMap.forEach((key, value) {
    if (value is! Map) return;
    final width = flexibleInt(key) ?? flexibleInt(value['Width']);
    final height = flexibleInt(value['Height']);
    final tileWidth = flexibleInt(value['TileWidth']);
    final tileHeight = flexibleInt(value['TileHeight']);
    final thumbnailCount = flexibleInt(value['ThumbnailCount']);
    final interval = flexibleInt(value['Interval']);
    if (width == null ||
        height == null ||
        tileWidth == null ||
        tileHeight == null ||
        thumbnailCount == null ||
        interval == null) {
      return;
    }
    if (width <= 0 || height <= 0 || tileWidth <= 0 || tileHeight <= 0 || thumbnailCount <= 0 || interval <= 0) {
      return;
    }
    final bandwidth = flexibleInt(value['Bandwidth']) ?? 0;
    result[width] = TrickplayInfo(
      width: width,
      height: height,
      tileWidth: tileWidth,
      tileHeight: tileHeight,
      thumbnailCount: thumbnailCount,
      interval: interval,
      bandwidth: bandwidth,
    );
  });

  return result.isEmpty ? null : result;
}

bool _looksLikeTrickplayInfo(Object? v) => v is Map && v.containsKey('Width') && v.containsKey('Height');

/// Build a [MediaVersion] list from a Jellyfin item's `MediaSources` array so
/// the existing version picker UI renders labels for alternate versions.
/// Resolution comes from the video stream inside `MediaStreams` — Jellyfin
/// doesn't surface Width/Height on the source itself.
///
/// Names are only attached when they actually disambiguate (i.e. the
/// sources have different `Name` values). For the common single-source
/// case the Name equals the item title and adds noise to the technical
/// label, so we skip it.
///
/// Exposed as a top-level function for unit testing the field mapping
/// without spinning up a [PlaybackInitializationService] or a [JellyfinClient].
List<MediaVersion> jellyfinSourcesToVersions(List<dynamic> sources) {
  final names = <String?>[];
  for (final src in sources) {
    names.add(src is Map<String, dynamic> ? src['Name'] as String? : null);
  }
  final useName = names.where((n) => n != null && n.isNotEmpty).toSet().length > 1;

  final versions = <MediaVersion>[];
  for (var i = 0; i < sources.length; i++) {
    final src = sources[i];
    if (src is! Map<String, dynamic>) continue;
    final sourceId = (src['Id'] as String?) ?? '';
    versions.add(
      jellyfinMediaSourceToVersion(
        src,
        versionId: i.toString(),
        partId: i.toString(),
        streamPath: sourceId,
        name: useName ? src['Name'] as String? : null,
      ),
    );
  }
  return versions;
}
