// Ch∆In Studio — desktop GUI for the Ch∆In hub.
//
// Visual language: warm minimalism / deep tech with soul.
// Tokens in lib/theme/, primitives in lib/widgets/.

import 'dart:async';

import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'data/chain_log.dart';
import 'data/error_presentation.dart';
import 'data/hub.dart';
import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
import 'l10n/app_localizations.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/federation.dart';
import 'pages/flows.dart';
import 'pages/store.dart';
import 'pages/welcome.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/chain_search_palette.dart';
import 'widgets/widgets.dart';

/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies — visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.70.0';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Restore the persisted endpoint, theme mode, locale, and
  // (when installed) the operator's theme-plugin choice
  // before the first frame so nothing flickers on startup.
  await HubService.instance.loadPersistedEndpoint();
  await SystemActions.loadFaiBinaryOverride();
  final themeMode = await HubService.instance.loadThemeMode();
  final locale = await HubService.instance.loadLocale();
  final themePlugin = await loadActiveThemePlugin();
  runApp(
    StudioApp(
      initialThemeMode: themeMode,
      initialLocale: locale,
      initialThemePlugin: themePlugin,
    ),
  );
}

class StudioApp extends StatefulWidget {
  final ThemeModeValue initialThemeMode;
  final Locale initialLocale;

  /// Capability name of the active theme plugin (e.g.
  /// `studio.theme.solarized`) or `null` for the built-in
  /// ChainTheme.
  final String? initialThemePlugin;

  const StudioApp({
    super.key,
    required this.initialThemeMode,
    required this.initialLocale,
    this.initialThemePlugin,
  });

  /// Lookup helper so descendants can flip the theme without
  /// passing callbacks down through every level.
  static StudioAppState? of(BuildContext context) =>
      context.findAncestorStateOfType<StudioAppState>();

  @override
  State<StudioApp> createState() => StudioAppState();
}

class StudioAppState extends State<StudioApp> {
  /// Exposed so any descendant can listen via
  /// `ValueListenableBuilder` and rebuild instantly when the
  /// theme flips. setState alone wouldn't propagate through
  /// `MaterialApp(home: const StudioShell())` because the
  /// const child's element doesn't get a `didUpdateWidget`.
  late final ValueNotifier<ThemeModeValue> modeNotifier;

  /// Active app locale. The sidebar's language toggle flips
  /// this, MaterialApp re-renders with the new
  /// AppLocalizations, and every translated string updates.
  late final ValueNotifier<Locale> localeNotifier;

  /// Capability name of the active `studio.theme.*` plugin
  /// (e.g. `studio.theme.solarized`) or `null` for
  /// ChainTheme's built-in palette. Flipping this triggers a
  /// re-fetch of the plugin's ColorSchemes; the MaterialApp
  /// rebuilds with the new themes.
  late final ValueNotifier<String?> themePluginNotifier;

  Future<void> setMode(ThemeModeValue m) async {
    modeNotifier.value = m;
    await HubService.instance.saveThemeMode(m);
  }

  Future<void> setLocale(Locale l) async {
    localeNotifier.value = l;
    await HubService.instance.saveLocale(l);
  }

  Future<void> setThemePlugin(String? capability) async {
    themePluginNotifier.value = capability;
    await saveActiveThemePlugin(capability);
  }

  @override
  void initState() {
    super.initState();
    modeNotifier = ValueNotifier(widget.initialThemeMode);
    localeNotifier = ValueNotifier(widget.initialLocale);
    themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
  }

  @override
  void dispose() {
    modeNotifier.dispose();
    localeNotifier.dispose();
    themePluginNotifier.dispose();
    super.dispose();
  }

  ThemeMode _flutterMode(ThemeModeValue v) {
    switch (v) {
      case ThemeModeValue.system:
        return ThemeMode.system;
      case ThemeModeValue.light:
        return ThemeMode.light;
      case ThemeModeValue.dark:
        return ThemeMode.dark;
    }
  }

  /// Build (or return null) ThemeData pair for the active
  /// theme plugin. Returns immediately with `null` when no
  /// plugin is selected; otherwise awaits the plugin's
  /// `theme_for` round-trip for both brightnesses.
  ///
  /// The capability slot also carries the `custom:#RRGGBB`
  /// sigil for free-color themes: when the value starts with
  /// `custom:`, the suffix is parsed as a hex seed and the
  /// ColorScheme is derived via Material 3's seed algorithm.
  /// That single channel covers both "pick from installed
  /// plugins" and "pick a seed colour" without a parallel
  /// notifier.
  Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
    String? capability,
  ) async {
    if (capability == null || capability.isEmpty) {
      return (light: null, dark: null);
    }
    if (capability.startsWith('custom:')) {
      final hex = capability.substring('custom:'.length).replaceAll('#', '');
      if (hex.length != 6) return (light: null, dark: null);
      final raw = int.tryParse(hex, radix: 16);
      if (raw == null) return (light: null, dark: null);
      final seed = Color(0xFF000000 | raw);
      final light = ColorScheme.fromSeed(
        seedColor: seed,
        brightness: Brightness.light,
      );
      final dark = ColorScheme.fromSeed(
        seedColor: seed,
        brightness: Brightness.dark,
      );
      return (
        light: themeDataFromScheme(light),
        dark: themeDataFromScheme(dark),
      );
    }
    try {
      final schemes = await loadThemePluginSchemes(capability);
      return (
        light: schemes.light == null
            ? null
            : themeDataFromScheme(schemes.light!),
        dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
      );
    } catch (e) {
      // Plugin unreachable, hub down, manifest schema drift —
      // any of these end up here. Log the failure so doctor +
      // the in-app log viewer can surface it; the picker's
      // own "applied" snackbar is replaced upstream by the
      // friendlier failure message via [themePluginFailed].
      // ignore: discarded_futures
      ChainLog.instance.error(
        'theme.plugin.load',
        e,
        context: 'capability=$capability',
      );
      return (light: null, dark: null);
    }
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<ThemeModeValue>(
      valueListenable: modeNotifier,
      builder: (_, mode, _) => ValueListenableBuilder<Locale>(
        valueListenable: localeNotifier,
        builder: (_, locale, _) => ValueListenableBuilder<String?>(
          valueListenable: themePluginNotifier,
          builder: (_, pluginCap, _) =>
              FutureBuilder<({ThemeData? light, ThemeData? dark})>(
                // Re-key on the capability so a plugin change
                // discards the in-flight future for the previous
                // plugin and starts fresh.
                key: ValueKey('plugin_themes:$pluginCap'),
                future: _pluginThemes(pluginCap),
                builder: (context, snap) {
                  final p = snap.data ?? (light: null, dark: null);
                  return MaterialApp(
                    title: 'Ch∆In Studio',
                    debugShowCheckedModeBanner: false,
                    theme: p.light ?? ChainTheme.light(),
                    darkTheme: p.dark ?? ChainTheme.dark(),
                    themeMode: _flutterMode(mode),
                    locale: locale,
                    supportedLocales: AppLocalizations.supportedLocales,
                    localizationsDelegates:
                        AppLocalizations.localizationsDelegates,
                    home: const StudioShell(),
                  );
                },
              ),
        ),
      ),
    );
  }
}

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

  @override
  State<StudioShell> createState() => StudioShellState();
}

class StudioShellState extends State<StudioShell> {
  int _selectedIndex = 0;
  bool? _connected; // null = unknown, polled on a timer
  String? _activeChannel; // local / dev / beta / production
  Timer? _healthPoll;

  /// Consecutive failed health polls. After [_unreachableThreshold]
  /// in a row we stop showing an indefinite silent "connecting…"
  /// and surface a one-line "can't reach the hub" banner with a
  /// jump to Settings.
  int _failedPolls = 0;
  static const int _unreachableThreshold = 3;

  /// Whether to render the unreachable banner. Distinct from
  /// `_connected == false` so a single transient miss doesn't
  /// flash the banner — only sustained failure does.
  bool get _hubUnreachable => _failedPolls >= _unreachableThreshold;

  /// Current connection state, for descendants (e.g. WelcomePage)
  /// that adapt their content to hub availability.
  bool? get connected => _connected;

  /// Start the local daemon via the platform binary, then surface
  /// the outcome as a snackbar. Shared by the sidebar's connection
  /// row and the WelcomePage hero CTA.
  Future<void> startDaemon() async {
    final l = AppLocalizations.of(context)!;
    final r = await SystemActions.chainDaemon(['start']);
    if (!mounted) return;
    if (r.ok) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(l.daemonStartRequested)),
      );
      Future.delayed(const Duration(seconds: 1), _checkHealth);
      return;
    }
    // The start command reported failure — but very often the daemon is
    // simply already running (port in use). Probe before crying error,
    // so "tap to start" on an already-up hub just connects.
    final alive =
        await HubService.instance.healthy().catchError((_) => false);
    if (!mounted) return;
    if (alive) {
      await _checkHealth();
      return;
    }
    // Genuinely down: a persistent, copyable dialog — a SnackBar flashes
    // away before the operator can read or copy the daemon's stderr.
    await showFaiProcessErrorDialog(
      context,
      'daemon.start',
      r.stdout,
      r.stderr,
      title: l.daemonStartFailed(''),
    );
    if (mounted) Future.delayed(const Duration(seconds: 1), _checkHealth);
  }

  /// Switch the sidebar's selected page by its registered id
  /// (`store`, `flows`, `audit`, etc.). Used by the welcome
  /// page's onboarding checklist to make each item a real
  /// navigation hop instead of a static hint. Silently
  /// ignores unknown ids.
  void navigateTo(String pageId) {
    final idx = _pages.indexWhere((p) => p.id == pageId);
    if (idx < 0) return;
    setState(() => _selectedIndex = idx);
  }

  /// Lookup helper — descendants call this to drive nav
  /// without holding a controller reference.
  static StudioShellState? of(BuildContext context) =>
      context.findAncestorStateOfType<StudioShellState>();

  static const _pages = <_NavPage>[
    _NavPage(
      id: 'welcome',
      icon: Icons.waving_hand_outlined,
      selectedIcon: Icons.waving_hand,
      page: WelcomePage(),
    ),
    // Store moved to slot 2 in v0.39.1 — it's the
    // operator's daily-use surface and belongs above Doctor,
    // which is a diagnostics page.
    _NavPage(
      id: 'store',
      icon: Icons.storefront_outlined,
      selectedIcon: Icons.storefront,
      page: StorePage(),
    ),
    _NavPage(
      id: 'doctor',
      icon: Icons.health_and_safety_outlined,
      selectedIcon: Icons.health_and_safety,
      page: DoctorPage(),
    ),
    // Modules tab dropped in v0.35.0 — installed-module info
    // (permissions, on-disk path, uninstall) now lives inside
    // the Store's detail sheet, and the Store with the
    // "Installed" filter covers the listing role. Cmd+K still
    // opens ChainModuleSheet for quick info.
    // Flows destination IS the editor as of 0.52.0 — the
    // graph / text / run tabs all live behind one icon. The
    // separate "flow-editor" destination is gone; the Cmd+K
    // palette still navigates to flows when the operator
    // wants the editor surface.
    _NavPage(
      id: 'flows',
      icon: Icons.account_tree_outlined,
      selectedIcon: Icons.account_tree,
      page: FlowsPage(),
    ),
    _NavPage(
      id: 'audit',
      icon: Icons.timeline_outlined,
      selectedIcon: Icons.timeline,
      page: AuditPage(),
    ),
    _NavPage(
      id: 'approvals',
      icon: Icons.inbox_outlined,
      selectedIcon: Icons.inbox,
      page: ApprovalsPage(),
    ),
    _NavPage(
      id: 'federation',
      icon: Icons.hub_outlined,
      selectedIcon: Icons.hub,
      page: FederationPage(),
    ),
  ];

  /// Pending-approval count surfaced as a sidebar badge so the
  /// operator notices new approvals without polling the page.
  /// Refreshed alongside the health probe — same 5 s tick.
  int _pendingApprovals = 0;

  @override
  void initState() {
    super.initState();
    _checkHealth();
    _healthPoll = Timer.periodic(
      const Duration(seconds: 5),
      (_) => _checkHealth(),
    );
  }

  Future<void> _checkHealth() async {
    final ok = await HubService.instance.healthy();
    if (!mounted) return;
    final wasUnreachable = _hubUnreachable;
    final nextFailed = ok ? 0 : _failedPolls + 1;
    final connectionChanged = _connected != ok;
    final bannerChanged =
        wasUnreachable != (nextFailed >= _unreachableThreshold);
    if (connectionChanged || _failedPolls != nextFailed || bannerChanged) {
      setState(() {
        _connected = ok;
        _failedPolls = nextFailed;
      });
    }
    if (ok) {
      try {
        final snap = await HubService.instance.channelStatus();
        if (!mounted) return;
        if (_activeChannel != snap.active) {
          setState(() => _activeChannel = snap.active);
        }
      } catch (_) {/* best-effort */}
      try {
        final pending = await HubService.instance.pendingApprovals();
        if (!mounted) return;
        if (_pendingApprovals != pending.length) {
          setState(() => _pendingApprovals = pending.length);
        }
      } catch (_) {/* best-effort */}
    }
  }

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

  void _openSearchPalette() {
    final l = AppLocalizations.of(context)!;
    final pagesGroup = l.searchGroupPages;
    final hits = <ChainSearchHit>[
      for (var i = 0; i < _pages.length; i++)
        ChainSearchHit(
          label: _pages[i].labelOf(context),
          hint: l.searchPageHint(i + 1),
          icon: _pages[i].icon,
          group: pagesGroup,
          onSelect: () {
            Navigator.of(context).pop();
            setState(() => _selectedIndex = i);
          },
        ),
      ChainSearchHit(
        label: l.searchSettingsLabel,
        hint: l.searchSettingsHint,
        icon: Icons.settings_outlined,
        group: pagesGroup,
        onSelect: () {
          Navigator.of(context).pop();
          ChainSettingsDialog.show(context);
        },
      ),
    ];
    ChainSearchPalette.show(context, staticHits: hits);
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Shortcuts(
      shortcuts: <ShortcutActivator, Intent>{
        // Cmd+1..6 jumps to the matching destination. Numbered
        // 1-based to match the visual order in the sidebar.
        for (var i = 0; i < _pages.length; i++)
          SingleActivator(
            LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
            meta: true,
          ): _GoToPageIntent(
            i,
          ),
        // Settings dialog. macOS reserves Cmd+, for the native
        // app-Preferences menu item, which Flutter Desktop
        // doesn't wire up — the keystroke is captured by the OS
        // before it reaches our Shortcuts widget. Cmd+; is the
        // working alternative; we register Cmd+, too for
        // completeness in case a future Flutter version (or a
        // native-menu setup) lets it through.
        const SingleActivator(LogicalKeyboardKey.semicolon, meta: true):
            const _OpenSettingsIntent(),
        const SingleActivator(LogicalKeyboardKey.comma, meta: true):
            const _OpenSettingsIntent(),
        // Universal command palette. Mirrors Cmd+K from VSCode /
        // Linear / 1Password — operators jump anywhere by typing.
        const SingleActivator(LogicalKeyboardKey.keyK, meta: true):
            const _OpenSearchIntent(),
        const SingleActivator(LogicalKeyboardKey.keyK, control: true):
            const _OpenSearchIntent(),
      },
      child: Actions(
        actions: <Type, Action<Intent>>{
          _GoToPageIntent: CallbackAction<_GoToPageIntent>(
            onInvoke: (intent) {
              setState(() => _selectedIndex = intent.index);
              return null;
            },
          ),
          _OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>(
            onInvoke: (_) {
              ChainSettingsDialog.show(context);
              return null;
            },
          ),
          _OpenSearchIntent: CallbackAction<_OpenSearchIntent>(
            onInvoke: (_) {
              _openSearchPalette();
              return null;
            },
          ),
        },
        child: Focus(
          autofocus: true,
          child: Scaffold(
            body: Row(
              children: [
                _Sidebar(
                  selectedIndex: _selectedIndex,
                  onSelect: (i) => setState(() => _selectedIndex = i),
                  pages: _pages,
                  connected: _connected,
                  endpointLabel: HubService.instance.endpointLabel,
                  activeChannel: _activeChannel,
                  pendingApprovals: _pendingApprovals,
                ),
                Container(width: 1, color: theme.colorScheme.outlineVariant),
                Expanded(
                  child: Column(
                    children: [
                      if (_hubUnreachable)
                        _HubUnreachableBanner(
                          endpoint: HubService.instance.endpointLabel,
                          onOpenSettings: () => ChainSettingsDialog.show(context),
                        ),
                      Expanded(
                        child: AnimatedSwitcher(
                          duration: ChainMotion.base,
                          switchInCurve: ChainMotion.easing,
                          switchOutCurve: ChainMotion.easing,
                          transitionBuilder: (child, anim) => FadeTransition(
                            opacity: anim,
                            child: SlideTransition(
                              position: Tween<Offset>(
                                begin: const Offset(0, 0.02),
                                end: Offset.zero,
                              ).animate(anim),
                              child: child,
                            ),
                          ),
                          child: KeyedSubtree(
                            key: ValueKey(_selectedIndex),
                            child: _pages[_selectedIndex].page,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

/// One-line banner shown when the hub has been unreachable for
/// several consecutive health polls. Replaces the indefinite
/// silent "connecting…" with an explicit message + a jump to
/// Settings, where the operator can fix the endpoint.
class _HubUnreachableBanner extends StatelessWidget {
  final String endpoint;
  final VoidCallback onOpenSettings;

  const _HubUnreachableBanner({
    required this.endpoint,
    required this.onOpenSettings,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return Material(
      color: theme.colorScheme.errorContainer,
      child: Padding(
        padding: const EdgeInsets.symmetric(
          horizontal: ChainSpace.lg,
          vertical: ChainSpace.sm,
        ),
        child: Row(
          children: [
            Icon(
              Icons.cloud_off_outlined,
              size: 18,
              color: theme.colorScheme.onErrorContainer,
            ),
            const SizedBox(width: ChainSpace.sm),
            Expanded(
              child: Text(
                l.hubUnreachableBanner(endpoint),
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onErrorContainer,
                ),
              ),
            ),
            const SizedBox(width: ChainSpace.sm),
            TextButton(
              onPressed: onOpenSettings,
              child: Text(l.hubUnreachableOpenSettings),
            ),
          ],
        ),
      ),
    );
  }
}

/// Intents driven by the Shortcuts/Actions pair on the shell.
class _GoToPageIntent extends Intent {
  final int index;
  const _GoToPageIntent(this.index);
}

class _OpenSettingsIntent extends Intent {
  const _OpenSettingsIntent();
}

class _OpenSearchIntent extends Intent {
  const _OpenSearchIntent();
}

class _Sidebar extends StatefulWidget {
  final int selectedIndex;
  final ValueChanged<int> onSelect;
  final List<_NavPage> pages;
  final bool? connected;
  final String endpointLabel;

  /// Active channel name from the running daemon
  /// (`local`/`dev`/`beta`/`production`). Null while the
  /// initial fetch is in flight or the hub is unreachable.
  final String? activeChannel;

  /// Number of pending approvals — surfaced as a badge on the
  /// matching nav item so the operator notices a new approval
  /// without polling the page.
  final int pendingApprovals;

  const _Sidebar({
    required this.selectedIndex,
    required this.onSelect,
    required this.pages,
    required this.connected,
    required this.endpointLabel,
    required this.activeChannel,
    this.pendingApprovals = 0,
  });

  @override
  State<_Sidebar> createState() => _SidebarState();
}

class _SidebarState extends State<_Sidebar>
    with SingleTickerProviderStateMixin {
  // Default-collapsed (icons only); expands on hover. 72 / 220
  // mirrors NavigationRail's spec.
  static const double _collapsedWidth = 72;
  static const double _expandedWidth = 220;
  // Fixed pixel heights for every header row so the layout
  // is the SAME total height in collapsed and expanded
  // states. Items below the header (destinations list,
  // footer) sit at a mathematically constant Y — they
  // cannot move when the rail expands because nothing
  // above them grows. This is the structural fix; the
  // earlier opacity-tied animation only made the broken
  // height transition smoother to the eye.
  static const double _brandRowH = 48;
  static const double _connRowH = 44;
  static const double _channelRowH = 28;
  static const double _rowGap = 8;
  late final AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      // Expand uses the slow (~320 ms) token: the icon→label
      // reveal is a deliberate, eased gesture, not a flicker.
      // The collapse runs faster (reverseDuration) so dismissing
      // the rail feels crisp rather than sluggish.
      duration: ChainMotion.slow,
      reverseDuration: ChainMotion.base,
      value: 0,
    );
  }

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

  Future<void> _startDaemon(BuildContext context) async {
    final l = AppLocalizations.of(context)!;
    final r = await SystemActions.chainDaemon(['start']);
    if (!context.mounted) return;
    if (r.ok) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(l.daemonStartRequested)),
      );
      return;
    }
    // The daemon may already be running (port in use) — probe before
    // showing an error, so this just connects on an already-up hub.
    final alive =
        await HubService.instance.healthy().catchError((_) => false);
    if (!context.mounted) return;
    if (alive) return;
    // Genuinely down: persistent, copyable dialog (a SnackBar flashes
    // away before the operator can read or copy the daemon's stderr).
    await showFaiProcessErrorDialog(
      context,
      'daemon.start',
      r.stdout,
      r.stderr,
      title: l.daemonStartFailed(''),
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final mode = widget.connected == true
        ? ChainDeltaMode.live
        : ChainDeltaMode.idle;
    final hasChannel =
        widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
    return MouseRegion(
      onEnter: (_) => _ctrl.forward(),
      onExit: (_) => _ctrl.reverse(),
      child: AnimatedBuilder(
        animation: _ctrl,
        builder: (context, _) {
          final t = Curves.easeInOutCubic.transform(_ctrl.value);
          final width =
              _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
          final labelsInteractive = t > 0.5;
          return Container(
            width: width,
            color: theme.colorScheme.surfaceContainerLow,
            padding: const EdgeInsets.symmetric(vertical: ChainSpace.xl),
            child: ClipRect(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Brand row — fixed 48px regardless of state.
                  SizedBox(
                    height: _brandRowH,
                    child: _sidebarRow(
                      leading: ChainDeltaMark(
                        color: theme.colorScheme.primary,
                        mode: mode,
                      ),
                      label: _BrandLabel(),
                      t: t,
                      labelsInteractive: labelsInteractive,
                    ),
                  ),
                  const SizedBox(height: _rowGap),
                  // Connection row — fixed 44px. Tapping the row
                  // (anywhere in either icon col or label) when
                  // disconnected fires the daemon-start action,
                  // so we can drop the inline "Start hub" button
                  // that used to make this row's height balloon.
                  Builder(
                    builder: (context) {
                      final l = AppLocalizations.of(context)!;
                      final ch = HubService.instance.connectedChannelName;
                      final caption = widget.connected == true
                          ? (ch != null
                                ? '${l.connectionConnected} · $ch'
                                : l.connectionConnected)
                          : widget.connected == false
                          ? l.connectionTapToStart
                          : l.connectionConnecting;
                      return SizedBox(
                        height: _connRowH,
                        child: _sidebarRow(
                          leading: Tooltip(
                            message: '$caption · ${widget.endpointLabel}',
                            child: _CollapsedConnectionDot(
                              connected: widget.connected,
                            ),
                          ),
                          label: _ConnectionLabel(
                            connected: widget.connected,
                            endpoint: widget.endpointLabel,
                          ),
                          t: t,
                          labelsInteractive: labelsInteractive,
                          onTap: widget.connected == false
                              ? () => _startDaemon(context)
                              : null,
                        ),
                      );
                    },
                  ),
                  const SizedBox(height: _rowGap),
                  // Channel row — fixed 28px. Always reserved
                  // (with empty content when there's no channel
                  // selected) so toggling channels at runtime
                  // doesn't shift the destinations list below.
                  SizedBox(
                    height: _channelRowH,
                    child: hasChannel
                        ? _sidebarRow(
                            leading: Tooltip(
                              message: widget.activeChannel!,
                              child: _CollapsedChannelChip(
                                channel: widget.activeChannel!,
                              ),
                            ),
                            label: _ChannelPill(channel: widget.activeChannel!),
                            t: t,
                            labelsInteractive: labelsInteractive,
                          )
                        : const SizedBox.shrink(),
                  ),
                  const SizedBox(height: ChainSpace.md),
                  Expanded(
                    child: ListView(
                      padding: EdgeInsets.zero,
                      children: [
                        for (var i = 0; i < widget.pages.length; i++)
                          _SidebarItem(
                            page: widget.pages[i],
                            selected: i == widget.selectedIndex,
                            t: t,
                            labelsInteractive: labelsInteractive,
                            iconColumnWidth: _collapsedWidth,
                            badge: widget.pages[i].id == 'approvals' &&
                                    widget.pendingApprovals > 0
                                ? widget.pendingApprovals
                                : null,
                            onTap: () => widget.onSelect(i),
                          ),
                      ],
                    ),
                  ),
                  _Footer(
                    t: t,
                    labelsInteractive: labelsInteractive,
                    iconColumnWidth: _collapsedWidth,
                    onOpenSettings: () => _openSettings(context),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

  /// Shared row geometry for the brand / connection / channel
  /// headers. Leading widget sits in a fixed-width icon column
  /// anchored to the rail's left edge. The label slot is in
  /// the tree as soon as the animation kicks off (t > 0) and
  /// fades in via Opacity tied to the same controller. Skipping
  /// layout at t == 0 keeps the row's collapsed height = the
  /// leading widget's natural height (rather than including
  /// the label's tall "Start hub" affordance even when
  /// invisible). The opacity at t ≈ 0.01 is low enough that
  /// the layout's first-frame appearance isn't perceived as a
  /// pop — the label visually grows in from invisible.
  Widget _sidebarRow({
    required Widget leading,
    required Widget label,
    required double t,
    required bool labelsInteractive,
    VoidCallback? onTap,
  }) {
    final body = Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        SizedBox(
          width: _collapsedWidth,
          child: Center(child: leading),
        ),
        Expanded(
          child: t > 0
              ? IgnorePointer(
                  // The whole row is interactive when onTap is set
                  // (we don't want the label sub-widgets eating
                  // the tap), so ignore pointer events on the
                  // label sub-tree even when the rail is expanded.
                  ignoring: onTap != null || !labelsInteractive,
                  child: Opacity(
                    opacity: t,
                    child: Padding(
                      padding: const EdgeInsets.only(right: ChainSpace.md),
                      child: label,
                    ),
                  ),
                )
              : const SizedBox.shrink(),
        ),
      ],
    );
    if (onTap == null) return body;
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      child: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: onTap,
        child: body,
      ),
    );
  }

  Future<void> _openSettings(BuildContext context) async {
    final saved = await ChainSettingsDialog.show(context);
    if (saved && context.mounted) {
      // Force a sidebar rebuild so the new endpoint shows in
      // the connection pill.
      (context as Element).markNeedsBuild();
    }
  }
}

/// Accent colour for a channel name. Color-coded so production
/// deployments look visibly different from local-dev — the kind of
/// thing you want to see before clicking "Clear log". Shared by the
/// pill, the collapsed chip, and the switch menu so all three stay
/// in lockstep.
Color _channelAccent(String channel, ColorScheme cs) {
  switch (channel) {
    case 'production':
      // "Live & healthy", not an error — red here read as a fault.
      return ChainColors.success;
    case 'beta':
      return ChainColors.warning;
    case 'dev':
      return cs.primary;
    case 'local':
    default:
      return cs.onSurfaceVariant;
  }
}

IconData _channelIcon(String channel) {
  switch (channel) {
    case 'production':
      return Icons.shield_outlined;
    case 'beta':
      return Icons.science_outlined;
    case 'dev':
      return Icons.bolt_outlined;
    case 'local':
    default:
      return Icons.home_work_outlined;
  }
}

/// Open the channel-switch menu anchored at [anchor]'s widget, then
/// act on the selection. Switching runs `chain channel switch`
/// (writes `~/.chain/current-channel` AND restarts that channel's
/// daemon), then repoints Studio's wire at the new channel so the
/// connection follows the switch immediately — the 5s health poll
/// then refreshes the active-channel label. Shared by the expanded
/// pill and the collapsed chip.
///
/// Falls back to the full Settings dialog when the hub is down (the
/// snapshot RPC fails) so the operator can still pick + start a
/// channel from there.
Future<void> _showChannelSwitchMenu(BuildContext anchor, String current) async {
  // Capture everything that reads from the context up front, before
  // any await — these stay valid for the rest of the flow and keep
  // the analyzer happy about context-across-async-gaps.
  final l = AppLocalizations.of(anchor)!;
  final messenger = ScaffoldMessenger.of(anchor);
  final box = anchor.findRenderObject() as RenderBox?;
  final overlay = Overlay.of(anchor).context.findRenderObject() as RenderBox?;
  if (box == null || overlay == null) return;

  ChannelStatusSnapshot snap;
  try {
    snap = await HubService.instance.channelStatus();
  } catch (_) {
    if (anchor.mounted) ChainSettingsDialog.show(anchor);
    return;
  }
  if (!anchor.mounted) return;

  // Anchor the menu to the pill's top edge, opening downward.
  final origin = box.localToGlobal(Offset.zero, ancestor: overlay);
  final position = RelativeRect.fromLTRB(
    origin.dx,
    origin.dy + box.size.height,
    overlay.size.width - origin.dx - box.size.width,
    0,
  );

  const settingsValue = '__settings__';
  final selected = await showMenu<String>(
    context: anchor,
    position: position,
    items: [
      for (final ch in snap.channels)
        PopupMenuItem<String>(
          value: ch.name,
          child: _ChannelMenuItem(channel: ch, active: ch.name == snap.active),
        ),
      const PopupMenuDivider(),
      PopupMenuItem<String>(
        value: settingsValue,
        child: Row(
          children: [
            Icon(
              Icons.settings_outlined,
              size: 16,
              color: Theme.of(anchor).colorScheme.onSurfaceVariant,
            ),
            const SizedBox(width: ChainSpace.sm),
            Text(l.searchSettingsLabel),
          ],
        ),
      ),
    ],
  );
  if (selected == null || !anchor.mounted) return;
  if (selected == settingsValue) {
    ChainSettingsDialog.show(anchor);
    return;
  }
  if (selected == snap.active) return;

  final r = await SystemActions.chainChannelSwitch(selected);
  if (!anchor.mounted) return;
  if (!r.ok) {
    showFaiProcessError(
      anchor,
      'channel.switch',
      r.stdout,
      r.stderr,
      title: l.channelSwitchFailed(''),
    );
    return;
  }
  // Repoint Studio at the freshly-restarted channel daemon. persist:
  // false keeps launch-time auto-discovery following ~/.chain, so the
  // CLI pointer remains the single source of truth.
  final target = snap.channels.firstWhere(
    (c) => c.name == selected,
    orElse: () =>
        ChannelInfo(name: selected, port: 50051, running: true, endpoint: ''),
  );
  try {
    await HubService.instance.reconnect(
      HubEndpoint(host: '127.0.0.1', port: target.port),
      persist: false,
    );
  } catch (_) {/* health poll will retry */}
  if (anchor.mounted) {
    messenger.showSnackBar(
      SnackBar(content: Text(l.channelSwitchOk(selected))),
    );
  }
}

/// One row in the channel-switch menu: accent icon, name, a
/// running/stopped dot, and a check on the active channel.
class _ChannelMenuItem extends StatelessWidget {
  final ChannelInfo channel;
  final bool active;
  const _ChannelMenuItem({required this.channel, required this.active});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final accent = _channelAccent(channel.name, theme.colorScheme);
    return Row(
      children: [
        Icon(_channelIcon(channel.name), size: 15, color: accent),
        const SizedBox(width: ChainSpace.sm),
        Text(
          channel.name,
          style: ChainTheme.mono(
            size: 12,
            weight: FontWeight.w600,
            color: theme.colorScheme.onSurface,
          ),
        ),
        const SizedBox(width: ChainSpace.sm),
        Container(
          width: 7,
          height: 7,
          decoration: BoxDecoration(
            color: channel.running
                ? ChainColors.success
                : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
            shape: BoxShape.circle,
          ),
        ),
        const Spacer(),
        if (active)
          Icon(Icons.check, size: 16, color: theme.colorScheme.primary),
      ],
    );
  }
}

/// Active-channel indicator + switcher. Click to open the channel
/// menu and switch directly (writes `~/.chain/current-channel` and
/// restarts the daemon). Color-coded so production stays
/// unmistakeable.
class _ChannelPill extends StatelessWidget {
  final String channel;
  const _ChannelPill({required this.channel});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final accent = _channelAccent(channel, theme.colorScheme);
    final l = AppLocalizations.of(context)!;
    return Tooltip(
      message: l.sidebarChannelTooltip,
      child: Material(
        color: theme.colorScheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(ChainRadius.sm),
        child: InkWell(
          borderRadius: BorderRadius.circular(ChainRadius.sm),
          onTap: () => _showChannelSwitchMenu(context, channel),
          child: Container(
            padding: const EdgeInsets.symmetric(
              horizontal: ChainSpace.sm,
              vertical: 4,
            ),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(ChainRadius.sm),
              border: Border.all(color: accent.withValues(alpha: 0.4)),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(_channelIcon(channel), size: 11, color: accent),
                const SizedBox(width: 4),
                Text(
                  channel,
                  style: ChainTheme.mono(
                    size: 10,
                    weight: FontWeight.w600,
                    color: accent,
                  ),
                ),
                const SizedBox(width: 2),
                Icon(Icons.arrow_drop_down, size: 12, color: accent),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

/// Mini-version of [_ConnectionLabel] used when the sidebar is
/// collapsed. A 10px dot in the same green/red/amber tonality
/// the ChainDeltaMark uses for live / idle / unknown.
class _CollapsedConnectionDot extends StatelessWidget {
  final bool? connected;
  const _CollapsedConnectionDot({required this.connected});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final color = connected == true
        ? ChainColors.success
        : connected == false
        ? theme.colorScheme.error
        : ChainColors.warning;
    // Pulse only while connected — a living "this is live" signal,
    // steady when down/unknown. Restores the pulse lost in the
    // sidebar refactor; reuses the shared ChainStatusDot.
    return ChainStatusDot(color: color, pulsing: connected == true, size: 10);
  }
}

/// Mini-version of [_ChannelPill] used when the sidebar is
/// collapsed. A 16px circular chip with the first letter of
/// the channel (P / B / D / L) in the same accent tone the
/// full pill uses, so production stays unmistakeable at a
/// glance.
class _CollapsedChannelChip extends StatelessWidget {
  final String channel;
  const _CollapsedChannelChip({required this.channel});

  String get _letter {
    if (channel.isEmpty) return '·';
    return channel.substring(0, 1).toUpperCase();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final accent = _channelAccent(channel, theme.colorScheme);
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      child: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: () => _showChannelSwitchMenu(context, channel),
        child: Container(
          width: 18,
          height: 18,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(color: accent.withValues(alpha: 0.55)),
          ),
          alignment: Alignment.center,
          child: Text(
            _letter,
            style: ChainTheme.mono(
              size: 10,
              weight: FontWeight.w700,
              color: accent,
            ),
          ),
        ),
      ),
    );
  }
}

/// Two-line brand label shown in the rail's expanded state.
/// Fits inside a 48-px fixed-height row by design: 16-px title
/// + 2-px gap + 10-px monospaced version stamp = 28-px content
/// with 10 px of vertical breathing room top + bottom.
class _BrandLabel extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'Ch∆In Studio',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
        const SizedBox(height: 2),
        Text(
          'v$kStudioVersion',
          style: ChainTheme.mono(size: 10, color: theme.colorScheme.primary),
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
      ],
    );
  }
}

/// Single-conceptual-block connection label shown in the
/// rail's expanded state. Two single-line rows: a caption
/// (`connected` / `tap to start` / `connecting…`) and the
/// endpoint label below in mono. Fits inside the 44-px fixed
/// connection-row height — see `_connRowH` in `_SidebarState`.
/// The old inline "Start hub" button is gone; the parent row
/// is tappable when `connected == false` and fires the same
/// daemon-start action.
class _ConnectionLabel extends StatelessWidget {
  final bool? connected;
  final String endpoint;

  const _ConnectionLabel({required this.connected, required this.endpoint});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final captionColor = connected == false
        ? theme.colorScheme.error
        : theme.colorScheme.onSurface;
    // Name the channel the connection is on (production / local / …) so
    // "connected" can never look like it contradicts the Diagnose page,
    // which reports the *active* channel's daemon.
    final ch = HubService.instance.connectedChannelName;
    final caption = connected == true
        ? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected)
        : connected == false
        ? l.connectionTapToStart
        : l.connectionConnecting;
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          caption,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: theme.textTheme.labelMedium?.copyWith(
            color: captionColor,
            fontWeight: FontWeight.w500,
          ),
        ),
        const SizedBox(height: 2),
        Text(
          endpoint,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: ChainTheme.mono(
            size: 10,
            color: theme.colorScheme.onSurfaceVariant,
          ),
        ),
      ],
    );
  }
}

class _SidebarItem extends StatefulWidget {
  final _NavPage page;
  final bool selected;

  /// 0 = fully collapsed, 1 = fully expanded. Drives label
  /// opacity in lockstep with the parent rail's width
  /// animation — there is no second timer for the label, so
  /// label and rail can't fall out of sync.
  final double t;
  final bool labelsInteractive;
  final double iconColumnWidth;
  final VoidCallback onTap;

  /// Optional count badge — null hides it, non-null renders a
  /// small accent pill on top-right of the icon. Used by the
  /// Approvals item for pending count.
  final int? badge;

  const _SidebarItem({
    required this.page,
    required this.selected,
    required this.t,
    required this.labelsInteractive,
    required this.iconColumnWidth,
    required this.onTap,
    this.badge,
  });

  @override
  State<_SidebarItem> createState() => _SidebarItemState();
}

class _SidebarItemState extends State<_SidebarItem> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final color = widget.selected
        ? theme.colorScheme.primary
        : _hovered
        ? theme.colorScheme.onSurface
        : theme.colorScheme.onSurfaceVariant;
    final bg = widget.selected
        ? theme.colorScheme.primary.withValues(alpha: 0.08)
        : _hovered
        ? theme.colorScheme.surfaceContainerHigh
        : Colors.transparent;
    final label = widget.page.labelOf(context);

    Widget icon = Icon(
      widget.selected ? widget.page.selectedIcon : widget.page.icon,
      size: 18,
      color: color,
    );
    final badge = widget.badge;
    if (badge != null) {
      icon = Stack(
        clipBehavior: Clip.none,
        children: [
          icon,
          Positioned(
            right: -6,
            top: -4,
            child: Container(
              padding: const EdgeInsets.symmetric(
                horizontal: 5,
                vertical: 1,
              ),
              constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
              decoration: BoxDecoration(
                color: theme.colorScheme.primary,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                badge > 9 ? '9+' : '$badge',
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: theme.colorScheme.onPrimary,
                  fontSize: 9,
                  fontWeight: FontWeight.w700,
                  height: 1.1,
                ),
              ),
            ),
          ),
        ],
      );
    }
    // Label widget is ALWAYS in the tree — opacity = t makes
    // it fade in/out in lockstep with the rail's width
    // animation. Expanded's flex space goes from 0 (at t=0,
    // rail width = 72) to (rail width − 72) at t=1, so the
    // label has natural space to fill without any conditional
    // tree mutation.
    final content = Row(
      children: [
        SizedBox(
          width: widget.iconColumnWidth,
          child: Center(child: icon),
        ),
        Expanded(
          child: widget.t > 0
              ? IgnorePointer(
                  ignoring: !widget.labelsInteractive,
                  child: Opacity(
                    opacity: widget.t,
                    child: Padding(
                      padding: const EdgeInsets.only(right: ChainSpace.md),
                      child: Text(
                        label,
                        overflow: TextOverflow.fade,
                        softWrap: false,
                        style: theme.textTheme.labelMedium?.copyWith(
                          color: color,
                        ),
                      ),
                    ),
                  ),
                )
              : const SizedBox.shrink(),
        ),
      ],
    );

    final inner = Padding(
      padding: const EdgeInsets.symmetric(vertical: 2),
      child: MouseRegion(
        onEnter: (_) => setState(() => _hovered = true),
        onExit: (_) => setState(() => _hovered = false),
        cursor: SystemMouseCursors.click,
        child: GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: widget.onTap,
          child: AnimatedContainer(
            duration: ChainMotion.fast,
            // No horizontal padding — the icon column anchors
            // the left. The bg highlight spans the rail's
            // current width (typical nav-rail behaviour).
            padding: const EdgeInsets.symmetric(vertical: ChainSpace.md),
            decoration: BoxDecoration(
              color: bg,
              borderRadius: BorderRadius.circular(ChainRadius.sm),
              // Lift only the selected item — a whisper of depth
              // marks "you are here" without competing with the
              // accent fill.
              boxShadow: widget.selected
                  ? ChainElevation.low(theme.brightness)
                  : null,
            ),
            child: content,
          ),
        ),
      ),
    );

    // Tooltip only while the label is hidden — once the label
    // is visible inline the tooltip becomes redundant noise.
    if (widget.t > 0.5) return inner;
    return Tooltip(message: label, preferBelow: false, child: inner);
  }
}

/// Bottom-of-rail control strip. Settings icon is always the
/// last item in the icon column so its pixel-X never shifts;
/// theme + language toggles and clock fade in beside it as
/// the rail expands, sharing the same `t` as the rest of the
/// sidebar so motion stays in lockstep.
class _Footer extends StatelessWidget {
  final double t;
  final bool labelsInteractive;
  final double iconColumnWidth;
  final VoidCallback onOpenSettings;

  const _Footer({
    required this.t,
    required this.labelsInteractive,
    required this.iconColumnWidth,
    required this.onOpenSettings,
  });

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        SizedBox(
          width: iconColumnWidth,
          child: Center(
            child: IconButton(
              icon: const Icon(Icons.settings_outlined, size: 16),
              tooltip: l.sidebarSettingsTooltip,
              visualDensity: VisualDensity.compact,
              onPressed: onOpenSettings,
            ),
          ),
        ),
        Expanded(
          child: t > 0
              ? IgnorePointer(
                  ignoring: !labelsInteractive,
                  child: Opacity(
                    opacity: t,
                    child: Padding(
                      padding: const EdgeInsets.only(right: ChainSpace.sm),
                      child: Row(
                        children: [
                          _ThemeToggle(),
                          _LanguageToggle(),
                          const Expanded(child: Center(child: _SidebarClock())),
                        ],
                      ),
                    ),
                  ),
                )
              : const SizedBox.shrink(),
        ),
      ],
    );
  }
}

class _NavPage {
  /// Stable id used for the Cmd+K palette and the sidebar
  /// shortcut keys; never shown to the operator. The visible
  /// label comes from [labelOf] which reads the active
  /// AppLocalizations.
  final String id;
  final IconData icon;
  final IconData selectedIcon;
  final Widget page;

  const _NavPage({
    required this.id,
    required this.icon,
    required this.selectedIcon,
    required this.page,
  });

  /// Resolve the localized label via AppLocalizations.
  String labelOf(BuildContext context) {
    final l = AppLocalizations.of(context);
    if (l == null) return id;
    switch (id) {
      case 'welcome':
        return l.navWelcome;
      case 'doctor':
        return l.navDoctor;
      case 'modules':
        return l.navModules;
      case 'store':
        return l.navStore;
      case 'flows':
        return l.navFlows;
      case 'flow-editor':
        return l.navFlowEditor;
      case 'audit':
        return l.navAudit;
      case 'approvals':
        return l.navApprovals;
      case 'federation':
        return l.navFederation;
      default:
        return id;
    }
  }
}

class _ThemeToggle extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final app = StudioApp.of(context);
    if (app == null) return const SizedBox.shrink();
    return ValueListenableBuilder<ThemeModeValue>(
      valueListenable: app.modeNotifier,
      builder: (_, mode, _) {
        final icon = switch (mode) {
          ThemeModeValue.system => Icons.brightness_auto_outlined,
          ThemeModeValue.light => Icons.light_mode_outlined,
          ThemeModeValue.dark => Icons.dark_mode_outlined,
        };
        final tooltip = switch (mode) {
          ThemeModeValue.system => 'theme: follow system',
          ThemeModeValue.light => 'theme: light',
          ThemeModeValue.dark => 'theme: dark',
        };
        return IconButton(
          icon: Icon(icon, size: 16),
          tooltip: tooltip,
          visualDensity: VisualDensity.compact,
          onPressed: () {
            final next = switch (mode) {
              ThemeModeValue.system => ThemeModeValue.light,
              ThemeModeValue.light => ThemeModeValue.dark,
              ThemeModeValue.dark => ThemeModeValue.system,
            };
            app.setMode(next);
          },
        );
      },
    );
  }
}

/// Small ticking wall-clock for the sidebar. Aligns the on-screen
/// time with the timestamps the audit log writes — operators
/// glance here when an event timestamp looks off and want a
/// reference point. Updates on the second; tooltip exposes the
/// full ISO + UTC offset for unambiguous comparison.
class _SidebarClock extends StatefulWidget {
  const _SidebarClock();

  @override
  State<_SidebarClock> createState() => _SidebarClockState();
}

class _SidebarClockState extends State<_SidebarClock> {
  late DateTime _now;
  Timer? _ticker;

  @override
  void initState() {
    super.initState();
    _now = DateTime.now();
    _scheduleNextTick();
  }

  void _scheduleNextTick() {
    // Align ticks on the second boundary so the seconds digit
    // changes when the wall-clock second changes, not 0–999 ms
    // late depending on when the widget mounted.
    final delay = Duration(milliseconds: 1000 - _now.millisecond);
    _ticker = Timer(delay, () {
      if (!mounted) return;
      setState(() => _now = DateTime.now());
      _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
        if (!mounted) return;
        setState(() => _now = DateTime.now());
      });
    });
  }

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

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final hh = _now.hour.toString().padLeft(2, '0');
    final mm = _now.minute.toString().padLeft(2, '0');
    final ss = _now.second.toString().padLeft(2, '0');
    final yyyy = _now.year.toString().padLeft(4, '0');
    final mo = _now.month.toString().padLeft(2, '0');
    final dd = _now.day.toString().padLeft(2, '0');

    final offsetMin = _now.timeZoneOffset.inMinutes;
    final sign = offsetMin >= 0 ? '+' : '-';
    final absMin = offsetMin.abs();
    final offH = (absMin ~/ 60).toString().padLeft(2, '0');
    final offM = (absMin % 60).toString().padLeft(2, '0');
    final tooltip =
        'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n'
        'UTC:   ${_now.toUtc().toIso8601String()}';

    return Tooltip(
      message: tooltip,
      child: Text(
        '$hh:$mm:$ss',
        style: ChainTheme.mono(
          size: 11,
          color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
        ),
      ),
    );
  }
}

/// Two-state language toggle (DE / EN) parked in the sidebar
/// footer next to the theme button. Flipping it updates the
/// app-wide locale via the parent [`StudioAppState`]; every
/// AppLocalizations consumer rebuilds with the new strings.
/// The Store-page detail sheet still renders bilingual store
/// fields off the same locale, so a single click flips
/// chrome + content together.
class _LanguageToggle extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final app = StudioApp.of(context);
    if (app == null) return const SizedBox.shrink();
    return ValueListenableBuilder<Locale>(
      valueListenable: app.localeNotifier,
      builder: (context, locale, _) {
        final isDe = locale.languageCode == 'de';
        return Tooltip(
          message: isDe
              ? 'Sprache: Deutsch — Klick wechselt zu English'
              : 'Language: English — click to switch to Deutsch',
          child: TextButton(
            onPressed: () => app.setLocale(Locale(isDe ? 'en' : 'de')),
            style: TextButton.styleFrom(
              minimumSize: const Size(36, 32),
              padding: const EdgeInsets.symmetric(horizontal: 6),
              visualDensity: VisualDensity.compact,
            ),
            child: Text(
              isDe ? 'DE' : 'EN',
              style: ChainTheme.mono(
                size: 11,
                weight: FontWeight.w600,
                color: Theme.of(context).colorScheme.primary,
              ),
            ),
          ),
        );
      },
    );
  }
}

// _FlowEditorAdapter removed in 0.52.0 — FlowsPage now hosts
// the editor directly with the full runDriver bridge. There
// is one Flows destination; the standalone editor route is
// gone.
