import 'dart:async';
import 'dart:io' show Directory, Platform, ProcessInfo;
import 'dart:ui' show AppExitResponse;
import 'package:flutter/foundation.dart';
// ignore: depend_on_referenced_packages
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' as material show ThemeMode;
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import 'package:provider/provider.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'connection/connection.dart';
import 'connection/connection_bootstrap.dart';
import 'connection/connection_registry.dart';
import 'navigation/profile_navigation_scope.dart';
import 'navigation/profile_session_screen.dart';
import 'profiles/active_profile_binder.dart';
import 'profiles/active_profile_provider.dart';
import 'profiles/profile.dart';
import 'profiles/profile_connection_cleanup.dart';
import 'profiles/profile_connection_registry.dart';
import 'profiles/profile_registry.dart';
import 'profiles/profile_selection_policy.dart';
import 'mixins/mounted_set_state_mixin.dart';
import 'theme/mono_theme.dart';
import 'profiles/plex_home_service.dart';
import 'screens/auth_screen.dart';
import 'screens/profile/pin_entry_dialog.dart';
import 'screens/profile/profile_switch_screen.dart';
import 'services/storage_service.dart';
import 'services/device_performance.dart';
import 'services/macos_window_service.dart';
import 'services/native_window_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'services/settings_service.dart';
import 'utils/platform_detector.dart';
import 'services/apple_tv_remote_touch_service.dart';
import 'services/discord_rpc_service.dart';
import 'package:path_provider/path_provider.dart';
import 'services/image_cache_service.dart';
import 'services/gamepad_service.dart';
import 'services/trakt/trakt_scrobble_service.dart';
import 'services/trakt/trakt_sync_service.dart';
import 'services/trackers/tracker_coordinator.dart';
import 'providers/user_profile_provider.dart';
import 'providers/multi_server_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/download_provider.dart';
import 'providers/offline_mode_provider.dart';
import 'providers/offline_watch_provider.dart';
import 'providers/shader_provider.dart';
import 'utils/snackbar_helper.dart';
import 'services/multi_server_manager.dart';
import 'services/offline_watch_sync_service.dart';
import 'services/data_aggregation_service.dart';
import 'services/server_registry.dart';
import 'services/download_manager_service.dart';
import 'services/pip_service.dart';
import 'services/download_storage_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'services/jellyfin_api_cache.dart';
import 'services/plex_api_cache.dart';
import 'database/app_database.dart';
import 'database/download_operations.dart';
import 'database/tvos_database_recovery_store.dart';
import 'screens/video_player_screen.dart';
import 'utils/app_logger.dart';
import 'utils/managed_http_client.dart';
import 'utils/media_server_http_client.dart';
import 'utils/orientation_helper.dart';
import 'utils/watch_state_notifier.dart';
import 'i18n/app_locale_utils.dart';
import 'i18n/strings.g.dart';
import 'widgets/app_icon.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/focusable_button.dart';
import 'focus/key_event_utils.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'utils/navigation_transitions.dart';
import 'utils/log_redaction_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'utils/android_exit_diagnostics.dart';
import 'utils/storage_failure.dart';

const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
const String gitCommit = String.fromEnvironment('GIT_COMMIT');
const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT');
const String _sentryDist = String.fromEnvironment('SENTRY_DIST');

// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
// at (0,0) as barrier taps, causing modals to dismiss immediately.
// Remove when Flutter PR #179643 is merged.
bool _zeroOffsetPointerGuardInstalled = false;

void _installZeroOffsetPointerGuard() {
  if (_zeroOffsetPointerGuardInstalled || !Platform.isIOS) return;
  GestureBinding.instance.pointerRouter.addGlobalRoute(_absorbZeroOffsetPointerEvent);
  _zeroOffsetPointerGuardInstalled = true;
}

void _absorbZeroOffsetPointerEvent(PointerEvent event) {
  if (event is PointerDownEvent && event.position == Offset.zero) {
    GestureBinding.instance.cancelPointer(event.pointer);
  }
}

/// Register platform plugin stores manually for tvOS. Flutter's tool
/// doesn't support tvOS so it never generates a plugin registrant for it.
/// Each plugin whose iOS Swift implementation is tvOS-compatible must be
/// wired here; the Swift side (GeneratedPluginRegistrant.m / AppDelegate)
/// also needs to call the plugin's Swift register(with:) to attach its
/// message channels.
void _registerTvosPlatformPlugins() {
  if (!Platform.isIOS) return; // tvOS reports as iOS via dart:io.
  SharedPreferencesFoundation.registerWith();
}

void main() {
  final binding = WidgetsFlutterBinding.ensureInitialized();
  AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.dartMain);
  // Keep the accessibility tree available to Maestro and other UI automation
  // without adding release-build overhead.
  if (kDebugMode) binding.ensureSemantics();
  _installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug

  // On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS
  // target in Flutter's tool), so register platform stores manually for
  // the plugins we use.
  _registerTvosPlatformPlugins();
  _bootstrapApp();
}

void _bootstrapApp() {
  // In release mode, show a colored placeholder instead of a blank/white screen
  // when a widget build() throws an unhandled exception.
  ErrorWidget.builder = (FlutterErrorDetails details) {
    if (kDebugMode) return ErrorWidget(details.exception);
    return const ColoredBox(color: Color(0xFF000000));
  };

  AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.runApp);
  runApp(
    StartupBootstrap<_StartupDependencies>(
      initialize: _initializeApplication,
      buildApp: (context, dependencies) => MainApp(
        settings: dependencies.settings,
        storage: dependencies.storage,
        appDatabase: dependencies.appDatabase,
        databaseRecoveryOutcome: dependencies.databaseRecoveryOutcome,
      ),
      discard: (dependencies) => dependencies.appDatabase.close(),
      onCommitted: (dependencies) => _startNonessentialInitialization(dependencies.settings),
      lightTheme: monoTheme(dark: false),
      darkTheme: monoTheme(dark: true),
    ),
  );
}

Future<_StartupDependencies> _initializeApplication() async {
  final settings = await SettingsService.getInstance();
  setLoggerLevel(settings.read(SettingsService.enableDebugLogging));

  _StartupDependencies? dependencies;
  Future<void> initializeStartup() async {
    AndroidExitDiagnostics.markTelemetryReady();
    dependencies = await _initializeStartup(settings);
  }

  if (_enableSentry) {
    final packageInfo = await PackageInfo.fromPlatform();
    await SentryFlutter.init((options) {
      options.dsn = _sentryDsn;
      options.release = gitCommit.isNotEmpty
          ? 'plezy@${gitCommit.substring(0, 7)}'
          : 'plezy@${packageInfo.version}+${packageInfo.buildNumber}';
      if (_sentryEnvironment.isNotEmpty) options.environment = _sentryEnvironment;
      if (_sentryDist.isNotEmpty) options.dist = _sentryDist;
      options.tracesSampleRate = 0;
      options.attachStacktrace = true;
      options.enableAutoSessionTracking = false;
      options.recordHttpBreadcrumbs = false;
      options.captureNativeFailedRequests = false;
      options.enableAppHangTracking = !kDebugMode;
      options.appHangTimeoutInterval = const Duration(seconds: 3);
      options.beforeSend = _beforeSend;
      options.beforeBreadcrumb = _beforeBreadcrumb;
    }, appRunner: initializeStartup);
  } else {
    await initializeStartup();
  }
  return dependencies!;
}

const startupBootstrapProgressKey = Key('startup-bootstrap-progress');
const startupBootstrapFailureKey = Key('startup-bootstrap-failure');
const startupBootstrapRetryKey = Key('startup-bootstrap-retry');

/// Mounts a Flutter-owned startup frame before invoking the asynchronous
/// initialization gate. The generic seam keeps frame ordering, failure, and
/// retry behavior testable without constructing platform services.
@visibleForTesting
class StartupBootstrap<T> extends StatefulWidget {
  const StartupBootstrap({
    super.key,
    required this.initialize,
    required this.buildApp,
    this.discard,
    this.onCommitted,
    this.lightTheme,
    this.darkTheme,
    this.themeMode = material.ThemeMode.system,
  });

  final Future<T> Function() initialize;
  final Widget Function(BuildContext context, T value) buildApp;
  final FutureOr<void> Function(T value)? discard;
  final FutureOr<void> Function(T value)? onCommitted;
  final ThemeData? lightTheme;
  final ThemeData? darkTheme;
  final material.ThemeMode themeMode;

  @override
  State<StartupBootstrap<T>> createState() => _StartupBootstrapState<T>();
}

class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
  T? _value;
  Object? _error;
  bool _completed = false;
  bool _initializing = false;
  int _generation = 0;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.firstFrame);
      if (mounted) unawaited(_initialize());
    });
  }

  Future<void> _initialize() async {
    if (_initializing) return;

    final generation = ++_generation;
    setState(() {
      _error = null;
      _initializing = true;
    });

    try {
      final value = await widget.initialize();
      if (!mounted || generation != _generation) {
        await _discard(value);
        return;
      }

      setState(() {
        _value = value;
        _completed = true;
        _initializing = false;
      });
      unawaited(Future.sync(() => widget.onCommitted?.call(value)));
    } catch (error, stackTrace) {
      if (!mounted || generation != _generation) return;
      appLogger.e('Startup initialization failed (${error.runtimeType})', stackTrace: stackTrace);
      setState(() {
        _error = error;
        _initializing = false;
      });
    }
  }

  Future<void> _discard(T value) async {
    try {
      await widget.discard?.call(value);
    } catch (error, stackTrace) {
      appLogger.e('Failed to dispose an uncommitted startup result (${error.runtimeType})', stackTrace: stackTrace);
    }
  }

  @override
  void dispose() {
    _generation++;
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_completed) return widget.buildApp(context, _value as T);

    return TranslationProvider(
      child: Builder(
        builder: (context) => InputModeTracker(
          child: MaterialApp(
            debugShowCheckedModeBanner: false,
            theme: widget.lightTheme,
            darkTheme: widget.darkTheme,
            themeMode: widget.themeMode,
            home: Builder(builder: _buildBootstrapHome),
          ),
        ),
      ),
    );
  }

  Widget _buildBootstrapHome(BuildContext context) {
    return Scaffold(
      body: Center(
        child: _error == null
            ? const CircularProgressIndicator(key: startupBootstrapProgressKey)
            : Column(
                key: startupBootstrapFailureKey,
                mainAxisSize: MainAxisSize.min,
                children: [
                  const AppIcon(Symbols.error_rounded, size: 48),
                  const SizedBox(height: 16),
                  Text(t.common.error, style: Theme.of(context).textTheme.titleLarge),
                  const SizedBox(height: 16),
                  FocusableButton(
                    autofocus: true,
                    onPressed: _initializing ? null : () => unawaited(_initialize()),
                    child: FilledButton(
                      key: startupBootstrapRetryKey,
                      onPressed: _initializing ? null : () => unawaited(_initialize()),
                      child: Text(t.common.retry),
                    ),
                  ),
                ],
              ),
      ),
    );
  }
}

class _StartupDependencies {
  const _StartupDependencies({
    required this.settings,
    required this.storage,
    required this.appDatabase,
    required this.databaseRecoveryOutcome,
  });

  final SettingsService settings;
  final StorageService storage;
  final AppDatabase appDatabase;
  final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
}

@visibleForTesting
Future<AppDatabaseBootstrap> openAppDatabaseWithDownloadRecovery({
  required Future<AppDatabaseBootstrap> Function() openDatabase,
  required Future<void> Function() recoverNativeDownloads,
  required String storageFullMessage,
}) async {
  try {
    return await openDatabase();
  } catch (error, stackTrace) {
    if (!isStorageFullError(error)) rethrow;
    appLogger.w(
      'Application database could not open because storage is full; discarding interrupted downloads',
      error: error,
      stackTrace: stackTrace,
    );
  }

  await recoverNativeDownloads();
  final bootstrap = await openDatabase();
  final failedCount = await bootstrap.database.failActiveDownloadsForStorageFull(storageFullMessage);
  appLogger.w('Recovered startup after storage exhaustion; stopped $failedCount active download(s)');
  return bootstrap;
}

Future<_StartupDependencies> _initializeStartup(SettingsService settings) async {
  final startupWatch = Stopwatch()..start();
  var lastStartupMarkMs = 0;
  void markStartupPhase(String phase) {
    if (!kProfileMode) return;
    final elapsedMs = startupWatch.elapsedMilliseconds;
    appLogger.i('Startup phase $phase: ${elapsedMs - lastStartupMarkMs}ms (total ${elapsedMs}ms)');
    lastStartupMarkMs = elapsedMs;
  }

  AppDatabase? openedDatabase;
  try {
    final savedLocale = settings.read(SettingsService.appLocale);
    await LocaleSettings.setLocale(savedLocale);
    await initializeDateFormatting(savedLocale.intlLocaleName, null);
    markStartupPhase('locale');

    final futures = <Future<void>>[];
    if (PlatformDetector.isDesktopOS()) {
      if (Platform.isMacOS) {
        futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar()));
      } else {
        futures.add(windowManager.ensureInitialized());
      }
    }

    // MainApp reads both synchronous facades during its first build.
    futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
    futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)));

    final storageFuture = StorageService.getInstance();
    futures.add(storageFuture);
    await Future.wait(futures);
    final storage = await storageFuture;
    markStartupPhase('platform-services');

    AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseOpenStarted);
    final databaseBootstrap = await openAppDatabaseWithDownloadRecovery(
      openDatabase: () => AppDatabase.open(isTvos: PlatformDetector.isAppleTV()),
      recoverNativeDownloads: DownloadManagerService.discardInterruptedNativeDownloadsAfterStorageFailure,
      storageFullMessage: t.downloads.storageFull,
    );
    openedDatabase = databaseBootstrap.database;
    AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseReady);
    markStartupPhase('database-recovery');

    DevicePerformance.applyImageCacheBudget();

    // DownloadManagerService reads this singleton synchronously in MainApp's
    // initState, so its recoverable storage check remains in the explicit gate.
    await DownloadStorageService.instance.initialize(settings);
    markStartupPhase('download-storage');

    return _StartupDependencies(
      settings: settings,
      storage: storage,
      appDatabase: databaseBootstrap.database,
      databaseRecoveryOutcome: databaseBootstrap.recoveryOutcome,
    );
  } catch (_) {
    await openedDatabase?.close();
    rethrow;
  }
}

void _startNonessentialInitialization(SettingsService settings) {
  void bestEffort(String name, FutureOr<void> Function() action) {
    unawaited(
      Future.sync(action).catchError((Object error, StackTrace stackTrace) {
        appLogger.e('$name startup task failed (${error.runtimeType})', stackTrace: stackTrace);
      }),
    );
  }

  bestEffort('Legacy image cache cleanup', () async {
    if (settings.read(SettingsService.cleanedOldImageCache)) return;
    try {
      final tempDir = await getTemporaryDirectory();
      final oldCacheDir = Directory('${tempDir.path}/plexImageCache');
      if (await oldCacheDir.exists()) await oldCacheDir.delete(recursive: true);
    } finally {
      await settings.write(SettingsService.cleanedOldImageCache, true);
    }
  });

  bestEffort('Native window', () {
    if (Platform.isAndroid) PipService();
    NativeWindowService.initialize();
  });

  bestEffort('Fullscreen monitor', () async {
    FullscreenStateManager().startMonitoring();
    if (PlatformDetector.isDesktopOS() && settings.read(SettingsService.startInFullscreen)) {
      await FullscreenStateManager().enterFullscreen();
    }
  });

  bestEffort('Gamepad', () {
    GamepadService.instance.start();
    if (PlatformDetector.isAppleTV()) AppleTvRemoteTouchService.instance.start();
  });

  if (PlatformDetector.isDesktopOS()) {
    bestEffort('Discord RPC', DiscordRPCService.instance.initialize);
  }

  if (settings.read(SettingsService.crashReporting)) {
    unawaited(AndroidExitDiagnostics.logPreviousExit());
  }

  bestEffort('Trakt scrobble', TraktScrobbleService.instance.initialize);
  bestEffort('Shader licenses', _registerShaderLicenses);
  bestEffort('Environment diagnostics', _logEnvironmentDiagnostics);
}

Future<void> _logEnvironmentDiagnostics() async {
  final packageInfo = await PackageInfo.fromPlatform();
  final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
  String renderer = '';
  if (Platform.isAndroid) {
    final rendererName = await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer');
    renderer = ' [$rendererName]';
    await Future.sync(() => Sentry.configureScope((scope) => scope.setTag('renderer', rendererName ?? 'unknown')));
  }
  appLogger.i(
    'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer'
    ' [effects: ${DevicePerformance.describeSync()}]',
  );
  if (Platform.isAndroid) {
    appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB');
  }
}

Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
  if (breadcrumb == null) return null;

  final message = breadcrumb.message;
  final data = breadcrumb.data;
  if (message == null && (data == null || data.isEmpty)) return breadcrumb;

  if (message != null) breadcrumb.message = LogRedactionManager.redact(message);
  if (data != null) breadcrumb.data = data.map((k, v) => MapEntry(k, v is String ? LogRedactionManager.redact(v) : v));
  return breadcrumb;
}

FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
  // Drop event if user opted out of crash reporting.
  final instance = SettingsService.instanceOrNull;
  if (instance != null && !instance.read(SettingsService.crashReporting)) return null;

  // Drop unactionable errors
  final exceptions = event.exceptions;
  if (exceptions != null) {
    bool shouldDrop(SentryException e) {
      final v = e.value;
      final lowerValue = v?.toLowerCase();
      // Windows file-lock errors from cache manager cleanup
      if (e.type == 'FileSystemException' && v != null && v.contains('plexImageCache') && v.contains('errno = 32')) {
        return true;
      }
      if (e.type == 'FileSystemException' &&
          lowerValue != null &&
          lowerValue.contains('cached_network_image_ce') &&
          (lowerValue.contains('lock failed') || lowerValue.contains('writefrom failed'))) {
        return true;
      }
      // Linux without DBus/NetworkManager
      if (e.type == 'DBusServiceUnknownException' || (v != null && v.contains('system_bus_socket'))) {
        return true;
      }
      // Device out of disk space
      if (isStorageFullMessage(v)) return true;
      // Native HTTP errors from CFNetwork (server errors, not actionable)
      if (e.type == 'HTTPClientError') return true;
      // Benign EventChannel teardown race: the engine replies this when a
      // 'cancel' lands after the stream is already gone, and the framework
      // reports it via FlutterError — nothing was ever wrong user-side.
      if (e.type == 'PlatformException' && v != null && v.contains('No active stream to cancel')) return true;
      // Discord RPC errors when Discord is not running
      if (e.type == 'DiscordStateException') return true;
      return false;
    }

    if (exceptions.any(shouldDrop)) return null;

    // Scrub Plex tokens and server URLs from exception messages
    for (final e in exceptions) {
      final value = e.value;
      if (value != null) {
        e.value = LogRedactionManager.redact(value);
      }
    }
  }

  // Enrich TimeoutException with operation name + duration as tags/fingerprint.
  // value format: "TimeoutException after 0:00:05.000000: <operation> timed out"
  if (exceptions != null) {
    final timeoutException = exceptions.where((e) => e.type == 'TimeoutException').firstOrNull;
    if (timeoutException != null) {
      final value = timeoutException.value ?? '';
      final colonIdx = value.indexOf(': ');
      final message = colonIdx >= 0 ? value.substring(colonIdx + 2) : value;
      final operation = message.endsWith(' timed out')
          ? message.substring(0, message.length - ' timed out'.length)
          : null;
      final durationMatch = RegExp(r'after (\d+:\d{2}:\d{2}\.\d+)').firstMatch(value);

      final tags = event.tags ??= {};
      if (operation != null) tags['timeout.operation'] = operation;
      if (durationMatch != null) tags['timeout.duration'] = durationMatch.group(1)!;
      event.fingerprint = ['TimeoutException', ?operation];
    }
  }

  // Scrub breadcrumb messages and data
  final breadcrumbs = event.breadcrumbs;
  if (breadcrumbs != null) {
    for (final b in breadcrumbs) {
      final message = b.message;
      final data = b.data;
      if (message != null) b.message = LogRedactionManager.redact(message);
      if (data != null) b.data = data.map((k, v) => MapEntry(k, v is String ? LogRedactionManager.redact(v) : v));
    }
  }

  return event;
}

void _registerShaderLicenses() {
  LicenseRegistry.addLicense(() async* {
    yield const LicenseEntryWithLineBreaks(
      ['Anime4K'],
      'MIT License\n'
      '\n'
      'Copyright (c) 2019-2021 bloc97\n'
      'All rights reserved.\n'
      '\n'
      'Permission is hereby granted, free of charge, to any person obtaining a copy '
      'of this software and associated documentation files (the "Software"), to deal '
      'in the Software without restriction, including without limitation the rights '
      'to use, copy, modify, merge, publish, distribute, sublicense, and/or sell '
      'copies of the Software, and to permit persons to whom the Software is '
      'furnished to do so, subject to the following conditions:\n'
      '\n'
      'The above copyright notice and this permission notice shall be included in all '
      'copies or substantial portions of the Software.\n'
      '\n'
      'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR '
      'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, '
      'FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE '
      'AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER '
      'LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, '
      'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE '
      'SOFTWARE.',
    );
    yield const LicenseEntryWithLineBreaks(
      ['NVIDIA Image Scaling (NVScaler)'],
      'The MIT License (MIT)\n'
      '\n'
      'Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\n'
      '\n'
      'Permission is hereby granted, free of charge, to any person obtaining a copy of '
      'this software and associated documentation files (the "Software"), to deal in '
      'the Software without restriction, including without limitation the rights to '
      'use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of '
      'the Software, and to permit persons to whom the Software is furnished to do so, '
      'subject to the following conditions:\n'
      '\n'
      'The above copyright notice and this permission notice shall be included in all '
      'copies or substantial portions of the Software.\n'
      '\n'
      'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR '
      'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS '
      'FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR '
      'COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER '
      'IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN '
      'CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.',
    );
  });
}

final rootNavigatorKey = GlobalKey<NavigatorState>();

@visibleForTesting
bool shouldEnterOfflineModeAfterStartupBind({required bool bindingSucceeded, required bool hasOnlineServers}) {
  return !bindingSucceeded && !hasOnlineServers;
}

/// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the
/// profile-scoped widget tree. Routes through the app-global
/// [rootNavigatorKey] so the dialog survives profile-session remounts. Returns
/// `null` when no Navigator is available yet (early boot, post-dispose) so the
/// binder treats it as "PIN cancelled".
Future<String?> _rootPinPrompt(Profile profile, {String? errorMessage}) {
  final ctx = rootNavigatorKey.currentContext;
  if (ctx == null) return Future.value(null);
  return showPinEntryDialog(ctx, profile.displayName, errorMessage: errorMessage);
}

class MainApp extends StatefulWidget {
  final SettingsService settings;
  final StorageService storage;
  final AppDatabase appDatabase;
  final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;

  const MainApp({
    super.key,
    required this.settings,
    required this.storage,
    required this.appDatabase,
    required this.databaseRecoveryOutcome,
  });

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
  late final MultiServerManager _serverManager;
  late final DataAggregationService _aggregationService;
  late final AppDatabase _appDatabase;
  late final DownloadManagerService _downloadManager;
  late final OfflineWatchSyncService _offlineWatchSyncService;
  late final AppLifecycleListener _appLifecycleListener;
  StreamSubscription<WatchStateEvent>? _watchStateSubscription;

  /// WiFi-reconnect sync trigger, listening on [OfflineModeProvider] — the
  /// app's single connectivity subscription lives there.
  VoidCallback? _connectivitySyncListener;
  OfflineModeProvider? _connectivitySyncProvider;
  Timer? _syncDebounce;
  final Set<String> _pendingSyncKeys = <String>{};
  bool _isAutoDeleteRunning = false;
  bool _lastConnectivityWasWifi = false;
  bool _shutdownStarted = false;

  /// Last time server health probes ran from a resume event (cooldown for desktop)
  DateTime _lastResumeProbe = DateTime(0);

  /// Periodic RSS watchdog timer (desktop + Android).
  Timer? _memoryCheckTimer;

  /// Last watchdog eviction, for the cooldown; RSS at that moment so a
  /// still-climbing RSS can re-evict inside the cooldown window.
  DateTime _lastRssEviction = DateTime(0);
  int _lastEvictionRss = 0;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    _startRssWatchdog();

    _serverManager = MultiServerManager();
    _aggregationService = DataAggregationService(_serverManager);
    _appDatabase = widget.appDatabase;

    PlexApiCache.initialize(_appDatabase);
    JellyfinApiCache.initialize(_appDatabase);

    _downloadManager = DownloadManagerService(
      database: _appDatabase,
      storageService: DownloadStorageService.instance,
      clientResolver: _serverManager.resolveDownloadClient,
    );
    _downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads();

    _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager);

    // Trakt sync service (subscribes to WatchStateNotifier, requires serverManager
    // to resolve PlexClients for GUID lookups).
    TraktSyncService.instance.initialize(serverManager: _serverManager);
    // Tracker singletons init once per app; per-profile hydration happens in
    // the profile-scoped provider subtree's create callbacks.
    unawaited(TrackerCoordinator.instance.initialize());

    _appLifecycleListener = AppLifecycleListener(
      onExitRequested: () async {
        await _shutdownForExit();
        return AppExitResponse.exit;
      },
    );
  }

  Future<void> _shutdownForExit() async {
    if (_shutdownStarted) return;
    _shutdownStarted = true;

    _syncDebounce?.cancel();
    await _watchStateSubscription?.cancel();
    _removeConnectivitySyncListener();
    _memoryCheckTimer?.cancel();

    _downloadManager.dispose();
    TrackerCoordinator.instance.cancelInFlight();
    TraktScrobbleService.instance.cancelInFlight();
    await TraktSyncService.instance.dispose();

    await _serverManager.disconnectAllGracefully();
    await Future.wait([
      httpClient.closeGracefully(drainTimeout: const Duration(seconds: 5)),
      closeArtworkHttpClientGracefully(),
    ], eagerError: false);
    await ManagedHttpClient.closeAllGracefully();
    await _appDatabase.close();
  }

  @override
  void dispose() {
    _syncDebounce?.cancel();
    _watchStateSubscription?.cancel();
    _removeConnectivitySyncListener();
    _memoryCheckTimer?.cancel();
    _appLifecycleListener.dispose();
    if (!_shutdownStarted) {
      _downloadManager.dispose();
      _serverManager.dispose();
    }
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didHaveMemoryPressure() {
    super.didHaveMemoryPressure();
    appLogger.w('System memory pressure, evicting image caches');
    _evictImageCaches();
  }

  /// RSS-based image-cache eviction. Desktop keeps its fixed 1.5GB bar;
  /// Android scales to the device because LMK on a 2GB TV box kills well
  /// below any fixed desktop threshold — and Android trim callbacks
  /// ([didHaveMemoryPressure]) are best-effort, LMK can kill without ever
  /// delivering one (#1349).
  void _startRssWatchdog() {
    final int threshold;
    final Duration period;
    if (PlatformDetector.isDesktopOS()) {
      threshold = 1536 << 20; // 1.5GB
      period = const Duration(seconds: 30);
    } else if (Platform.isAndroid) {
      final totalMem = DevicePerformance.totalMemBytes;
      threshold = totalMem != null ? (totalMem * 0.45).round().clamp(512 << 20, 1536 << 20) : 1 << 30;
      // Decode bursts can spike RSS in seconds on low-end boxes; the read
      // itself is an in-process syscall, cheap enough for a short period.
      period = DevicePerformance.isLowEndHardware ? const Duration(seconds: 15) : const Duration(seconds: 30);
    } else {
      return; // iOS/tvOS: jetsam pressure arrives via didHaveMemoryPressure.
    }

    _memoryCheckTimer = Timer.periodic(period, (_) {
      final rss = ProcessInfo.currentRss;
      if (rss <= threshold) return;
      final cache = PaintingBinding.instance.imageCache;
      // Floor + cooldown: clearing an already-small cache buys nothing, and
      // refetch churn is its own memory-spike and jank source. Inside the
      // cooldown, re-evict only if RSS kept climbing past the last eviction.
      if (cache.currentSizeBytes < (8 << 20)) return;
      final now = DateTime.now();
      final inCooldown = now.difference(_lastRssEviction) < const Duration(seconds: 60);
      if (inCooldown && rss <= _lastEvictionRss) return;
      _lastRssEviction = now;
      _lastEvictionRss = rss;
      appLogger.w(
        'RSS high (${rss >> 20}MB > ${threshold >> 20}MB), evicting image caches '
        '(cache ${cache.currentSizeBytes >> 20}MB/${cache.currentSize} images, ${cache.liveImageCount} live)',
      );
      _evictImageCaches();
    });
  }

  void _evictImageCaches() {
    PaintingBinding.instance.imageCache.clear();
    PaintingBinding.instance.imageCache.clearLiveImages();
  }

  /// Fires [_autoDeleteAndSync] on each WiFi/Ethernet reconnect so rules run
  /// as soon as the device is back online. Listens on [OfflineModeProvider],
  /// which owns the app's single connectivity subscription and notifies on
  /// connection-type changes. Rapid flapping is bounded by the executor's
  /// cooldown.
  void _startConnectivitySyncTrigger(DownloadProvider downloadProvider, OfflineModeProvider offlineModeProvider) {
    _removeConnectivitySyncListener();
    _lastConnectivityWasWifi = offlineModeProvider.hasWifiOrEthernet;
    _connectivitySyncProvider = offlineModeProvider;
    _connectivitySyncListener = () {
      final hasWifi = offlineModeProvider.hasWifiOrEthernet;
      final transitioned = hasWifi && !_lastConnectivityWasWifi;
      _lastConnectivityWasWifi = hasWifi;
      if (transitioned) {
        appLogger.d('Connectivity moved onto WiFi/Ethernet — triggering sync pass');
        _autoDeleteAndSync(downloadProvider);
      }
    };
    offlineModeProvider.addListener(_connectivitySyncListener!);
  }

  void _removeConnectivitySyncListener() {
    final listener = _connectivitySyncListener;
    if (listener != null) _connectivitySyncProvider?.removeListener(listener);
    _connectivitySyncListener = null;
    _connectivitySyncProvider = null;
  }

  /// Run auto-delete (if enabled) and then a sync-rule pass.
  ///
  /// When [targetKeys] is non-null, only those rules are re-evaluated
  /// (cooldown doesn't apply — targeted runs are always "we know this
  /// changed"). When null, every rule runs via the executor, with [force]
  /// gating the cooldown: `true` for user-initiated drains, `false` for
  /// background probes like a connectivity reconnect.
  Future<void> _autoDeleteAndSync(
    DownloadProvider downloadProvider, {
    List<String>? targetKeys,
    bool force = false,
  }) async {
    if (_isAutoDeleteRunning) {
      if (targetKeys != null) _pendingSyncKeys.addAll(targetKeys);
      return;
    }
    _isAutoDeleteRunning = true;
    try {
      await downloadProvider.refreshMetadataFromCache();
      final activeGlobalKey = VideoPlayerScreenState.activeGlobalKey;
      final settings = SettingsService.instanceOrNull;
      if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) {
        final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeGlobalKey: activeGlobalKey);
        if (deleted.isNotEmpty) {
          final msg = deleted.length == 1
              ? t.messages.autoRemovedWatchedDownload(title: deleted.first)
              : t.messages.autoRemovedWatchedDownloads(n: deleted.length);
          showMainSnackBar(msg);
        }
      }

      if (targetKeys != null) {
        for (final key in targetKeys) {
          if (!downloadProvider.hasSyncRule(key)) continue;
          final result = await downloadProvider.executeSyncRuleFor(key, _serverManager);
          if (result != null && result.queuedCount > 0) {
            final title = result.title ?? 'Unknown';
            showMainSnackBar(t.downloads.syncedNewEpisodes(count: '1', title: '$title (${result.queuedCount})'));
          }
        }
      } else {
        final synced = await downloadProvider.executeSyncRules(_serverManager, force: force);
        if (synced.isNotEmpty) {
          showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
        }
      }
    } finally {
      _isAutoDeleteRunning = false;
      if (_pendingSyncKeys.isNotEmpty) {
        final queuedKeys = _pendingSyncKeys.toList();
        _pendingSyncKeys.clear();
        unawaited(_autoDeleteAndSync(downloadProvider, targetKeys: queuedKeys));
      }
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        // App came back to foreground - trigger sync check
        _offlineWatchSyncService.onAppResumed();
        TraktSyncService.instance.flushQueue();
        // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep.
        // On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown
        // to avoid piling up network probes from rapid alt-tabbing.
        final now = DateTime.now();
        final cooldown = (Platform.isIOS || Platform.isAndroid)
            ? const Duration(seconds: 10)
            : const Duration(minutes: 2);
        if (now.difference(_lastResumeProbe) >= cooldown) {
          _lastResumeProbe = now;
          // Await health check before reconnecting so stale "online" servers
          // get marked offline and included in the reconnection sweep.
          unawaited(() async {
            await _serverManager.checkServerHealth();
            await _serverManager.reconnectOfflineServers();
          }());
        }
      case AppLifecycleState.paused:
      case AppLifecycleState.detached:
        // Database is session-scoped and must survive suspend/resume.
        // Closing here would kill the Drift isolate channel while services
        // (sync, downloads, cache) still hold references to the executor.
        // SQLite WAL mode handles process death; desktop uses onExitRequested.
        if (PlatformDetector.isDesktopOS()) {
          if (ProcessInfo.currentRss > 1024 * 1024 * 1024) {
            // 1GB
            _evictImageCaches();
          }
        } else if (Platform.isAndroid) {
          // A backgrounded app is LMK's first candidate; shed the image
          // caches at a lower bar than the foreground watchdog to survive
          // the HOME press on low-RAM boxes.
          final totalMem = DevicePerformance.totalMemBytes;
          final bar = totalMem != null ? (totalMem * 0.35).round() : 768 << 20;
          if (ProcessInfo.currentRss > bar) {
            _evictImageCaches();
          }
        }
      case AppLifecycleState.inactive:
      case AppLifecycleState.hidden:
        // Transitional states - don't trigger session events
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        // Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup)
        // can manage stored Jellyfin/Plex connections without re-creating
        // the registry per-call site.
        Provider<SettingsService>.value(value: widget.settings),
        Provider<StorageService>.value(value: widget.storage),
        Provider<AppDatabase>.value(value: _appDatabase),
        Provider<ConnectionRegistry>(create: (_) => ConnectionRegistry(_appDatabase)),
        Provider<ProfileRegistry>(create: (_) => ProfileRegistry(_appDatabase)),
        Provider<ProfileConnectionRegistry>(create: (_) => ProfileConnectionRegistry(_appDatabase)),
        Provider<PlexHomeService>(
          create: (context) {
            // start() resolves StorageService internally — the singleton was
            // already initialised eagerly during boot, so the await is a
            // microtask hop in practice.
            final service = PlexHomeService(
              connections: context.read<ConnectionRegistry>(),
              profileConnections: context.read<ProfileConnectionRegistry>(),
              storage: context.read<StorageService>(),
            );
            unawaited(service.start());
            return service;
          },
          dispose: (_, s) => s.dispose(),
        ),
        ChangeNotifierProvider<ActiveProfileProvider>(
          create: (context) {
            final provider = ActiveProfileProvider(
              registry: context.read<ProfileRegistry>(),
              plexHome: context.read<PlexHomeService>(),
              connections: context.read<ConnectionRegistry>(),
              storage: context.read<StorageService>(),
            );
            unawaited(provider.initialize());
            return provider;
          },
        ),
        ChangeNotifierProvider(
          create: (context) {
            _serverManager.onJellyfinConnectionUpdated = context.read<ConnectionRegistry>().upsert;
            return MultiServerProvider(_serverManager, _aggregationService);
          },
        ),
        ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
          create: (context) {
            final provider = OfflineModeProvider(
              _serverManager,
              multiServerProvider: context.read<MultiServerProvider>(),
            );
            provider.initialize(); // Initialize immediately so statusStream listener is ready
            return provider;
          },
          update: (_, multiServerProvider, previous) {
            final provider = previous!;
            provider.updateMultiServerProvider(multiServerProvider);
            provider.initialize(); // Idempotent - safe to call again
            return provider;
          },
        ),
        // Profile binder owns the cold-start client connect: Plex token
        // refresh + Jellyfin client creation. Hoisted out of MainScreen so
        // the splash can await its first settle — without this, MainScreen
        // mounts (and discover/libraries query) before any client exists.
        // It is intentionally not auto-started here: SetupScreen first checks
        // whether startup should go straight offline, otherwise the binder's
        // microtask can begin network work before the offline decision lands.
        Provider<ActiveProfileBinder>(
          lazy: false,
          create: (context) {
            final activeProfile = context.read<ActiveProfileProvider>();
            return ActiveProfileBinder(
              activeProfile: activeProfile,
              connections: context.read<ConnectionRegistry>(),
              profileConnections: context.read<ProfileConnectionRegistry>(),
              serverManager: _serverManager,
              multiServerProvider: context.read<MultiServerProvider>(),
              pinPrompt: _rootPinPrompt,
              shouldDeferInitialBind: (_) async {
                final settings = await SettingsService.getInstance();
                return activeProfile.requiresSelectionOnOpen(settings);
              },
            );
          },
          dispose: (_, binder) => binder.dispose(),
        ),
        // Download provider. Downloads are shared, but sync rules are scoped to
        // the active profile and reload when the profile changes.
        ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
          create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
          update: (context, activeProfile, previous) {
            final provider = previous!;
            provider.setActiveProfileId(activeProfile.activeId);
            return provider;
          },
        ),
        ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
          create: (context) {
            final offlineModeProvider = context.read<OfflineModeProvider>();
            final downloadProvider = context.read<DownloadProvider>();
            final activeProfile = context.read<ActiveProfileProvider>();
            _offlineWatchSyncService.setActiveProfileId(
              activeProfile.activeId,
              // Legacy-adoption gate: only trust the count once the provider
              // has hydrated (locals + cached home users) — a transient
              // count of 1 mid-load would permanently mis-adopt pre-profile
              // watch actions.
              availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null,
            );

            // Offline-sync drain replays a batch of queued watch actions without
            // per-item data, so we can't target rules — force a full pass.
            _offlineWatchSyncService.onWatchStatesRefreshed = () async {
              await _autoDeleteAndSync(downloadProvider, force: true);
            };

            // In-session watch events carry the episode's parent chain, so we
            // only re-evaluate rules that actually cover the watched item —
            // leaves unrelated collection/playlist rules alone. Debounced so
            // binge-watching coalesces into one pass.
            _watchStateSubscription = WatchStateNotifier().stream.listen((event) {
              if (event.changeType != WatchStateChangeType.watched) return;
              if (VideoPlayerScreenState.activeGlobalKey == event.globalKey) return;

              _pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event));

              _syncDebounce?.cancel();
              _syncDebounce = Timer(const Duration(seconds: 5), () {
                final keys = _pendingSyncKeys.toList();
                _pendingSyncKeys.clear();
                _autoDeleteAndSync(downloadProvider, targetKeys: keys);
              });
            });

            _startConnectivitySyncTrigger(downloadProvider, offlineModeProvider);

            // Thread the offline flag into services so queue/resume paths can
            // short-circuit instead of hitting the network and failing.
            downloadProvider.setOfflineSource(offlineModeProvider);

            _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
            return _offlineWatchSyncService;
          },
          update: (_, activeProfile, previous) {
            final provider = previous!;
            provider.setActiveProfileId(
              activeProfile.activeId,
              availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null,
            );
            return provider;
          },
        ),
        ChangeNotifierProxyProvider2<OfflineWatchSyncService, DownloadProvider, OfflineWatchProvider>(
          create: (context) => OfflineWatchProvider(
            syncService: context.read<OfflineWatchSyncService>(),
            downloadProvider: context.read<DownloadProvider>(),
          ),
          update: (_, syncService, downloadProvider, previous) => previous!,
        ),
        ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
          create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
          update: (context, activeProfile, connections, previous) {
            final provider = previous!;
            provider.attach(
              connections: connections,
              activeProfile: activeProfile,
              profileConnections: context.read<ProfileConnectionRegistry>(),
              serverManager: context.read<MultiServerProvider>().serverManager,
            );
            return provider;
          },
        ),
        ChangeNotifierProvider(create: (context) => ThemeProvider()),
        // Shader presets are app-global — deliberately outside the
        // profile-scoped session in ProfileSessionScreen.
        ChangeNotifierProvider(create: (context) => ShaderProvider()),
      ],
      child: _AppShell(databaseRecoveryOutcome: widget.databaseRecoveryOutcome),
    );
  }
}

/// App-global shell: theme consumer, translations, root input handling, and the
/// root MaterialApp. Profile-scoped providers/navigation live in
/// [ProfileSessionScreen], not here, so root auth/PIN/global dialogs survive a
/// profile switch.
class _AppShell extends StatelessWidget {
  const _AppShell({required this.databaseRecoveryOutcome});

  final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;

  @override
  Widget build(BuildContext context) {
    return Consumer<ThemeProvider>(
      builder: (context, themeProvider, child) {
        return TranslationProvider(
          child: Builder(
            builder: (context) {
              return Listener(
                onPointerDown: (event) {
                  if ((event.buttons & kBackMouseButton) != 0) {
                    unawaited(() async {
                      final rootNavigator = rootNavigatorKey.currentState;
                      if (rootNavigator?.canPop() ?? false) {
                        await rootNavigator?.maybePop();
                        return;
                      }
                      await profileNavigationRegistry.maybePopProfileRoute();
                    }());
                  }
                },
                behavior: HitTestBehavior.translucent,
                child: InputModeTracker(
                  child: MaterialApp(
                    title: t.app.title,
                    debugShowCheckedModeBanner: false,
                    theme: themeProvider.lightTheme,
                    darkTheme: themeProvider.darkTheme,
                    themeMode: themeProvider.materialThemeMode,
                    navigatorKey: rootNavigatorKey,
                    navigatorObservers: [BackKeySuppressorObserver()],
                    home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
                    // Siri Remote select + gamepad A report as
                    // LogicalKeyboardKey.{select,gameButtonA} which aren't
                    // in Flutter's default shortcut set — Material-level
                    // widgets (menu items, showModalBottomSheet actions)
                    // ignore them. Map both to ActivateIntent so tapping
                    // select on tvOS activates the focused widget.
                    shortcuts: <ShortcutActivator, Intent>{
                      ...WidgetsApp.defaultShortcuts,
                      const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(),
                      const SingleActivator(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
                      const SingleActivator(LogicalKeyboardKey.goBack): const DismissIntent(),
                      const SingleActivator(LogicalKeyboardKey.browserBack): const DismissIntent(),
                      const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(),
                    },
                    builder: (context, child) => ScaffoldMessenger(
                      key: rootScaffoldMessengerKey,
                      child: Scaffold(
                        backgroundColor: Colors.transparent,
                        body: _AppleTvScale(child: child),
                      ),
                    ),
                  ),
                ),
              );
            },
          ),
        );
      },
    );
  }
}

/// On Apple TV the system hands Flutter a 1920×1080 surface at
/// devicePixelRatio 1.0, the same logical pixel count as a phablet. That's
/// too dense for a 10ft viewing distance, so everything ends up tiny. We
/// shrink the effective logical size to half and scale the rendered output
/// back up so fonts, icons, and paddings end up visually ~2× larger — roughly
/// matching the UI feel of Android TV (which renders at lower logical DPI).
class _AppleTvScale extends StatelessWidget {
  final Widget? child;
  const _AppleTvScale({required this.child});

  static const double _scale = 2.0;

  @override
  Widget build(BuildContext context) {
    if (child == null || !PlatformDetector.isAppleTV()) {
      return child ?? const SizedBox.shrink();
    }
    return LayoutBuilder(
      builder: (context, constraints) {
        final logicalSize = Size(constraints.maxWidth / _scale, constraints.maxHeight / _scale);
        final outerQ = MediaQuery.of(context);
        // tvOS reports conservative overscan insets (~60pt top/bottom,
        // ~90pt left/right). Modern TVs don't overscan, so treat them as
        // dead margin and zero them out — the UI can use the full surface.
        return Transform.scale(
          scale: _scale,
          alignment: .topLeft,
          transformHitTests: true,
          child: SizedBox(
            width: logicalSize.width,
            height: logicalSize.height,
            child: MediaQuery(
              data: outerQ.copyWith(
                size: logicalSize,
                devicePixelRatio: outerQ.devicePixelRatio * _scale,
                padding: .zero,
                viewPadding: .zero,
                viewInsets: .zero,
                systemGestureInsets: .zero,
              ),
              child: child!,
            ),
          ),
        );
      },
    );
  }
}

@visibleForTesting
bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
  return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
}

class SetupScreen extends StatefulWidget {
  const SetupScreen({
    super.key,
    required this.databaseRecoveryOutcome,
    this.initializeAuthServices = true,
    this.debugRecoveryRequiredRouter,
  });

  final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
  final bool initializeAuthServices;
  @visibleForTesting
  final FutureOr<void> Function(BuildContext context, String message)? debugRecoveryRequiredRouter;

  @override
  State<SetupScreen> createState() => _SetupScreenState();
}

class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
  String _statusMessage = '';
  bool _enteringOffline = false;

  // Per-server connection status: serverId -> (name, connected?)
  final Map<String, (String name, bool? connected)> _serverStatus = {};

  @override
  void initState() {
    super.initState();
    _loadSavedCredentials();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // The app's first screen: undo any orientation lock a previous run's
    // full-screen player left behind, and re-apply it whenever the form
    // factor signals (Theme.platform / MediaQuery size) change.
    OrientationHelper.restoreDefaultOrientations(context);
  }

  void _setStatus(String message) {
    setStateIfMounted(() => _statusMessage = message);
  }

  Future<void> _enterOfflineMode() async {
    if (_enteringOffline) return;
    _enteringOffline = true;
    _setStatus(t.common.startingOfflineMode);
    await context.read<DownloadProvider>().ensureInitialized();
    if (!mounted) return;
    AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen);
    unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true))));
  }

  Future<void> _loadSavedCredentials() async {
    if (shouldBypassSetupForDatabaseRecovery(widget.databaseRecoveryOutcome)) {
      final message = t.auth.localDataRecoveryRequired;
      final debugRouter = widget.debugRecoveryRequiredRouter;
      if (debugRouter != null) {
        await Future.sync(() => debugRouter(context, message));
        return;
      }
      await Future<void>.delayed(Duration.zero);
      if (mounted) {
        unawaited(
          Navigator.pushReplacement(
            context,
            fadeRoute(
              AuthScreen(
                initialErrorMessage: message,
                initializeServices: widget.initializeAuthServices,
                databaseRecoveryRequired: true,
              ),
            ),
          ),
        );
      }
      return;
    }

    _setStatus(t.common.checkingNetwork);

    final storage = await StorageService.getInstance();
    final registry = ServerRegistry(storage);

    // Idempotent: brings legacy SharedPreferences state (plexToken,
    // currentUserUUID, homeUsersCache) into the new ConnectionRegistry +
    // ProfileRegistry tables. No-op on subsequent launches.
    if (mounted) {
      try {
        final connRegistry = context.read<ConnectionRegistry>();
        final profileConnections = context.read<ProfileConnectionRegistry>();
        final profileRegistry = context.read<ProfileRegistry>();
        final activeProfiles = context.read<ActiveProfileProvider>();
        final serverManager = context.read<MultiServerProvider>().serverManager;
        final bootstrap = ConnectionBootstrap(
          storage: storage,
          connectionRegistry: connRegistry,
          serverRegistry: registry,
          profileRegistry: profileRegistry,
        );
        await bootstrap.run();
        final pruned = await ProfileConnectionCleanup(
          profileConnections: profileConnections,
          connections: connRegistry,
          storage: storage,
          serverManager: serverManager,
        ).pruneUnreferencedJellyfinConnections();
        if (pruned > 0) {
          appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
        }
        // Provider initialization starts before this screen runs the legacy
        // migration. Reload after bootstrap so copied Plex Home users and the
        // selected active profile are visible before setup decides binding is
        // already settled and navigates to MainScreen.
        await activeProfiles.reloadFromStorage();
      } catch (e, st) {
        appLogger.w('Boot-time migration failed', error: e, stackTrace: st);
      }
    }

    // Check network connectivity early to fast-path airplane mode.
    // Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
    bool hasNetwork;
    unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Checking network connectivity', category: 'setup')));
    try {
      final connectivityResult = await Connectivity().checkConnectivity().timeout(
        const Duration(seconds: 3),
        onTimeout: () => [ConnectivityResult.other],
      );
      hasNetwork = !connectivityResult.contains(ConnectivityResult.none);
    } catch (e) {
      // connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager
      hasNetwork = true;
    }

    unawaited(
      Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup')),
    );

    _setStatus(t.common.loadingServers);

    if (!mounted) return;

    // Snapshot ConnectionRegistry before we cross any awaits — Provider lookups
    // through `context` after async gaps trip the use_build_context_synchronously
    // lint, and reading early is safe because the registry is a singleton.
    final connectionRegistry = context.read<ConnectionRegistry>();
    final List<Connection> allConnections;
    try {
      allConnections = await connectionRegistry.list();
      AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.credentialsLoaded);
    } catch (e, st) {
      // Defence-in-depth: a DB-open failure here used to propagate
      // uncaught and strand the splash forever (#1022). Route to auth so
      // the user is never trapped, and surface to Sentry so an unknown
      // regression doesn't go silent.
      appLogger.e('Setup: failed to load connections; returning to auth', error: e, stackTrace: st);
      unawaited(Sentry.captureException(e, stackTrace: st));
      if (mounted) {
        unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
      }
      return;
    }

    if (allConnections.isEmpty) {
      if (mounted) {
        unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
      }
      return;
    }

    if (!mounted) return;

    // No network — skip connection attempts and go straight to offline mode
    if (!hasNetwork) {
      await _enterOfflineMode();
      return;
    }

    if (mounted) {
      setState(() {
        for (final conn in allConnections) {
          if (conn is PlexAccountConnection) {
            for (final s in conn.servers) {
              _serverStatus[s.clientIdentifier] = (s.name, null);
            }
          } else if (conn is JellyfinConnection) {
            _serverStatus[conn.serverMachineId] = (conn.serverName, null);
          }
        }
      });
    }

    final plexCount = allConnections.whereType<PlexAccountConnection>().fold<int>(0, (n, c) => n + c.servers.length);
    final jellyfinCount = allConnections.whereType<JellyfinConnection>().length;
    unawaited(
      Sentry.addBreadcrumb(
        Breadcrumb(
          message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin',
          category: 'setup',
        ),
      ),
    );
    _setStatus(t.common.connectingToServers);

    // Snapshot Provider refs before further awaits.
    final activeProfile = context.read<ActiveProfileProvider>();
    // The Provider is `lazy: false` so the binder is constructed already, but
    // SetupScreen starts it only after the offline fast path has been ruled out.
    final binder = context.read<ActiveProfileBinder>();
    final downloadProvider = context.read<DownloadProvider>();

    // Wait for the active profile to load from disk so the binder has a
    // profile to bind. `initialize` is fire-and-forget at provider creation,
    // so awaiting here pulls control through the same future and triggers
    // the listener-driven rebind synchronously.
    await activeProfile.reloadFromStorage();
    if (!mounted) return;

    if (activeProfile.active == null && activeProfile.profiles.isEmpty) {
      appLogger.w('Setup: stored connections exist but no profiles resolved after bootstrap; returning to auth');
      unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
      return;
    }

    // Wire the per-server status listener before either branch so the splash
    // checkmarks fill in even while the user is choosing a profile.
    _bindServerStatusListener(activeProfile, _serverManagerFromContext);

    // Start only after network/offline startup has been decided and the
    // active profile snapshot is hydrated. This prevents an eager binder
    // microtask from racing the no-network/manual-offline fast path.
    AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.bindingStarted);
    binder.start();

    // If "prompt for profile on launch" is on (or no profile is selected
    // yet), surface the picker BEFORE waiting for the previously-active
    // profile's bind to settle — otherwise the user sees the splash fully
    // connect before the prompt arrives. The picker's own `_switchTo` calls
    // `awaitBindingSettle` after activation, so by the time it pops, the
    // chosen profile's bind is settled.
    final settings = await SettingsService.getInstance();
    if (!mounted) return;
    final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
    final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings);

    var bindingSucceeded = activeProfile.lastBindingSucceeded;
    if (shouldPrompt) {
      await Navigator.of(
        context,
      ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
      if (!mounted) return;
      bindingSucceeded = activeProfile.active != null && activeProfile.lastBindingSucceeded;
    } else {
      // Now wait for the binder to settle. This is the Plex/Jellyfin server
      // race: per-server status flips on the splash list as each client comes
      // online, and we don't push MainScreen until they're all done (success
      // or fail). Eliminates the "Failed to load discover content: No servers
      // available" race the old eager-navigate flow caused.
      bindingSucceeded = await activeProfile.awaitBindingSettle();
      if (!mounted) return;
    }
    AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.bindingSettled);

    if (shouldEnterOfflineModeAfterStartupBind(
      bindingSucceeded: bindingSucceeded,
      hasOnlineServers: _serverManagerFromContext().onlineServerIds.isNotEmpty,
    )) {
      appLogger.w('Setup: no servers online after startup bind; starting offline mode');
      await _enterOfflineMode();
      return;
    }

    // Repopulate metadata for downloaded items now that per-backend caches
    // are resolvable (the Connections row + live JellyfinClient are in
    // place). Without this the downloads list and sync-rule titles render
    // empty until something forces a later refresh.
    await downloadProvider.refreshMetadataFromCache();
    if (!mounted) return;

    AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen);
    unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt))));
  }

  /// Wire per-server status updates from [MultiServerManager] into the
  /// splash list so the user sees check/cross marks land as the binder
  /// brings each client online. [MultiServerManager.connectProgressStream]
  /// fires as each individual server settles; [MultiServerManager.statusStream]
  /// emits once per connect pass and back-fills anything the progress stream
  /// missed (e.g. servers torn down by the binder's visibility sweep).
  /// Best-effort: stops listening when the state goes away.
  StreamSubscription<Map<String, bool>>? _statusSub;
  StreamSubscription<({String serverId, bool online})>? _connectProgressSub;

  void _bindServerStatusListener(ActiveProfileProvider _, MultiServerManager Function() resolveManager) {
    _statusSub?.cancel();
    _connectProgressSub?.cancel();
    final manager = resolveManager();
    _connectProgressSub = manager.connectProgressStream.listen((progress) {
      if (!mounted) return;
      final existing = _serverStatus[progress.serverId];
      if (existing == null) return;
      setState(() {
        _serverStatus[progress.serverId] = (existing.$1, progress.online);
      });
    });
    _statusSub = manager.statusStream.listen((status) {
      if (!mounted) return;
      setState(() {
        for (final entry in status.entries) {
          final existing = _serverStatus[entry.key];
          if (existing != null) {
            _serverStatus[entry.key] = (existing.$1, entry.value);
          }
        }
      });
    });
  }

  MultiServerManager _serverManagerFromContext() => context.read<MultiServerProvider>().serverManager;

  @override
  void dispose() {
    _statusSub?.cancel();
    _connectProgressSub?.cancel();
    super.dispose();
  }

  Widget _buildStatusText(BuildContext context) {
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 200),
      child: Text(
        _statusMessage,
        key: ValueKey(_statusMessage),
        textAlign: TextAlign.center,
        style: Theme.of(
          context,
        ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
      ),
    );
  }

  Widget _buildServerStatusList(BuildContext context) {
    if (_serverStatus.isEmpty) return const SizedBox.shrink();
    final textTheme = Theme.of(context).textTheme;
    final dimColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5);
    const coralColor = Color(0xFFE5A00D);
    const successColor = Color(0xFF4CAF50);
    const failColor = Color(0xFFEF5350);

    return Column(
      mainAxisSize: .min,
      children: _serverStatus.entries.map((entry) {
        final (name, connected) = entry.value;
        final Widget statusIcon;
        if (connected == null) {
          statusIcon = const SizedBox(
            width: 12,
            height: 12,
            child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
          );
        } else if (connected) {
          statusIcon = const AppIcon(Symbols.check_circle_rounded, size: 14, color: successColor);
        } else {
          statusIcon = const AppIcon(Symbols.cancel_rounded, size: 14, color: failColor);
        }
        return Padding(
          key: ValueKey(entry.key),
          padding: const EdgeInsets.symmetric(vertical: 2),
          child: Row(
            mainAxisSize: .min,
            children: [
              statusIcon,
              const SizedBox(width: 8),
              Text(name, style: textTheme.bodySmall?.copyWith(color: dimColor)),
            ],
          ),
        );
      }).toList(),
    );
  }

  @override
  Widget build(BuildContext context) {
    const coralColor = Color(0xFFE5A00D);
    return ColoredBox(
      color: Theme.of(context).scaffoldBackgroundColor,
      child: Stack(
        children: [
          Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
          Positioned(
            left: 0,
            right: 0,
            bottom: MediaQuery.sizeOf(context).height * 0.5 - 170,
            child: _buildStatusText(context),
          ),
          Positioned(
            left: 0,
            right: 0,
            top: MediaQuery.sizeOf(context).height * 0.5 + 180,
            child: Center(
              child: _serverStatus.isEmpty
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2, color: coralColor),
                    )
                  : _buildServerStatusList(context),
            ),
          ),
        ],
      ),
    );
  }
}
