// Chanora Flutter application — External Beta build.
//
// Builds on Internal Beta v0.3.0-beta.1:
//   * server password field
//   * bookmark list with add / connect / delete actions
//   * channel tap-to-join with optional channel password
//   * self input + output mute toggles + master output gain slider
//   * diagnostics dialog + reconnect banner + identity persistence
//     (all carried over from v0.3.0-beta.1)

import 'dart:async';
import 'dart:io' show Platform, Process;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';

import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart';
import 'services/audio_lifecycle_service.dart';
import 'services/channel_join_error_mapper.dart';
import 'services/connection_phase_state.dart';
import 'services/ios_permissions_service.dart';
import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart';
import 'services/ui_preferences_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/audio_processing_config_state.dart';
import 'widgets/chat_views.dart';
import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
import 'package:share_plus/share_plus.dart';

bool get _isMacOS => !kIsWeb && Platform.isMacOS;

const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');

const Color _appSurfaceColor = Color(0xFFFFFBFE);

/// Top padding for macOS to clear traffic-light buttons.
const double _macOSTrafficLightPad = 56.0;

/// Public version string shown in the About dialog. Resolved at
/// app init by combining a hardcoded semver baseline (kept in sync
/// with the git tag and pubspec.yaml's `version:` field) with the
/// platform-canonical build counter from `package_info_plus`.
///
/// Why hardcode the semver instead of reading the whole string
/// from `package_info_plus`: iOS rejects non-numeric characters
/// in `CFBundleShortVersionString` and Flutter therefore strips
/// `-rc.8` to `.8` when populating the Info.plist field. The
/// resulting `1.0.0.8` is technically valid on the App Store but
/// useless to humans tracking pre-release builds.
/// `package_info_plus.version` reflects that mangled value. The
/// build counter (`CFBundleVersion` / Android `versionCode`) does
/// pass through unmodified, so we use platform info for the
/// `+<build>` suffix only and pair it with the human-readable
/// semver baseline that this codebase already maintains as the
/// canonical release identity.
///
/// Bump [appSemverBaseline] whenever the semver portion of
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
/// build-counter suffix changes automatically on every pubspec
/// `+<n>` bump because Flutter writes it into Info.plist.
String _kAppVersion = appSemverBaseline;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  unawaited(wireStorage());
  unawaited(wireConnectivity());
  wireAudioLifecycle();
  await configureBundledVadModels();
  runApp(const ChanoraApp());
  unawaited(_finishDeferredStartup());
}

Future<void> _finishDeferredStartup() async {
  try {
    _kAppVersion = await resolveAppVersion();
  } catch (_) {}
}

class ChanoraApp extends StatefulWidget {
  const ChanoraApp({super.key});

  @override
  State<ChanoraApp> createState() => _ChanoraAppState();
}

class _ChanoraAppState extends State<ChanoraApp> {
  final UiPreferencesService _uiPreferences = const UiPreferencesService();
  ThemeMode _themeMode = ThemeMode.system;

  @override
  void initState() {
    super.initState();
    unawaited(_loadThemeMode());
  }

  Future<void> _loadThemeMode() async {
    try {
      final settings = await _uiPreferences.loadSettings();
      if (!mounted) return;
      setState(() {
        _themeMode = settings.themeMode.toFlutterThemeMode();
      });
    } catch (_) {}
  }

  @override
  Widget build(BuildContext context) {
    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: const SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        statusBarIconBrightness: Brightness.dark,
        systemNavigationBarColor: _appSurfaceColor,
        systemNavigationBarDividerColor: _appSurfaceColor,
        systemNavigationBarIconBrightness: Brightness.dark,
      ),
      child: MaterialApp(
        onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
        // No "DEBUG" banner in the top-right corner. This is purely
        // cosmetic for the developer-build experience; release builds
        // never render it regardless of this flag.
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          useMaterial3: true,
          colorSchemeSeed: const Color(0xFF3F51B5),
          scaffoldBackgroundColor: _appSurfaceColor,
        ),
        darkTheme: ThemeData(
          useMaterial3: true,
          colorSchemeSeed: const Color(0xFF3F51B5),
          brightness: Brightness.dark,
        ),
        themeMode: _themeMode,
        localizationsDelegates: AppL10n.localizationsDelegates,
        supportedLocales: AppL10n.supportedLocales,
        home: _BetaHome(
          themeMode: _themeMode,
          onThemeModeChanged: _setThemeMode,
        ),
      ),
    );
  }

  Future<void> _setThemeMode(ThemeMode themeMode) async {
    setState(() {
      _themeMode = themeMode;
    });
    try {
      await _uiPreferences.saveThemeMode(themeMode.toUiThemeMode());
    } catch (_) {}
  }
}

extension on UiThemeMode {
  ThemeMode toFlutterThemeMode() {
    return switch (this) {
      UiThemeMode.system => ThemeMode.system,
      UiThemeMode.light => ThemeMode.light,
      UiThemeMode.dark => ThemeMode.dark,
    };
  }
}

extension on ThemeMode {
  UiThemeMode toUiThemeMode() {
    return switch (this) {
      ThemeMode.system => UiThemeMode.system,
      ThemeMode.light => UiThemeMode.light,
      ThemeMode.dark => UiThemeMode.dark,
    };
  }
}

class ChanoraThemeModeMenu extends StatelessWidget {
  const ChanoraThemeModeMenu({
    super.key,
    required this.themeMode,
    required this.onThemeModeChanged,
  });

  final ThemeMode themeMode;
  final Future<void> Function(ThemeMode mode) onThemeModeChanged;

  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<ThemeMode>(
      tooltip: 'Theme',
      icon: const Icon(Icons.palette_outlined),
      initialValue: themeMode,
      onSelected: (mode) {
        unawaited(onThemeModeChanged(mode));
      },
      itemBuilder: (context) => const [
        PopupMenuItem(value: ThemeMode.system, child: Text('System')),
        PopupMenuItem(value: ThemeMode.light, child: Text('Light')),
        PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')),
      ],
    );
  }
}

class ChanoraMobileScaffold extends StatelessWidget {
  const ChanoraMobileScaffold({
    super.key,
    required this.compactIdleChrome,
    required this.canDisconnect,
    required this.title,
    required this.actions,
    required this.onDisconnect,
    required this.compactHeader,
    required this.body,
    this.disconnectTooltip = 'Disconnect',
  });

  final bool compactIdleChrome;
  final bool canDisconnect;
  final Widget? title;
  final List<Widget> actions;
  final VoidCallback onDisconnect;
  final Widget compactHeader;
  final Widget body;
  final String disconnectTooltip;

  @override
  Widget build(BuildContext context) {
    final paddedBody = SafeArea(
      top: compactIdleChrome,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: compactIdleChrome
            ? Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  compactHeader,
                  const SizedBox(height: 8),
                  Expanded(child: body),
                ],
              )
            : body,
      ),
    );

    return Scaffold(
      appBar: compactIdleChrome
          ? null
          : AppBar(
              leading: canDisconnect
                  ? IconButton(
                      tooltip: disconnectTooltip,
                      icon: const Icon(Icons.arrow_back),
                      onPressed: onDisconnect,
                    )
                  : null,
              leadingWidth: canDisconnect ? 44 : null,
              titleSpacing: canDisconnect ? 4 : null,
              title: title,
              actions: actions,
            ),
      body: paddedBody,
    );
  }
}

class _BetaHome extends StatefulWidget {
  const _BetaHome({required this.themeMode, required this.onThemeModeChanged});

  final ThemeMode themeMode;
  final Future<void> Function(ThemeMode mode) onThemeModeChanged;

  @override
  State<_BetaHome> createState() => _BetaHomeState();
}

class _ReceivedPoke {
  const _ReceivedPoke({
    required this.senderName,
    required this.message,
    required this.receivedAt,
  });

  final String senderName;
  final String message;
  final DateTime receivedAt;
}

class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
  static const _wideBreakpoint = 600.0;

  final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
  final _nickCtl = TextEditingController(text: 'ChanoraBeta');
  final _passwordCtl = TextEditingController();

  ConnectionPhase _phase = ConnectionPhase.idle;
  bool get _serverReachable => _phase.isServerReachable;
  rust.BridgeSnapshot? _snapshot;
  final List<String> _uiDiagnostics = [];
  rust.BridgeAudioStats? _audioStats;
  Timer? _statsTimer;
  bool _snapshotRefreshInFlight = false;
  bool _snapshotRefreshQueued = false;
  bool _snapshotRefreshQueuedRecordActivity = false;
  bool _snapshotRefreshQueuedReportErrors = false;
  int _connectionEpoch = 0;
  StreamSubscription<rust.BridgeEvent>? _eventsSub;
  late final PrefetchDebouncer _prefetch;

  // v1 voice subsystem state (SDD-094/095/096/097). Driven by
  // BridgeEvent::VoiceState.
  bool _inChannel = false;
  rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
  bool _hardMute = false;
  bool _hardMuteByPermission = false;
  bool _hardMuteByTalkPower = false;
  bool _permissionHardMuteClearInFlight = false;
  int _releaseTailMs = 200;
  BigInt? _currentVoiceChannelId;
  BigInt? _pendingVoiceChannelId;
  bool _canJoinVoiceChannel = true;
  bool _voiceStateInitialized = false;

  String? _lostReason;
  int? _reconnectAttempt;
  int? _reconnectDelay;

  bool _inputMuted = false;
  bool _outputMuted = false;

  // Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091).
  // Populated by `BridgeEvent.PttCapability`. Defaults match the
  // universal `FocusedPttBackend::focused()` descriptor so the UI
  // shows an honest baseline even before the first event arrives.
  String _pttLevel = 'L0Focused';
  String _pttBackendId = 'focused';
  String _pttBoundInputClass = 'keyboard';
  // Last platform-neutral key label the user saved in the
  // `PttBindingCaptureDialog` (e.g. "Space", "F10",
  // "mouse-side-button:8"). Surfaced next to the capability
  // badge so the user can remember which key drives PTT. The
  // bridge-side `PttController` (SDD-088) holds the authoritative
  // binding; this is a display-only cache that resets on app
  // restart. Per DEC-027 the raw OS key code never lives here —
  // only the platform-neutral label that already crossed into
  // the Rust side.
  String _pttBoundKeyLabel = '';
  final Set<LogicalKeyboardKey> _focusedPttHeldKeys = <LogicalKeyboardKey>{};

  List<rust.BridgeBookmark> _bookmarks = const [];
  final List<ChatEntry> _chatMessages = [];
  final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
  int _chatUnread = 0;
  bool _chatOpen = false;
  final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
    const [],
  );
  bool _pokeSnackBarVisible = false;

  // SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
  // Constructed at startup so cold-launch state is captured before the
  // first voice_join attempt. On non-Android hosts the service
  // short-circuits to "granted" and never wires the MethodChannel
  // (see AndroidPermissionsService for the platform branch).
  final AndroidPermissionsService _androidPermissions =
      AndroidPermissionsService();
  final IosPermissionsService _iosPermissions = IosPermissionsService();
  final UiPreferencesService _uiPreferences = const UiPreferencesService();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
    _prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer);
    _hostCtl.addListener(_onHostEdited);
    _eventsSub = rust.eventsStream().listen(_onEvent);
    // SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
    // events as early as possible so the listen-only banner reflects
    // the system state on first frame.
    _androidPermissions.start();
    unawaited(_iosPermissions.start());
    _androidPermissions.recordAudioState.addListener(
      _onRecordAudioPermissionChanged,
    );
    _iosPermissions.recordAudioState.addListener(
      _onRecordAudioPermissionChanged,
    );
    WidgetsBinding.instance.addPostFrameCallback((_) {
      unawaited(_requestRecordAudioOnStartup());
    });
    unawaited(_reloadBookmarks());
    unawaited(_hydratePttBinding());
    unawaited(_loadUiSettings());
  }

  Future<void> _loadUiSettings() async {
    try {
      final settings = await _uiPreferences.loadSettings();
      if (settings.host.isNotEmpty) {
        _hostCtl.text = settings.host;
      }
      if (settings.nickname.isNotEmpty) {
        _nickCtl.text = settings.nickname;
      }
      if (settings.host.isNotEmpty) {
        _prefetch.schedule(settings.host);
      }
    } catch (_) {}
  }

  void _onHostEdited() {
    _prefetch.schedule(_hostCtl.text);
  }

  Future<void> _onPrefetchServer(String host) async {
    try {
      await rust.prefetchServer(host: host);
    } catch (e) {
      _recordUiDiagnostic('prefetch server', e);
    }
  }

  Future<void> _saveUiSettings({String? host, String? nickname}) async {
    try {
      await _uiPreferences.saveSettings(host: host, nickname: nickname);
    } catch (_) {}
  }

  Future<void> _requestRecordAudioOnStartup() async {
    try {
      if (Platform.isAndroid) {
        final permsExplained = await _uiPreferences.hasExplainedPermissions();
        if (!permsExplained) {
          if (!mounted) return;
          await showDialog(
            context: context,
            builder: (ctx) => AlertDialog(
              title: Text(AppL10n.of(ctx).startupPermissionsTitle),
              content: Text(AppL10n.of(ctx).startupPermissionsBody),
              actions: [
                TextButton(
                  onPressed: () => Navigator.pop(ctx),
                  child: Text(AppL10n.of(ctx).startupPermissionsNotNow),
                ),
                FilledButton(
                  onPressed: () {
                    Navigator.pop(ctx);
                    _androidPermissions.ensureStartupPermissions();
                  },
                  child: Text(AppL10n.of(ctx).startupPermissionsAllow),
                ),
              ],
            ),
          );
          await _uiPreferences.markPermissionsExplained();
        } else {
          await _androidPermissions.ensureStartupPermissions();
        }
      }
    } catch (_) {}
  }

  ValueListenable<AndroidRecordAudioPermissionState>
  get _activeRecordAudioState => Platform.isIOS
      ? _iosPermissions.recordAudioState
      : _androidPermissions.recordAudioState;

  Future<AndroidRecordAudioPermissionState> _ensureActiveRecordAudio() {
    return Platform.isIOS
        ? _iosPermissions.ensureRecordAudio()
        : _androidPermissions.ensureRecordAudio();
  }

  Future<void> _openActivePermissionSettings() {
    return Platform.isIOS
        ? _iosPermissions.openAppSettings()
        : _androidPermissions.openAppSettings();
  }

  void _onRecordAudioPermissionChanged() {
    if (_activeRecordAudioState.value ==
        AndroidRecordAudioPermissionState.granted) {
      unawaited(_clearPermissionHardMute());
    }
  }

  Future<void> _clearPermissionHardMute() async {
    if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
    _permissionHardMuteClearInFlight = true;
    try {
      await rust.setHardMute(muted: false);
      if (!mounted || !_hardMuteByPermission) return;
      setState(() {
        _hardMute = false;
        _hardMuteByPermission = false;
      });
    } catch (e) {
      if (!mounted) return;
      _showUiError('clear permission mute', e);
    } finally {
      _permissionHardMuteClearInFlight = false;
    }
  }

  void _recordUiDiagnostic(String area, Object error) {
    final line = '${DateTime.now().toIso8601String()} [$area] $error';
    debugPrint('chanora: $line');
    _uiDiagnostics.add(line);
    if (_uiDiagnostics.length > 100) {
      _uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100);
    }
  }

  void _showUiError(String area, Object error) {
    if (!mounted) return;
    _showUiErrorSnackBar(area: area, error: error);
  }

  void _showUiErrorSnackBar({
    required String area,
    required Object error,
    String? displayMessage,
  }) {
    _recordUiDiagnostic(area, error);
    final l10n = AppL10n.of(context);
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        behavior: SnackBarBehavior.floating,
        content: Text(
          displayMessage ?? l10n.statusError(error.toString()),
          maxLines: 3,
          overflow: TextOverflow.ellipsis,
        ),
      ),
    );
  }

  bool _handleFocusedPttKey(KeyEvent event) {
    final label = pttDisplayLabelForKey(event.logicalKey);
    final isBoundKey =
        _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
    if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
      if (_focusedPttHeldKeys.remove(event.logicalKey)) {
        unawaited(_setPtt(false));
      }
      return true;
    }
    if (_pttBackendId != 'focused' ||
        !_serverReachable ||
        !_inChannel ||
        _transmitMode != rust.BridgeTransmitMode.ptt ||
        !isBoundKey) {
      return false;
    }
    if (event is KeyDownEvent) {
      if (_focusedPttHeldKeys.add(event.logicalKey)) {
        unawaited(_setPtt(true));
      }
      return true;
    }
    return false;
  }

  void _releaseFocusedPttIfHeld() {
    if (_focusedPttHeldKeys.isEmpty) return;
    _focusedPttHeldKeys.clear();
    unawaited(_setPtt(false, reportError: false));
  }

  /// Hydrate the bound-key display state from the bridge so the
  /// Voice Bar shows the user's persisted hotkey label immediately
  /// at launch — without having to wait for them to open the
  /// binding dialog. The bridge returns empty strings when no
  /// binding has ever been saved.
  Future<void> _hydratePttBinding() async {
    try {
      final binding = await rust.getPttBinding();
      if (!mounted) return;
      if (binding.keyLabel.isNotEmpty || binding.inputClass.isNotEmpty) {
        setState(() {
          _pttBoundKeyLabel = binding.keyLabel;
          _pttBoundInputClass = binding.inputClass;
        });
      }
    } catch (_) {
      // No persisted binding or storage not yet wired; not an error.
    }
  }

  Future<void> _reloadBookmarks() async {
    try {
      await wireStorage();
      final list = await rust.listBookmarks();
      if (!mounted) return;
      setState(() => _bookmarks = list);
    } catch (_) {
      // Bookmark store missing on this platform — empty list is fine.
    }
  }

  void _onEvent(rust.BridgeEvent evt) {
    if (!mounted) return;
    switch (evt) {
      case rust.BridgeEvent_Connected():
        final hadSnapshot = _snapshot != null;
        setState(() {
          _phase = phaseAfterConnectedEvent(_phase, hasSnapshot: hadSnapshot);
          _lostReason = null;
          _reconnectAttempt = null;
          _reconnectDelay = null;
        });
        unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true));
      case rust.BridgeEvent_Lost(:final reason):
        setState(() {
          _phase = ConnectionPhase.reconnecting;
          _lostReason = reason;
          _reconnectAttempt = null;
          _reconnectDelay = null;
        });
      case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
        setState(() {
          _phase = ConnectionPhase.reconnecting;
          _reconnectAttempt = attempt;
          _reconnectDelay = delaySecs;
        });
      case rust.BridgeEvent_Disconnected():
        setState(() {
          _resetConnectionUiState(phase: ConnectionPhase.disconnected);
        });
      case rust.BridgeEvent_AudioStarted():
        _ensureStatsTimer();
      case rust.BridgeEvent_AudioStopped():
        _statsTimer?.cancel();
        _statsTimer = null;
      case rust.BridgeEvent_SnapshotChanged():
        setState(() {
          if (_phase == ConnectionPhase.synchronizing) {
            _phase = ConnectionPhase.connected;
          }
        });
        unawaited(_refreshSnapshot(recordActivity: true, reportErrors: true));
      case rust.BridgeEvent_PttCapability(
        :final level,
        :final backendId,
        :final boundInputClass,
      ):
        setState(() {
          _pttLevel = level;
          _pttBackendId = backendId;
          _pttBoundInputClass = boundInputClass;
        });
        if (backendId != 'focused') {
          _releaseFocusedPttIfHeld();
        }
      case rust.BridgeEvent_VoiceState(
        :final inChannel,
        :final transmitMode,
        :final mute,
        :final releaseTailMs,
        :final currentChannelId,
        :final pendingTargetChannelId,
        :final canJoin,
      ):
        setState(() {
          _voiceStateInitialized = true;
          _inChannel = inChannel;
          _transmitMode = transmitMode;
          _hardMute = mute;
          if (!mute) _hardMuteByPermission = false;
          _releaseTailMs = releaseTailMs;
          _currentVoiceChannelId = currentChannelId;
          _pendingVoiceChannelId = pendingTargetChannelId;
          _canJoinVoiceChannel = canJoin;
        });
        if (inChannel) {
          _ensureStatsTimer();
          unawaited(_onRefresh());
        } else {
          _statsTimer?.cancel();
          _statsTimer = null;
          _releaseFocusedPttIfHeld();
        }
        if (transmitMode != rust.BridgeTransmitMode.ptt) {
          _releaseFocusedPttIfHeld();
        }
      case rust.BridgeEvent_InterruptionState(
        :final began,
        :final shouldResume,
      ):
        // Surface iOS audio interruption to the user (SDD-101).
        // Use unawaited to stay inside the sync _onEvent stream
        // without blocking it.
        if (!mounted) return;
        unawaited(() async {
          if (!mounted) return;
          final messenger = ScaffoldMessenger.of(context);
          if (began) {
            messenger.showSnackBar(
              SnackBar(
                content: Text(AppL10n.of(context).iosAudioInterrupted),
                duration: Duration(seconds: 3),
                backgroundColor: Colors.orange,
              ),
            );
          } else if (shouldResume) {
            messenger.showSnackBar(
              SnackBar(
                content: Text(AppL10n.of(context).iosAudioResuming),
                duration: Duration(seconds: 2),
                backgroundColor: Colors.green,
              ),
            );
          }
        }());
      // SDD-106 §5/§6 / SRS-209: defensive observer of the
      // authoritative Rust-side permission stream. The transmit
      // clamp is already applied inside `chanora_bridge` before
      // this event is broadcast. When permission later becomes
      // granted, release only the permission-owned hard-mute; do
      // not touch server-side input mute, which may be user-owned.
      case rust.BridgeEvent_PermissionState(:final permission, :final state):
        debugPrint(
          'bridge permission_state: permission=$permission state=$state',
        );
        if (permission == 'android.permission.RECORD_AUDIO' &&
            state == rust.PermissionStateKind.granted) {
          unawaited(_clearPermissionHardMute());
        }
      case rust.BridgeEvent_ChatMessage(
        :final senderId,
        :final senderName,
        :final message,
        :final target,
      ):
        // Skip echo of self-sent messages (already added locally).
        if (senderId == _snapshot?.ownClientId) return;
        final isPoke = target is rust.BridgeMessageTarget_Poke;
        final receivedAt = DateTime.now();
        setState(() {
          _appendChatEntryUnlocked(
            ChatEntry(
              senderId: senderId,
              senderName: senderName,
              message: message,
              target: target,
              isSelf: senderId == _snapshot?.ownClientId,
              timestamp: receivedAt,
            ),
          );
        });
        if (isPoke) {
          _showPokeSnackBar(
            senderName: senderName,
            message: message,
            receivedAt: receivedAt,
          );
          return;
        }
        if (!_chatOpen && _chatUnread > 0) {
          if (!mounted) return;
          unawaited(() async {
            if (!mounted) return;
            _showChatMessageSnackBar(
              senderName: senderName,
              message: message,
              target: target,
            );
          }());
        }
      case rust.BridgeEvent_ServerActivity(:final message):
        setState(() {
          _appendChatEntryUnlocked(
            ChatEntry(
              senderId: BigInt.zero,
              senderName: 'Server',
              message: message,
              target: const rust.BridgeMessageTarget.server(),
              timestamp: DateTime.now(),
              countsTowardUnread: false,
            ),
          );
        });
      case rust.BridgeEvent_AudioRouteChanged():
        break;
    }
  }

  void _stopStatsPolling() {
    _statsTimer?.cancel();
    _statsTimer = null;
  }

  void _startStatsPollingIfConnected() {
    if (_serverReachable) _ensureStatsTimer();
  }

  void _ensureStatsTimer() {
    if (_statsTimer != null) return;
    _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
      try {
        final s = await rust.audioStats();
        if (!mounted) return;
        setState(() => _audioStats = s);
      } catch (_) {}
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final stateName = state.name;
    debugPrint('chanora: app $stateName');
    rust.recordLifecycleEvent(state: stateName);
    switch (state) {
      case AppLifecycleState.paused:
        _stopStatsPolling();
      case AppLifecycleState.resumed:
        _startStatsPollingIfConnected();
      case AppLifecycleState.hidden:
      case AppLifecycleState.inactive:
        break;
      case AppLifecycleState.detached:
        _stopStatsPolling();
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
    _eventsSub?.cancel();
    _statsTimer?.cancel();
    _snapshotRefreshInFlight = false;
    _snapshotRefreshQueued = false;
    _snapshotRefreshQueuedRecordActivity = false;
    _snapshotRefreshQueuedReportErrors = false;
    _hostCtl.removeListener(_onHostEdited);
    _prefetch.dispose();
    _hostCtl.dispose();
    _nickCtl.dispose();
    _passwordCtl.dispose();
    _chatFeedRevision.dispose();
    _pokeSnackBarPokes.dispose();
    _androidPermissions.recordAudioState.removeListener(
      _onRecordAudioPermissionChanged,
    );
    _iosPermissions.recordAudioState.removeListener(
      _onRecordAudioPermissionChanged,
    );
    // SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
    // late invokeMethod from the platform side cannot land on this
    // disposed state.
    _androidPermissions.stop();
    _iosPermissions.stop();
    super.dispose();
  }

  Future<void> _onConnect({
    String? host,
    String? nickname,
    String? password,
  }) async {
    final connectEpoch = ++_connectionEpoch;
    setState(() {
      _phase = ConnectionPhase.connecting;
      _snapshot = null;
      _chatMessages.clear();
    });
    _releaseFocusedPttIfHeld();
    try {
      final snap = await rust.connect(
        host: (host ?? _hostCtl.text).trim(),
        nickname: (nickname ?? _nickCtl.text).trim(),
        password: password ?? _passwordCtl.text,
      );
      if (!mounted) return;
      final connectedHost = host ?? _hostCtl.text.trim();
      final connectedNickname = nickname ?? _nickCtl.text.trim();
      setState(() {
        _phase = ConnectionPhase.synchronizing;
        _applySnapshot(snap);
      });
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (!mounted ||
            _connectionEpoch != connectEpoch ||
            !_serverReachable ||
            _snapshot?.serverName != snap.serverName) {
          return;
        }
        unawaited(
          _runPostConnectSideEffects(
            snap: snap,
            host: connectedHost,
            nickname: connectedNickname,
          ),
        );
      });
    } catch (e) {
      if (!mounted) return;
      final errorStr = e.toString();
      if (errorStr.contains('PermissionDenied') ||
          errorStr.contains('Operation not permitted')) {
        _showPermissionDeniedDialog(errorStr);
      }
      setState(() {
        _phase = ConnectionPhase.idle;
      });
      _showUiErrorSnackBar(area: 'connect', error: e);
    }
  }

  Future<void> _runPostConnectSideEffects({
    required rust.BridgeSnapshot snap,
    required String host,
    required String nickname,
  }) async {
    unawaited(_saveUiSettings(host: host, nickname: nickname));
    try {
      await FlutterForegroundTask.startService(
        notificationTitle: 'Chanora',
        notificationText: 'Connected to ${snap.serverName}',
        notificationButtons: const [
          NotificationButton(id: 'disconnect', text: 'Disconnect'),
        ],
      );
    } catch (_) {}
  }

  void _showPermissionDeniedDialog(String rawError) {
    final l10n = AppL10n.of(context);
    final isNetwork = rawError.contains('9987') || rawError.contains('connect');
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(
          isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
        ),
        content: Text(
          isNetwork
              ? l10n.networkPermissionBody
              : l10n.microphonePermissionBody,
        ),
        actions: [
          if (Platform.isIOS && !isNetwork)
            TextButton(
              onPressed: () {
                Navigator.pop(ctx);
                unawaited(_openIosAppSettings());
              },
              child: Text(l10n.networkPermissionOpenSettings),
            ),
          if (Platform.isMacOS)
            TextButton(
              onPressed: () {
                Navigator.pop(ctx);
                try {
                  Process.run('open', [
                    'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
                  ]);
                } catch (_) {}
              },
              child: Text(l10n.networkPermissionOpenSettings),
            ),
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: Text(MaterialLocalizations.of(context).okButtonLabel),
          ),
        ],
      ),
    );
  }

  Future<void> _openIosAppSettings() async {
    try {
      await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
    } catch (_) {
      // Best-effort affordance only; if iOS refuses the URL, the
      // dialog still explained the missing microphone permission.
    }
  }

  Future<void> _setPtt(bool active, {bool reportError = true}) async {
    if (active) {
      try {
        HapticFeedback.lightImpact();
      } catch (_) {}
    }
    try {
      await rust.setPtt(active: active);
    } catch (e) {
      if (!mounted || !reportError) return;
      _showUiError('ptt', e);
    }
  }

  Future<void> _toggleOutputMute() async {
    final next = !_outputMuted;
    try {
      await rust.setOutputMuted(muted: next);
      if (!mounted) return;
      setState(() {
        _outputMuted = next;
      });
    } catch (e) {
      if (!mounted) return;
      _showUiError('output mute', e);
    }
  }

  Future<void> _onJoinChannel(
    rust.BridgeChannel ch, {
    bool askForPassword = false,
  }) async {
    if (!_canJoinVoiceChannel) return;
    if (_pendingVoiceChannelId != null) return;
    if (ch.id == _currentVoiceChannelId) return;
    final l10n = AppL10n.of(context);
    String? password;
    if (askForPassword) {
      password = await _askChannelPassword(l10n);
      if (password == null) return; // cancelled
    }
    setState(() => _pendingVoiceChannelId = ch.id);
    try {
      // SDD-106 §1, §6 + SRS-209: Android runtime permission gate.
      // Request RECORD_AUDIO at or before voice_join. On denial or
      // permanent denial we still proceed (listen-only is a
      // first-class mode per SDD-106) but clamp local transmit via
      // setHardMute so the audio engine never opens the capture
      // stream as a sender. On non-Android the service short-circuits
      // to granted and this branch is a no-op.
      //
      // Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
      //        §3 (path to settings on permanent denial), §6
      //        (TransmitModeSelector clamp); SRS-209.
      final permState = Platform.isAndroid
          ? await _androidPermissions.ensureRecordAudio()
          : Platform.isIOS
          ? await _iosPermissions.ensureRecordAudio()
          : AndroidRecordAudioPermissionState.granted;
      if (permState != AndroidRecordAudioPermissionState.granted) {
        // Listen-only: clamp hard-mute. The permission_state_banner
        // surfaces the path-to-grant; the user can re-attempt at any
        // time via the Grant / Open Settings action.
        try {
          await rust.setHardMute(muted: true);
          if (mounted) {
            setState(() {
              _hardMute = true;
              _hardMuteByPermission = true;
            });
          }
        } catch (_) {
          // Best-effort clamp; if the bridge isn't ready we still
          // proceed. The capture path also self-clamps on Android
          // when RECORD_AUDIO is not granted (SDD-106 §6 Rust-side,
          // via BridgeEvent::PermissionState → TransmitModeSelector
          // AtomicU8 clamp); this Dart setHardMute is the
          // defence-in-depth path.
        }
      } else if (_hardMuteByPermission) {
        await rust.setHardMute(muted: false);
        if (mounted) {
          setState(() {
            _hardMute = false;
            _hardMuteByPermission = false;
          });
        }
      }
      await rust.voiceJoin(channelId: ch.id, password: password ?? '');
      if (!mounted) return;
      setState(() {
        _currentVoiceChannelId = ch.id;
        _pendingVoiceChannelId = null;
        _inChannel = true;
        _canJoinVoiceChannel = true;
        _voiceStateInitialized = true;
      });
      unawaited(_onRefresh());
    } catch (e) {
      if (!mounted) return;
      // Surface as a SnackBar so the user sees it even while
      // connected. The message is selected by TS3 error code per
      // the canonical catalogue at
      // https://github.com/ReSpeak/tsdeclarations.
      final message = channelJoinErrorMessage(l10n, e);
      setState(() => _pendingVoiceChannelId = null);
      _showUiErrorSnackBar(
        area: 'join channel',
        error: e,
        displayMessage: message,
      );
    }
  }

  Future<void> _onToggleHardMute() async {
    // Don't allow manual unmute when talk-power-muted.
    if (_hardMuteByTalkPower && _hardMute) {
      return;
    }
    final next = !_hardMute;
    try {
      // Hard-mute is two coordinated effects:
      //  * setHardMute  — local TransmitGate clamp; we stop sending
      //    Opus frames the instant this returns.
      //  * setInputMuted — server-side ClientMuted flag so other
      //    clients see the mic-off icon next to our name and the
      //    server stops relaying any in-flight frames.
      // Sending only one of them is user-confusing; clients see
      // silence but no icon, or icon but a beat of audio leaks
      // through. Drive them together.
      await rust.setHardMute(muted: next);
      await rust.setInputMuted(muted: next);
      if (!mounted) return;
      setState(() {
        _inputMuted = next;
        _hardMute = next;
        _hardMuteByPermission = false;
      });
    } catch (e) {
      if (!mounted) return;
      _showUiError('hard mute', e);
    }
  }

  /// Touch-only PTT (iOS / iPadOS / Android). The on-screen
  /// `_PttHoldButton` calls this with `true` on finger-down and
  /// `false` on finger-up (or cancel). The bridge's
  /// `setPtt(active:)` routes the press edge through the same
  /// release-tail timer + transmit-mode selector the desktop
  /// hardware-key paths use (SDD-096 / SAD-083), so the user-
  /// visible behaviour is identical across platforms — only the
  /// input device changes.
  ///
  /// Errors are swallowed silently in the held=false branch
  /// because the timer's `key_up` is idempotent; a failed send
  /// would still let the tail expire naturally. Errors on
  /// held=true surface in the UI banner so the user knows the
  /// mic didn't open.
  Future<void> _onOnscreenPttHeldChanged(bool held) async {
    try {
      await rust.setPtt(active: held);
    } catch (e) {
      if (held) {
        if (!mounted) return;
        _showUiError('onscreen ptt', e);
      }
    }
  }

  Future<rust.BridgeAudioProcessingConfig> _loadAudioProcessingConfig() async {
    try {
      return await rust.getAudioProcessingConfig();
    } catch (_) {
      return defaultAudioProcessingConfig();
    }
  }

  void _appendChatEntryUnlocked(ChatEntry entry) {
    _chatMessages.add(entry);
    if (_chatMessages.length > 200) {
      _chatMessages.removeRange(0, _chatMessages.length - 200);
    }
    _notifyChatFeedChanged();
    if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) {
      _chatUnread++;
    }
  }

  void _notifyChatFeedChanged() {
    _chatFeedRevision.value++;
  }

  /// Narrow-mode voice controls modal sheet (Plan E status chip
  /// trigger). On mobile this is the **single** voice-controls
  /// surface: route picker + inline mode radio + inline release-tail
  /// slider + level meter + stats + audio processing + (desktop-only)
  /// capability badge. Zero navigation depth — no nested dialog.
  Future<void> _onOpenVoiceDetailsSheet() async {
    final audioConfig = await _loadAudioProcessingConfig();
    if (!mounted) return;

    await showVoiceDetailsSheet(
      context,
      transmitMode: _transmitMode,
      releaseTailMs: _releaseTailMs,
      pttBoundKeyLabel: _pttBoundKeyLabel,
      pttLevel: _pttLevel,
      pttBackendId: _pttBackendId,
      pttBoundInputClass: _pttBoundInputClass,
      isTouchOnly: isTouchOnlyPttHost,
      initialAudioConfig: audioConfig,
      onModeChanged: (mode) async {
        try {
          await rust.setTransmitMode(mode: mode);
          if (!mounted) return;
          setState(() => _transmitMode = mode);
        } catch (e) {
          if (!mounted) return;
          _showUiError('transmit mode', e);
        }
      },
      onReleaseTailChanged: (ms) async {
        try {
          await rust.setReleaseTailMs(ms: ms);
          if (!mounted) return;
          setState(() => _releaseTailMs = ms);
        } catch (e) {
          if (!mounted) return;
          _showUiError('release tail', e);
        }
      },
      onAudioConfigChanged: (config) async {
        try {
          await rust.setAudioProcessingConfig(config: config);
        } catch (e) {
          if (!mounted) return;
          _showUiError('audio processing config', e);
        }
      },
    );
  }

  Future<void> _onOpenVoiceSettings() async {
    final audioConfig = await _loadAudioProcessingConfig();
    if (!mounted) return;

    final result = await showDialog<VoiceSettingsResult>(
      context: context,
      builder: (ctx) => VoiceSettingsDialog(
        initialMode: _transmitMode,
        initialReleaseTailMs: _releaseTailMs,
        initialAudioConfig: audioConfig,
        pttLevel: _pttLevel,
        pttBackendId: _pttBackendId,
        pttBoundInputClass: _pttBoundInputClass,
      ),
    );
    if (result == null) return;
    try {
      await rust.setTransmitMode(mode: result.mode);
      await rust.setReleaseTailMs(ms: result.releaseTailMs);
      await rust.setAudioProcessingConfig(config: result.audioConfig);
    } catch (e) {
      if (!mounted) return;
      _showUiError('voice settings', e);
    }
    if (result.bindKeyRequested && mounted) {
      await _onConfigurePtt(context);
      if (mounted) {
        unawaited(_onOpenVoiceSettings());
      }
    }
  }

  Future<String?> _askChannelPassword(AppL10n l10n) async {
    // Same pattern as _onAddCurrentBookmark: route the dialog
    // through a dedicated StatefulWidget so its
    // TextEditingController is disposed at unmount time, not
    // synchronously after `await showDialog` resumes. Inline
    // dispose-after-await caused framework.dart:6268
    // _dependents.isEmpty assertions on iOS \u2014 the controller was
    // torn out while EditableText still depended on InheritedWidgets
    // belonging to the still-popping dialog route.
    return await showDialog<String>(
      context: context,
      builder: (ctx) => const ChannelPasswordDialog(),
    );
  }

  Future<void> _onRefresh() async {
    await _refreshSnapshot(recordActivity: true, reportErrors: true);
  }

  Future<void> _refreshSnapshot({
    required bool recordActivity,
    required bool reportErrors,
  }) async {
    if (_snapshotRefreshInFlight) {
      _snapshotRefreshQueued = true;
      _snapshotRefreshQueuedRecordActivity =
          _snapshotRefreshQueuedRecordActivity || recordActivity;
      _snapshotRefreshQueuedReportErrors =
          _snapshotRefreshQueuedReportErrors || reportErrors;
      return;
    }

    _snapshotRefreshInFlight = true;
    var nextRecordActivity = recordActivity;
    var nextReportErrors = reportErrors;

    try {
      while (mounted) {
        _snapshotRefreshQueued = false;
        _snapshotRefreshQueuedRecordActivity = false;
        _snapshotRefreshQueuedReportErrors = false;

        final previousSnapshot = _snapshot;
        try {
          final snap = await rust.snapshot();
          if (!mounted) return;
          final activityEntries = nextRecordActivity && previousSnapshot != null
              ? buildServerActivityEntries(
                  previous: previousSnapshot,
                  current: snap,
                )
              : const <ChatEntry>[];
          setState(() {
            _applySnapshot(snap);
            for (final entry in activityEntries) {
              _appendChatEntryUnlocked(entry);
            }
          });
        } catch (e) {
          if (nextReportErrors && mounted) {
            _showUiError('refresh snapshot', e);
          }
        }

        if (!_snapshotRefreshQueued) {
          break;
        }
        nextRecordActivity = _snapshotRefreshQueuedRecordActivity;
        nextReportErrors = _snapshotRefreshQueuedReportErrors;
      }
    } finally {
      _snapshotRefreshInFlight = false;
      _snapshotRefreshQueued = false;
      _snapshotRefreshQueuedRecordActivity = false;
      _snapshotRefreshQueuedReportErrors = false;
    }
  }

  Future<void> _onDisconnect() async {
    _connectionEpoch++;
    _releaseFocusedPttIfHeld();
    if (mounted) {
      setState(() {
        _resetConnectionUiState(phase: ConnectionPhase.disconnected);
      });
    }
    unawaited(_finishDisconnect());
  }

  Future<void> _onConfirmDisconnect() async {
    final l10n = AppL10n.of(context);
    final confirmed = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l10n.disconnectConfirmTitle),
        content: Text(l10n.disconnectConfirmBody),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(false),
            child: Text(l10n.disconnectConfirmCancel),
          ),
          FilledButton(
            onPressed: () => Navigator.of(ctx).pop(true),
            child: Text(l10n.disconnectConfirmAction),
          ),
        ],
      ),
    );
    if (confirmed == true && mounted && _phase.canDisconnect) {
      await _onDisconnect();
    }
  }

  Future<void> _finishDisconnect() async {
    _statsTimer?.cancel();
    _statsTimer = null;
    _snapshotRefreshInFlight = false;
    _snapshotRefreshQueued = false;
    _snapshotRefreshQueuedRecordActivity = false;
    _snapshotRefreshQueuedReportErrors = false;
    try {
      await rust.disconnect();
    } catch (_) {}
    try {
      await FlutterForegroundTask.stopService();
    } catch (_) {}
    // Reload bookmarks so auto-saved recent-server entries from the
    // just-ended session appear in the connect-page bookmark list.
    unawaited(_reloadBookmarks());
  }

  void _resetConnectionUiState({required ConnectionPhase phase}) {
    _phase = phase;
    _snapshot = null;
    _audioStats = null;
    _inputMuted = false;
    _outputMuted = false;
    _hardMute = false;
    _hardMuteByPermission = false;
    _hardMuteByTalkPower = false;
    _inChannel = false;
    _currentVoiceChannelId = null;
    _pendingVoiceChannelId = null;
    _canJoinVoiceChannel = true;
    _voiceStateInitialized = false;
    _lostReason = null;
    _reconnectAttempt = null;
    _reconnectDelay = null;
    _chatMessages.clear();
    _chatUnread = 0;
    _chatOpen = false;
    _notifyChatFeedChanged();
  }

  Future<void> _onOpenChat({
    rust.BridgeMessageTarget? target,
    String clientName = '',
  }) async {
    final initialSnapshot = _snapshot!;
    setState(() {
      _chatUnread = 0;
      _chatOpen = true;
    });
    await Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) => ChatPage(
          messages: _chatMessages,
          snapshot: initialSnapshot,
          messagesSource: () => _chatMessages,
          snapshotSource: () => _snapshot ?? initialSnapshot,
          refreshListenable: _chatFeedRevision,
          initialTarget:
              target ??
              resolveInitialChatTarget(
                messages: _chatMessages,
                currentVoiceChannelId: _currentVoiceChannelId,
              ),
          initialClientName: clientName,
          onTs3ServerLink: _onTs3ServerLink,
        ),
      ),
    );
    if (mounted) setState(() => _chatOpen = false);
  }

  Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
    await showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      useSafeArea: true,
      showDragHandle: false,
      builder: (context) => FractionallySizedBox(
        heightFactor: 0.86,
        child: ClientInfoSheet(
          clientName: client.name,
          loadProfile: () => rust.clientProfile(clientId: client.id),
        ),
      ),
    );
  }

  void _showPokeSnackBar({
    required String senderName,
    required String message,
    required DateTime receivedAt,
  }) {
    _pokeSnackBarPokes.value = [
      ..._pokeSnackBarPokes.value,
      _ReceivedPoke(
        senderName: senderName,
        message: message,
        receivedAt: receivedAt,
      ),
    ];
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _renderPokeSnackBar();
    });
  }

  void _renderPokeSnackBar() {
    if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
    _pokeSnackBarVisible = true;
    final messenger = ScaffoldMessenger.of(context);
    final controller = messenger.showSnackBar(
      SnackBar(
        behavior: SnackBarBehavior.floating,
        margin: _chatSnackBarMargin(),
        duration: const Duration(days: 365),
        dismissDirection: DismissDirection.none,
        content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
        action: SnackBarAction(
          label: AppL10n.of(context).pokeSnackBarClearAction,
          onPressed: () {
            _pokeSnackBarPokes.value = const [];
            _pokeSnackBarVisible = false;
          },
        ),
      ),
    );
    controller.closed.then((_) {
      if (!mounted) return;
      _pokeSnackBarVisible = false;
      if (_pokeSnackBarPokes.value.isEmpty) return;
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted) _renderPokeSnackBar();
      });
    });
  }

  void _showChatMessageSnackBar({
    required String senderName,
    required String message,
    required rust.BridgeMessageTarget target,
  }) {
    if (_pokeSnackBarPokes.value.isNotEmpty) return;
    final messenger = ScaffoldMessenger.of(context);
    messenger.hideCurrentSnackBar();
    messenger.showSnackBar(
      SnackBar(
        behavior: SnackBarBehavior.floating,
        margin: _chatSnackBarMargin(),
        duration: const Duration(seconds: 7),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '$senderName (${_chatTargetLabel(target)})',
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 4),
            Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
          ],
        ),
        action: SnackBarAction(
          label: AppL10n.of(context).openAction,
          onPressed: () => unawaited(
            _onOpenChat(
              target: target,
              clientName: _chatClientNameForTarget(target, senderName),
            ),
          ),
        ),
      ),
    );
  }

  EdgeInsetsGeometry _chatSnackBarMargin() {
    const side = 16.0;
    var bottom = 16.0;
    final wideConnectedLayout =
        MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
        _serverReachable &&
        _snapshot != null;

    if (_serverReachable && _snapshot != null && !wideConnectedLayout) {
      bottom += 64; // VoiceStatusChip.
      if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) {
        bottom += 64; // 8 dp gap + default 56 dp VoicePttButton.
      }
    }

    return EdgeInsetsDirectional.fromSTEB(side, 0, side, bottom);
  }

  String _chatTargetLabel(rust.BridgeMessageTarget target) {
    return switch (target) {
      rust.BridgeMessageTarget_Client() => 'Private Chat',
      rust.BridgeMessageTarget_Poke() => 'Poke',
      rust.BridgeMessageTarget_Channel() => 'Channel Chat',
      rust.BridgeMessageTarget_Server() => 'Server',
    };
  }

  String _chatClientNameForTarget(
    rust.BridgeMessageTarget target,
    String senderName,
  ) {
    return switch (target) {
      rust.BridgeMessageTarget_Client() ||
      rust.BridgeMessageTarget_Poke() => senderName,
      _ => '',
    };
  }

  void _applySnapshot(rust.BridgeSnapshot snap) {
    _snapshot = snap;
    _phase = phaseAfterSnapshotApplied(_phase);
    final own = ownClientSnapshotState(snap);
    if (own == null) return;
    _inputMuted = own.inputMuted;
    _outputMuted = own.outputMuted;
    if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
      _currentVoiceChannelId = own.channelId;
      _pendingVoiceChannelId = null;
      _inChannel = true;
      _canJoinVoiceChannel = true;
      _voiceStateInitialized = true;
    } else if (_pendingVoiceChannelId == null) {
      _currentVoiceChannelId = own.channelId;
    }
    _notifyChatFeedChanged();

    if (!own.talkPowerOk && !_hardMuteByTalkPower) {
      final talkPowerEpoch = _connectionEpoch;
      _hardMuteByTalkPower = true;
      _hardMute = true;
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (!mounted ||
            _connectionEpoch != talkPowerEpoch ||
            !_serverReachable ||
            !_hardMuteByTalkPower) {
          return;
        }
        unawaited(rust.setHardMute(muted: true));
        unawaited(rust.setInputMuted(muted: true));
      });
    } else if (own.talkPowerOk && _hardMuteByTalkPower) {
      final talkPowerEpoch = _connectionEpoch;
      _hardMuteByTalkPower = false;
      if (!_hardMuteByPermission) {
        _hardMute = false;
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (!mounted ||
              _connectionEpoch != talkPowerEpoch ||
              !_serverReachable ||
              _hardMuteByTalkPower ||
              _hardMuteByPermission) {
            return;
          }
          unawaited(rust.setHardMute(muted: false));
          unawaited(rust.setInputMuted(muted: false));
        });
      }
    }
  }

  OwnClientSnapshotState? get _ownClientState {
    final snap = _snapshot;
    return snap == null ? null : ownClientSnapshotState(snap);
  }

  String _diagnosticsText() {
    final rustText = rust.exportDiagnostics();
    final uiText = _uiDiagnostics.isEmpty
        ? 'UI diagnostics: none'
        : ['UI diagnostics:', ..._uiDiagnostics].join('\n');
    return '$uiText\n\nRust diagnostics:\n$rustText';
  }

  Future<void> _onShowDiagnostics() async {
    if (!mounted) return;
    await showDialog<void>(
      context: context,
      builder: (ctx) =>
          _LiveDiagnosticsDialog(diagnosticsTextBuilder: _diagnosticsText),
    );
  }

  Future<void> _onConfigurePtt(BuildContext context) async {
    // On the Linux GNOME-Wayland portal backend, the portal hosts
    // its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
    // Skip the in-app capture dialog entirely on that backend and
    // delegate to the portal via `setPttBinding` with a sentinel
    // platform_key. Show a SnackBar so the user isn't surprised
    // when their compositor opens a separate dialog.
    final l10n = AppL10n.of(context);
    if (!mounted) return;
    if (_pttBackendId == 'gnome-wayland-portal') {
      try {
        final messenger = ScaffoldMessenger.of(context);
        messenger.showSnackBar(
          SnackBar(content: Text(l10n.pttConfigurePortalRedirect)),
        );
        await rust.setPttBinding(
          inputClass: rust.BridgePttInputClass.keyboard,
          platformKey: 'portal',
        );
      } catch (e) {
        if (!mounted) return;
        _showUiErrorSnackBar(area: 'ptt portal binding', error: e);
      }
      return;
    }

    // Other backends: open the in-app focus-scoped capture
    // dialog. The bridge carries only the coarse input class and
    // an opaque platform-key string; the actual key value never
    // appears in any log record (DEC-027 / SRS-202).
    final binding = await showDialog<CapturedBinding>(
      context: context,
      builder: (ctx) => const PttBindingCaptureDialog(),
    );
    if (binding == null) return;
    try {
      await rust.setPttBinding(
        inputClass: binding.inputClass,
        platformKey: binding.platformKey,
      );
      // Cache the captured label so the badge can surface "Key:
      // Space" / "Key: Mouse4" next to the capability descriptor.
      // The bridge holds the authoritative binding; this is only
      // for display continuity until the next app restart.
      if (mounted) {
        setState(() {
          _pttBoundKeyLabel = binding.platformKey;
        });
      }
    } catch (e) {
      if (!mounted) return;
      // Surface the failure as a snackbar so the user sees that
      // their binding did not stick.
      _showUiErrorSnackBar(area: 'ptt binding', error: e);
    }
  }

  Future<void> _onShowAbout(BuildContext context) async {
    // DEC-018 / DEC-019 / DEC-020 surface: public name, non-
    // affiliation statement, dual-license declaration. The Flutter
    // showAboutDialog widget is intentionally bare so the legal
    // text comes from us, not a framework default.
    final l10n = AppL10n.of(context);
    final theme = Theme.of(context);
    if (!mounted) return;
    await showDialog<void>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l10n.aboutAction),
        content: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(l10n.appTitle, style: theme.textTheme.titleLarge),
              const SizedBox(height: 4),
              Text(
                l10n.aboutVersion(_kAppVersion),
                style: theme.textTheme.bodySmall,
              ),
              const SizedBox(height: 4),
              Text(l10n.aboutAuthor, style: theme.textTheme.bodySmall),
              const SizedBox(height: 16),
              Text(l10n.aboutNonAffiliation, style: theme.textTheme.bodyMedium),
              const SizedBox(height: 12),
              Text(l10n.aboutLicenseHeading, style: theme.textTheme.titleSmall),
              const SizedBox(height: 4),
              Text(l10n.aboutLicenseBody, style: theme.textTheme.bodySmall),
              const SizedBox(height: 12),
              Text(
                l10n.aboutThirdPartyHeading,
                style: theme.textTheme.titleSmall,
              ),
              const SizedBox(height: 4),
              Text(l10n.aboutThirdPartyBody, style: theme.textTheme.bodySmall),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () async {
              Navigator.of(ctx).pop();
              await Future<void>.delayed(Duration.zero);
              if (!mounted) return;
              await _onShowDiagnostics();
            },
            child: Text(l10n.diagnosticsAction),
          ),
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(),
            child: Text(l10n.closeAction),
          ),
        ],
      ),
    );
  }

  Future<void> _onAddCurrentBookmark() async {
    // Use the dialog's own context for AppL10n.of(...) inside its
    // builder. Capturing the outer _HomePageState context's l10n in
    // a closure and reading it inside the dialog's widget tree
    // caused the dialog's TextField to depend on InheritedElements
    // (Localizations / _LocalizationsScope) that belong to the
    // outer route. When the dialog popped, the framework
    // deactivated the dialog route's elements first while those
    // outer-route InheritedElements were still alive but had
    // dependents from the disposed dialog tree \u2014 producing the
    // assertion 'package:flutter/src/widgets/framework.dart line
    // 6268 _dependents.isEmpty is not true'.
    //
    // Also: dispose the TextEditingController via the dialog's own
    // StatefulBuilder lifecycle instead of an inline dispose() right
    // after showDialog returns. The inline dispose runs synchronously
    // before the dialog route is fully torn down (the route pop
    // animation is still mid-flight on iOS), and tearing the
    // controller out from under EditableText while it has a live
    // InheritedWidget dependency was the second trigger for the
    // same assertion.
    final initial = _hostCtl.text.trim();
    final name = await showDialog<String>(
      context: context,
      builder: (ctx) => BookmarkNameDialog(initialName: initial),
    );
    if (name == null || name.trim().isEmpty) return;
    if (!mounted) return;
    try {
      await wireStorage();
      await rust.addBookmark(
        b: rust.BridgeBookmark(
          id: 0,
          displayName: name.trim(),
          host: _hostCtl.text.trim(),
          nickname: _nickCtl.text.trim(),
          password: _passwordCtl.text,
        ),
      );
      await _reloadBookmarks();
    } catch (e) {
      if (!mounted) return;
      _showUiError('add bookmark', e);
    }
  }

  Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
    try {
      await wireStorage();
      await rust.deleteBookmark(id: b.id);
      await _reloadBookmarks();
    } catch (e) {
      if (!mounted) return;
      _showUiError('delete bookmark', e);
    }
  }

  Future<void> _onUseBookmark(rust.BridgeBookmark b) async {
    _hostCtl.text = b.host;
    _nickCtl.text = b.nickname;
    _passwordCtl.text = b.password;
    await _onConnect(host: b.host, nickname: b.nickname, password: b.password);
  }

  Future<void> _onTs3ServerLink(Ts3ServerLink link) async {
    final host = link.hostWithPort.trim();
    if (host.isEmpty) return;

    final nickname = link.nickname ?? _nickCtl.text.trim();
    final password = link.password ?? '';
    final bookmarkName = link.addBookmark?.trim();

    _hostCtl.text = host;
    if (link.nickname != null) _nickCtl.text = link.nickname!;
    if (link.password != null) _passwordCtl.text = link.password!;
    await _saveUiSettings(host: host, nickname: _nickCtl.text.trim());

    if (bookmarkName == null || bookmarkName.isEmpty) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Loaded TeamSpeak server link for $host')),
      );
      return;
    }

    try {
      await wireStorage();
      await rust.addBookmark(
        b: rust.BridgeBookmark(
          id: 0,
          displayName: bookmarkName,
          host: host,
          nickname: nickname,
          password: password,
        ),
      );
      await _reloadBookmarks();
      if (!mounted) return;
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
    } catch (e) {
      if (!mounted) return;
      _showUiError('teamspeak link bookmark', e);
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final theme = Theme.of(context);

    final headerActions = [
      if (_serverReachable && _inChannel) ...[
        IconButton(
          tooltip: _hardMuteByTalkPower
              ? 'Insufficient talk power to speak in this channel'
              : l10n.voiceHardMuteLabel,
          icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
          isSelected: _hardMute,
          selectedIcon: const Icon(Icons.mic_off),
          color: _hardMute ? theme.colorScheme.error : null,
          onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
        ),
        IconButton(
          tooltip: l10n.voiceOutputMuteLabel,
          icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
          isSelected: _outputMuted,
          selectedIcon: const Icon(Icons.headset_off),
          color: _outputMuted ? theme.colorScheme.error : null,
          onPressed: _toggleOutputMute,
        ),
      ],
      IconButton(
        tooltip: l10n.aboutAction,
        icon: const Icon(Icons.info_outline),
        onPressed: () => _onShowAbout(context),
      ),
      ChanoraThemeModeMenu(
        themeMode: widget.themeMode,
        onThemeModeChanged: widget.onThemeModeChanged,
      ),
      if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
        Padding(
          padding: const EdgeInsetsDirectional.only(end: 12),
          child: Badge(
            isLabelVisible: _chatUnread > 0,
            label: Text(_chatUnread.toString()),
            child: IconButton(
              tooltip: l10n.chatAction,
              icon: const Icon(Icons.chat_bubble_outline),
              onPressed: _onOpenChat,
            ),
          ),
        ),
      ],
    ];

    const headerTitle = SizedBox.shrink();

    final appBarTitle = _serverReachable
        ? Text(
            _snapshot?.serverName ?? l10n.appTitle,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: theme.textTheme.titleSmall?.copyWith(
              fontWeight: FontWeight.w600,
            ),
          )
        : headerTitle;
    final compactIdleChrome =
        !_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
    final compactIdleHeader = Row(
      children: [
        Expanded(
          child: Text(
            l10n.appTitle,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: theme.textTheme.titleMedium?.copyWith(
              fontWeight: FontWeight.w700,
            ),
          ),
        ),
        ...headerActions,
      ],
    );

    final connTokens = _phase.tokens(theme.colorScheme);
    final statusText = connectionStatusText(
      phase: _phase,
      l10n: l10n,
      serverName: _snapshot?.serverName,
      lostReason: _lostReason,
      reconnectAttempt: _reconnectAttempt,
      reconnectDelay: _reconnectDelay,
    );
    final awaitingServerSnapshot = _phase.shouldShowSnapshotLoading(
      hasSnapshot: _snapshot != null,
    );

    final bodyContent = LayoutBuilder(
      builder: (ctx, bodyConstraints) {
        final isWideSnapshot =
            bodyConstraints.maxWidth >= _wideBreakpoint &&
            _serverReachable &&
            _snapshot != null;
        final banner = Container(
          padding: const EdgeInsets.all(10),
          decoration: BoxDecoration(
            color: theme.colorScheme.tertiaryContainer,
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(
            l10n.homeNotProductionReadyBanner,
            style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
          ),
        );
        return Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
            if (!_serverReachable)
              Row(
                children: [
                  Icon(connTokens.icon, size: 18, color: connTokens.color),
                  const SizedBox(width: 6),
                  Expanded(
                    child: Text(
                      statusText,
                      softWrap: true,
                      style: theme.textTheme.titleMedium,
                    ),
                  ),
                ],
              ),
            if (_lostReason != null || _reconnectAttempt != null) ...[
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.all(10),
                decoration: BoxDecoration(
                  color:
                      connTokens.background ?? theme.colorScheme.errorContainer,
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Row(
                  children: [
                    SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(
                        strokeWidth: 2,
                        color: theme.colorScheme.onErrorContainer,
                      ),
                    ),
                    const SizedBox(width: 10),
                    Expanded(
                      child: Text(
                        _reconnectAttempt != null
                            ? l10n.statusReconnecting(
                                _reconnectAttempt!,
                                _reconnectDelay ?? 0,
                              )
                            : l10n.statusConnectionLost(_lostReason ?? ''),
                        style: TextStyle(
                          color: theme.colorScheme.onErrorContainer,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
            const SizedBox(height: 12),
            if (_phase == ConnectionPhase.idle ||
                _phase == ConnectionPhase.disconnected) ...[
              Expanded(
                child: AnimatedPadding(
                  duration: const Duration(milliseconds: 180),
                  curve: Curves.easeOut,
                  padding: EdgeInsets.only(
                    bottom: MediaQuery.viewInsetsOf(ctx).bottom,
                  ),
                  child: SingleChildScrollView(
                    keyboardDismissBehavior:
                        ScrollViewKeyboardDismissBehavior.onDrag,
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: [
                        ConnectForm(
                          hostCtl: _hostCtl,
                          nickCtl: _nickCtl,
                          passwordCtl: _passwordCtl,
                          onConnect: () => _onConnect(),
                          onAddBookmark: _onAddCurrentBookmark,
                        ),
                        const SizedBox(height: 16),
                        BookmarkList(
                          bookmarks: _bookmarks,
                          onConnect: _onUseBookmark,
                          onDelete: _onDeleteBookmark,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ] else if (_phase == ConnectionPhase.connecting ||
                awaitingServerSnapshot) ...[
              Expanded(
                child: Center(
                  child: Padding(
                    padding: const EdgeInsets.all(32),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        const CircularProgressIndicator(),
                        const SizedBox(height: 16),
                        Text(
                          statusText,
                          textAlign: TextAlign.center,
                          style: theme.textTheme.titleMedium,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ] else if (_serverReachable && _snapshot != null) ...[
              Expanded(
                child: LayoutBuilder(
                  builder: (ctx, constraints) {
                    final voiceBar = VoiceBar(
                      inChannel: _inChannel,
                      transmitMode: _transmitMode,
                      hardMute: _hardMute,
                      outputMuted: _outputMuted,
                      talkPowerBlocked: _hardMuteByTalkPower,
                      releaseTailMs: _releaseTailMs,
                      channelName: snapshotChannelName(
                        _snapshot,
                        _currentVoiceChannelId,
                      ),
                      audioStats: _audioStats,
                      pttLevel: _pttLevel,
                      pttBackendId: _pttBackendId,
                      pttBoundInputClass: _pttBoundInputClass,
                      pttBoundKeyLabel: _pttBoundKeyLabel,
                      onConfigure: _onOpenVoiceSettings,
                      onPttHeldChanged: _onOnscreenPttHeldChanged,
                    );
                    // SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
                    // banner. Self-hides on granted / unknown.
                    final permissionBanner =
                        PermissionStateBanner.fromCallbacks(
                          recordAudioState: _activeRecordAudioState,
                          ensureRecordAudio: _ensureActiveRecordAudio,
                          openAppSettings: _openActivePermissionSettings,
                        );
                    final snapshotView = SnapshotView(
                      snapshot: _snapshot!,
                      audioStats: _audioStats,
                      currentVoiceChannelId: _currentVoiceChannelId,
                      pendingVoiceChannelId: _pendingVoiceChannelId,
                      localInputMuted: _inputMuted || _hardMute,
                      localOutputMuted: _outputMuted,
                      hasJoinPending: _pendingVoiceChannelId != null,
                      canJoinVoiceChannel: _canJoinVoiceChannel,
                      onJoinChannel: (ch) => _onJoinChannel(ch),
                      onJoinChannelWithPassword: (ch) =>
                          _onJoinChannel(ch, askForPassword: true),
                      enableClientLongPressMenu: isTouchOnlyPttHost,
                      onOpenClientInfo: (client) =>
                          unawaited(_onOpenClientInfo(client)),
                      onOpenClientChat: (client) => unawaited(
                        _onOpenChat(
                          target: rust.BridgeMessageTarget.client(client.id),
                          clientName: client.name,
                        ),
                      ),
                      onOpenClientPoke: (client) => unawaited(
                        _onOpenChat(
                          target: rust.BridgeMessageTarget.poke(client.id),
                          clientName: client.name,
                        ),
                      ),
                      onTs3ServerLink: _onTs3ServerLink,
                    );
                    final ownClientState = _ownClientState;
                    const voiceBarWidthWide = 320.0;
                    if (constraints.maxWidth >= _wideBreakpoint) {
                      return Row(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: [
                          SizedBox(
                            width: voiceBarWidthWide,
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.stretch,
                              children: [
                                banner,
                                const SizedBox(height: 12),
                                permissionBanner,
                                voiceBar,
                              ],
                            ),
                          ),
                          const SizedBox(width: 12),
                          Expanded(child: snapshotView),
                        ],
                      );
                    }
                    return Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: [
                        Expanded(child: snapshotView),
                        const SizedBox(height: 8),
                        permissionBanner,
                        VoiceStatusChip(
                          transmitMode: _transmitMode,
                          releaseTailMs: _releaseTailMs,
                          pttBoundKeyLabel: _pttBoundKeyLabel,
                          audioStats: _audioStats,
                          isTouchOnly: isTouchOnlyPttHost,
                          inputMuted: _hardMute,
                          outputMuted: _outputMuted,
                          talkPower: ownClientState?.talkPower,
                          neededTalkPower: ownClientState?.neededTalkPower,
                          talkPowerGranted: ownClientState?.talkPowerGranted,
                          onTap: () => _onOpenVoiceDetailsSheet(),
                        ),
                        if (_inChannel &&
                            _transmitMode == rust.BridgeTransmitMode.ptt) ...[
                          const SizedBox(height: 8),
                          VoicePttButton(
                            active: _audioStats?.pttActive ?? false,
                            onHeldChanged: _onOnscreenPttHeldChanged,
                          ),
                        ],
                      ],
                    );
                  },
                ),
              ),
            ],
          ],
        );
      },
    );

    if (_isMacOS) {
      return Scaffold(
        body: Padding(
          padding: const EdgeInsets.only(top: _macOSTrafficLightPad),
          child: Column(
            children: [
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Row(
                  children: [
                    Expanded(child: headerTitle),
                    ...headerActions,
                  ],
                ),
              ),
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: bodyContent,
                ),
              ),
            ],
          ),
        ),
      );
    }

    return ChanoraMobileScaffold(
      compactIdleChrome: compactIdleChrome,
      canDisconnect: _phase.canDisconnect,
      title: appBarTitle,
      actions: headerActions,
      onDisconnect: _onConfirmDisconnect,
      compactHeader: compactIdleHeader,
      disconnectTooltip: l10n.disconnectAction,
      body: bodyContent,
    );
  }
}

class _LiveDiagnosticsDialog extends StatefulWidget {
  const _LiveDiagnosticsDialog({required this.diagnosticsTextBuilder});

  final String Function() diagnosticsTextBuilder;

  @override
  State<_LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState();
}

class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
  static const _refreshInterval = Duration(seconds: 1);

  Timer? _refreshTimer;
  String _text = '';

  @override
  void initState() {
    super.initState();
    _refresh();
    _refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh());
  }

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

  void _refresh() {
    final next = widget.diagnosticsTextBuilder();
    if (!mounted || next == _text) return;
    setState(() => _text = next);
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final size = MediaQuery.sizeOf(context);

    return AlertDialog(
      title: Row(
        children: [
          Expanded(child: Text(l10n.diagnosticsAction)),
          const SizedBox(width: 12),
          Tooltip(
            message: l10n.diagnosticsLiveUpdating,
            child: Icon(
              Icons.sync,
              size: 18,
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
        ],
      ),
      content: ConstrainedBox(
        constraints: BoxConstraints(
          maxWidth: 720,
          maxHeight: size.height * 0.65,
        ),
        child: SingleChildScrollView(
          child: SelectableText(
            _text,
            style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
          ),
        ),
      ),
      actions: [
        TextButton(
          onPressed: () async {
            await SharePlus.instance.share(ShareParams(text: _text));
          },
          child: Text(l10n.shareAction),
        ),
        TextButton(
          onPressed: () async {
            await Clipboard.setData(ClipboardData(text: _text));
            if (!context.mounted) return;
            Navigator.of(context).pop();
          },
          child: Text(l10n.copyAction),
        ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: Text(l10n.closeAction),
        ),
      ],
    );
  }
}

class _PokeSnackBarContent extends StatelessWidget {
  const _PokeSnackBarContent({required this.pokes});

  final ValueListenable<List<_ReceivedPoke>> pokes;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<List<_ReceivedPoke>>(
      valueListenable: pokes,
      builder: (context, entries, _) {
        final l10n = AppL10n.of(context);
        final visible = entries.length <= 3
            ? entries
            : entries.sublist(entries.length - 3);
        return Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (entries.length > 3)
              Padding(
                padding: const EdgeInsetsDirectional.only(bottom: 2),
                child: Text(
                  l10n.pokeSnackBarMoreIndicator,
                  style: const TextStyle(fontWeight: FontWeight.w600),
                ),
              ),
            for (final poke in visible) _PokeSnackBarRow(poke: poke),
          ],
        );
      },
    );
  }
}

class _PokeSnackBarRow extends StatelessWidget {
  const _PokeSnackBarRow({required this.poke});

  final _ReceivedPoke poke;

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final message = poke.message.trim();
    final text = message.isEmpty
        ? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
        : l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 1),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: Text(
              text,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
          ),
          const SizedBox(width: 12),
          Text(
            pokeSnackBarTimeLabel(poke.receivedAt),
            style: TextStyle(
              color: Theme.of(
                context,
              ).colorScheme.onInverseSurface.withValues(alpha: 0.72),
            ),
          ),
        ],
      ),
    );
  }
}

String pokeSnackBarTimeLabel(DateTime timestamp) {
  String two(int value) => value.toString().padLeft(2, '0');
  return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}
