// 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 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';

import 'l10n/generated/app_localizations.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';

/// Public version string shown in the About dialog. Aligned with
/// `pubspec.yaml` and the git tag for the MVP release candidate.
const String _kAppVersion = 'v1.0.0-rc.1';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  unawaited(_wireStorage());
  unawaited(_wireConnectivity());
  runApp(const ChanoraApp());
}

Future<void> _wireStorage() async {
  try {
    final dir = await getApplicationSupportDirectory();
    await rust.initStorage(dir: dir.path);
  } catch (_) {
    // Best-effort; missing storage just means no identity persistence
    // and no bookmark list this session.
  }
}

rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
  if (results.isEmpty) return rust.BridgeNetworkState.unknown;
  final allNone = results.every((r) => r == ConnectivityResult.none);
  if (allNone) return rust.BridgeNetworkState.offline;
  return rust.BridgeNetworkState.online;
}

Future<void> _wireConnectivity() async {
  final connectivity = Connectivity();
  try {
    final initial = await connectivity.checkConnectivity();
    rust.setNetworkState(state: _mapConnectivity(initial));
  } catch (_) {}
  connectivity.onConnectivityChanged.listen((results) {
    rust.setNetworkState(state: _mapConnectivity(results));
  });
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(0xFF3F51B5),
      ),
      localizationsDelegates: AppL10n.localizationsDelegates,
      supportedLocales: AppL10n.supportedLocales,
      home: const _BetaHome(),
    );
  }
}

enum _Phase { idle, connecting, connected }

class _BetaHome extends StatefulWidget {
  const _BetaHome();

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

class _BetaHomeState extends State<_BetaHome> {
  final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
  final _nickCtl = TextEditingController(text: 'ChanoraBeta');
  final _passwordCtl = TextEditingController();

  _Phase _phase = _Phase.idle;
  rust.BridgeSnapshot? _snapshot;
  String? _error;
  bool _audioStarted = false;
  rust.BridgeAudioStats? _audioStats;
  Timer? _statsTimer;
  StreamSubscription<rust.BridgeEvent>? _eventsSub;

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

  bool _inputMuted = false;
  bool _outputMuted = false;
  double _outputGain = 1.0;

  // 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 = '';

  List<rust.BridgeBookmark> _bookmarks = const [];

  @override
  void initState() {
    super.initState();
    _eventsSub = rust.eventsStream().listen(_onEvent);
    unawaited(_reloadBookmarks());
  }

  Future<void> _reloadBookmarks() async {
    try {
      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():
        setState(() {
          _phase = _Phase.connected;
          _lostReason = null;
          _reconnectAttempt = null;
          _reconnectDelay = null;
        });
      case rust.BridgeEvent_Lost(:final reason):
        setState(() {
          _lostReason = reason;
          _reconnectAttempt = null;
          _reconnectDelay = null;
        });
      case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
        setState(() {
          _reconnectAttempt = attempt;
          _reconnectDelay = delaySecs;
        });
      case rust.BridgeEvent_Disconnected():
        setState(() {
          _phase = _Phase.idle;
          _lostReason = null;
          _reconnectAttempt = null;
          _reconnectDelay = null;
        });
      case rust.BridgeEvent_AudioStarted():
        setState(() => _audioStarted = true);
      case rust.BridgeEvent_AudioStopped():
        setState(() => _audioStarted = false);
      case rust.BridgeEvent_SnapshotChanged():
        unawaited(_onRefresh());
      case rust.BridgeEvent_PttCapability(
            :final level,
            :final backendId,
            :final boundInputClass,
          ):
        setState(() {
          _pttLevel = level;
          _pttBackendId = backendId;
          _pttBoundInputClass = boundInputClass;
        });
    }
  }

  @override
  void dispose() {
    _eventsSub?.cancel();
    _statsTimer?.cancel();
    _hostCtl.dispose();
    _nickCtl.dispose();
    _passwordCtl.dispose();
    super.dispose();
  }

  Future<void> _onConnect({
    String? host,
    String? nickname,
    String? password,
  }) async {
    setState(() {
      _phase = _Phase.connecting;
      _error = null;
      _snapshot = null;
    });
    try {
      final snap = await rust.connect(
        host: (host ?? _hostCtl.text).trim(),
        nickname: (nickname ?? _nickCtl.text).trim(),
        password: password ?? _passwordCtl.text,
      );
      if (!mounted) return;
      setState(() {
        _phase = _Phase.connected;
        _snapshot = snap;
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _phase = _Phase.idle;
        _error = e.toString();
      });
    }
  }

  Future<void> _onStartAudio() async {
    try {
      await rust.startAudio();
      if (!mounted) return;
      setState(() => _audioStarted = true);
      _statsTimer?.cancel();
      _statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
        try {
          final s = await rust.audioStats();
          if (!mounted) return;
          setState(() => _audioStats = s);
        } catch (_) {}
      });
    } catch (e) {
      if (!mounted) return;
      setState(() => _error = e.toString());
    }
  }

  Future<void> _setPtt(bool active) async {
    try {
      await rust.setPtt(active: active);
    } catch (e) {
      if (!mounted) return;
      setState(() => _error = e.toString());
    }
  }

  Future<void> _toggleInputMute() async {
    final next = !_inputMuted;
    try {
      await rust.setInputMuted(muted: next);
      if (!mounted) return;
      setState(() => _inputMuted = next);
    } catch (e) {
      if (!mounted) return;
      setState(() => _error = e.toString());
    }
  }

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

  Future<void> _setOutputGain(double value) async {
    setState(() => _outputGain = value);
    try {
      await rust.setOutputGain(gain: value);
    } catch (_) {
      // Audio may not be started yet — that's fine; the next start_audio
      // picks up the current slider position next time we plumb it.
    }
  }

  Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
    final l10n = AppL10n.of(context);
    String? password;
    // Heuristic: a channel name annotated with a lock prompts.
    if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) {
      password = await _askChannelPassword(l10n);
      if (password == null) return; // cancelled
    }
    try {
      await rust.moveToChannel(
        channelId: ch.id,
        password: password ?? '',
      );
    } catch (e) {
      if (!mounted) return;
      setState(() => _error = e.toString());
    }
  }

  Future<String?> _askChannelPassword(AppL10n l10n) async {
    final ctl = TextEditingController();
    final result = await showDialog<String>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l10n.channelPasswordTitle),
        content: TextField(
          controller: ctl,
          obscureText: true,
          autofocus: true,
          decoration: InputDecoration(labelText: l10n.fieldPassword),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(),
            child: Text(l10n.closeAction),
          ),
          FilledButton(
            onPressed: () => Navigator.of(ctx).pop(ctl.text),
            child: Text(l10n.connectAction),
          ),
        ],
      ),
    );
    ctl.dispose();
    return result;
  }

  Future<void> _onRefresh() async {
    try {
      final snap = await rust.snapshot();
      if (!mounted) return;
      setState(() => _snapshot = snap);
    } catch (e) {
      if (!mounted) return;
      setState(() => _error = e.toString());
    }
  }

  Future<void> _onDisconnect() async {
    _statsTimer?.cancel();
    _statsTimer = null;
    try {
      await rust.disconnect();
    } catch (_) {}
    if (!mounted) return;
    setState(() {
      _phase = _Phase.idle;
      _snapshot = null;
      _audioStarted = false;
      _audioStats = null;
      _error = null;
      _inputMuted = false;
      _outputMuted = false;
    });
  }

  Future<void> _onShowDiagnostics(BuildContext context) async {
    final l10n = AppL10n.of(context);
    final text = rust.exportDiagnostics();
    if (!mounted) return;
    await showDialog<void>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l10n.diagnosticsAction),
        content: SingleChildScrollView(
          child: SelectableText(
            text,
            style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () async {
              await Clipboard.setData(ClipboardData(text: text));
              if (!ctx.mounted) return;
              Navigator.of(ctx).pop();
            },
            child: Text(l10n.copyAction),
          ),
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(),
            child: Text(l10n.closeAction),
          ),
        ],
      ),
    );
  }

  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;
        final messenger = ScaffoldMessenger.of(this.context);
        messenger.showSnackBar(SnackBar(
          content: Text(l10n.statusError(e.toString())),
        ));
      }
      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.
      if (!mounted) return;
      // Use the State's context (guaranteed valid because we
      // re-checked `mounted` immediately above).
      final messenger = ScaffoldMessenger.of(this.context);
      messenger.showSnackBar(SnackBar(
        content: Text(l10n.statusError(e.toString())),
      ));
    }
  }

  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: 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: () => Navigator.of(ctx).pop(),
            child: Text(l10n.closeAction),
          ),
        ],
      ),
    );
  }

  Future<void> _onAddCurrentBookmark() async {
    final l10n = AppL10n.of(context);
    final nameCtl = TextEditingController(text: _hostCtl.text.trim());
    final name = await showDialog<String>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l10n.bookmarkAddTitle),
        content: TextField(
          controller: nameCtl,
          autofocus: true,
          decoration: InputDecoration(labelText: l10n.fieldDisplayName),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(),
            child: Text(l10n.closeAction),
          ),
          FilledButton(
            onPressed: () => Navigator.of(ctx).pop(nameCtl.text),
            child: Text(l10n.bookmarkAddAction),
          ),
        ],
      ),
    );
    nameCtl.dispose();
    if (name == null || name.trim().isEmpty) return;
    try {
      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;
      setState(() => _error = e.toString());
    }
  }

  Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
    try {
      await rust.deleteBookmark(id: b.id);
      await _reloadBookmarks();
    } catch (e) {
      if (!mounted) return;
      setState(() => _error = e.toString());
    }
  }

  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,
    );
  }

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

    String statusText() {
      switch (_phase) {
        case _Phase.idle:
          return _error != null ? l10n.statusError(_error!) : l10n.statusIdle;
        case _Phase.connecting:
          return l10n.statusConnecting;
        case _Phase.connected:
          return l10n.statusConnected(_snapshot?.serverName ?? '');
      }
    }

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.appTitle),
        actions: [
          IconButton(
            tooltip: l10n.aboutAction,
            icon: const Icon(Icons.info_outline),
            onPressed: () => _onShowAbout(context),
          ),
          IconButton(
            tooltip: l10n.diagnosticsAction,
            icon: const Icon(Icons.bug_report_outlined),
            onPressed: () => _onShowDiagnostics(context),
          ),
          if (_phase == _Phase.connected) ...[
            IconButton(
              tooltip: l10n.refreshAction,
              icon: const Icon(Icons.refresh),
              onPressed: _onRefresh,
            ),
            IconButton(
              tooltip: l10n.disconnectAction,
              icon: const Icon(Icons.logout),
              onPressed: _onDisconnect,
            ),
          ],
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            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),
              ),
            ),
            const SizedBox(height: 12),
            Text(statusText(), style: theme.textTheme.titleMedium),
            if (_lostReason != null || _reconnectAttempt != null) ...[
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.all(10),
                decoration: BoxDecoration(
                  color: 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 == _Phase.idle) ...[
              Expanded(
                child: SingleChildScrollView(
                  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 == _Phase.connecting) ...[
              const Center(
                child: Padding(
                  padding: EdgeInsets.all(32),
                  child: CircularProgressIndicator(),
                ),
              ),
            ] else if (_phase == _Phase.connected && _snapshot != null) ...[
              if (!_audioStarted) ...[
                FilledButton.icon(
                  icon: const Icon(Icons.mic_none),
                  label: Text(l10n.startAudioAction),
                  onPressed: _onStartAudio,
                ),
                const SizedBox(height: 12),
              ] else ...[
                _AudioControls(
                  stats: _audioStats,
                  inputMuted: _inputMuted,
                  outputMuted: _outputMuted,
                  outputGain: _outputGain,
                  pttLevel: _pttLevel,
                  pttBackendId: _pttBackendId,
                  pttBoundInputClass: _pttBoundInputClass,
                  pttBoundKeyLabel: _pttBoundKeyLabel,
                  onPttDown: () => _setPtt(true),
                  onPttUp: () => _setPtt(false),
                  onToggleInputMute: _toggleInputMute,
                  onToggleOutputMute: _toggleOutputMute,
                  onGainChanged: _setOutputGain,
                  onConfigurePtt: () => _onConfigurePtt(context),
                ),
                const SizedBox(height: 12),
              ],
              Expanded(
                child: _SnapshotView(
                  snapshot: _snapshot!,
                  onJoinChannel: _onJoinChannel,
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class _ConnectForm extends StatelessWidget {
  const _ConnectForm({
    required this.hostCtl,
    required this.nickCtl,
    required this.passwordCtl,
    required this.onConnect,
    required this.onAddBookmark,
  });

  final TextEditingController hostCtl;
  final TextEditingController nickCtl;
  final TextEditingController passwordCtl;
  final VoidCallback onConnect;
  final VoidCallback onAddBookmark;

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        TextField(
          controller: hostCtl,
          decoration: InputDecoration(
            labelText: l10n.fieldServerHost,
            border: const OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 8),
        TextField(
          controller: nickCtl,
          decoration: InputDecoration(
            labelText: l10n.fieldNickname,
            border: const OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 8),
        TextField(
          controller: passwordCtl,
          obscureText: true,
          decoration: InputDecoration(
            labelText: l10n.fieldServerPassword,
            helperText: l10n.fieldServerPasswordHelp,
            border: const OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 16),
        Row(
          children: [
            Expanded(
              child: FilledButton.icon(
                icon: const Icon(Icons.login),
                label: Text(l10n.connectAction),
                onPressed: onConnect,
              ),
            ),
            const SizedBox(width: 8),
            OutlinedButton.icon(
              icon: const Icon(Icons.bookmark_add_outlined),
              label: Text(l10n.bookmarkAddAction),
              onPressed: onAddBookmark,
            ),
          ],
        ),
      ],
    );
  }
}

class _BookmarkList extends StatelessWidget {
  const _BookmarkList({
    required this.bookmarks,
    required this.onConnect,
    required this.onDelete,
  });

  final List<rust.BridgeBookmark> bookmarks;
  final ValueChanged<rust.BridgeBookmark> onConnect;
  final ValueChanged<rust.BridgeBookmark> onDelete;

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final theme = Theme.of(context);
    if (bookmarks.isEmpty) {
      return Padding(
        padding: const EdgeInsets.symmetric(vertical: 8),
        child: Text(
          l10n.bookmarksEmpty,
          style: theme.textTheme.bodySmall,
        ),
      );
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
        const SizedBox(height: 4),
        for (final b in bookmarks)
          Card(
            margin: const EdgeInsets.symmetric(vertical: 4),
            child: ListTile(
              title: Text(b.displayName),
              subtitle: Text('${b.host} — ${b.nickname}'),
              trailing: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  IconButton(
                    icon: const Icon(Icons.login),
                    tooltip: l10n.connectAction,
                    onPressed: () => onConnect(b),
                  ),
                  IconButton(
                    icon: const Icon(Icons.delete_outline),
                    tooltip: l10n.bookmarkDeleteAction,
                    onPressed: () => onDelete(b),
                  ),
                ],
              ),
            ),
          ),
      ],
    );
  }
}

class _AudioControls extends StatefulWidget {
  const _AudioControls({
    required this.stats,
    required this.inputMuted,
    required this.outputMuted,
    required this.outputGain,
    required this.pttLevel,
    required this.pttBackendId,
    required this.pttBoundInputClass,
    required this.pttBoundKeyLabel,
    required this.onPttDown,
    required this.onPttUp,
    required this.onToggleInputMute,
    required this.onToggleOutputMute,
    required this.onGainChanged,
    required this.onConfigurePtt,
  });

  final rust.BridgeAudioStats? stats;
  final bool inputMuted;
  final bool outputMuted;
  final double outputGain;
  final String pttLevel;
  final String pttBackendId;
  final String pttBoundInputClass;
  final String pttBoundKeyLabel;
  final VoidCallback onPttDown;
  final VoidCallback onPttUp;
  final VoidCallback onToggleInputMute;
  final VoidCallback onToggleOutputMute;
  final ValueChanged<double> onGainChanged;
  final VoidCallback onConfigurePtt;

  @override
  State<_AudioControls> createState() => _AudioControlsState();
}

class _AudioControlsState extends State<_AudioControls> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final theme = Theme.of(context);
    final stats = widget.stats;
    final statsText = stats == null
        ? '—'
        : l10n.audioStatsLine(
            stats.framesSent,
            stats.framesReceived,
            stats.pttActive ? 'on' : 'off',
          );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        // Capability badge (gen2 v0.9.3 / SDD-091). Renders the
        // active PTT level + backend so the user understands when
        // Global PTT has fallen back to Focused PTT, and surfaces
        // a per-platform explanation sheet when the resolved
        // capability is `L0Focused`.
        PttCapabilityBadge(
          level: widget.pttLevel,
          backendId: widget.pttBackendId,
          boundInputClass: widget.pttBoundInputClass,
          boundKeyLabel: widget.pttBoundKeyLabel,
          onConfigure: widget.onConfigurePtt,
        ),
        // Accessibility (SysRS-262 + SysRS-282 + SysRS-263):
        // wrap the custom Listener-based PTT control in a
        // `Semantics` node so screen readers announce its role
        // ("button") and its current state ("Transmitting" /
        // "Hold to talk"). The icon + text inside already
        // communicate the state without relying on colour
        // alone.
        Semantics(
          button: true,
          enabled: true,
          toggled: _pressed,
          label: _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk,
          hint: l10n.pttHoldToTalkSemanticsHint,
          excludeSemantics: true,
          child: Listener(
            onPointerDown: (_) {
              setState(() => _pressed = true);
              widget.onPttDown();
            },
            onPointerUp: (_) {
              setState(() => _pressed = false);
              widget.onPttUp();
            },
            onPointerCancel: (_) {
              setState(() => _pressed = false);
              widget.onPttUp();
            },
            child: Container(
              padding: const EdgeInsets.symmetric(vertical: 16),
              decoration: BoxDecoration(
                color: _pressed
                    ? theme.colorScheme.primary
                    : theme.colorScheme.primaryContainer,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(
                    _pressed ? Icons.mic : Icons.mic_off,
                    color: _pressed
                        ? theme.colorScheme.onPrimary
                        : theme.colorScheme.onPrimaryContainer,
                  ),
                  const SizedBox(width: 8),
                  Text(
                    _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk,
                    style: TextStyle(
                      color: _pressed
                          ? theme.colorScheme.onPrimary
                          : theme.colorScheme.onPrimaryContainer,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            Expanded(
              child: FilterChip(
                avatar: Icon(
                  widget.inputMuted ? Icons.mic_off : Icons.mic,
                  size: 18,
                ),
                label: Text(
                  widget.inputMuted ? l10n.inputUnmuteAction : l10n.inputMuteAction,
                ),
                selected: widget.inputMuted,
                onSelected: (_) => widget.onToggleInputMute(),
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: FilterChip(
                avatar: Icon(
                  widget.outputMuted ? Icons.volume_off : Icons.volume_up,
                  size: 18,
                ),
                label: Text(
                  widget.outputMuted ? l10n.outputUnmuteAction : l10n.outputMuteAction,
                ),
                selected: widget.outputMuted,
                onSelected: (_) => widget.onToggleOutputMute(),
              ),
            ),
          ],
        ),
        const SizedBox(height: 4),
        Row(
          children: [
            const Icon(Icons.volume_down, size: 18),
            Expanded(
              child: Slider(
                value: widget.outputGain.clamp(0.0, 2.0),
                min: 0.0,
                max: 2.0,
                divisions: 40,
                label: '${(widget.outputGain * 100).round()}%',
                onChanged: widget.onGainChanged,
              ),
            ),
            const Icon(Icons.volume_up, size: 18),
          ],
        ),
        Text(statsText, style: theme.textTheme.bodySmall),
      ],
    );
  }
}

/// PTT capability badge (gen2 v0.9.3 / SDD-091).
///
/// Renders the active PTT level + backend in the Voice Bar so the
/// user understands which input path is in effect. When the
/// resolved capability is `L0Focused` an info icon appears that
/// opens a per-platform explanation sheet describing why Global
/// PTT is not active and what the user can do to engage it.
///
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
/// updates the props on each transition.
class PttCapabilityBadge extends StatelessWidget {
  /// Construct a badge.
  const PttCapabilityBadge({
    super.key,
    required this.level,
    required this.backendId,
    required this.boundInputClass,
    required this.boundKeyLabel,
    required this.onConfigure,
  });

  /// Resolved capability level as the bridge emits it
  /// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
  /// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
  final String level;

  /// Stable backend identifier (`focused`, `windows-raw-input`, …).
  final String backendId;

  /// Privacy-safe input class (`keyboard`, `mouse-side-button`,
  /// or empty when no binding is set).
  final String boundInputClass;

  /// Platform-neutral key label captured by the binding dialog
  /// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Empty
  /// when the user has not saved a binding in the current
  /// process. Display-only; the bridge holds the authoritative
  /// binding. Per DEC-027 this string is the same one already
  /// crossed into the Rust side — no new privacy surface.
  final String boundKeyLabel;

  /// Open the configure-binding dialog. Wired by the caller.
  final VoidCallback onConfigure;

  bool get _isFocused => level == 'L0Focused';

  String _explainBodyForPlatform(AppL10n l10n) {
    // Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
    // because the latter is influenced by debug platform overrides
    // that callers may toggle in dev mode. We want the badge's
    // explanation to match the actual host OS.
    switch (defaultTargetPlatform) {
      case TargetPlatform.windows:
        return l10n.pttCapabilityExplainGoGlobalWindows;
      case TargetPlatform.macOS:
        return l10n.pttCapabilityExplainGoGlobalMacos;
      case TargetPlatform.linux:
        return l10n.pttCapabilityExplainGoGlobalLinux;
      default:
        return l10n.pttCapabilityExplainGoGlobalGeneric;
    }
  }

  void _openExplanationSheet(BuildContext context) {
    final l10n = AppL10n.of(context);
    showModalBottomSheet<void>(
      context: context,
      showDragHandle: true,
      builder: (sheetContext) {
        final theme = Theme.of(sheetContext);
        return SafeArea(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  l10n.pttCapabilityExplainTitle,
                  style: theme.textTheme.titleMedium,
                ),
                const SizedBox(height: 12),
                Text(
                  l10n.pttCapabilityExplainFocusedHeading,
                  style: theme.textTheme.titleSmall,
                ),
                const SizedBox(height: 4),
                Text(
                  l10n.pttCapabilityExplainFocusedBody,
                  style: theme.textTheme.bodyMedium,
                ),
                const SizedBox(height: 16),
                Text(
                  _explainBodyForPlatform(l10n),
                  style: theme.textTheme.bodyMedium,
                ),
                const SizedBox(height: 16),
                Align(
                  alignment: AlignmentDirectional.centerEnd,
                  child: TextButton(
                    onPressed: () => Navigator.of(sheetContext).pop(),
                    child: Text(l10n.closeAction),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final theme = Theme.of(context);
    final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
    final tooltipMessage = boundInputClass.isEmpty
        ? badgeLabel
        : '$badgeLabel\n($boundInputClass)';
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Tooltip(
            message: tooltipMessage,
            child: Row(
              children: [
                Icon(
                  _isFocused ? Icons.crop_free : Icons.public,
                  size: 14,
                  color: theme.colorScheme.onSurfaceVariant,
                ),
                const SizedBox(width: 4),
                Expanded(
                  child: Text(
                    badgeLabel,
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ),
                if (_isFocused)
                  IconButton(
                    icon: const Icon(Icons.info_outline, size: 16),
                    tooltip: l10n.pttCapabilityExplainTitle,
                    visualDensity: VisualDensity.compact,
                    onPressed: () => _openExplanationSheet(context),
                  ),
                TextButton.icon(
                  icon: const Icon(Icons.tune, size: 14),
                  label: Text(l10n.pttConfigureAction),
                  onPressed: onConfigure,
                ),
              ],
            ),
          ),
          // Second line: show which key the user just bound, so
          // they can remember what to press. Only rendered when a
          // binding has been saved in the current process (the
          // bridge holds the authoritative binding across restarts;
          // this label is display-only).
          if (boundKeyLabel.isNotEmpty)
            Padding(
              padding: const EdgeInsets.only(left: 18, top: 2),
              child: Text(
                l10n.pttCapabilityBoundKey(boundKeyLabel),
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  fontFamily: 'monospace',
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _SnapshotView extends StatelessWidget {
  const _SnapshotView({
    required this.snapshot,
    required this.onJoinChannel,
  });

  final rust.BridgeSnapshot snapshot;
  final ValueChanged<rust.BridgeChannel> onJoinChannel;

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

    final channels = [...snapshot.channels]
      ..sort((a, b) => a.order.compareTo(b.order));

    final byChannel = <BigInt, List<rust.BridgeClient>>{};
    for (final c in snapshot.clients) {
      byChannel.putIfAbsent(c.channel, () => []).add(c);
    }

    return ListView(
      children: [
        Text(
          l10n.countChannelsAndClients(
            snapshot.channels.length,
            snapshot.clients.length,
          ),
          style: theme.textTheme.bodyMedium,
        ),
        if (snapshot.welcomeMessage.isNotEmpty) ...[
          const SizedBox(height: 8),
          Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: theme.colorScheme.surfaceContainerHighest,
              borderRadius: BorderRadius.circular(6),
            ),
            child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall),
          ),
        ],
        const Divider(height: 24),
        Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
        const SizedBox(height: 4),
        for (final ch in channels) ...[
          ListTile(
            dense: true,
            leading: const Icon(Icons.tag),
            title: Text(ch.name),
            subtitle: Text('id=${ch.id}  parent=${ch.parent}'),
            trailing: IconButton(
              icon: const Icon(Icons.login),
              tooltip: l10n.joinChannelAction,
              onPressed: () => onJoinChannel(ch),
            ),
            onTap: () => onJoinChannel(ch),
          ),
          for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
            Padding(
              padding: const EdgeInsets.only(left: 64),
              child: ListTile(
                dense: true,
                visualDensity: VisualDensity.compact,
                leading: const Icon(Icons.person, size: 18),
                title: Text(cl.name),
              ),
            ),
        ],
      ],
    );
  }
}

/// Result of a successful PTT binding capture. Carries only the
/// coarse input class and an opaque platform-key label —
/// per DEC-027 the actual key code never crosses out of the
/// capture dialog.
class _CapturedBinding {
  const _CapturedBinding({required this.inputClass, required this.platformKey});
  final rust.BridgePttInputClass inputClass;
  final String platformKey;
}

/// Focus-scoped dialog that captures the next key press or mouse
/// side-button click and returns it as a `_CapturedBinding`. The
/// captured value is the platform-neutral `LogicalKeyboardKey`
/// debug label or `"mouse-side-button:{button}"`; the platform
/// backend interprets the string and never logs it.
class _PttBindingCaptureDialog extends StatefulWidget {
  const _PttBindingCaptureDialog();

  @override
  State<_PttBindingCaptureDialog> createState() =>
      _PttBindingCaptureDialogState();
}

class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
  final FocusNode _focusNode = FocusNode();
  String? _captured;
  rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _focusNode.requestFocus();
    });
  }

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
    if (event is! KeyDownEvent) return KeyEventResult.ignored;
    // Skip modifier-only presses so the user can chord into the
    // real binding.
    final keyLabel = event.logicalKey.keyLabel;
    if (keyLabel.isEmpty) return KeyEventResult.ignored;
    setState(() {
      _captured = keyLabel;
      _capturedClass = rust.BridgePttInputClass.keyboard;
    });
    return KeyEventResult.handled;
  }

  void _captureMouseSideButton(int button) {
    setState(() {
      _captured = 'mouse-side-button:$button';
      _capturedClass = rust.BridgePttInputClass.mouseSideButton;
    });
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppL10n.of(context);
    final theme = Theme.of(context);
    return AlertDialog(
      title: Text(l10n.pttConfigureTitle),
      content: SizedBox(
        width: 360,
        child: Focus(
          focusNode: _focusNode,
          onKeyEvent: _onKeyEvent,
          autofocus: true,
          child: Listener(
            // Capture mouse side buttons (4 and 5) without
            // capturing primary / secondary clicks which the
            // user uses to interact with the dialog itself. The
            // raw button bitmask values are stable per Flutter's
            // `PointerEvent.buttons` documentation (back = 0x08,
            // forward = 0x10).
            onPointerDown: (e) {
              const int back = 0x08;
              const int forward = 0x10;
              if (e.buttons == back || e.buttons == forward) {
                _captureMouseSideButton(e.buttons);
              }
            },
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  l10n.pttConfigurePrompt,
                  style: theme.textTheme.bodyMedium,
                ),
                const SizedBox(height: 12),
                Container(
                  padding: const EdgeInsets.symmetric(
                    vertical: 12,
                    horizontal: 16,
                  ),
                  decoration: BoxDecoration(
                    color: theme.colorScheme.surfaceContainerHighest,
                    borderRadius: BorderRadius.circular(6),
                  ),
                  child: Text(
                    _captured == null
                        ? l10n.pttConfigureWaiting
                        : '${l10n.pttConfigureCaptured}: $_captured',
                    style: theme.textTheme.bodyMedium?.copyWith(
                      fontFamily: 'monospace',
                    ),
                  ),
                ),
                const SizedBox(height: 12),
                Text(
                  l10n.pttConfigurePrivacyNote,
                  style: theme.textTheme.bodySmall?.copyWith(
                    color: theme.colorScheme.onSurfaceVariant,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: Text(l10n.closeAction),
        ),
        FilledButton(
          onPressed: _captured == null
              ? null
              : () => Navigator.of(context).pop(
                    _CapturedBinding(
                      inputClass: _capturedClass,
                      platformKey: _captured!,
                    ),
                  ),
          child: Text(l10n.pttConfigureSaveAction),
        ),
      ],
    );
  }
}
