import 'dart:async';

import '../../../core/contracts/auth.dart';

import '../../../core/contracts/cancellation.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../core/contracts/library.dart';
import '../../../core/contracts/playback.dart';
import '../../../core/contracts/trakt/trakt_scrobble_target.dart';
import '../../../core/domain/ports/authed_trakt_gateway.dart';
import '../../../core/domain/ports/emby_gateway.dart';
import '../../../core/infra/emby_api/emby_headers.dart';
import '../../../core/infra/trakt_api/trakt_media_matcher.dart';
import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
import '../../../core/infra/emby_api/playback_resolver.dart';
import '../../../core/infra/player_engine/fvp_player_engine.dart';
import '../../../core/infra/player_engine/media3_player_engine.dart';
import '../../../core/infra/player_engine/media_kit_player_engine.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../core/media/external_subtitle_loader.dart';
import '../../../core/media/media_capability_analyzer.dart';
import '../../../core/media/media_source_metadata_resolver.dart';
import '../../../providers/audio_settings_provider.dart';
import '../../../providers/player_engine_override_provider.dart';
import '../../../providers/player_settings_provider.dart';
import '../../../providers/subtitle_settings_provider.dart';
import '../../../shared/mappers/media_image_url.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/emby_ticks.dart';
import '../services/danmaku/danmaku_feeder.dart';
import '../services/emby_playback_reporter.dart';
import '../services/network_speed_monitor.dart';
import '../services/trakt_scrobble_reporter.dart';
import 'initial_subtitle_resolver.dart';
import 'playback_request.dart';
import 'playback_session.dart';
import 'player_engine_holder.dart';
import 'player_engine_resolution.dart';

const _tag = 'PlaybackSessionBuilder';
const _seriesIdentitySoftTimeout = Duration(milliseconds: 800);
const _engineOpenTimeout = Duration(seconds: 60);


class PlaybackSessionBuilder {
  PlaybackSessionBuilder({
    required this.resolver,
    required this.gateway,
    required this.metadataResolver,
    required this.engineHolder,
    required this.readActiveSession,
    required this.deviceId,
    required this.deviceName,
    required this.playerSettingsNotifier,
    required this.audioSettingsNotifier,
    required this.subtitleSettingsNotifier,
    required this.readEngineOverride,
    this.loadItemDetail,
    this.traktGateway,
    this.traktQueue,
    this.traktAppVersion,
    this.traktOnSuccess,
    this.traktOnError,
  });

  final PlaybackResolver resolver;
  final EmbyGateway gateway;
  final MediaSourceMetadataResolver metadataResolver;


  final PlayerEngineHolder engineHolder;


  final AuthedTraktGateway? traktGateway;
  final TraktPendingSyncQueue? traktQueue;
  final String? traktAppVersion;
  final void Function()? traktOnSuccess;
  final void Function(String error)? traktOnError;
  Future<bool>? _traktConnectedCache;


  final AuthedSession Function() readActiveSession;


  final PlayerEngineOverride Function() readEngineOverride;
  final String deviceId;
  final String deviceName;
  final PlayerSettingsNotifier playerSettingsNotifier;
  final AudioSettingsNotifier audioSettingsNotifier;
  final SubtitleSettingsNotifier subtitleSettingsNotifier;
  final Future<EmbyRawItem> Function(String serverId, String itemId)?
  loadItemDetail;


  Future<PlaybackSession> build({
    required PlaybackRequest request,
    required DanmakuFeeder feeder,
    CancellationToken? externalCancel,
    Future<void>? priorDisposeFuture,
    Map<String, dynamic>? preloadedPlaybackInfo,
    PlayerEngineKind? forcedEngineKind,
    void Function(PlayerEngineKind kind, bool isDolbyVision)? onEngineResolved,
  }) async {
    externalCancel?.throwIfCancelled();

    final directStreamUrl = request.directStreamUrl?.trim();
    final isDirectStream = directStreamUrl?.isNotEmpty ?? false;

    final identity = request.targetServer;
    final String effectiveServerId;
    final String effectiveServerUrl;
    final String effectiveToken;
    final String effectiveUserId;
    if (identity != null) {
      effectiveServerId = identity.serverId;
      effectiveServerUrl = identity.baseUrl;
      effectiveToken = identity.token;
      effectiveUserId = identity.userId;
    } else if (isDirectStream) {
      effectiveServerId = '';
      effectiveServerUrl = '';
      effectiveToken = '';
      effectiveUserId = '';
    } else {
      final session = readActiveSession();
      effectiveServerId = session.serverId;
      effectiveServerUrl = session.serverUrl;
      effectiveToken = session.token;
      effectiveUserId = session.userId;
    }
    final ctx = AuthedRequestContext(
      baseUrl: effectiveServerUrl,
      token: effectiveToken,
      userId: effectiveUserId,
    );
    final traktConnectedFuture = isDirectStream
        ? Future<bool>.value(false)
        : _readTraktConnected();

    final PlaybackRequestResult result;
    if (isDirectStream) {
      final item =
          request.preloadedItem ??
          EmbyRawItem(Id: request.itemId, Name: request.itemId, Type: 'Video');
      result = PlaybackRequestResult(
        streamUrl: directStreamUrl!,
        directStream: true,
        item: item,
        startPositionSeconds: request.playFromStart
            ? 0
            : (request.startPositionTicks ?? 0) / kTicksPerSecond,
        durationSeconds: item.RunTimeTicks == null
            ? null
            : item.RunTimeTicks! / kTicksPerSecond,
        playMethod: 'DirectStream',
      );
    } else {
      result = await resolver.prepare(
        ctx: ctx,
        payload: PlaybackRequestPayload(
          itemId: request.itemId,
          mediaSourceId: request.mediaSourceId,
          startPositionTicks: request.startPositionTicks,
          playFromStart: request.playFromStart,
        ),
        preloadedItem: request.preloadedItem,
        preloadedPlaybackInfo: preloadedPlaybackInfo,
      );
    }
    externalCancel?.throwIfCancelled();

    final providerIds = providerIdsOfItem(result.item);
    final parentIndexNumber = (result.item.extra['ParentIndexNumber'] as num?)
        ?.toInt();
    final memoryKey = playbackMemoryKey(
      serverId: effectiveServerId,
      userId: effectiveUserId,
      itemId: result.item.Id,
      seriesId: result.item.SeriesId,
    );
    final rememberedAudioFuture =
        !isDirectStream && request.audioSettings.rememberAudioTrack
        ? _loadRememberedAudio(memoryKey)
        : Future<RememberedAudioTrack?>.value();
    final rememberedSubtitleFuture = isDirectStream
        ? Future<RememberedSubtitleTrack?>.value()
        : _loadRememberedSubtitle(memoryKey);
    final directStreamHeaders = Map<String, String>.unmodifiable(
      request.directStreamHeaders,
    );
    final streamHeaders = isDirectStream
        ? directStreamHeaders
        : EmbyRequestHeaders.media(
            deviceId: deviceId,
            deviceName: deviceName,
            token: effectiveToken,
            userId: effectiveUserId,
          );
    final engineHeaders = isDirectStream
        ? directStreamHeaders
        : EmbyRequestHeaders.directMedia();
    final externalSubtitleLoader = ExternalSubtitleLoader();
    if (!isDirectStream) {
      unawaited(
        _prefetchInitialSubtitle(
          request: request,
          prepareResult: result,
          rememberedSubtitleFuture: rememberedSubtitleFuture,
          loader: externalSubtitleLoader,
          streamHeaders: streamHeaders,
        ),
      );
    }
    final seriesIdentityFuture = isDirectStream
        ? Future<
            ({Map<String, String> providerIds, int? productionYear})?
          >.value()
        : _loadSeriesIdentity(
            ctx: ctx,
            serverId: effectiveServerId,
            item: result.item,
            providerIds: providerIds,
            traktConnectedFuture: traktConnectedFuture,
            externalCancel: externalCancel,
          );

    final probeResult = isDirectStream
        ? null
        : await metadataResolver.resolve(
            source: result.mediaSource,
            streamUrl: result.streamUrl,
            item: result.item,
            timeout: const Duration(seconds: 5),
          );
    externalCancel?.throwIfCancelled();
    final capability = MediaCapabilityAnalyzer.analyze(
      result.mediaSource,
      probeResult: probeResult,
    );
    final isDolbyVision =
        capability.rangeCategory == VideoRangeCategory.dolbyVision;
    final isHdr = switch (capability.rangeCategory) {
      VideoRangeCategory.hdr10 || VideoRangeCategory.hlg => true,
      _ => false,
    };

    final engineOverride = readEngineOverride();
    final engineKind =
        forcedEngineKind ??
        resolvePlayerEngineKind(
          engineOverride,
          isDolbyVision: isDolbyVision,
          videoCodec: capability.codec,
        );
    AppLogger.debug(
      _tag,
      'route override=${engineOverride.name} '
      'range=${capability.rangeCategory.name} isDV=$isDolbyVision isHDR=$isHdr '
      'forced=${forcedEngineKind?.name ?? '<none>'} -> ${engineKind.name}',
    );
    onEngineResolved?.call(engineKind, isDolbyVision);
    if (priorDisposeFuture != null) {
      await priorDisposeFuture;
      externalCancel?.throwIfCancelled();
    }

    final engine = await engineHolder.acquire(
      engineKind,
      () => createEngine(engineKind),
    );
    externalCancel?.throwIfCancelled();
    if (engine is FvpPlayerEngine) {
      engine
        ..setDolbyVision(isDolbyVision)
        ..configureHardwareDecoding(request.playerSettings.hwdecMode);
    }
    final initialVolume = request.audioSettings.volumeBoost
        ? request.playerSettings.volume
        : request.playerSettings.volume.clamp(0.0, 100.0);
    await engine.setVolume(initialVolume);
    externalCancel?.throwIfCancelled();

    final isTranscode = result.playMethod == 'Transcode';
    final startSeconds = result.startPositionSeconds;
    final startDuration = (!isTranscode && startSeconds > 0)
        ? Duration(milliseconds: (startSeconds * 1000).round())
        : Duration.zero;
    externalCancel?.throwIfCancelled();
    AppLogger.debug(
      _tag,
      'open engine=${engineKind.name} method=${result.playMethod} '
      'start=${startDuration.inSeconds}s itemId=${request.itemId}',
    );
    final engineOpenFuture = engine.open(
      result.streamUrl,
      start: startDuration,
      play: false,
      headers: engineHeaders,
      cancel: externalCancel,
    );
    final awaitEngineOpenInBuild = !engine.usesPlatformSurface;

    final reporter = EmbyPlaybackReporter(
      gateway: gateway,
      context: ctx,
      enabled: !isDirectStream,
    );
    final speedMonitor = NetworkSpeedMonitor(
      totalRxBytes: engine.totalRxBytes,
    );

    final seriesIdentity = await seriesIdentityFuture;
    externalCancel?.throwIfCancelled();
    final seriesProviderIds =
        seriesIdentity?.providerIds ?? const <String, String>{};
    final seriesProductionYear = seriesIdentity?.productionYear;

    final context = PlaybackContext(
      itemId: result.item.Id,
      itemName: result.item.Name,
      itemType: result.item.Type,
      itemIndexNumber: result.item.IndexNumber,
      seriesName: result.item.SeriesName,
      seriesId: result.item.SeriesId,
      seasonId: result.item.SeasonId,
      mediaSourceId: result.mediaSource?.Id,
      playSessionId: result.playSessionId,
      runTimeTicks: result.item.RunTimeTicks,
      playMethod: result.playMethod,
      isDolbyVision: isDolbyVision,
      embySubtitles: result.subtitles,
      embyAudioTracks: result.audioTracks,
      allMediaSources: mediaSourcesOfItem(result.item),
      providerIds: providerIds,
      seriesProviderIds: seriesProviderIds,
      parentIndexNumber: parentIndexNumber,
      danmakuMatchContext: _buildDanmakuMatchContext(result.item),
      serverId: effectiveServerId,
      userId: effectiveUserId,
      baseUrl: effectiveServerUrl,
      token: effectiveToken,
      logoUrl: _buildLogoUrl(
        result.item,
        baseUrl: effectiveServerUrl,
        token: effectiveToken,
      ),
    );

    final rememberedAudioTrack = await rememberedAudioFuture;
    final rememberedPrimarySubtitle = await rememberedSubtitleFuture;
    final traktConnected = await traktConnectedFuture;

    final traktReporterFactory = isDirectStream
        ? null
        : await _buildTraktReporterFactory(
            item: result.item,
            providerIds: providerIds,
            seriesProviderIds: seriesProviderIds,
            seriesProductionYear: seriesProductionYear,
            parentIndexNumber: parentIndexNumber,
            traktConnected: traktConnected,
          );
    externalCancel?.throwIfCancelled();

    if (awaitEngineOpenInBuild) {
      try {
        await _runWithTimeout(engineOpenFuture, _engineOpenTimeout);
        externalCancel?.throwIfCancelled();
      } catch (_) {
        try {
          await engine.stop();
        } catch (e) {
          AppLogger.warn(_tag, 'engine.stop after open failure failed', e);
        }
        rethrow;
      }
    }

    return PlaybackSession(
      engine: engine,
      reporter: reporter,
      speedMonitor: speedMonitor,
      feeder: feeder,
      context: context,
      request: request,
      prepareResult: result,
      streamHeaders: streamHeaders,
      pendingEngineOpenFuture: awaitEngineOpenInBuild ? null : engineOpenFuture,
      playerSettingsNotifier: playerSettingsNotifier,
      traktReporterFactory: traktReporterFactory,
      rememberedAudioTrack: rememberedAudioTrack,
      rememberedPrimarySubtitle: rememberedPrimarySubtitle,
      externalSubtitleLoader: externalSubtitleLoader,
    );
  }

  Future<void> _prefetchInitialSubtitle({
    required PlaybackRequest request,
    required PlaybackRequestResult prepareResult,
    required Future<RememberedSubtitleTrack?> rememberedSubtitleFuture,
    required ExternalSubtitleLoader loader,
    required Map<String, String> streamHeaders,
  }) async {
    try {
      final rememberedSubtitle = await rememberedSubtitleFuture;
      final selection = resolveInitialSubtitleSelection(
        subtitles: prepareResult.subtitles,
        preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex,
        defaultSubtitleStreamIndex: prepareResult.defaultSubtitleIndex,
        rememberedSubtitle: rememberedSubtitle,
      );
      final selectedTrack = selection.track;
      final url = selectedTrack?.deliveryUrl;
      final canPrefetch =
          selection.type == InitialSubtitleSelectionType.track &&
          selectedTrack != null &&
          url != null &&
          url.isNotEmpty &&
          PlaybackResolver.isTextSubtitleCodec(selectedTrack.codec);
      if (!canPrefetch) return;

      AppLogger.debug(
        _tag,
        'prefetch initial subtitle index=${selectedTrack.index} '
        'external=${selectedTrack.isExternal}',
      );
      await loader.load(
        url,
        headers: streamHeaders,
        receiveTimeout: selectedTrack.isExternal
            ? null
            : embeddedSubtitleFetchTimeout,
      );
    } catch (error) {

      AppLogger.debug(_tag, 'initial subtitle prefetch failed', error);
    }
  }

  Future<void> _runWithTimeout(Future<void> future, Duration timeout) {
    return future.timeout(
      timeout,
      onTimeout: () {
        throw TimeoutException('engine.open exceeded $timeout', timeout);
      },
    );
  }


  static PlayerEngine createEngine(PlayerEngineKind kind) {
    switch (kind) {
      case PlayerEngineKind.fvp:
        return FvpPlayerEngine();
      case PlayerEngineKind.mpv:
        return MediaKitPlayerEngine();
      case PlayerEngineKind.media3:
        return Media3PlayerEngine();
    }
  }


  Future<TraktScrobbleReporter Function(double Function())?>
  _buildTraktReporterFactory({
    required EmbyRawItem item,
    required Map<String, String> providerIds,
    required Map<String, String> seriesProviderIds,
    required int? seriesProductionYear,
    required int? parentIndexNumber,
    required bool traktConnected,
  }) async {
    final tGateway = traktGateway;
    final tQueue = traktQueue;
    if (tGateway == null || tQueue == null) return null;

    final target = TraktMediaMatcher.match(
      itemType: item.Type,
      itemName: item.Name,
      productionYear: item.ProductionYear,
      providerIds: providerIds,
      seriesProviderIds: seriesProviderIds,
      seriesName: item.SeriesName,
      seriesProductionYear: seriesProductionYear,
      parentIndexNumber: parentIndexNumber,
      itemIndexNumber: item.IndexNumber,
    );
    if (target == null) {
      AppLogger.debug(_tag, 'trakt: no match for ${item.Type} "${item.Name}"');
      return null;
    }

    if (!traktConnected) {
      AppLogger.debug(_tag, 'trakt: not connected, scrobble disabled');
      return null;
    }

    final TraktScrobbleTarget matched = target;
    return (readProgress) => TraktScrobbleReporter(
      gateway: tGateway,
      queue: tQueue,
      target: matched,
      readProgress: readProgress,
      appVersion: traktAppVersion,
      onSuccess: traktOnSuccess,
      onError: traktOnError,
    );
  }

  Future<bool> _readTraktConnected() {
    final tGateway = traktGateway;
    if (tGateway == null || traktQueue == null) {
      return Future<bool>.value(false);
    }
    return _traktConnectedCache ??= tGateway.isConnected().catchError((
      Object e,
    ) {
      AppLogger.debug(_tag, 'trakt: isConnected failed', e);
      return false;
    });
  }

  Future<RememberedAudioTrack?> _loadRememberedAudio(String key) async {
    try {
      return await audioSettingsNotifier.getLastAudioTrack(key);
    } catch (e) {
      AppLogger.warn(_tag, 'load remembered audio failed', e);
      return null;
    }
  }

  Future<RememberedSubtitleTrack?> _loadRememberedSubtitle(String key) async {
    try {
      return await subtitleSettingsNotifier.getLastSubtitleTrack(key);
    } catch (e) {
      AppLogger.warn(_tag, 'load remembered subtitle failed', e);
      return null;
    }
  }

  Future<({Map<String, String> providerIds, int? productionYear})?>
  _loadSeriesIdentity({
    required AuthedRequestContext ctx,
    required String serverId,
    required EmbyRawItem item,
    required Map<String, String> providerIds,
    required Future<bool> traktConnectedFuture,
    CancellationToken? externalCancel,
  }) async {
    if (item.Type != 'Episode' || !(item.SeriesId?.isNotEmpty ?? false)) {
      return null;
    }
    final traktConnected = await traktConnectedFuture;
    externalCancel?.throwIfCancelled();
    if (!traktConnected) return null;

    try {
      final loadItem = loadItemDetail;
      final Future<EmbyRawItem?> future = loadItem != null
          ? loadItem(serverId, item.SeriesId!).then<EmbyRawItem?>((v) => v)
          : gateway
                .getItemDetail(ctx, item.SeriesId!)
                .then<EmbyRawItem?>((v) => v);
      final seriesItem = await future.timeout(
        _seriesIdentitySoftTimeout,
        onTimeout: () {
          AppLogger.debug(
            _tag,
            'fetch series identity timed out after '
            '${_seriesIdentitySoftTimeout.inMilliseconds}ms',
          );
          return null;
        },
      );
      externalCancel?.throwIfCancelled();
      if (seriesItem == null) return null;
      return (
        providerIds: providerIdsOfItem(seriesItem),
        productionYear: seriesItem.ProductionYear,
      );
    } on CancelledException {
      rethrow;
    } catch (e) {
      AppLogger.warn(_tag, 'fetch series identity failed', e);
      return null;
    }
  }

  static DanmakuMatchContext _buildDanmakuMatchContext(EmbyRawItem item) {
    return DanmakuMatchContext(
      itemId: item.Id,
      name: item.Name,
      type: item.Type,
      seriesName: item.SeriesName,
      season: (item.extra['ParentIndexNumber'] as num?)?.toInt(),
      episode: item.IndexNumber,
      durationSec: ((item.RunTimeTicks ?? 0) / kTicksPerSecond).round(),
    );
  }

  static String? _buildLogoUrl(
    EmbyRawItem item, {
    required String baseUrl,
    required String token,
  }) {
    final isEpisode = item.Type == 'Episode';
    final logoItemId = isEpisode && (item.SeriesId?.isNotEmpty ?? false)
        ? item.SeriesId!
        : item.Id;
    final logoTag = isEpisode ? null : item.ImageTags?['Logo'];
    return EmbyImageUrl.logo(
      baseUrl: baseUrl,
      token: token,
      itemId: logoItemId,
      tag: logoTag,
    );
  }
}
