import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/services/jellyfin_mappers.dart';
import 'package:plezy/services/settings_service.dart' show EpisodePosterMode;

const _serverId = 'jf-machine-1';

void main() {
  group('JellyfinMappers.mediaItem', () {
    test('maps a movie with watch state, ratings, genres, and people', () {
      final json = {
        'Id': 'abc123',
        'Name': 'Inception',
        'OriginalTitle': 'Inception',
        'Type': 'Movie',
        'Overview': 'Dream within a dream.',
        'Taglines': ['Your mind is the scene of the crime.'],
        'ProductionYear': 2010,
        'PremiereDate': '2010-07-16T00:00:00.0000000Z',
        'OfficialRating': 'PG-13',
        'CommunityRating': 8.8,
        'Genres': ['Action', 'Sci-Fi'],
        'People': [
          {'Type': 'Actor', 'Name': 'Leo', 'Id': 'p1', 'PrimaryImageTag': 'tag1', 'Role': 'Cobb'},
          {'Type': 'Director', 'Name': 'Christopher Nolan'},
        ],
        'Studios': [
          {'Name': 'Warner Bros'},
        ],
        'ProductionLocations': ['United States'],
        'RunTimeTicks': 88800000000, // 8880 sec * 10_000_000
        'UserData': {
          'PlayCount': 1,
          'PlaybackPositionTicks': 30000000000, // 3000 sec
          'Played': true,
          'LastPlayedDate': '2026-04-25T20:00:00.0000000Z',
        },
        'DateCreated': '2025-01-15T10:00:00.0000000Z',
        'DateLastSaved': '2026-03-01T10:00:00.0000000Z',
        'ImageTags': {'Primary': 'thumbtag', 'Logo': 'logotag'},
        'BackdropImageTags': ['backtag'],
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, serverName: 'Home', absolutizer: null)!;

      expect(item.id, 'abc123');
      expect(item.backend, MediaBackend.jellyfin);
      expect(item.kind, MediaKind.movie);
      expect(item.title, 'Inception');
      expect(item.summary, 'Dream within a dream.');
      expect(item.tagline, 'Your mind is the scene of the crime.');
      expect(item.year, 2010);
      expect(item.originallyAvailableAt, '2010-07-16');
      expect(item.contentRating, 'PG-13');
      expect(item.studio, 'Warner Bros');
      expect(item.rating, 8.8);
      expect(item.genres, ['Action', 'Sci-Fi']);
      expect(item.directors, ['Christopher Nolan']);
      expect(item.countries, ['United States']);
      expect(item.roles, isNotNull);
      expect(item.roles!.length, 1);
      expect(item.roles![0].tag, 'Leo');
      expect(item.roles![0].role, 'Cobb');
      expect(item.roles![0].thumbPath, '/Items/p1/Images/Primary?tag=tag1');

      // Tick conversion: 100ns ticks → ms.
      expect(item.durationMs, 8880000); // 8880s in ms
      expect(item.viewOffsetMs, 3000000); // 3000s in ms
      expect(item.viewCount, 1);

      // Image paths.
      expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag');
      expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag');
      expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag');

      // Multi-server fields.
      expect(item.serverId, _serverId);
      expect(item.serverName, 'Home');
    });

    test('does not treat Jellyfin PlayCount as watched when Played is false', () {
      final json = {
        'Id': 'started-only',
        'Name': 'Started Only',
        'Type': 'Movie',
        'UserData': {'PlayCount': 1, 'Played': false},
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;

      expect(item.viewCount, 0);
      expect(item.isWatched, isFalse);
    });

    test('episode preserves series/season hierarchy', () {
      final json = {
        'Id': 'ep1',
        'Name': 'Pilot',
        'Type': 'Episode',
        'IndexNumber': 1,
        'ParentIndexNumber': 1,
        'SeriesId': 'series-1',
        'SeriesName': 'Breaking Bad',
        'SeriesPrimaryImageTag': 'seriesPrimary',
        'SeasonId': 'season-1',
        'SeasonName': 'Season 1',
        'SeasonPrimaryImageTag': 'seasonPrimary',
        'UserData': {'UnplayedItemCount': 0},
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;

      expect(item.kind, MediaKind.episode);
      expect(item.index, 1);
      expect(item.parentIndex, 1);
      expect(item.parentId, 'season-1');
      expect(item.parentTitle, 'Season 1');
      expect(item.parentThumbPath, '/Items/season-1/Images/Primary?tag=seasonPrimary');
      expect(item.grandparentId, 'series-1');
      expect(item.grandparentTitle, 'Breaking Bad');
      expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary');
      expect(item.grandparentArtPath, '/Items/series-1/Images/Backdrop/0');
    });

    test('episode season poster falls back to series poster when season image tag is absent', () {
      final json = {
        'Id': 'ep1',
        'Name': 'Pilot',
        'Type': 'Episode',
        'SeriesId': 'series-1',
        'SeriesName': 'Breaking Bad',
        'SeriesPrimaryImageTag': 'seriesPrimary',
        'SeasonId': 'season-1',
        'SeasonName': 'Season 1',
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;

      expect(item.parentThumbPath, '/Items/season-1/Images/Primary');
      expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary');
      expect(item.posterThumb(mode: EpisodePosterMode.seasonPoster), '/Items/season-1/Images/Primary');
      expect(
        item.posterThumbFallback(mode: EpisodePosterMode.seasonPoster),
        '/Items/series-1/Images/Primary?tag=seriesPrimary',
      );
    });

    test('series viewedLeafCount derived from total - UnplayedItemCount', () {
      final json = {
        'Id': 's1',
        'Name': 'Show',
        'Type': 'Series',
        'ChildCount': 12,
        'RecursiveItemCount': 12,
        'UserData': {'UnplayedItemCount': 4},
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;

      expect(item.leafCount, 12);
      expect(item.viewedLeafCount, 8);
      expect(item.isPartiallyWatched, isTrue);
      expect(item.isWatched, isFalse);
    });

    test('path-encodes image ids and tag query values', () {
      final item = JellyfinMappers.mediaItem(
        {
          'Id': 'folder/item #1?x',
          'Type': 'Episode',
          'Name': 'Reserved IDs',
          'SeriesId': 'series/id #1?x',
          'SeriesPrimaryImageTag': 'series/tag ?x',
          'ParentLogoItemId': 'logo/id #1?x',
          'ParentLogoImageTag': 'logo/tag ?x',
          'ImageTags': {'Primary': 'primary/tag ?x'},
          'People': [
            {'Type': 'Actor', 'Name': 'Actor', 'Id': 'person/id #1?x', 'PrimaryImageTag': 'person/tag ?x'},
          ],
        },
        serverId: _serverId,
        absolutizer: null,
      )!;

      expect(item.thumbPath, '/Items/folder%2Fitem%20%231%3Fx/Images/Primary?tag=primary%2Ftag%20%3Fx');
      expect(item.grandparentThumbPath, '/Items/series%2Fid%20%231%3Fx/Images/Primary?tag=series%2Ftag%20%3Fx');
      expect(item.clearLogoPath, '/Items/logo%2Fid%20%231%3Fx/Images/Logo?tag=logo%2Ftag%20%3Fx');
      expect(item.roles!.single.thumbPath, '/Items/person%2Fid%20%231%3Fx/Images/Primary?tag=person%2Ftag%20%3Fx');
    });

    test('series leafCount uses RecursiveItemCount over ChildCount', () {
      // Realistic Jellyfin shape for a Series: ChildCount = season count,
      // RecursiveItemCount = total episode count. Plex `leafCount` semantics
      // are leaves (episodes), so we must prefer the recursive total or the
      // unwatched badge ends up showing seasons instead of episodes.
      final json = {
        'Id': 's2',
        'Name': 'Show with seasons',
        'Type': 'Series',
        'ChildCount': 4, // 4 seasons
        'RecursiveItemCount': 50, // 50 episodes
        'UserData': {'UnplayedItemCount': 7},
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;

      expect(item.leafCount, 50);
      expect(item.viewedLeafCount, 43);
    });

    test('media versions map MediaSources + MediaStreams faithfully', () {
      final json = {
        'Id': 'movie-1',
        'Name': 'Movie',
        'Type': 'Movie',
        'MediaSources': [
          {
            'Id': 'src-1',
            'Container': 'mkv',
            'Bitrate': 8000000,
            'Size': 10737418240,
            'RunTimeTicks': 60000000000,
            'MediaStreams': [
              {
                'Index': 0,
                'Type': 'Video',
                'Codec': 'h264',
                'IsDefault': true,
                'RealFrameRate': 23.976,
                'Width': 1920,
                'Height': 1080,
              },
              {
                'Index': 1,
                'Type': 'Audio',
                'Codec': 'eac3',
                'Language': 'eng',
                'DisplayLanguage': 'English',
                'Channels': 6,
                'IsDefault': true,
              },
              {
                'Index': 2,
                'Type': 'Subtitle',
                'Codec': 'srt',
                'Language': 'eng',
                'IsExternal': true,
                'IsForced': true,
                'DeliveryUrl': '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt',
              },
            ],
          },
        ],
      };

      final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
      expect(item.mediaVersions, isNotNull);
      final v = item.mediaVersions!.single;
      expect(v.id, 'src-1');
      expect(v.width, 1920);
      expect(v.height, 1080);
      expect(v.videoResolution, '1080');
      expect(v.videoCodec, 'h264');
      // Jellyfin's `Bitrate` (8 Mbps in bps) is converted to kbps to match
      // MediaVersion.bitrate's contract (and Plex's encoding).
      expect(v.bitrate, 8000);
      expect(v.container, 'mkv');

      final part = v.parts.single;
      expect(part.id, 'src-1');
      expect(part.streamPath, '/Videos/src-1/stream');
      expect(part.sizeBytes, 10737418240);
      expect(part.durationMs, 6000000); // 6000s

      final video = part.streams.firstWhere((s) => s.kind == MediaStreamKind.video);
      expect(video.codec, 'h264');
      expect(video.frameRate, closeTo(23.976, 0.001));
      expect(video.selected, isTrue);

      final audio = part.streams.firstWhere((s) => s.kind == MediaStreamKind.audio);
      expect(audio.codec, 'eac3');
      expect(audio.channels, 6);
      expect(audio.languageCode, 'eng');

      final subtitle = part.streams.firstWhere((s) => s.kind == MediaStreamKind.subtitle);
      expect(subtitle.forced, isTrue);
      expect(subtitle.isExternal, isTrue);
      expect(subtitle.sidecarPath, '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt');
    });
  });

  group('JellyfinMappers.library', () {
    test('translates Jellyfin CollectionType to neutral MediaKind', () {
      final cases = {
        'movies': MediaKind.movie,
        'tvshows': MediaKind.show,
        'music': MediaKind.artist,
        'photos': MediaKind.photo,
        'boxsets': MediaKind.collection,
      };
      for (final entry in cases.entries) {
        final lib = JellyfinMappers.library({
          'Id': 'view-${entry.key}',
          'Name': 'Library',
          'CollectionType': entry.key,
        }, serverId: _serverId)!;
        expect(lib.kind, entry.value, reason: 'CollectionType ${entry.key}');
        expect(lib.backend, MediaBackend.jellyfin);
      }
    });

    test('falls back to MediaKind.unknown for unrecognised collections', () {
      final lib = JellyfinMappers.library({
        'Id': 'view-x',
        'Name': 'Mixed',
        'CollectionType': 'mixed',
      }, serverId: _serverId)!;
      expect(lib.kind, MediaKind.unknown);
    });
  });

  // Past regression: a Jellyfin server can omit any of these fields when
  // the item is freshly created or the user has restricted permissions.
  // Confirms the mapper degrades gracefully — none of these inputs should
  // throw and every output field should have a sane fallback.
  group('JellyfinMappers.mediaItem null-tolerance', () {
    test('minimal payload (just Id + Type) yields a MediaItem with sane defaults', () {
      final item = JellyfinMappers.mediaItem(
        {'Id': 'bare-1', 'Type': 'Movie'},
        serverId: _serverId,
        serverName: 'Home',
        absolutizer: null,
      )!;
      expect(item.id, 'bare-1');
      expect(item.kind, MediaKind.movie);
      expect(item.summary, isNull);
      expect(item.year, isNull);
      expect(item.isWatched, isFalse);
      // Optional list fields can be null OR empty — both are sane.
      expect(item.genres, anyOf(isNull, isEmpty));
      expect(item.directors, anyOf(isNull, isEmpty));
    });

    test('missing UserData leaves watch state nullable without throwing', () {
      final item = JellyfinMappers.mediaItem(
        {'Id': 'i', 'Type': 'Movie', 'Name': 'X'},
        serverId: _serverId,
        absolutizer: null,
      )!;
      // Either 0 or null is acceptable as long as we don't crash.
      expect(item.viewCount, anyOf(isNull, 0));
      expect(item.viewOffsetMs, isNull);
      expect(item.lastViewedAt, isNull);
    });

    test('null People array does not crash', () {
      final item = JellyfinMappers.mediaItem(
        {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'People': null},
        serverId: _serverId,
        absolutizer: null,
      )!;
      expect(item.directors, anyOf(isNull, isEmpty));
      expect(item.writers, anyOf(isNull, isEmpty));
      expect(item.roles, anyOf(isNull, isEmpty));
    });

    test('null Genres / Studios / ProductionLocations degrade gracefully', () {
      final item = JellyfinMappers.mediaItem(
        {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'Genres': null, 'Studios': null, 'ProductionLocations': null},
        serverId: _serverId,
        absolutizer: null,
      )!;
      expect(item.genres, anyOf(isNull, isEmpty));
      expect(item.studio, isNull);
      expect(item.countries, anyOf(isNull, isEmpty));
    });

    test('malformed RunTimeTicks does not throw — duration left null', () {
      final item = JellyfinMappers.mediaItem(
        {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'RunTimeTicks': 'not-a-number'},
        serverId: _serverId,
        absolutizer: null,
      )!;
      expect(item.durationMs, isNull);
    });

    test('null MediaSources does not crash', () {
      final item = JellyfinMappers.mediaItem(
        {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'MediaSources': null},
        serverId: _serverId,
        absolutizer: null,
      )!;
      expect(item.mediaVersions, anyOf(isNull, isEmpty));
    });
  });

  group('JellyfinMappers.mediaItem missing-Id rejection', () {
    test('returns null when Id is absent', () {
      expect(
        JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: _serverId, absolutizer: null),
        isNull,
      );
    });

    test('returns null when Id is empty string', () {
      expect(
        JellyfinMappers.mediaItem(
          {'Id': '', 'Type': 'Movie', 'Name': 'emptyId'},
          serverId: _serverId,
          absolutizer: null,
        ),
        isNull,
      );
    });

    test('drops MediaSources entries with missing Id', () {
      final item = JellyfinMappers.mediaItem(
        {
          'Id': 'movie-x',
          'Type': 'Movie',
          'MediaSources': [
            {'Container': 'mkv', 'Bitrate': 8000000, 'MediaStreams': []},
            {'Id': 'src-ok', 'Container': 'mp4', 'Bitrate': 4000000, 'MediaStreams': []},
          ],
        },
        serverId: _serverId,
        absolutizer: null,
      )!;
      expect(item.mediaVersions!.length, 1);
      expect(item.mediaVersions!.single.id, 'src-ok');
    });
  });

  group('JellyfinMappers.library missing-Id rejection', () {
    test('returns null when Id is absent', () {
      expect(JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: _serverId), isNull);
    });
  });
}
