import '../test_helpers/paged_fakes.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/utils/download_version_utils.dart';
import 'package:plezy/media/episode_collection.dart';
import '../test_helpers/media_items.dart';

MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount, int? childCount}) => testMediaItem(
  id: id,
  backend: MediaBackend.plex,
  kind: MediaKind.season,
  title: 'Season $index',
  index: index,
  leafCount: leafCount,
  viewedLeafCount: viewedLeafCount,
  childCount: childCount,
);

MediaItem _episode(
  String id, {
  List<MediaVersion>? versions,
  String? parentId,
  int? parentIndex,
  int? index,
  String? grandparentId,
  int? viewCount,
  int? viewOffsetMs,
  int? durationMs,
  String? originallyAvailableAt,
}) => testMediaItem(
  id: id,
  backend: MediaBackend.plex,
  kind: MediaKind.episode,
  title: 'Episode',
  mediaVersions: versions,
  parentId: parentId,
  parentIndex: parentIndex,
  index: index,
  grandparentId: grandparentId,
  viewCount: viewCount,
  viewOffsetMs: viewOffsetMs,
  durationMs: durationMs,
  originallyAvailableAt: originallyAvailableAt,
);

MediaItem _clip(String id) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.clip, title: 'Clip');

class _RecordingClient implements MediaServerClient {
  _RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}});

  final Map<String, List<MediaItem>> childrenByParent;
  final Map<String, List<MediaItem>> childrenPageByParent;
  final Map<String, MediaItem> itemsById;
  final childrenCalls = <String>[];
  final childrenPageCalls = <({String parentId, int? start, int? size})>[];

  @override
  Future<List<MediaItem>> fetchChildren(String parentId) async {
    childrenCalls.add(parentId);
    return childrenByParent[parentId] ?? const [];
  }

  @override
  Future<LibraryPage<MediaItem>> fetchChildrenPage(String parentId, {int? start, int? size, abort}) async {
    childrenPageCalls.add((parentId: parentId, start: start, size: size));
    final all = childrenPageByParent[parentId] ?? const <MediaItem>[];
    return fakeLibraryPage(all, start: start, size: size);
  }

  @override
  Future<MediaItem?> fetchItem(String id) async => itemsById[id];

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

class _SeasonPagingRecordingClient extends _RecordingClient implements SeasonEpisodePagingClient {
  _SeasonPagingRecordingClient({this.seasonPageBySeason = const {}});

  final Map<({String seriesId, String seasonId}), List<MediaItem>> seasonPageBySeason;
  final seasonEpisodePageCalls = <({String seriesId, String seasonId, int? start, int? size})>[];

  @override
  Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
    String seriesId,
    String seasonId, {
    int? start,
    int? size,
    abort,
  }) async {
    seasonEpisodePageCalls.add((seriesId: seriesId, seasonId: seasonId, start: start, size: size));
    final all = seasonPageBySeason[(seriesId: seriesId, seasonId: seasonId)] ?? const <MediaItem>[];
    return fakeLibraryPage(all, start: start, size: size);
  }
}

class _LeavesClient implements MediaServerClient {
  _LeavesClient(this.leaves);

  final List<MediaItem> leaves;

  @override
  Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => leaves;

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

void main() {
  test('collectEpisodes drops Specials when includeSpecials is false', () async {
    final client = _LeavesClient([
      _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
      _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
      _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'),
    ]);

    final withoutSpecials = <MediaItem>[];
    await collectEpisodes(client, 'show-1', unwatchedOnly: false, out: withoutSpecials, includeSpecials: false);
    expect(withoutSpecials.map((e) => e.id), ['s1e1', 's1e2']);

    // Default keeps Specials, interleaved into aired order.
    final withSpecials = <MediaItem>[];
    await collectEpisodes(client, 'show-1', unwatchedOnly: false, out: withSpecials);
    expect(withSpecials.map((e) => e.id), ['s1e1', 's0e1', 's1e2']);
  });

  test('defaultPlaybackSeason skips specials when a regular season exists', () {
    final special = _season('specials', index: 0);
    final season1 = _season('season-1');
    final episodeRow = _episode('episode-row', parentIndex: 99);

    expect(defaultPlaybackSeason([special, season1]), same(season1));
    expect(defaultPlaybackSeasonIndex([special, season1]), 1);
    expect(preferredSeasonIndex([episodeRow, special, season1], initialSeasonIndex: 99), 2);
  });

  test('preferredSeasonIndex honors explicit and on-deck season choices', () {
    final special = _season('specials', index: 0);
    final season1 = _season('season-1');
    final season2 = _season('season-2', index: 2);

    expect(preferredSeasonIndex([special, season1, season2], initialSeasonId: season2.id, initialSeasonIndex: 1), 2);
    expect(preferredSeasonIndex([special, season1, season2], initialSeasonIndex: 2), 2);
    expect(
      preferredSeasonIndex([special, season1, season2], onDeckEpisode: _episode('episode-2', parentId: season2.id)),
      2,
    );
    expect(preferredSeasonIndex([special, season1, season2], onDeckEpisode: _episode('episode-1', parentIndex: 1)), 1);
  });

  test('firstUnwatchedSeasonIndex prefers the first regular season with unwatched episodes', () {
    final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 0);
    final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 5); // fully watched
    final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 2); // partially watched
    final season3 = _season('season-3', index: 3, leafCount: 5, viewedLeafCount: 0);

    expect(firstUnwatchedSeasonIndex([special, season1, season2, season3]), 2);
  });

  test('firstUnwatchedSeasonIndex uses direct episode count when the leaf total is missing', () {
    final season = _season('season-1', childCount: 5, viewedLeafCount: 2);

    expect(firstUnwatchedSeasonIndex([season]), 0);
  });

  test('firstUnwatchedSeasonIndex falls back to specials only when no regular season qualifies', () {
    final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 1);
    final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4);

    expect(firstUnwatchedSeasonIndex([special, season1]), 0);
  });

  test('firstUnwatchedSeasonIndex returns null when fully watched or counts are missing', () {
    final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4);
    final season2 = _season('season-2', index: 2, leafCount: 6, viewedLeafCount: 6);
    expect(firstUnwatchedSeasonIndex([season1, season2]), isNull);

    // No leaf counts at all → can't tell, so returns null and callers fall back.
    expect(firstUnwatchedSeasonIndex([_season('season-1'), _season('season-2', index: 2)]), isNull);
  });

  test('preferredSeasonIndex falls back to the first unwatched season without an on-deck episode', () {
    final special = _season('specials', index: 0, leafCount: 2, viewedLeafCount: 0);
    final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 5);
    final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 1);

    // No explicit target, no backend on-deck → first season with unwatched episodes.
    expect(preferredSeasonIndex([special, season1, season2]), 2);
  });

  test('preferredSeasonIndex keeps the default season for a fully-unwatched show', () {
    final special = _season('specials', index: 0, leafCount: 2, viewedLeafCount: 0);
    final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 0);
    final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 0);

    // Identical to defaultPlaybackSeasonIndex (first regular season).
    expect(preferredSeasonIndex([special, season1, season2]), 1);
    expect(preferredSeasonIndex([special, season1, season2]), defaultPlaybackSeasonIndex([special, season1, season2]));
  });

  test('firstUnwatchedEpisode skips watched but keeps in-progress episodes', () {
    final watched = _episode('e1', index: 1, viewCount: 1);
    final inProgress = _episode('e2', index: 2, viewCount: 1, viewOffsetMs: 500, durationMs: 1000);
    final unwatched = _episode('e3', index: 3);

    // In-progress is returned even though it is flagged watched.
    expect(firstUnwatchedEpisode([watched, inProgress, unwatched]), same(inProgress));
    // Otherwise the first fully-unwatched episode wins.
    expect(firstUnwatchedEpisode([watched, unwatched]), same(unwatched));
    // Non-episode rows are ignored.
    expect(firstUnwatchedEpisode([_season('season-1'), unwatched]), same(unwatched));
  });

  test('firstUnwatchedEpisode returns null when every episode is watched', () {
    expect(
      firstUnwatchedEpisode([_episode('e1', index: 1, viewCount: 1), _episode('e2', index: 2, viewCount: 2)]),
      isNull,
    );
  });

  test('sortEpisodesByWatchOrder interleaves Specials into aired order by air date', () {
    // Mirrors a real interleaved-Specials show (e.g. The Eminence in Shadow):
    // S00E01 aired between S01E02 and S01E05, so it plays there — not after the
    // whole season. This is what Plex's own play queue returns (#1416).
    final s1e1 = _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05');
    final s1e2 = _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-10-12');
    final s0e1 = _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27');
    final s1e5 = _episode('s1e5', parentIndex: 1, index: 5, originallyAvailableAt: '2022-11-02');
    final s0e2 = _episode('s0e2', parentIndex: 0, index: 2, originallyAvailableAt: '2022-11-03');

    // Raw container order would clump the Specials folder first.
    final episodes = [s0e1, s0e2, s1e1, s1e2, s1e5];
    sortEpisodesByWatchOrder(episodes);

    expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's0e1', 's1e5', 's0e2']);
  });

  test('sortEpisodesByWatchOrder falls back to Specials-last when episodes have no air dates', () {
    final s0e1 = _episode('s0e1', parentIndex: 0, index: 1);
    final s0e2 = _episode('s0e2', parentIndex: 0, index: 2);
    final s1e1 = _episode('s1e1', parentIndex: 1, index: 1);
    final s1e2 = _episode('s1e2', parentIndex: 1, index: 2);
    final s2e1 = _episode('s2e1', parentIndex: 2, index: 1);

    // Raw /grandchildren order would lead with the Specials folder.
    final episodes = [s0e1, s0e2, s1e1, s1e2, s2e1];
    sortEpisodesByWatchOrder(episodes);

    // With no air dates, a "next 2 unwatched" cut still takes S01E01/S01E02.
    expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's2e1', 's0e1', 's0e2']);
  });

  test('sortEpisodesByWatchOrder trails undated Specials after dated episodes', () {
    // A dated regular run with an undated Special: the Special can't be placed
    // in the aired timeline, so it sorts last rather than wedging into the run.
    final s1e1 = _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05');
    final s1e2 = _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-10-12');
    final s0e1 = _episode('s0e1', parentIndex: 0, index: 1);

    final episodes = [s0e1, s1e2, s1e1];
    sortEpisodesByWatchOrder(episodes);

    expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's0e1']);
  });

  test('compareEpisodesByWatchOrder breaks index ties on id for deterministic cuts', () {
    final a = _episode('a', parentIndex: 1, index: 1);
    final b = _episode('b', parentIndex: 1, index: 1);

    expect(compareEpisodesByWatchOrder(a, b), lessThan(0));
    expect(compareEpisodesByWatchOrder(b, a), greaterThan(0));
    expect(compareEpisodesByWatchOrder(a, a), 0);
  });

  test('isSpecialSeasonNumber treats season 0 and missing numbers as Specials', () {
    expect(isSpecialSeasonNumber(0), isTrue);
    expect(isSpecialSeasonNumber(null), isTrue);
    expect(isSpecialSeasonNumber(1), isFalse);
    expect(isSpecialSeasonNumber(2), isFalse);
  });

  test('isUnwatchedOrInProgress keeps unwatched and resumable episodes', () {
    // Unwatched.
    expect(_episode('a', viewCount: 0).isUnwatchedOrInProgress, isTrue);
    // Watched with no resume point — counts as done.
    expect(_episode('b', viewCount: 1).isUnwatchedOrInProgress, isFalse);
    // Watched but still resumable (re-watching) — counts as to-watch.
    expect(_episode('c', viewCount: 1, viewOffsetMs: 500, durationMs: 1000).isUnwatchedOrInProgress, isTrue);
  });

  test('fetchFirstEpisodeForSeason requests only the first children page', () async {
    final episode = _episode('episode-1');
    final client = _RecordingClient(
      childrenPageByParent: {
        'season-1': [episode, _episode('episode-2')],
      },
    );

    final result = await fetchFirstEpisodeForSeason(client, 'season-1');

    expect(result, same(episode));
    expect(client.childrenCalls, isEmpty);
    expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
  });

  test('fetchFirstEpisodeForSeason uses season episode paging when available', () async {
    final episode = _episode('episode-1');
    final client = _SeasonPagingRecordingClient(
      seasonPageBySeason: {
        (seriesId: 'show-1', seasonId: 'season-1'): [episode, _episode('episode-2')],
      },
    );

    final result = await fetchFirstEpisodeForSeason(client, 'season-1', seriesId: 'show-1');

    expect(result, same(episode));
    expect(client.childrenPageCalls, isEmpty);
    expect(client.seasonEpisodePageCalls, [(seriesId: 'show-1', seasonId: 'season-1', start: 0, size: 1)]);
  });

  test('fetchSeasonEpisodePage normalizes show and season identity', () async {
    final show = testMediaItem(
      id: 'show-1',
      backend: MediaBackend.plex,
      kind: MediaKind.show,
      title: 'Show',
      serverId: 'server-1',
      serverName: 'Server',
      libraryId: 'lib-1',
      libraryTitle: 'Library',
    );
    final season = _season('season-1').copyWith(index: 1, libraryId: show.libraryId, libraryTitle: show.libraryTitle);
    final row = _episode('episode-1');
    final client = _RecordingClient(
      childrenPageByParent: {
        season.id: [row],
      },
    );

    final page = await fetchSeasonEpisodePage(client, show: show, season: season, start: 0, size: 200);

    expect(client.childrenCalls, isEmpty);
    expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 200)]);
    expect(page.totalCount, 1);
    expect(page.items.single.serverId, show.serverId);
    expect(page.items.single.serverName, show.serverName);
    expect(page.items.single.grandparentId, show.id);
    expect(page.items.single.grandparentTitle, show.title);
    expect(page.items.single.parentId, season.id);
    expect(page.items.single.parentIndex, season.index);
    expect(page.items.single.libraryId, show.libraryId);
  });

  test('fetchSeasonEpisodePage uses season episode paging when available', () async {
    final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
    final season = _season('season-1');
    final row = _episode('episode-1');
    final client = _SeasonPagingRecordingClient(
      seasonPageBySeason: {
        (seriesId: show.id, seasonId: season.id): [row],
      },
    );

    final page = await fetchSeasonEpisodePage(client, show: show, season: season, start: 0, size: 10);

    expect(client.childrenPageCalls, isEmpty);
    expect(client.seasonEpisodePageCalls, [(seriesId: show.id, seasonId: season.id, start: 0, size: 10)]);
    expect(page.items.single.id, row.id);
  });

  test('normalizeSeasonEpisodes ignores non-episode rows', () {
    final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
    final season = _season('season-1');

    final normalized = normalizeSeasonEpisodes([_clip('extra-1'), _episode('episode-1')], show: show, season: season);

    expect(normalized.map((item) => item.id), ['episode-1']);
  });

  test('fetchRepresentativeVersions uses paged lookup for season metadata', () async {
    final versions = [const MediaVersion(id: '1080', videoResolution: '1080')];
    final episodeRow = _episode('episode-1');
    final fullEpisode = _episode('episode-1', versions: versions);
    final client = _RecordingClient(
      childrenPageByParent: {
        'season-1': [episodeRow],
      },
      itemsById: {'episode-1': fullEpisode},
    );

    final result = await fetchRepresentativeVersions(client, _season('season-1'));

    expect(result, same(versions));
    expect(client.childrenCalls, isEmpty);
    expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
  });

  test('fetchRepresentativeVersions keeps full season lookup but pages selected season episodes', () async {
    final versions = [const MediaVersion(id: '1080', videoResolution: '1080')];
    final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
    final special = _season('specials', index: 0);
    final firstRegularSeason = _season('season-1');
    final episodeRow = _episode('episode-1');
    final fullEpisode = _episode('episode-1', versions: versions);
    final client = _RecordingClient(
      childrenByParent: {
        'show-1': [special, firstRegularSeason],
      },
      childrenPageByParent: {
        'season-1': [episodeRow],
      },
      itemsById: {'episode-1': fullEpisode},
    );

    final result = await fetchRepresentativeVersions(client, show);

    expect(result, same(versions));
    expect(client.childrenCalls, ['show-1']);
    expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
  });

  group('same-file adjacency (#1500)', () {
    // Episodes of a Plex multi-episode file (S02E24-E25.mkv) are distinct
    // items with distinct part ids but the same Part.file: e24/e25 here.
    // e23 and e26 are their own files.
    MediaVersion version(String key, String file) => MediaVersion(
      id: 'v-$key',
      parts: [MediaPart(id: 'part-$key', file: file)],
    );
    late final episodes = [
      _episode('e23', versions: [version('e23', '/tv/S02E23.mkv')]),
      _episode('e24', versions: [version('e24', '/tv/S02E24-E25.mkv')]),
      _episode('e25', versions: [version('e25', '/tv/S02E24-E25.mkv')]),
      _episode('e26', versions: [version('e26', '/tv/S02E26-E27.mkv')]),
    ];

    test('nextEpisodeSkippingSameFile skips same-file siblings', () {
      expect(nextEpisodeSkippingSameFile(episodes, 1)!.id, 'e26');
      expect(nextEpisodeSkippingSameFile(episodes, 0)!.id, 'e24');
      expect(nextEpisodeSkippingSameFile(episodes, 3), isNull);
      // No same-file sibling left before the end → null.
      expect(nextEpisodeSkippingSameFile(episodes.sublist(0, 3), 1), isNull);
    });

    test('nextEpisodeSkippingSameFile degrades to plain adjacency without part data', () {
      final plain = [_episode('a'), _episode('b')];
      expect(nextEpisodeSkippingSameFile(plain, 0)!.id, 'b');
    });

    test('previousEpisodeSkippingSameFile collapses to the group head', () {
      // From e26, previous is the e24-e25 file, entered at e24.
      expect(previousEpisodeSkippingSameFile(episodes, 3)!.id, 'e24');
      // From inside the group (e25), previous skips the same file entirely.
      expect(previousEpisodeSkippingSameFile(episodes, 2)!.id, 'e23');
      expect(previousEpisodeSkippingSameFile(episodes, 0), isNull);
    });
  });
}
