import 'dart:async';

import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';

import '../connection/connection.dart';
import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import 'favorite_channels_repository.dart';
import 'file_info_parser.dart';
import 'library_query_translator.dart';
import '../media/media_filter.dart';
import '../media/live_tv_support.dart';
import '../media/media_backend.dart';
import '../media/media_file_info.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../media/server_capabilities.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/external_ids.dart';
import '../utils/media_server_http_client.dart';
import '../utils/resolution_label.dart';
import '../utils/track_label_builder.dart';
import '../utils/watch_state_notifier.dart';
import '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../utils/jellyfin_time.dart';
import 'jellyfin_auth_header.dart';
import '../media/download_resolution.dart';
import 'api_cache.dart';
import 'download_artwork_helpers.dart';
import 'jellyfin_api_cache.dart';
import 'jellyfin_mappers.dart';
import 'jellyfin_media_info_mapper.dart';
import 'jellyfin_playback_bundle.dart';
import 'jellyfin_playback_urls.dart';
import 'jellyfin_trickplay_service.dart';
import 'playback_initialization_types.dart';
import 'scrub_preview_source.dart';
import '../mpv/mpv.dart';

part 'jellyfin_client/parts/browse.dart';
part 'jellyfin_client/parts/playback.dart';
part 'jellyfin_client/parts/watch_state.dart';
part 'jellyfin_client/parts/playlists.dart';
part 'jellyfin_client/parts/collections.dart';
part 'jellyfin_client/parts/file_info.dart';
part 'jellyfin_client/parts/live_tv.dart';
part 'jellyfin_client/parts/images_downloads.dart';

/// [MediaServerClient] over a Jellyfin server.
///
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
/// HTTP wrapper is backend-agnostic despite the name). Implements the full
/// neutral interface: browse, watch state, playlist read, playback session
/// reporting, and live TV via [LiveTvSupport].
class JellyfinClient
    with
        MediaServerCacheMixin,
        _JellyfinBrowseMethods,
        _JellyfinPlaybackMethods,
        _JellyfinWatchStateMethods,
        _JellyfinPlaylistMethods,
        _JellyfinCollectionMethods,
        _JellyfinFileInfoMethods,
        _JellyfinLiveTvMethods,
        _JellyfinImageDownloadMethods
    implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable {
  JellyfinClient._({
    required JellyfinConnection connection,
    required MediaServerHttpClient http,
    FavoriteChannelsRepository? favoritesRepository,
  }) : _connection = connection,
       _http = http,
       _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();

  /// Build a fully-initialised [JellyfinClient]. The factory probes
  /// `/System/Info/Public` to confirm the server is reachable; callers can
  /// catch a [MediaServerHttpException] to surface a clean "unavailable" UI.
  ///
  /// Sends the full `Authorization: MediaBrowser …, Token="…"` header on
  /// every request — that's what the official Jellyfin SDK (and Findroid by
  /// extension) does. Modern Jellyfin servers behind reverse proxies often
  /// reject requests that only carry the legacy `X-Emby-Token` header,
  /// returning 404 from the proxy or a routing-level handler instead of
  /// 401. We send `X-Emby-Token` too for old Emby/Jellyfin builds.
  static Future<JellyfinClient> create(
    JellyfinConnection connection, {
    FavoriteChannelsRepository? favoritesRepository,
  }) async {
    // Register before any HTTP traffic so the very first probe URL doesn't
    // leak the token verbatim. `LogRedactionManager.redact()` also has
    // pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the
    // `Authorization: MediaBrowser ... Token="..."` header.
    LogRedactionManager.registerServer(connection.baseUrl, connection.accessToken);
    String version = '1.0';
    try {
      final pkg = await PackageInfo.fromPlatform();
      if (pkg.version.isNotEmpty) version = pkg.version;
    } catch (_) {
      // Tests / non-platform contexts — keep the fallback version.
    }
    final authHeader = buildJellyfinAuthHeader(
      clientName: 'Plezy',
      clientVersion: version,
      deviceName: 'Plezy',
      deviceId: connection.deviceId,
      accessToken: connection.accessToken,
    );
    final headers = {
      'Authorization': authHeader,
      'X-Emby-Token': connection.accessToken,
      'Accept': 'application/json',
      // Jellyfin's session reporting endpoints (`/Sessions/Playing*`) reject
      // any content-type carrying a `; charset=utf-8` suffix with 415 —
      // pin to the SDK's exact wire format up-front.
      'Content-Type': 'application/json',
    };
    final http = MediaServerHttpClient(baseUrl: connection.baseUrl, defaultHeaders: headers);
    final client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository);
    return client;
  }

  /// Test-only factory that injects an [http.Client] so URL-builder tests
  /// can capture the request URI without spinning up a real Jellyfin server.
  @visibleForTesting
  static JellyfinClient forTesting({
    required JellyfinConnection connection,
    required http.Client httpClient,
    FavoriteChannelsRepository? favoritesRepository,
  }) {
    final mediaHttp = MediaServerHttpClient(
      baseUrl: connection.baseUrl,
      defaultHeaders: {'X-Emby-Token': connection.accessToken, 'Accept': 'application/json'},
      client: httpClient,
    );
    return JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository);
  }

  /// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the
  /// `/Users/Me` probe response — admin status changed server-side should
  /// propagate without forcing the user to re-auth.
  JellyfinConnection _connection;
  @override
  JellyfinConnection get connection => _connection;
  @override
  final MediaServerHttpClient _http;
  final FavoriteChannelsRepository _favoritesRepository;
  bool _offlineMode = false;

  /// Fired when the live `connection` snapshot diverges from the cached one
  /// (currently only on admin-status change). [MultiServerManager] uses this
  /// to re-broadcast status so admin-gated UI rebuilds.
  FutureOr<void> Function(JellyfinConnection connection)? onConnectionUpdated;

  /// Per-collection cache for [fetchCollectionPage]. Jellyfin's API doesn't
  /// paginate collection children, so the first call materialises the full
  /// list and subsequent paged calls slice from the same in-memory copy.
  /// Lifetime is the client's lifetime — collections rarely change in a
  /// single session, and a stale-but-bounded list is acceptable.
  @override
  final Map<String, List<MediaItem>> _collectionItemsCache = {};

  /// Read-only view of the headers attached to every outgoing request.
  /// Test-only entry point for asserting the SDK-style `MediaBrowser`
  /// Authorization shape — Findroid (and the official SDK) sends the same
  /// thing.
  @visibleForTesting
  Map<String, String> get defaultHeadersForTesting => Map.unmodifiable(_http.defaultHeaders);

  /// Image-path absolutizer scoped to this client's [connection]. Shared with
  /// [JellyfinApiCache] (which constructs its own from the connection row's
  /// `configJson`) so cache reads carry the same absolute URLs as live API
  /// reads — see [JellyfinImageAbsolutizer].
  JellyfinImageAbsolutizer get _absolutizer =>
      JellyfinImageAbsolutizer(baseUrl: connection.baseUrl, accessToken: connection.accessToken);

  @override
  String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path);

  @override
  MediaItem? _mapItem(Map<String, dynamic> json) =>
      JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer);

  @override
  List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items) =>
      items.map(_mapItem).whereType<MediaItem>().toList();

  @override
  String get serverId => connection.serverMachineId;

  @override
  String get scopedServerId => connection.id;

  @override
  String? get serverName => connection.serverName;

  @override
  MediaBackend get backend => MediaBackend.jellyfin;

  @override
  ServerCapabilities get capabilities => ServerCapabilities.jellyfin;

  /// Jellyfin doesn't expose a per-server played-threshold pref, so we mirror
  /// Plex's default of 90%.
  @override
  double get watchedThreshold => 0.9;

  @override
  void close() => _http.close();

  @override
  Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) =>
      _http.closeGracefully(drainTimeout: drainTimeout);

  /// Reachable *and* token-valid. We probe `/Users/Me` (auth-required)
  /// rather than `/System/Info/Public` so a revoked token surfaces as
  /// unhealthy on the very next sweep, instead of waiting for the first
  /// real call to 401.
  ///
  /// Side-effect: when the response body carries a fresh
  /// `Policy.IsAdministrator` that differs from the cached one, refresh the
  /// connection so admin-gated UI catches the server-side change without
  /// requiring re-auth (see [onConnectionUpdated]).
  ///
  /// 401/403 surfaces as [HealthStatus.authError] so the manager can
  /// distinguish a revoked token from a generic transport failure.
  @override
  Future<HealthStatus> checkHealth() async {
    try {
      final response = await _http.get('/Users/Me', timeout: const Duration(seconds: 8));
      final ok = response.statusCode >= 200 && response.statusCode < 300;
      if (ok) {
        final data = response.data;
        if (data is Map<String, dynamic>) {
          final policy = data['Policy'];
          if (policy is Map<String, dynamic>) {
            final fresh = policy['IsAdministrator'] as bool?;
            if (fresh != null && fresh != _connection.isAdministrator) {
              _connection = _connection.copyWith(isAdministrator: fresh);
              final listener = onConnectionUpdated;
              if (listener != null) {
                try {
                  await Future.sync(() => listener(_connection));
                } catch (e, st) {
                  appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
                }
              }
            }
          }
        }
        return HealthStatus.online;
      }
      if (response.statusCode == 401 || response.statusCode == 403) {
        return HealthStatus.authError;
      }
      return HealthStatus.offline;
    } on MediaServerHttpException catch (e) {
      if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
      return HealthStatus.offline;
    } catch (_) {
      return HealthStatus.offline;
    }
  }

  @override
  Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;

  /// Fetch the authenticated user's `Configuration` (audio/subtitle language
  /// prefs, auto-select flag) so the player can apply per-user defaults.
  /// Returns null on transport failures — caller treats as "no preference".
  Future<JellyfinUserProfile?> fetchUserProfile() async {
    try {
      final response = await _http.get('/Users/Me');
      throwIfHttpError(response);
      final data = response.data;
      if (data is! Map<String, dynamic>) return null;
      return JellyfinUserProfile.fromUserDto(data);
    } catch (e, st) {
      appLogger.w('JellyfinClient.fetchUserProfile failed', error: e, stackTrace: st);
      return null;
    }
  }

  @override
  Future<String?> getMachineIdentifier() async {
    try {
      final response = await _http.get('/System/Info/Public');
      throwIfHttpError(response);
      final data = response.data;
      if (data is Map<String, dynamic>) {
        return data['Id'] as String?;
      }
      return connection.serverMachineId;
    } catch (e) {
      appLogger.w('JellyfinClient: getMachineIdentifier failed: $e');
      return connection.serverMachineId;
    }
  }

  @override
  bool get isOfflineMode => _offlineMode;

  @override
  void setOfflineMode(bool offline) {
    _offlineMode = offline;
  }

  /// Expose the Jellyfin cache through the [MediaServerClient] interface so
  /// the shared `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers
  /// route through the correct backend's cache substrate.
  @override
  ApiCache get cache => JellyfinApiCache.instance;
}
