import 'dart:async';

import 'package:http/http.dart' as http;

import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../media/media_server_client.dart';
import '../../media/playback_timeline.dart';
import '../../models/trakt/trakt_ids.dart';
import '../../models/trakt/trakt_scrobble_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/json_utils.dart';
import '../settings_service.dart';
import '../trackers/tracker.dart';
import '../trackers/tracker_constants.dart';
import '../trackers/tracker_id_resolver.dart';
import '../trackers/tracker_rating_match.dart';
import '../trackers/tracker_session.dart';
import 'trakt_client.dart';
import 'trakt_constants.dart';

/// Real-time scrobble service for Trakt.
///
/// Mirrors the lifecycle shape of `DiscordRPCService`: invoked from
/// `video_player_screen.dart` at the same call sites (start/pause/resume/stop,
/// position updates).
class TraktScrobbleService implements TrackerRatingSource {
  /// Drop a duplicate state transition within this window — mpv emits multiple
  /// playing-state events on seek.
  static const Duration _duplicateStateDebounce = Duration(seconds: 1);

  /// Drop a `start` re-send within this window of the previous start.
  /// Trakt enforces "max one scrobble per 15 min per item"; this avoids
  /// spamming 409s during rapid pause/play cycles.
  static const Duration _startResendThrottle = Duration(seconds: 30);

  /// Max one seek-checkpoint per this window — slider drag fires many position
  /// updates per second; we only want to ship one to Trakt.
  static const Duration _seekCheckpointThrottle = Duration(seconds: 5);

  static TraktScrobbleService? _instance;
  static TraktScrobbleService get instance => _instance ??= TraktScrobbleService._();

  TraktScrobbleService._();

  bool _isInitialized = false;
  bool _isEnabled = false;

  TraktClient? _client;
  TrackerIdResolver? _resolver;
  TraktScrobbleRequest? _currentBody;
  final PlaybackTimeline _timeline = PlaybackTimeline();
  TraktScrobbleState? _lastSentState;
  DateTime? _lastSentAt;
  DateTime? _lastSeekCheckpointAt;
  int _playbackRevision = 0;

  Future<void> initialize() async {
    if (_isInitialized) return;
    _isInitialized = true;
    final settings = await SettingsService.getInstance();
    _isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt));
  }

  Future<void> setEnabled(bool enabled) async {
    _isEnabled = enabled;
    if (!enabled) cancelInFlight();
  }

  /// Switch to a different account. Cancels any in-flight scrobble for the
  /// previous account so we don't send a stop event to the wrong user.
  void rebindToProfile(
    TrackerSession? session, {
    required void Function() onSessionInvalidated,
    void Function(TrackerSession session)? onSessionUpdated,
    http.Client? httpClient,
  }) {
    _client?.dispose();
    _client = session != null
        ? TraktClient(
            session,
            onSessionInvalidated: onSessionInvalidated,
            onSessionUpdated: onSessionUpdated,
            httpClient: httpClient,
          )
        : null;
    cancelInFlight();
  }

  void updateSession(TrackerSession session) {
    _client?.updateSession(session);
  }

  @override
  Future<int?> getRating(TrackerRatingContext ctx) async {
    final client = _client;
    if (client == null) throw const TrackerRatingUnavailableException('Trakt');
    final localIds = TraktIds.fromExternal(ctx.ids.external).toJson();
    if (localIds.isEmpty) throw const TrackerRatingUnavailableException('Trakt');

    final entries = await client.getRatings(_ratingType(ctx));
    for (final entry in entries) {
      if (entry is! Map) continue;
      if (!_ratingEntryMatches(ctx, entry.cast<String, dynamic>(), localIds)) continue;
      final rating = flexibleInt(entry['rating']);
      return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
    }
    return null;
  }

  @override
  Future<void> rate(TrackerRatingContext ctx, int score) async {
    final client = _client;
    if (client == null) throw const TrackerRatingUnavailableException('Trakt');
    await client.addRatings(_ratingBody(ctx, rating: score.clamp(1, 10).toInt()));
  }

  @override
  Future<void> clearRating(TrackerRatingContext ctx) async {
    final client = _client;
    if (client == null) throw const TrackerRatingUnavailableException('Trakt');
    await client.removeRatings(_ratingBody(ctx));
  }

  /// Drop the current scrobble state without sending a stop. Called on profile
  /// switch and when the service is disabled mid-playback.
  void cancelInFlight() {
    ++_playbackRevision;
    _clearPlaybackState();
  }

  void _clearPlaybackState() {
    _currentBody = null;
    _lastSentState = null;
    _lastSentAt = null;
    _lastSeekCheckpointAt = null;
    _resolver?.clearCache();
    _resolver = null;
    _timeline.reset();
  }

  bool get _canScrobble => _isEnabled && _client != null;

  String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) {
    MediaKind.movie => 'movies',
    MediaKind.show => 'shows',
    MediaKind.season => 'seasons',
    MediaKind.episode => 'episodes',
    _ => throw const TrackerRatingUnavailableException('Trakt'),
  };

  bool _ratingEntryMatches(TrackerRatingContext ctx, Map<String, dynamic> entry, Map<String, dynamic> localIds) {
    final show = entry['show'];
    final movie = entry['movie'];
    return switch (ctx.kind) {
      MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds),
      MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds),
      MediaKind.season =>
        trackerIdsMatch(trackerNestedIds(show), localIds) && _numberMatches(entry['season'], ctx.season),
      MediaKind.episode =>
        trackerIdsMatch(trackerNestedIds(show), localIds) &&
            _numberMatches(entry['episode'], ctx.episodeNumber) &&
            _seasonMatches(entry['episode'], ctx.season),
      _ => false,
    };
  }

  bool _numberMatches(Object? value, int? expected) {
    if (expected == null || value is! Map) return false;
    return flexibleInt(value['number']) == expected;
  }

  bool _seasonMatches(Object? value, int? expected) {
    if (expected == null || value is! Map) return false;
    return flexibleInt(value['season']) == expected;
  }

  Map<String, dynamic> _ratingBody(TrackerRatingContext ctx, {int? rating}) {
    final ids = TraktIds.fromExternal(ctx.ids.external).toJson();
    final item = {'ids': ids, 'rating': ?rating};

    return switch (ctx.kind) {
      MediaKind.movie => {
        'movies': [item],
      },
      MediaKind.show => {
        'shows': [item],
      },
      MediaKind.season => {
        'shows': [
          {
            'ids': ids,
            'seasons': [
              {'number': ctx.season, 'rating': ?rating},
            ],
          },
        ],
      },
      MediaKind.episode => {
        'shows': [
          {
            'ids': ids,
            'seasons': [
              {
                'number': ctx.season,
                'episodes': [
                  {'number': ctx.episodeNumber, 'rating': ?rating},
                ],
              },
            ],
          },
        ],
      },
      _ => throw const TrackerRatingUnavailableException('Trakt'),
    };
  }

  Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async {
    final revision = ++_playbackRevision;
    _clearPlaybackState();
    if (!_canScrobble || isLive) return;

    final type = metadata.kind;
    if (type != MediaKind.movie && type != MediaKind.episode) return;

    final settings = SettingsService.instanceOrNull;
    if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, metadata.libraryGlobalKey)) {
      appLogger.d('Trakt: library filtered out for ${metadata.id}');
      return;
    }

    // Seed with the resume offset so the first real position update doesn't
    // look like a seek when resuming mid-item.
    _timeline.reset(
      position: metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero,
      duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null,
    );
    _resolver = TrackerIdResolver(client, needsFribb: () => false);

    final body = await _buildBody(metadata);
    if (revision != _playbackRevision) return;
    if (body == null) {
      appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.id}');
      _clearPlaybackState();
      return;
    }
    _currentBody = body;
    await _send(TraktScrobbleState.start, progress: _progressPercent());
  }

  void updatePosition(Duration position) {
    final isSeek = _timeline.updatePosition(position);

    // Trakt has no seek event — instead, official apps send pause+start with
    // the new progress to checkpoint. Without this, the "resume on another
    // device" feature is stuck on the pre-seek position until the next
    // pause/stop.
    if (_currentBody == null) return;
    if (_lastSentState != TraktScrobbleState.start) return;
    if (!isSeek) return;

    final now = DateTime.now();
    if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) {
      return;
    }
    _lastSeekCheckpointAt = now;
    unawaited(_sendSeekCheckpoint());
  }

  void updateDuration(Duration duration) {
    _timeline.updateDuration(duration);
  }

  Future<void> pausePlayback() async {
    if (_currentBody == null) return;
    await _send(TraktScrobbleState.pause, progress: _progressPercent());
  }

  Future<void> resumePlayback() async {
    if (_currentBody == null) return;
    await _send(TraktScrobbleState.start, progress: _progressPercent());
  }

  Future<void> stopPlayback() async {
    final revision = ++_playbackRevision;
    if (_currentBody == null) {
      _clearPlaybackState();
      return;
    }
    await _send(TraktScrobbleState.stop, progress: _progressPercent());
    if (revision == _playbackRevision) _clearPlaybackState();
  }

  Future<TraktScrobbleRequest?> _buildBody(MediaItem metadata) async {
    final resolver = _resolver;
    if (resolver == null) return null;

    if (metadata.kind == MediaKind.movie) {
      final ids = await resolver.resolveForMovie(metadata.id);
      if (ids == null) return null;
      return TraktScrobbleRequest.movie(ids: TraktIds.fromExternal(ids.external));
    }

    final season = metadata.parentIndex;
    final number = metadata.index;
    if (season == null || number == null) return null;

    final showIds = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false);
    if (showIds == null) return null;

    return TraktScrobbleRequest.episode(
      showIds: TraktIds.fromExternal(showIds.external),
      season: season,
      number: number,
    );
  }

  double _progressPercent() => _timeline.progressPercent;

  /// Send pause→start to Trakt so the playback-progress endpoint reflects the
  /// new position. Bypasses [_send]'s state throttle (this is a checkpoint,
  /// not a state change) but updates the throttle bookkeeping so a regular
  /// `start` immediately after won't double-fire.
  Future<void> _sendSeekCheckpoint() async {
    final client = _client;
    final body = _currentBody;
    if (client == null || body == null) return;

    final progress = _progressPercent();
    final scrobble = body.copyWith(progress: progress);
    try {
      await client.scrobblePause(scrobble);
      await client.scrobbleStart(scrobble);
      _lastSentState = TraktScrobbleState.start;
      _lastSentAt = DateTime.now();
      appLogger.d('Trakt: seek checkpoint @ ${progress.toStringAsFixed(1)}%');
    } catch (e) {
      appLogger.d('Trakt: seek checkpoint failed', error: e);
    }
  }

  Future<void> _send(TraktScrobbleState state, {required double progress}) async {
    final client = _client;
    final body = _currentBody;
    if (client == null || body == null) return;

    final now = DateTime.now();
    if (_lastSentState == state && _lastSentAt != null) {
      final elapsed = now.difference(_lastSentAt!);
      if (elapsed < _duplicateStateDebounce) return;
      if (state == TraktScrobbleState.start && elapsed < _startResendThrottle) return;
    }
    _lastSentState = state;
    _lastSentAt = now;

    final scrobble = body.copyWith(progress: progress);
    try {
      switch (state) {
        case TraktScrobbleState.start:
          await client.scrobbleStart(scrobble);
        case TraktScrobbleState.pause:
          await client.scrobblePause(scrobble);
        case TraktScrobbleState.stop:
          await client.scrobbleStop(scrobble);
      }
      appLogger.d('Trakt: scrobble ${state.name} @ ${progress.toStringAsFixed(1)}%');
    } catch (e) {
      // Never let scrobble errors block playback.
      appLogger.d('Trakt: scrobble ${state.name} failed', error: e);
    }
  }
}
