import '../exceptions/media_server_exceptions.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../services/api_cache.dart';
import '../services/playback_initialization_types.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse, throwIfHttpError;
import '../utils/external_ids.dart';
import '../utils/watch_state_notifier.dart';
import 'download_resolution.dart';
import 'ids.dart';
import 'library_filter_result.dart';
import 'library_first_character.dart';
import 'library_query.dart';
import 'live_tv_support.dart';
import 'lyrics.dart';
import 'media_backend.dart';
import 'media_file_info.dart';
import 'media_hub.dart';
import '../services/scrub_preview_source.dart';
import 'media_item.dart';
import 'media_kind.dart';
import 'media_library.dart';
import 'media_playlist.dart';
import 'playback_report_metadata.dart';
import 'server_capabilities.dart';

/// Default number of items requested for horizontal hub previews.
const int defaultHubPreviewLimit = 20;

/// Backend-neutral client for a single media server (Plex or Jellyfin).
///
/// Each implementation wraps the per-backend HTTP layer and exposes the same
/// operations the rest of the app needs to browse libraries, mark watch
/// state, and render items. Concrete classes ([PlexClient], `JellyfinClient`)
/// own the per-backend networking — providers and UI consume them only
/// through this interface.
///
/// ## Naming
///
/// Read methods use a `fetch*` prefix. Backend-specific operations that do not
/// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata
/// edit adapters) live on concrete clients or feature modules.
///
/// ## Mutation error and result contracts
///
/// HTTP status, timeout, connection, decode, and cancellation failures from
/// the shared transport surface as [MediaServerHttpException]. Calls for an
/// unsupported advertised capability may throw [UnsupportedError] where the
/// method documents that boundary.
///
/// Result semantics follow each method's declared family. Completion of a
/// `Future<void>` mutation is success and carries no business-result value.
/// Nullable creation methods return `null` only after an accepted request
/// produced no usable created entity or id; request failures throw. Boolean
/// mutations return `true` on their accepted success path, and return `false`
/// only for local preconditions explicitly documented by that method rather
/// than as a substitute for request failure.
///
/// `fetchItem` returns `null` on a real 404 (item gone) and on a 200 that
/// can't be parsed; auth/server errors throw rather than silently dropping
/// to `null`.

/// Outcome of a health probe. Distinguishes "session expired" (token was
/// rejected) from a generic transport failure, so the manager can route the
/// two states to different UI ("Sign in again" vs "Server offline").
enum HealthStatus { online, offline, authError }

abstract interface class GracefullyCloseable {
  Future<void> closeGracefully({Duration drainTimeout});
}

abstract class MediaServerClient {
  ServerId get serverId;
  String? get serverName;
  MediaBackend get backend;
  ServerCapabilities get capabilities;

  /// Release HTTP resources and any other long-lived state. Idempotent.
  void close();

  /// Probe the server with a lightweight auth-required round-trip and
  /// classify the outcome. Implementations must surface 401/403 as
  /// [HealthStatus.authError] so the manager can flag a revoked token
  /// distinctly from a generic network failure.
  Future<HealthStatus> checkHealth();

  /// Convenience predicate over [checkHealth] for callers that only need a
  /// boolean. Treats both `offline` and `authError` as unhealthy.
  Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;

  /// Server-reported unique identifier (Plex `machineIdentifier`,
  /// Jellyfin `Id`). Returns `null` if the probe fails.
  Future<String?> getMachineIdentifier();

  /// When `true`, the client serves cached responses only and never hits the
  /// network.
  bool get isOfflineMode;
  void setOfflineMode(bool offline);

  /// Backend-specific cache substrate. Subclasses override this so the
  /// shared [MediaServerCacheMixin] helpers can read and write through the
  /// appropriate cache instance.
  ApiCache get cache;

  Future<List<MediaLibrary>> fetchLibraries();

  /// Page through items in [libraryId] using the neutral [query]. Backends
  /// translate sort/filter clauses into their own DSL.
  Future<LibraryPage<MediaItem>> fetchLibraryContent(String libraryId, LibraryQuery query);

  /// Backend-aware paginated content fetch.
  ///
  /// Pagination lives on [LibraryQuery.offset] / [LibraryQuery.limit].
  /// [libraryKind] disambiguates a Jellyfin "Shows" library so it returns
  /// Series rows rather than the recursive episode expansion the server
  /// defaults to. Plex ignores it (the section id already pins the type).
  ///
  /// The previous `plexStyleFilters: Map<String,String>` parameter was
  /// retired — the library UI now builds a neutral [LibraryQuery] at the
  /// call boundary via `libraryQueryFromPlexMap`, and the Plex client
  /// translates back to wire params via [PlexLibraryQueryTranslator].
  Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
    String libraryId, {
    required LibraryQuery query,
    MediaKind? libraryKind,
    AbortController? abort,
  });

  /// Filter categories for [libraryId] plus any values the backend serves
  /// up-front. Plex returns categories without values (the FiltersBottomSheet
  /// fetches values lazily per category); Jellyfin returns both in a single
  /// `/Items/Filters` call and pre-populates [LibraryFilterResult.cachedValues].
  /// [libraryKind] lets backends use media-appropriate labels for synthetic
  /// filters. Backends that have no filter listing return
  /// [LibraryFilterResult.empty].
  Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind});

  /// Backend-aware sort options for [libraryId]. Plex hits
  /// `/library/sections/{id}/sorts`; Jellyfin returns a hardcoded list
  /// (the API has no equivalent endpoint). Returns [] when the server
  /// has no opinion. [libraryType] disambiguates Plex's per-type sort
  /// lists (movie vs show).
  Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType});

  /// First-character bucket counts for the alpha-jump bar in the library
  /// browse view. Plex returns real counts from
  /// `/library/sections/{id}/firstCharacter` (filterable); Jellyfin has no
  /// equivalent endpoint and synthesises a 27-letter alphabet so the bar
  /// can act as a name-prefix filter (`size: 1` per entry).
  Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters});

  /// Queue a metadata refresh for [libraryId]. The id is the backend-native
  /// library identifier (Plex section id / Jellyfin view item id, both
  /// surfaced via [MediaLibrary.key]). Plex hits
  /// `/library/sections/{id}/refresh?force=1`; Jellyfin posts to
  /// `/Items/{id}/Refresh` with `metadataRefreshMode=FullRefresh` (the
  /// library view is itself a Jellyfin item, and refresh recurses into its
  /// children).
  Future<void> refreshLibraryMetadata(String libraryId);

  /// Fetch a single item by its backend-opaque id. An online HTTP 404 returns
  /// `null`; every other HTTP status remains an error. Implementations may use
  /// cached metadata while explicitly offline or after a classified transient
  /// transport failure, but must not turn other online failures into stale
  /// success.
  Future<MediaItem?> fetchItem(String id);

  /// Fetch a single item *and* its on-deck episode (the next unwatched /
  /// in-progress episode) in one round-trip when the backend supports it.
  /// The item follows [fetchItem]'s error contract: an online HTTP 404 returns
  /// both nullable fields as `null`, while every other HTTP status throws.
  /// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
  /// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
  /// leaving callers to fetch on-deck separately if they need it.
  Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id);

  /// Direct children of [parentId] — episodes of a season, seasons of a
  /// show, tracks of an album, items of a collection.
  Future<List<MediaItem>> fetchChildren(String parentId);

  /// Top-level rows of a library in folder-browsing mode. Directory rows come
  /// back as [MediaKind.folder] (Plex additionally stamps
  /// [MediaItem.backendFolderKey]). [onPage] surfaces accumulated items after
  /// each intermediate page on backends that paginate (Jellyfin); single-shot
  /// backends (Plex) never call it.
  Future<List<MediaItem>> fetchLibraryFolders(String libraryId, {void Function(List<MediaItem> itemsSoFar)? onPage});

  /// Children of a [MediaKind.folder] row from [fetchLibraryFolders] /
  /// [fetchFolderChildren] — or, on Jellyfin, of a show/season row, which act
  /// as expandable folders in folder browsing. Same [onPage] semantics as
  /// [fetchLibraryFolders].
  Future<List<MediaItem>> fetchFolderChildren(
    MediaItem folder, {
    String? libraryId,
    String? libraryTitle,
    void Function(List<MediaItem> itemsSoFar)? onPage,
  });

  /// Page through direct children of [parentId]. Unlike
  /// [fetchPlayableDescendantsPage], this preserves container shape and is used
  /// for large season episode lists where the children endpoint is the correct
  /// backend primitive.
  Future<LibraryPage<MediaItem>> fetchChildrenPage(String parentId, {int? start, int? size, AbortController? abort});

  /// Page through playable descendants of [parentId]. Used by flattened show /
  /// season detail views so episodes can render before the full list has loaded.
  /// [fetchPlayableDescendants] remains the complete-list helper for
  /// playback/download/sync paths.
  Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
    String parentId, {
    int? start,
    int? size,
    AbortController? abort,
  });

  /// Playable descendants of [parentId] in one server-side query — for a
  /// show this returns every episode across every season; for a season the
  /// same episodes as [fetchChildren]; on Jellyfin a collection/playlist
  /// expands to its Movies + Episodes (Series containers are skipped).
  /// Used by playback launch (Jellyfin only — Plex routes containers
  /// through `/playQueues`) and by bulk download/sync (both backends) so
  /// neither has to walk show → seasons → episodes itself, and neither
  /// inherits a per-page Limit cap.
  ///
  /// Plex hits `/library/metadata/{id}/grandchildren` (the only endpoint
  /// the server will one-shot for both show and season, and the
  /// recommended path for `skipChildren=true` mini-series); Jellyfin hits
  /// `/Items?ParentId={id}&Recursive=true&IncludeItemTypes=Movie,Episode`.
  /// Plex's choice means a *collection* ratingKey is not currently
  /// supported (no Plex consumer needs that today); add a kind-specific
  /// branch if/when one does.
  Future<List<MediaItem>> fetchPlayableDescendants(String parentId);

  /// All episodes of a series across every season in playback order. Used
  /// to build a local navigation queue for backends without server-side
  /// queues and as a fallback when a server-side queue could not be created.
  /// Returns null only when a backend cannot supply a client-side queue;
  /// an empty list means the series itself was empty.
  Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId);

  /// Albums credited to [artist], newest first. Plex filters album rows in
  /// the artist's music section so release formats omitted from
  /// `/library/metadata/{id}/children` remain visible. Jellyfin links albums
  /// to artists via tags and queries
  /// `/Items?AlbumArtistIds={id}&IncludeItemTypes=MusicAlbum`.
  Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist);

  /// Tracks of album [albumId] in disc/track order. Plex:
  /// `/library/metadata/{id}/children`; Jellyfin:
  /// `/Items?AlbumIds={id}&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber`
  /// (AlbumIds rather than ParentId so tag-based albums whose files share one
  /// physical folder still resolve).
  Future<List<MediaItem>> fetchAlbumTracks(String albumId);

  /// Server-built "instant mix" / radio track list seeded from [itemId]
  /// (track, album, artist, or playlist). Jellyfin:
  /// `/Items/{id}/InstantMix`; Plex: a station play queue
  /// (`POST /playQueues?type=audio&uri=...station...`), consumed here as a
  /// plain track list — music playback is queue-managed client-side on both
  /// backends. Gated by [ServerCapabilities.instantMix].
  Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100});

  /// Lyrics for [track], or `null` when the server has none. Jellyfin:
  /// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a
  /// sidecar-lyrics track stream (`streamType 4`) fetched from
  /// `/library/streams/{id}` and parsed from LRC. Synced-ness is per
  /// [Lyrics.synced]; per-track absence is the runtime gate.
  Future<Lyrics?> fetchLyrics(MediaItem track);

  /// Free-text search across the user's libraries. [limit] is a per-request
  /// candidate budget; a backend may supplement omitted media categories and
  /// return more candidates for cross-server ranking. [abort] cancels every
  /// backend request owned by this search pass.
  Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});

  /// Items the user has started but not finished. Plex calls this "On Deck"
  /// internally; the neutral name matches the Continue Watching UI surface.
  Future<List<MediaItem>> fetchContinueWatching({int? count = 20});

  /// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin
  /// synthesizes `Latest` plus optional `Resume` + `NextUp`).
  Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true});

  /// Hubs scoped to a single library section. [libraryName] is baked into
  /// the title of synthetic hubs (Jellyfin) so per-library "Recently Added"
  /// / "Next Up" hubs aren't all identically named on the home screen.
  /// [includePlaybackHubs] lets surfaces that already render Continue
  /// Watching skip duplicate playback rows. [libraryKind] lets backends avoid
  /// irrelevant expensive probes, e.g. Jellyfin `NextUp` for movie libraries.
  Future<List<MediaHub>> fetchLibraryHubs(
    String libraryId, {
    required String libraryName,
    int limit = defaultHubPreviewLimit,
    bool includePlaybackHubs = true,
    MediaKind? libraryKind,
  });

  /// "More like this" recommendations for [id].
  Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10});

  /// Playable extras attached to [id] (trailers, featurettes, deleted scenes,
  /// behind-the-scenes clips). Backends return only items that can be opened by
  /// the normal video playback flow; external/remote trailer URLs are out of
  /// scope for this neutral surface.
  Future<List<MediaItem>> fetchExtras(String id);

  /// Media featuring a specific person/actor.
  Future<List<MediaItem>> fetchPersonMedia(String personId);

  /// Page through media featuring a specific person/actor.
  Future<LibraryPage<MediaItem>> fetchPersonMediaPage(String personId, {int? start, int? size, AbortController? abort});

  /// Page through items in [hubId] when the hub previewed only the first N
  /// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same
  /// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query
  /// (Latest / Resume / NextUp) without the preview limit.
  Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit});

  /// Page through expanded hub content where the backend exposes a paged
  /// endpoint. Backends without true hub pagination may return a single page.
  Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(String hubId, {int? start, int? size, AbortController? abort});

  /// Mark [item] as watched. Transport only: no [WatchStateEvent] is emitted
  /// here — UI surfaces go through `WatchActions`, which owns the single
  /// emission (and tracker fan-out); the offline sync replay calls this
  /// directly precisely because the event already fired when the action was
  /// queued.
  Future<void> markWatched(MediaItem item);

  /// Mark [item] as unwatched. Transport only — see [markWatched].
  Future<void> markUnwatched(MediaItem item);

  /// Hide an item from Continue Watching without changing watched status or
  /// progress. Only call when [capabilities.continueWatchingRemoval] is true;
  /// unsupported backends throw [UnsupportedError].
  Future<void> removeFromContinueWatching(MediaItem item);

  /// Rate the item on a 0–10 scale. Backends without numeric ratings
  /// (Jellyfin) collapse to like/dislike — see [ServerCapabilities.numericUserRating].
  /// Throws [MediaServerHttpException] on failure, mirroring [markWatched] /
  /// [markUnwatched] / [removeFromContinueWatching] — callers wrap the
  /// awaited call in `try/catch` and surface a snackbar on the catch arm.
  Future<void> rate(MediaItem item, double rating);

  /// Set or clear the per-user favorite flag ("heart") for [item]. Only call
  /// when [ServerCapabilities.userFavorites] is true; unsupported backends
  /// throw [UnsupportedError]. Throws [MediaServerHttpException] on failure.
  Future<void> setFavorite(MediaItem item, bool isFavorite);

  Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart});

  /// Page through server playlists. Used by the library Playlists tab so large
  /// servers can render incrementally; [fetchPlaylists] remains the complete
  /// list helper for dialogs and bulk operations.
  Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
    String playlistType = 'video',
    bool? smart,
    int? start,
    int? size,
    AbortController? abort,
  });

  /// Metadata only — items are fetched via [fetchPlaylistItems].
  Future<MediaPlaylist?> fetchPlaylistMetadata(String id);

  Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100});

  /// Page through items in [id]. Backends preserve playlist order and include
  /// per-playlist item ids where the server exposes them.
  Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort});

  /// Create a new playlist seeded with [items]. Returns the created playlist
  /// when it can be recovered from an accepted response, or `null` when that
  /// response contains no usable created playlist. Request failures throw.
  /// Plex builds a metadata URI from the item ids; Jellyfin posts
  /// `Ids=<comma-joined>`.
  Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items});

  /// Append [items] to an existing playlist. Returns `true` on success.
  Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items});

  /// Delete [playlist] from the server. Returns `true` on success.
  Future<bool> deletePlaylist(MediaPlaylist playlist);

  /// Move an item to a new position within a playlist. The item must have come
  /// from this client's [fetchPlaylistItems] (i.e. carry a per-playlist id).
  ///
  /// [newIndex]  - 0-based target position after the move
  /// [afterItem] - the item that should sit immediately before [item] after
  ///   the move, or null when [newIndex] == 0. Plex uses this to derive its
  ///   `?after=` query param; Jellyfin ignores it (it takes an absolute index).
  ///
  /// Returns `false` (without throwing) if [item] is from the wrong backend
  /// or is missing its per-playlist id — callers should surface a snackbar.
  Future<bool> movePlaylistItem({
    required String playlistId,
    required MediaItem item,
    required int newIndex,
    required MediaItem? afterItem,
  });

  /// Remove [item] from the playlist [playlistId]. See [movePlaylistItem] for
  /// the same caveats about backend tagging and the per-playlist id.
  Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item});

  /// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`;
  /// Jellyfin resolves its top-level `boxsets` view and walks that root in
  /// bounded pages.
  /// Each result carries `kind == MediaKind.collection`.
  Future<List<MediaItem>> fetchCollections(String libraryId);

  /// Page through collections in [libraryId]. Used by the library Collections
  /// tab so large Jellyfin/Plex servers can render incrementally while the
  /// user scrolls. [fetchCollections] remains the complete-list helper for
  /// dialogs and bulk operations.
  Future<LibraryPage<MediaItem>> fetchCollectionsPage(
    String libraryId, {
    int? start,
    int? size,
    AbortController? abort,
  });

  /// Page through items in [collectionId]. Plex paginates server-side via
  /// `/library/collections/{id}/children`; Jellyfin uses `/Items` with
  /// `ParentId`, `StartIndex`, and `Limit`. Callers can rely on
  /// [LibraryPage.totalCount] either way.
  Future<LibraryPage<MediaItem>> fetchCollectionPage(
    String collectionId, {
    int? start,
    int? size,
    AbortController? abort,
    String? libraryId,
    String? libraryTitle,
  });

  /// Create a new collection in [libraryId] seeded with [items]. Returns the
  /// created collection id when it can be recovered from an accepted response,
  /// or `null` when that response contains no usable id. Request failures
  /// throw. [itemKind] is only used by Plex (it disambiguates the section type
  /// — movie/show/season/episode); Jellyfin ignores it.
  Future<String?> createCollection({
    required String libraryId,
    required String title,
    required List<MediaItem> items,
    MediaKind? itemKind,
  });

  /// Append [items] to an existing collection.
  Future<bool> addToCollection({required String collectionId, required List<MediaItem> items});

  /// Remove a single [item] from [collectionId].
  Future<bool> removeFromCollection({required String collectionId, required MediaItem item});

  /// Delete a collection from the server. The collection is passed as a
  /// [MediaItem] (kind == [MediaKind.collection]) so the implementation can
  /// read [MediaItem.libraryId] for backends that need it (Plex).
  Future<bool> deleteCollection(MediaItem collection);

  /// Permanently delete [item] from the library.
  Future<bool> deleteMediaItem(MediaItem item);

  /// File info (codec / resolution / bitrate / file path) for [item].
  /// Plex round-trips `/library/metadata/{id}` for the full set; Jellyfin
  /// reads inline `MediaSources` for the subset it has. Returns `null` if
  /// the server has no info to show.
  Future<MediaFileInfo?> getFileInfo(MediaItem item);

  /// Resolve a backend-relative thumbnail path to a fully-qualified URL ready
  /// for cached image providers. Returns an empty string for null/empty
  /// inputs.
  ///
  /// When [width]/[height] are provided, the implementation should request
  /// a server-side resize: Plex builds a `/photo/:/transcode` URL; Jellyfin
  /// appends `MaxWidth`/`MaxHeight` to the image endpoint.
  ///
  /// [cover] picks how the requested box is interpreted. The default sizes the
  /// image to *cover* the box — every pixel of a poster/backdrop slot is
  /// filled, at the cost of overshooting on the long axis. Pass `false` for
  /// artwork drawn with [BoxFit.contain] (clear logos), where the overshoot is
  /// decoded and thrown away: a 4313×1035 logo asked for at 1200×360 comes back
  /// 1500×360 covering versus 1200×288 fitting, for ~20-30% more bytes and no
  /// extra rendered detail. Neither mode crops or changes the aspect ratio.
  String thumbnailUrl(String? path, {int? width, int? height, bool cover = true});

  /// Proxy an absolute external image URL through the server's transcoder
  /// (Plex `/photo/:/transcode?url=...`). Backends without a proxy endpoint
  /// (Jellyfin) should return the URL unchanged. Used for EPG provider art
  /// and other off-server images that benefit from re-encoding.
  ///
  /// [cover] carries the same meaning as in [thumbnailUrl].
  String externalImageUrl(String url, {int? width, int? height, bool cover = true});

  /// Headers that must be attached when the player fetches a direct-play
  /// URL from this server. Plex requires `X-Plex-Token` (and identity
  /// headers); Jellyfin embeds its `api_key` in the query string and
  /// returns an empty map. Player code should pass these through to the
  /// engine alongside the URL.
  Map<String, String> get streamHeaders;

  /// External IDs (IMDb / TMDB / TVDB) for [itemId]. Plex hits
  /// `/library/metadata/{id}?includeGuids=1`; Jellyfin reads the inline
  /// `ProviderIds` map. Returns an empty [ExternalIds] when the server
  /// has no external mapping for the item.
  Future<ExternalIds> fetchExternalIds(String itemId);

  /// Reverse lookup: find a library movie/show matching any of [ids].
  /// Both backends search by [title] (narrowed by a ±1 [year] window when
  /// known, with an unfiltered fallback) and verify candidates against
  /// their exact external ids. Plex checks its modern `Guid` array first,
  /// then recognized legacy scalar `guid` formats; its `guid=` field filter
  /// only matches the primary `plex://` guid. Jellyfin checks the inline
  /// `ProviderIds`. False negatives remain possible on differing titles,
  /// but title alone never produces a match. Returns null when this server
  /// has no match or [kind] is not movie/show. Used to match external catalog
  /// items (Explore tab) back to the user's libraries.
  Future<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year});

  /// Chapters and intro/credits markers for [itemId]. Plex returns both in one
  /// round trip; Jellyfin combines item-level chapters with best-effort native
  /// media segments. Implementations may cache.
  Future<PlaybackExtras> fetchPlaybackExtras(
    String itemId, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
    bool forceRefresh = false,
  });

  /// Cache-only [PlaybackExtras] read for [itemId]. Used as the offline
  /// fallback when [fetchPlaybackExtras] cannot reach the network. Returns
  /// `null` when no row is cached or the row carries no chapter/marker
  /// data — callers treat that as "no extras available" without surfacing
  /// an error.
  Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
    String itemId, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
  });

  /// Cache-only [MediaSourceInfo] read for [itemId]. Used by the offline
  /// playback path to recover audio/subtitle track info (track ids, language
  /// codes, displayTitles) without hitting the network. Returns `null` when
  /// the row isn't cached or carries no usable media source.
  Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);

  /// Build a scrub preview source for [item] using [mediaSource]. Plex
  /// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
  /// reader from the trickplay manifest. Returns `null` when scrub
  /// previews aren't available for this item — either because the
  /// backend doesn't advertise the capability, or the per-item inputs
  /// are missing (Plex needs `partId`, Jellyfin needs a non-empty
  /// `trickplayByWidth` map).
  Future<ScrubPreviewSource?> createScrubPreviewSource({required MediaItem item, required MediaSourceInfo mediaSource});

  /// Watched threshold (0.0–1.0). An item is considered "watched" when
  /// `position / duration` crosses this value. Plex reads it from the
  /// server's `LibraryVideoPlayedThreshold` pref; Jellyfin doesn't expose
  /// one and returns a fixed 0.9.
  double get watchedThreshold;

  /// Whether a playback-stopped report past [watchedThreshold] already marks
  /// the item played server-side. When true, in-player auto-scrobble must NOT
  /// also call [markWatched]: the server marks it played from the stop report,
  /// and the extra `/UserPlayedItems` toggle double-scrobbles through
  /// integrations that watch both the played-state change and the
  /// playback-stop — e.g. Jellyfin's Trakt plugin fires once on `TogglePlayed`
  /// and again on `PlayedToCompletion` (#1287). Plex returns false: its
  /// timeline stop doesn't reliably mark watched without an active play
  /// session, so the explicit call is still required.
  bool get marksWatchedOnPlaybackStopped;

  /// First playback signal for [itemId]. Plex sends a `/:/timeline?state=playing`
  /// heartbeat; Jellyfin opens a `/Sessions/Playing` session row. Subsequent
  /// ticks must call [reportPlaybackProgress] (Jellyfin distinguishes session
  /// open from progress; Plex treats them identically).
  ///
  /// [duration] is the media's total length — passed through to Plex's
  /// timeline param so the server can use it. Jellyfin ignores [duration] but
  /// uses [mediaSourceId], [liveStreamId], and stream indexes for active-session
  /// state. Jellyfin needs [liveStreamId] to close an auto-opened live source.
  Future<void> reportPlaybackStarted({
    required String itemId,
    required Duration position,
    Duration? duration,
    String? playSessionId,
    String? playMethod,
    String? liveStreamId,
    String? mediaSourceId,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
  });

  /// Progress heartbeat after [reportPlaybackStarted]. State is derived from
  /// [isPaused]. Jellyfin persists remembered audio/subtitle choices from the
  /// selected stream indexes on this call.
  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,
  });

  /// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes
  /// the session row. [report] carries semantic metadata such as offline
  /// replay timing without leaking backend-specific wire parameter names.
  Future<void> reportPlaybackStopped({
    required String itemId,
    required Duration position,
    Duration? duration,
    String? playSessionId,
    String? liveStreamId,
    String? mediaSourceId,
    PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
  });

  /// Resolve the video URL, media info, and external subtitle list for
  /// playback. Backends own the per-backend particulars: Plex runs the
  /// transcode-decision flow for non-original quality; Jellyfin negotiates
  /// both original and non-original playback through PlaybackInfo. Typed
  /// request, cancellation, and malformed-payload failures propagate. Only an
  /// applicable successful decision may select a direct-play fallback.
  /// Unusable successful playback metadata throws [PlaybackException].
  ///
  /// Offline-file substitution is handled centrally in
  /// `PlaybackInitializationService` — backends always produce online
  /// metadata, even when the caller intends to play a downloaded copy.
  Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options);

  /// Backend-neutral live-TV operations. Always returns a wrapper; consult
  /// [LiveTvSupport.isAvailable] to find out whether the server actually
  /// has live TV configured before calling other methods. Recording and DVR
  /// administration are available through [MediaServerClientLiveTv.liveTvDvr].
  LiveTvSupport get liveTv;

  /// Resolve the download URL for [item]'s primary video file along with
  /// any external subtitle tracks that should be saved alongside it.
  ///
  /// [mediaIndex] selects among multiple media versions when an item has them.
  ///
  /// A successful applicable response may contain no URL. Request,
  /// cancellation, and malformed-payload failures throw rather than returning
  /// a partial resolution.
  Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});

  /// The artwork files the download pipeline should persist for [item] so
  /// the offline UI can render its poster, clear logo, and background art.
  /// Each entry pairs the absolute URL with a stable `localKey` the
  /// storage service hashes to deduplicate across items that share blobs.
  List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item);

  /// Resolve a fully-qualified URL the OS-level external player (VLC, Infuse,
  /// MX Player, etc.) can fetch directly. Plex builds this from the chosen
  /// media version's part path; Jellyfin returns its `/Videos/{id}/stream`
  /// endpoint with `Static=true` so transcoding is bypassed. Returns null only
  /// when a successful response has no playable URL for the item. Request,
  /// cancellation, and malformed-payload failures throw.
  ///
  /// Deliberately separate from the in-app playback funnel
  /// (`PlaybackSourceResolver`): external players can't send custom headers,
  /// so the URL must be self-contained (token in the query string), and
  /// there's no session/transcode negotiation to carry. Likewise their
  /// progress reporting is a one-shot started/stopped pair in
  /// `ExternalPlayerService` — an external app exposes no live position
  /// stream for the in-player tracker to follow.
  Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
}

/// Optional interface for backends whose public server id is not specific
/// enough for user-scoped local state.
abstract interface class ScopedMediaServerClient {
  String get scopedServerId;
}

extension MediaServerClientScope on MediaServerClient {
  /// Internal cache/sync namespace. Most backends use [serverId]; Jellyfin
  /// overrides this with its compound `{machineId}/{userId}` connection id so
  /// per-user `UserData` and queued progress never bleed across profiles.
  String get cacheServerId => switch (this) {
    ScopedMediaServerClient(:final scopedServerId) => scopedServerId,
    _ => serverId,
  };

  /// Mark [item] watched because it crossed [watchedThreshold] during playback,
  /// when a playback-stopped report is/was also sent for the same playback.
  /// Backends that mark played from the stop report
  /// ([marksWatchedOnPlaybackStopped]) skip the server call — issuing
  /// [markWatched] too would double-scrobble via the Jellyfin Trakt plugin
  /// (#1287). The single local event emitted here keeps the UI and Plezy's
  /// own Trakt sync (which key on `watched` events, not progress) in sync;
  /// the stop report syncs the server.
  Future<void> markWatchedFromPlaybackStop(MediaItem item) async {
    if (!marksWatchedOnPlaybackStopped) {
      await markWatched(item);
    }
    WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
  }
}

extension MediaServerClientLiveTv on MediaServerClient {
  /// Optional recording/admin adapter, gated by the backend capability flag.
  /// Call sites use this rather than assuming every Live TV backend supports
  /// Plex's DVR surface.
  LiveTvDvrSupport? get liveTvDvr => capabilities.liveTvDvr ? liveTv.dvr : null;
}

/// Optional capability for clients that can fetch a season's episodes without
/// listing generic children. Jellyfin uses this to avoid mixing local extras or
/// missing/virtual placeholders into normal season episode rails.
abstract interface class SeasonEpisodePagingClient {
  Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
    String seriesId,
    String seasonId, {
    int? start,
    int? size,
    AbortController? abort,
  });
}

/// Cache-aware fetch helpers shared by both backends so the offline-first /
/// network-then-cache pattern lives in one place.
///
/// Originally a Plex-only inline helper; lifted into a mixin so [JellyfinClient]
/// can stop reimplementing it (and gets the missing "fall back to cache on
/// non-network errors" branch). Mixed onto concrete [MediaServerClient]
/// implementations — both clients use `implements MediaServerClient` so a
/// shared base class isn't an option, but a `mixin on MediaServerClient` is.
mixin MediaServerCacheMixin implements MediaServerClient {
  /// Fetch with cache fallback: offline → cached only; online → try network,
  /// cache the result, then fall back to cached data on an accepted error.
  ///
  /// When [shouldFallback] is omitted, every error remains eligible for cache
  /// fallback. A supplied selector narrows that policy; rejected errors are
  /// rethrown unchanged before the fallback cache is read. Response-parser
  /// failures can therefore be propagated instead of hidden by stale data.
  ///
  /// Returns `null` when offline mode is on and no cached row exists, or
  /// when both network and cache come up empty.
  Future<T?> fetchWithCacheFallback<T>({
    required String cacheKey,
    required Future<MediaServerResponse> Function() networkCall,
    required T? Function(dynamic cachedData) parseCache,
    required T? Function(MediaServerResponse response) parseResponse,
    bool Function(Object error)? shouldFallback,
    bool cacheResponse = true,
  }) async {
    final cacheScope = ServerId(cacheServerId);
    if (isOfflineMode) {
      final cached = await cache.get(cacheScope, cacheKey);
      if (cached != null) return parseCache(cached);
      return null;
    }
    try {
      final response = await networkCall();
      throwIfHttpError(response);
      final parsed = parseResponse(response);
      if (cacheResponse) {
        await _putCacheResponse(cacheScope, cacheKey, response.data);
      }
      return parsed;
    } catch (e) {
      if (shouldFallback != null && !shouldFallback(e)) rethrow;
      appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
      final cached = await cache.get(cacheScope, cacheKey);
      if (cached != null) return parseCache(cached);
      rethrow;
    }
  }

  /// Cache-first fetch: serve from cache when available, hit the network
  /// only on miss. Use when freshness is non-critical and prior fetches are
  /// likely to have populated the cache (e.g. playback after the detail
  /// screen pre-warmed the row).
  ///
  /// [cacheScope] must be captured from the same request context as
  /// [networkCall]. The cache lookup may yield before a miss is known, so
  /// sampling a live profile inside [networkCall] can cross profile identities.
  Future<T?> fetchWithCacheFirst<T>({
    required ServerId cacheScope,
    required String cacheKey,
    required Future<MediaServerResponse> Function() networkCall,
    required T? Function(dynamic cachedData) parseCache,
    required T? Function(MediaServerResponse response) parseResponse,
    bool cacheResponse = true,
  }) async {
    final cached = await cache.get(cacheScope, cacheKey);
    if (cached != null) return parseCache(cached);
    if (isOfflineMode) return null;
    final response = await networkCall();
    throwIfHttpError(response);
    final parsed = parseResponse(response);
    if (cacheResponse) {
      await _putCacheResponse(cacheScope, cacheKey, response.data);
    }
    return parsed;
  }

  Future<void> _putCacheResponse(ServerId cacheScope, String cacheKey, dynamic data) async {
    try {
      if (data is Map<String, dynamic>) {
        await cache.put(cacheScope, cacheKey, data);
      } else if (data != null) {
        appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}');
      }
    } catch (e, st) {
      appLogger.w('Cache write failed for $cacheKey', error: e, stackTrace: st);
    }
  }
}
