import 'dart:async';
import '../media/ids.dart';

import 'package:flutter/material.dart';

import 'package:provider/provider.dart';

import '../media/media_item.dart';
import '../media/media_version.dart';
import '../media/media_version_preference.dart';
import '../mpv/mpv.dart';
import '../models/transcode_quality_preset.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/watch_state_store.dart';
import '../watch_together/providers/watch_together_provider.dart';
import '../screens/video_player_screen.dart';
import '../services/external_player_service.dart';
import '../services/local_playback_history.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/settings_service.dart';
import 'app_logger.dart';
import 'global_key_utils.dart';
import 'platform_detector.dart';

const String kVideoPlayerRouteName = '/video_player';

/// The route contract shared by VOD and Live TV playback.
///
/// The stable route name drives player lifecycle observation, while the
/// opaque zero-duration route prevents the underlying detail screen flashing
/// during player startup and teardown.
PageRouteBuilder<bool> buildVideoPlayerRoute({required WidgetBuilder builder}) {
  return PageRouteBuilder<bool>(
    settings: const RouteSettings(name: kVideoPlayerRouteName),
    pageBuilder: (context, _, _) => builder(context),
    transitionDuration: Duration.zero,
    reverseTransitionDuration: Duration.zero,
  );
}

enum VideoPlayerRouteKind { vod, liveTv }

@immutable
final class VideoPlayerLaunchIdentity {
  VideoPlayerLaunchIdentity({
    required MediaItem metadata,
    required this.mediaIndex,
    required String? selectedMediaSourceId,
    required this.selectedQualityPreset,
    required this.isOffline,
    required this.routeKind,
  }) : globalKey = metadata.globalKey,
       mediaSourceId = _normalizeMediaSourceId(selectedMediaSourceId);

  final String globalKey;
  final int mediaIndex;
  final String? mediaSourceId;
  final TranscodeQualityPreset? selectedQualityPreset;
  final bool isOffline;
  final VideoPlayerRouteKind routeKind;

  static String? _normalizeMediaSourceId(String? mediaSourceId) {
    if (mediaSourceId == null || mediaSourceId.trim().isEmpty) return null;
    return mediaSourceId;
  }

  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is VideoPlayerLaunchIdentity &&
            other.globalKey == globalKey &&
            other.mediaIndex == mediaIndex &&
            other.mediaSourceId == mediaSourceId &&
            other.selectedQualityPreset == selectedQualityPreset &&
            other.isOffline == isOffline &&
            other.routeKind == routeKind;
  }

  @override
  int get hashCode => Object.hash(globalKey, mediaIndex, mediaSourceId, selectedQualityPreset, isOffline, routeKind);
}

class VideoPlayerNavigationInFlightGuard {
  final Set<VideoPlayerLaunchIdentity> _identities = <VideoPlayerLaunchIdentity>{};

  bool tryStart(VideoPlayerLaunchIdentity identity) => _identities.add(identity);

  void finish(VideoPlayerLaunchIdentity identity) => _identities.remove(identity);
}

class VideoPlayerActiveRouteGuard {
  Object? _owner;
  VideoPlayerLaunchIdentity? _identity;

  String? get activeGlobalKey => _identity?.globalKey;

  VideoPlayerLaunchIdentity? identityFor(Object owner) => identical(_owner, owner) ? _identity : null;

  bool blocks(VideoPlayerLaunchIdentity identity) => _identity == identity;

  void activate(Object owner, VideoPlayerLaunchIdentity identity) {
    _owner = owner;
    _identity = identity;
  }

  bool update(Object owner, VideoPlayerLaunchIdentity identity) {
    if (!identical(_owner, owner)) return false;
    _identity = identity;
    return true;
  }

  bool clear(Object owner) {
    if (!identical(_owner, owner)) return false;
    _owner = null;
    _identity = null;
    return true;
  }
}

final _videoPlayerNavigationInFlightGuard = VideoPlayerNavigationInFlightGuard();

class WatchTogetherPlaybackNavigationException implements Exception {
  final String message;

  const WatchTogetherPlaybackNavigationException(this.message);

  @override
  String toString() => message;
}

/// Series (keyed by grandparent) or standalone-item key under
/// [SettingsService.mediaVersionPreferences], scoped by server — raw Plex
/// rating keys are small integers that can collide across servers.
String _mediaVersionPreferenceKey(MediaItem metadata) {
  final serverId = serverIdOrNull(metadata.serverId);
  final id = metadata.grandparentId ?? metadata.id;
  return serverId != null ? buildGlobalKey(serverId, id) : id;
}

/// Key entries were stored under before server scoping. Reads fall back to
/// it; writes migrate it to the scoped key.
String _legacyMediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id;

/// Entry cap for [SettingsService.mediaVersionPreferences]; oldest entries
/// (by write time, legacy entries first) are evicted past it.
const _maxMediaVersionPreferences = 500;

/// Saved media-version preference for [metadata]'s series/movie, or null when
/// none is stored. Shared by launch navigation and in-player version
/// switching so reads and writes can't drift onto different keys.
Future<MediaVersionPreference?> savedMediaVersionPreferenceFor(MediaItem metadata) async {
  try {
    final settingsService = await SettingsService.getInstance();
    final prefs = settingsService.read(SettingsService.mediaVersionPreferences);
    return prefs[_mediaVersionPreferenceKey(metadata)] ?? prefs[_legacyMediaVersionPreferenceKey(metadata)];
  } catch (_) {
    return null;
  }
}

/// Persist the version at [index] in [versions] as the preferred media
/// version for [metadata]'s series/movie. Callers are explicit-selection
/// sites only — plain plays and backend fallbacks must not write, so a
/// server-side clamp can't silently overwrite the user's choice.
Future<void> saveMediaVersionPreferenceFor(
  MediaItem metadata, {
  required int index,
  required List<MediaVersion> versions,
}) async {
  final settingsService = await SettingsService.getInstance();
  final pref = index >= 0 && index < versions.length
      ? MediaVersionPreference.forVersion(versions[index], index)
      : MediaVersionPreference(index: index, updatedAt: DateTime.now().millisecondsSinceEpoch);
  final updated = {...settingsService.read(SettingsService.mediaVersionPreferences)}
    ..remove(_legacyMediaVersionPreferenceKey(metadata))
    ..[_mediaVersionPreferenceKey(metadata)] = pref;
  await settingsService.write(SettingsService.mediaVersionPreferences, _pruneMediaVersionPreferences(updated));
}

Map<String, MediaVersionPreference> _pruneMediaVersionPreferences(Map<String, MediaVersionPreference> prefs) {
  if (prefs.length <= _maxMediaVersionPreferences) return prefs;
  final entries = prefs.entries.toList()..sort((a, b) => (b.value.updatedAt ?? 0).compareTo(a.value.updatedAt ?? 0));
  return Map.fromEntries(entries.take(_maxMediaVersionPreferences));
}

/// A saved preference resolved for launch: the index to request plus the
/// id/signature evidence for re-resolving it against the authoritative
/// version list during playback initialization.
typedef ResolvedMediaVersionPreference = ({int index, String? sourceId, String? signature});

/// Resolve the saved preference for [metadata] against its version list.
///
/// When [MediaItem.mediaVersions] is populated (Plex hub/detail fetches) the
/// index is verified and the matched version's real id is returned. When it
/// isn't (Jellyfin resume rows omit `MediaSources`), the stored index and
/// signature pass through with a null sourceId — an unverified id from a
/// sibling episode would be meaningless downstream, while a signature is
/// safely re-matched there.
Future<ResolvedMediaVersionPreference?> resolveSavedMediaVersionFor(MediaItem metadata) async {
  final pref = await savedMediaVersionPreferenceFor(metadata);
  if (pref == null) return null;
  final versions = metadata.mediaVersions ?? const <MediaVersion>[];
  if (versions.isEmpty) return (index: pref.index, sourceId: null, signature: pref.signature);
  final index = pref.resolveIndex(versions);
  if (index == null) return null;
  final version = versions[index];
  return (index: index, sourceId: version.id.isEmpty ? null : version.id, signature: version.signature);
}

/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
///
/// This utility function provides a consistent way to navigate to the video player
/// across the app, using PageRouteBuilder with zero-duration transitions to eliminate
/// the white flash that occurs with MaterialPageRoute.
///
/// Parameters:
/// - [context]: The build context for navigation
/// - [metadata]: The neutral [MediaItem] for the content to play
/// - [preferredAudioTrack]: Optional audio track to select on playback start
/// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start
/// - [selectedMediaIndex]: Optional media version index to use; if not provided,
///   loads the saved preference for the series/movie. Defaults to 0 if no preference exists.
/// - [selectedMediaSourceId]: Optional stable backend source id for the chosen version.
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
///   useful for episode-to-episode navigation. Defaults to false.
/// - [isOffline]: If true, plays from downloaded content without requiring server connection.
/// - [resolveWatchState]: Resolve [metadata] through [WatchStateStore] so the
///   resume offset/watched flag are session-fresh even when the caller holds a
///   stale list snapshot. Pass false for explicit intents like play-from-start.
///
/// Returns a Future that completes with a boolean indicating whether the content
/// was watched, or null if navigation was cancelled.
Future<bool?> navigateToVideoPlayer(
  BuildContext context, {
  required MediaItem metadata,
  AudioTrack? preferredAudioTrack,
  SubtitleTrack? preferredSubtitleTrack,
  SubtitleTrack? preferredSecondarySubtitleTrack,
  int? selectedMediaIndex,
  String? selectedMediaSourceId,
  TranscodeQualityPreset? selectedQualityPreset,
  bool usePushReplacement = false,
  bool isOffline = false,
  bool resolveWatchState = true,
}) async {
  if (resolveWatchState) {
    metadata = context.readFreshWatchState(metadata);
  }
  final navigator = Navigator.of(context);
  final downloadProvider = context.read<DownloadProvider>();
  // Use the manager-routed lookup so Jellyfin items don't trip the
  // Plex-only client. The player branches on the returned type internally.
  final manager = context.read<MultiServerProvider>().serverManager;
  final offlineWatchService = context.read<OfflineWatchSyncService>();
  final serverId = serverIdOrNull(metadata.serverId);
  final mediaClient = serverId != null && (!isOffline || manager.isClientOnline(serverId))
      ? manager.getClient(serverId)
      : null;

  // Plain Play on a downloaded item must target the version actually on
  // disk. Only one version can be downloaded per item, and saved version
  // preferences describe online intent — they may point at a version that
  // was never downloaded (issue #1440). Explicit caller selections still win.
  int? downloadedMediaIndex;
  String? downloadedMediaSourceId;
  if (isOffline && selectedMediaIndex == null && selectedMediaSourceId == null) {
    final downloaded = await downloadProvider.getCompletedDownload(metadata.globalKey);
    if (downloaded != null) {
      downloadedMediaIndex = downloaded.mediaIndex;
      downloadedMediaSourceId = downloaded.mediaSourceId;
    }
  }

  // Saved preferences only apply when nothing explicit is in play — an
  // explicit caller selection or a downloaded version must never be
  // second-guessed by a remembered choice.
  ResolvedMediaVersionPreference? savedVersion;
  if (selectedMediaIndex == null &&
      selectedMediaSourceId == null &&
      downloadedMediaIndex == null &&
      downloadedMediaSourceId == null) {
    savedVersion = await resolveSavedMediaVersionFor(metadata);
  }
  final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? savedVersion?.index ?? 0;
  final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId ?? savedVersion?.sourceId;

  final launchIdentity = VideoPlayerLaunchIdentity(
    metadata: metadata,
    mediaIndex: mediaIndex,
    selectedMediaSourceId: mediaSourceId,
    selectedQualityPreset: selectedQualityPreset,
    isOffline: isOffline,
    routeKind: VideoPlayerRouteKind.vod,
  );
  var markedInFlight = false;
  if (!usePushReplacement) {
    markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart(launchIdentity);
    if (!markedInFlight) {
      appLogger.d(
        'Video player navigation already in flight for ${metadata.id} (mediaIndex=$mediaIndex), '
        'skipping duplicate navigation',
      );
      return null;
    }
  }

  try {
    // Check if external player is enabled
    try {
      final settingsService = await SettingsService.getInstance();
      if (PlatformDetector.supportsExternalPlayers() && settingsService.read(SettingsService.useExternalPlayer)) {
        bool launched = false;

        if (isOffline) {
          final globalKey = metadata.globalKey;
          final videoPath = await downloadProvider.getVideoFilePath(
            globalKey,
            mediaIndex: mediaIndex,
            mediaSourceId: mediaSourceId,
          );
          if (videoPath != null && context.mounted) {
            final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
            launched = await ExternalPlayerService.launch(
              context: context,
              videoUrl: videoUrl,
              metadata: metadata,
              client: mediaClient,
              offlineWatchService: offlineWatchService,
              mediaIndex: mediaIndex,
              mediaSourceId: mediaSourceId,
            );
          }
        } else if (context.mounted) {
          launched = await ExternalPlayerService.launch(
            context: context,
            metadata: metadata,
            client: mediaClient,
            offlineWatchService: offlineWatchService,
            mediaIndex: mediaIndex,
            mediaSourceId: mediaSourceId,
          );
        }

        if (launched) {
          // External playback never reaches the in-player session commit, so
          // record the local last-played history here.
          if (!isOffline) unawaited(LocalPlaybackHistory.recordPlayback(metadata));
          return null;
        }
      }
    } catch (e) {
      appLogger.w('External player launch failed, falling back to built-in player', error: e);
    }

    // Prevent stacking an identical video player when already active.
    if (!usePushReplacement && VideoPlayerScreenState.isNavigationActive(launchIdentity)) {
      appLogger.d(
        'Video player already active for ${metadata.globalKey} (mediaIndex=$mediaIndex), skipping duplicate navigation',
      );
      return null;
    }

    final route = buildVideoPlayerRoute(
      builder: (_) => VideoPlayerScreen(
        metadata: metadata,
        preferredAudioTrack: preferredAudioTrack,
        preferredSubtitleTrack: preferredSubtitleTrack,
        preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
        selectedMediaIndex: mediaIndex,
        selectedMediaSourceId: mediaSourceId,
        preferredVersionSignature: savedVersion?.signature,
        selectedQualityPreset: selectedQualityPreset,
        isOffline: isOffline,
      ),
    );

    return usePushReplacement ? navigator.pushReplacement<bool, bool>(route) : navigator.push<bool>(route);
  } finally {
    if (markedInFlight) {
      _videoPlayerNavigationInFlightGuard.finish(launchIdentity);
    }
  }
}

/// Navigates to the video player and optionally refreshes content when returning.
///
/// This helper consolidates the common pattern of:
/// 1. Navigating to the video player
/// 2. Logging the return
/// 3. Calling a refresh callback if not offline
///
/// Parameters:
/// - [context]: The build context for navigation
/// - [metadata]: The neutral [MediaItem] for the content to play
/// - [isOffline]: If true, plays from downloaded content
/// - [onRefresh]: Optional callback to refresh data when returning from playback
///   (only called when not offline)
/// - All other parameters are passed through to [navigateToVideoPlayer]
Future<bool?> navigateToVideoPlayerWithRefresh(
  BuildContext context, {
  required MediaItem metadata,
  bool isOffline = false,
  VoidCallback? onRefresh,
  AudioTrack? preferredAudioTrack,
  SubtitleTrack? preferredSubtitleTrack,
  SubtitleTrack? preferredSecondarySubtitleTrack,
  int? selectedMediaIndex,
  String? selectedMediaSourceId,
  bool usePushReplacement = false,
}) async {
  final result = await navigateToVideoPlayer(
    context,
    metadata: metadata,
    isOffline: isOffline,
    preferredAudioTrack: preferredAudioTrack,
    preferredSubtitleTrack: preferredSubtitleTrack,
    preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
    selectedMediaIndex: selectedMediaIndex,
    selectedMediaSourceId: selectedMediaSourceId,
    usePushReplacement: usePushReplacement,
  );

  appLogger.d('Returned from playback, refreshing metadata');

  if (!isOffline && onRefresh != null && context.mounted) {
    onRefresh();
  }

  return result;
}

/// Resolves the current Watch Together media and opens the video player.
///
/// Returns whether navigation was initiated. The fetch can outlive the
/// dispatch that requested it (slow server, host switching again, dispatcher
/// timeout); navigating then would stack a stale player route on top of the
/// live one, so the key is re-validated against the session's current
/// playback snapshot before the push.
Future<bool> navigateToWatchTogetherPlayback(
  BuildContext context, {
  required String ratingKey,
  required ServerId serverId,
  VoidCallback? onBeforeNavigate,
}) async {
  final multiServer = context.read<MultiServerProvider>();
  final client = multiServer.getClientForServer(serverId);

  if (client == null) {
    throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable');
  }

  final metadata = await client.fetchItem(ratingKey);
  if (metadata == null) {
    throw const WatchTogetherPlaybackNavigationException('Current Watch Together media is unavailable');
  }

  if (!context.mounted) return false;

  final watchTogether = context.read<WatchTogetherProvider>();
  if (watchTogether.currentMediaRatingKey != ratingKey || watchTogether.currentMediaServerId != serverId) {
    appLogger.d('WatchTogether: Skipping stale navigation to $ratingKey');
    return false;
  }

  onBeforeNavigate?.call();
  unawaited(navigateToVideoPlayer(context, metadata: metadata));
  return true;
}
