import 'package:flutter_test/flutter_test.dart';
import 'package:linthra/core/catalog/library_grouping.dart';
import 'package:linthra/core/sources/plex/plex_api.dart';
import 'package:linthra/core/sources/plex/plex_track_mapper.dart';

void main() {
  group('PlexTrackMapper.toTrack (Plex type 10)', () {
    test('maps a track to a token-free plex: uri', () {
      const item = PlexMetadata(
        ratingKey: '301',
        type: 'track',
        title: 'Nightcall',
        parentRatingKey: '201',
        grandparentRatingKey: '101',
        parentTitle: 'OutRun',
        grandparentTitle: 'Kavinsky',
        thumb: '/library/metadata/201/thumb/1700000000',
        duration: 258000,
      );

      final track = PlexTrackMapper.toTrack(item);

      expect(track.id, '301');
      expect(track.uri, 'plex:301');
      expect(track.title, 'Nightcall');
      // The parent/grandparent links carry the album/artist relationship.
      expect(track.albumName, 'OutRun');
      expect(track.artistName, 'Kavinsky');
      // Plex reports duration in milliseconds.
      expect(track.duration, const Duration(minutes: 4, seconds: 18));
      expect(
        track.artworkUri.toString(),
        'plex-thumb:/library/metadata/201/thumb/1700000000',
      );
    });

    test('uses the ratingKey, never the Part path, for the uri', () {
      // A realistic track item carries its Media/Part (the playable file
      // path); the mapped uri must stay the opaque ratingKey reference so the
      // catalog never names a server file path.
      final PlexMetadata item = PlexMetadata.fromJson(<String, dynamic>{
        'ratingKey': '301',
        'type': 'track',
        'title': 'Nightcall',
        'Media': <dynamic>[
          <String, dynamic>{
            'Part': <dynamic>[
              <String, dynamic>{
                'key': '/library/parts/9001/1700000000/file.flac',
              },
            ],
          },
        ],
      })!;

      final track = PlexTrackMapper.toTrack(item);

      expect(track.uri, 'plex:301');
      expect(track.uri, isNot(contains('parts')));
      expect(track.uri, isNot(contains('file.flac')));
    });

    test('the uri and artwork reference never embed a credential or server',
        () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'x',
        thumb: '/library/metadata/201/thumb/1700000000',
      );

      final track = PlexTrackMapper.toTrack(item);

      // The uri is the opaque ratingKey only — no query, no token.
      expect(track.uri, 'plex:301');
      expect(track.uri, isNot(contains('?')));
      expect(track.uri, isNot(contains('X-Plex-Token')));
      expect(track.uri, isNot(contains('=')));
      // The artwork reference is credential-free and points at no server: the
      // token is woven in only at render time, never persisted here.
      final String artwork = track.artworkUri.toString();
      expect(artwork, 'plex-thumb:/library/metadata/201/thumb/1700000000');
      expect(artwork, isNot(contains('?')));
      expect(artwork, isNot(contains('X-Plex-Token')));
      expect(artwork, isNot(contains('http')));
    });

    test('falls back to a safe title when Plex omits or blanks it', () {
      const missing = PlexMetadata(ratingKey: '1');
      const blank = PlexMetadata(ratingKey: '2', title: '   ');
      expect(PlexTrackMapper.toTrack(missing).title, 'Untitled');
      expect(PlexTrackMapper.toTrack(blank).title, 'Untitled');
    });

    test('leaves artist/album null when the parent titles are missing', () {
      const item = PlexMetadata(ratingKey: '301', title: 'Nightcall');
      final track = PlexTrackMapper.toTrack(item);
      // Null names let the grouping layer fold the track into its Unknown
      // Album/Artist buckets, same as a tagless local file.
      expect(track.artistName, isNull);
      expect(track.albumName, isNull);
    });

    test('prefers the album artist (grandparentTitle) over originalTitle', () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'Nightcall',
        grandparentTitle: 'Kavinsky',
        originalTitle: 'Lovefoxxx',
      );
      // The album artist keeps every track of one album under a single
      // grouping key, mirroring Jellyfin's albumArtist preference — so the
      // per-track originalTitle is only a fallback, never an override.
      expect(PlexTrackMapper.toTrack(item).artistName, 'Kavinsky');
    });

    test('falls back to the credited originalTitle when no album artist', () {
      const missing = PlexMetadata(
        ratingKey: '301',
        title: 'Avril 14th',
        originalTitle: 'Aphex Twin',
      );
      const blank = PlexMetadata(
        ratingKey: '302',
        title: 'Avril 14th',
        grandparentTitle: '   ',
        originalTitle: 'Aphex Twin',
      );
      // A track whose album-artist link PMS didn't denormalise still surfaces
      // its real performer instead of folding into "Unknown Artist".
      expect(PlexTrackMapper.toTrack(missing).artistName, 'Aphex Twin');
      expect(PlexTrackMapper.toTrack(blank).artistName, 'Aphex Twin');
    });

    // Issue #281: these drive album grouping, so a collaboration track stays
    // with its album instead of splitting off into its own entry.
    test('maps the album id from parentRatingKey, namespaced like the uri', () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'Nightcall',
        parentRatingKey: '201',
        grandparentRatingKey: '101',
      );
      // The album's ratingKey (parent), never the artist's (grandparent).
      expect(PlexTrackMapper.toTrack(item).albumId, 'plex:201');
    });

    test('maps the album artist from grandparentTitle', () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'Nightcall',
        grandparentTitle: 'Kavinsky',
      );
      expect(PlexTrackMapper.toTrack(item).albumArtistName, 'Kavinsky');
    });

    test('never falls back to originalTitle for the album artist', () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'Nightcall',
        originalTitle: 'Lovefoxxx',
      );
      final track = PlexTrackMapper.toTrack(item);
      // originalTitle is a track's *own* credited artist — exactly what
      // differs across a collaboration album — so using it as the album
      // artist would re-split the album it is meant to hold together. The
      // per-track artistName still surfaces it.
      expect(track.albumArtistName, isNull);
      expect(track.artistName, 'Lovefoxxx');
    });

    test('leaves both album grouping fields null when PMS omits them', () {
      const item = PlexMetadata(ratingKey: '301', title: 'x');
      final track = PlexTrackMapper.toTrack(item);
      expect(track.albumId, isNull);
      expect(track.albumArtistName, isNull);
    });

    test('treats a blank parentRatingKey/grandparentTitle as absent', () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'x',
        parentRatingKey: '   ',
        grandparentTitle: '  ',
      );
      final track = PlexTrackMapper.toTrack(item);
      expect(track.albumId, isNull);
      expect(track.albumArtistName, isNull);
    });

    test('the album id never embeds a credential or server', () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'x',
        parentRatingKey: '201',
      );
      final track = PlexTrackMapper.toTrack(item);
      expect(track.albumId, isNot(contains('?')));
      expect(track.albumId, isNot(contains('X-Plex-Token')));
      expect(track.albumId, isNot(contains('http')));
    });

    test('prefers the track\'s own thumb over the album cover (parentThumb)',
        () {
      const item = PlexMetadata(
        ratingKey: '301',
        title: 'Nightcall',
        thumb: '/library/metadata/301/thumb/1',
        parentThumb: '/library/metadata/201/thumb/9',
      );
      expect(
        PlexTrackMapper.toTrack(item).artworkUri.toString(),
        'plex-thumb:/library/metadata/301/thumb/1',
      );
    });

    test('falls back to the album cover (parentThumb) when no track thumb', () {
      const missing = PlexMetadata(
        ratingKey: '301',
        title: 'Nightcall',
        parentThumb: '/library/metadata/201/thumb/9',
      );
      const blank = PlexMetadata(
        ratingKey: '302',
        title: 'Nightcall',
        thumb: '  ',
        parentThumb: '/library/metadata/201/thumb/9',
      );
      // A track without distinct art still shows its album cover, the way a
      // Subsonic track (whose coverArt is the album's) always does — and the
      // reference stays a credential-free plex-thumb: path.
      expect(
        PlexTrackMapper.toTrack(missing).artworkUri.toString(),
        'plex-thumb:/library/metadata/201/thumb/9',
      );
      expect(
        PlexTrackMapper.toTrack(blank).artworkUri.toString(),
        'plex-thumb:/library/metadata/201/thumb/9',
      );
    });

    test('maps absent or zero duration to zero', () {
      const absent = PlexMetadata(ratingKey: '1', title: 't');
      const zero = PlexMetadata(ratingKey: '2', title: 't', duration: 0);
      expect(PlexTrackMapper.toTrack(absent).duration, Duration.zero);
      expect(PlexTrackMapper.toTrack(zero).duration, Duration.zero);
    });

    test('carries the track number from the Plex index', () {
      const item = PlexMetadata(ratingKey: '301', title: 'Nightcall', index: 7);
      expect(PlexTrackMapper.toTrack(item).trackNumber, 7);
    });

    test('leaves trackNumber null for an absent or non-positive index', () {
      const absent = PlexMetadata(ratingKey: '1', title: 't');
      const zero = PlexMetadata(ratingKey: '2', title: 't', index: 0);
      // PMS omits the index it doesn't know; a stray 0 is meaningless as a
      // track number, so both fold to null (the grouping layer keeps the
      // server's listing order rather than forcing a bogus "track 0").
      expect(PlexTrackMapper.toTrack(absent).trackNumber, isNull);
      expect(PlexTrackMapper.toTrack(zero).trackNumber, isNull);
    });

    test('leaves artworkUri null when the item reports no thumb', () {
      const item = PlexMetadata(ratingKey: '1', title: 't');
      // No thumb → null → the UI shows its placeholder (not a broken image).
      expect(PlexTrackMapper.toTrack(item).artworkUri, isNull);
    });
  });

  group('PlexTrackMapper.toAlbum (Plex type 9)', () {
    test('maps an album, with its parentTitle as the artist name', () {
      const item = PlexMetadata(
        ratingKey: '201',
        type: 'album',
        title: 'OutRun',
        parentRatingKey: '101',
        parentTitle: 'Kavinsky',
        thumb: '/library/metadata/201/thumb/1700000000',
      );

      final album = PlexTrackMapper.toAlbum(item);

      expect(album.id, '201');
      expect(album.title, 'OutRun');
      expect(album.artistName, 'Kavinsky');
      expect(
        album.artworkUri.toString(),
        'plex-thumb:/library/metadata/201/thumb/1700000000',
      );
    });

    test('falls back to the shared Unknown Album label for a missing title',
        () {
      const item = PlexMetadata(ratingKey: '201', type: 'album');
      expect(PlexTrackMapper.toAlbum(item).title, kUnknownAlbum);
    });

    test('leaves artistName and artwork null when Plex omits them', () {
      const item = PlexMetadata(ratingKey: '201', title: 'OutRun');
      final album = PlexTrackMapper.toAlbum(item);
      expect(album.artistName, isNull);
      expect(album.artworkUri, isNull);
    });

    test('carries the release year and track count when reported', () {
      const item = PlexMetadata(
        ratingKey: '201',
        type: 'album',
        title: 'OutRun',
        year: 2013,
        leafCount: 13,
      );
      final album = PlexTrackMapper.toAlbum(item);
      expect(album.year, 2013);
      expect(album.trackCount, 13);
    });

    test('defaults year to null and track count to 0 when PMS omits them', () {
      const item = PlexMetadata(ratingKey: '201', title: 'OutRun');
      final album = PlexTrackMapper.toAlbum(item);
      // A missing year stays null (Unknown); a missing count is 0, mirroring
      // Jellyfin's `childCount ?? 0`.
      expect(album.year, isNull);
      expect(album.trackCount, 0);
    });

    test('folds a non-positive year to null', () {
      const item = PlexMetadata(ratingKey: '201', title: 'OutRun', year: 0);
      // PMS occasionally reports year 0 for an unknown date; that is not a real
      // year, so it folds to null like an omitted field.
      expect(PlexTrackMapper.toAlbum(item).year, isNull);
    });
  });

  group('PlexTrackMapper.toArtist (Plex type 8)', () {
    test('maps artist id, name, and artwork reference', () {
      const item = PlexMetadata(
        ratingKey: '101',
        type: 'artist',
        title: 'Kavinsky',
        thumb: '/library/metadata/101/thumb/1700000000',
      );

      final artist = PlexTrackMapper.toArtist(item);

      expect(artist.id, '101');
      expect(artist.name, 'Kavinsky');
      expect(
        artist.artworkUri.toString(),
        'plex-thumb:/library/metadata/101/thumb/1700000000',
      );
    });

    test('falls back to the shared Unknown Artist label for a missing title',
        () {
      const item = PlexMetadata(ratingKey: '101', type: 'artist');
      expect(PlexTrackMapper.toArtist(item).name, kUnknownArtist);
    });
  });

  group('plex-thumb artwork reference', () {
    test('round-trips through the catalog persistence shape', () {
      const item = PlexMetadata(
        ratingKey: '1',
        title: 't',
        thumb: '/library/metadata/201/thumb/1700000000',
      );
      final Uri reference = PlexTrackMapper.toTrack(item).artworkUri!;

      // The catalog persists artworkUri as a string; the reference must come
      // back out of Uri.parse with its thumb path intact.
      final Uri reparsed = Uri.parse(reference.toString());
      expect(
        PlexTrackMapper.thumbPath(reparsed),
        '/library/metadata/201/thumb/1700000000',
      );
    });

    test('round-trips a sizing-transcoder thumb, query and all', () {
      // Some items report art as a photo-transcoder path whose query *is* the
      // request. Riding as a Uri path encodes the `?`; thumbPath must hand
      // back exactly the string PMS reported, or the render-time URL would
      // carry a literal `%3F` path and the server would 404 the cover.
      const String transcodeThumb =
          '/photo/:/transcode?url=/library/metadata/201/thumb/167&width=200';
      const item =
          PlexMetadata(ratingKey: '1', title: 't', thumb: transcodeThumb);
      final Uri reference = PlexTrackMapper.toTrack(item).artworkUri!;

      // Still a credential-free plex-thumb reference…
      expect(reference.scheme, PlexTrackMapper.artworkScheme);
      // …whose persisted string round-trips to the exact reported path.
      final Uri reparsed = Uri.parse(reference.toString());
      expect(PlexTrackMapper.thumbPath(reparsed), transcodeThumb);
    });

    test('round-trips a transcoder thumb whose url value PMS pre-encoded', () {
      // The trap: the Uri constructor *preserves* valid escape triplets, so a
      // thumb whose `url=` value is itself percent-encoded would store with
      // the server's `%3A`/`%26` and the builder's `%3F` collapsed into one
      // encoding level — and a single decode would strip the server's level
      // too, promoting the inner `&b=2` to a top-level param. The builder
      // pre-escapes `%` so the decode restores the exact reported bytes.
      const String nestedThumb = '/photo/:/transcode'
          '?url=http%3A%2F%2Fhost%2Fcover%3Fa%3D1%26b%3D2&width=200';
      const item = PlexMetadata(ratingKey: '1', title: 't', thumb: nestedThumb);
      final Uri reference = PlexTrackMapper.toTrack(item).artworkUri!;

      final Uri reparsed = Uri.parse(reference.toString());
      expect(PlexTrackMapper.thumbPath(reparsed), nestedThumb);
    });

    test('thumbPath leaves other providers\' artwork untouched', () {
      expect(
        PlexTrackMapper.thumbPath(Uri.parse('https://x.example/cover.jpg')),
        isNull,
      );
      expect(
        PlexTrackMapper.thumbPath(Uri.parse('subsonic-cover:al-7')),
        isNull,
      );
      expect(
        PlexTrackMapper.thumbPath(Uri.parse('file:///covers/a.jpg')),
        isNull,
      );
    });
  });
}
