import 'dart:async';

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

import '../mixins/disposable_change_notifier_mixin.dart';
import '../models/trackers/device_code.dart';
import '../services/trackers/tracker_account_store.dart';
import '../services/trackers/tracker_connect_runner.dart';
import '../services/trackers/tracker_constants.dart';
import '../services/trackers/tracker_session.dart';
import '../services/trackers/tracker_username_enricher.dart';
import '../services/trakt/trakt_auth_service.dart';
import '../services/trakt/trakt_client.dart';
import '../services/trakt/trakt_scrobble_service.dart';
import '../services/trakt/trakt_sync_service.dart';

/// Owns the active Trakt session for the currently-selected Plex profile.
///
/// Single rebind seam: `onActiveProfileChanged` loads the new profile's
/// session and pushes it to both `TraktScrobbleService` and `TraktSyncService`.
class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
  /// Each client returned by [httpClientFactory] is owned by this provider and
  /// closed when the provider is disposed.
  TraktAccountProvider({http.Client Function()? httpClientFactory})
    : _auth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory());

  final TraktAuthService _auth;
  final TrackerAccountStore _store = trackerAccountStore(TrackerService.trakt);

  TrackerSession? _session;
  String _activeUserUuid = '';
  int _bindingGeneration = 0;
  bool _isConnecting = false;
  Completer<void>? _cancelCompleter;
  TraktClient? _catalogClient;

  TrackerSession? get session => _session;
  bool get isConnected => _session != null;
  String? get username => _session?.username;
  bool get isConnecting => _isConnecting;

  /// Client for the catalog/watchlist surfaces (Explore tab). Owned and
  /// rebound here alongside the scrobble/sync services; null when
  /// disconnected.
  TraktClient? get catalogClient => _catalogClient;

  /// Cancel an in-flight `connect()` (e.g. user dismissed the device-code
  /// dialog). Completing the completer both wakes the blocking `Future.any`
  /// race and flips `isCompleted` for the next sync check.
  void cancelConnect() {
    final c = _cancelCompleter;
    if (c != null && !c.isCompleted) c.complete();
  }

  /// Called whenever the active Plex profile changes (or on initial load).
  Future<void> onActiveProfileChanged(String? newUserUuid) async {
    if (isDisposed) return;
    final userUuid = newUserUuid ?? '';
    final generation = ++_bindingGeneration;
    _activeUserUuid = userUuid;
    final loaded = await _store.load(userUuid);
    _setSessionAndRebind(userUuid, generation, loaded);
  }

  /// Run the device-code OAuth flow.
  ///
  /// [onCodeReady] is invoked once with the user code + verification URL so
  /// the UI can render the dialog.
  Future<bool> connect({required void Function(DeviceCode code) onCodeReady}) async {
    if (_isConnecting || isConnected) return false;
    _isConnecting = true;
    _cancelCompleter = Completer<void>();
    notifyListeners();
    try {
      return await runConnectPipeline<TrackerSession>(
        logLabel: 'Trakt',
        authorize: () => _auth.authorize(
          onCodeReady: onCodeReady,
          shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
          onCancel: _cancelCompleter!.future,
        ),
        enrich: _enrichUsername,
        save: (s) => _store.save(_activeUserUuid, s),
        assign: _bindCurrentSession,
      );
    } finally {
      final c = _cancelCompleter;
      if (c != null && !c.isCompleted) c.complete();
      _cancelCompleter = null;
      _isConnecting = false;
      safeNotifyListeners();
    }
  }

  Future<TrackerSession> _enrichUsername(TrackerSession raw) => enrichTrackerSessionUsername(
    session: raw,
    failureMessage: 'Trakt: getUserSettings failed (non-fatal)',
    createClient: () => TraktClient(raw, onSessionInvalidated: () {}),
    fetchUsername: (client) async => (await client.getUserSettings()).username,
  );

  /// Revoke the access token and clear local state.
  Future<void> disconnect() async {
    final userUuid = _activeUserUuid;
    final generation = ++_bindingGeneration;
    final session = _session;
    _setSessionAndRebind(userUuid, generation, null);
    if (session != null) {
      final client = TraktClient(session, onSessionInvalidated: () {});
      try {
        await client.revoke();
      } finally {
        client.dispose();
      }
    }
    await _store.clear(userUuid);
  }

  void _bindCurrentSession(TrackerSession? session) {
    _setSessionAndRebind(_activeUserUuid, ++_bindingGeneration, session);
  }

  void _setSessionAndRebind(String userUuid, int generation, TrackerSession? session) {
    if (!_isCurrentBinding(userUuid, generation)) return;
    _session = session;

    void handleInvalidated() => _handleSessionInvalidated(userUuid, generation);
    void handleUpdated(TrackerSession next) => _handleSessionUpdated(userUuid, generation, next);

    TraktScrobbleService.instance.rebindToProfile(
      session,
      onSessionInvalidated: handleInvalidated,
      onSessionUpdated: handleUpdated,
    );
    TraktSyncService.instance.rebindToProfile(
      userUuid,
      session,
      onSessionInvalidated: handleInvalidated,
      onSessionUpdated: handleUpdated,
    );
    _catalogClient?.dispose();
    _catalogClient = session == null
        ? null
        : TraktClient(session, onSessionInvalidated: handleInvalidated, onSessionUpdated: handleUpdated);
    safeNotifyListeners();
  }

  bool _isCurrentBinding(String userUuid, int generation) {
    return !isDisposed && userUuid == _activeUserUuid && generation == _bindingGeneration;
  }

  void _handleSessionUpdated(String userUuid, int generation, TrackerSession session) {
    if (!_isCurrentBinding(userUuid, generation)) return;
    _session = session;
    TraktScrobbleService.instance.updateSession(session);
    TraktSyncService.instance.updateSession(session);
    _catalogClient?.updateSession(session);
    unawaited(_store.save(userUuid, session));
    safeNotifyListeners();
  }

  /// Called by [TraktClient] when refresh fails permanently. Clears local state
  /// so the UI shows "not connected" and the user can re-link.
  void _handleSessionInvalidated(String userUuid, int generation) {
    if (!_isCurrentBinding(userUuid, generation)) return;
    final nextGeneration = ++_bindingGeneration;
    unawaited(_store.clear(userUuid));
    _setSessionAndRebind(userUuid, nextGeneration, null);
  }

  @visibleForTesting
  int get debugBindingGenerationForTesting => _bindingGeneration;

  @visibleForTesting
  void debugHandleSessionUpdatedForTesting(String userUuid, int generation, TrackerSession session) {
    _handleSessionUpdated(userUuid, generation, session);
  }

  @visibleForTesting
  void debugHandleSessionInvalidatedForTesting(String userUuid, int generation) {
    _handleSessionInvalidated(userUuid, generation);
  }

  @override
  void dispose() {
    _auth.dispose();
    _catalogClient?.dispose();
    _catalogClient = null;
    super.dispose();
  }
}
