// Store browser. Lists modules from the hub's bundled
// store-index. Top-level surface mirrors the look operators
// expect from app stores (search bar, category strip, filter
// chips, status chips), with a per-entry detail sheet that
// renders the full bilingual description, requires-list, and
// repository link.

import 'dart:async';
import 'dart:convert';

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

import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../data/today_story_loader.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';

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

  @override
  State<StorePage> createState() => _StorePageState();
}

class _StorePageState extends State<StorePage> {
  final _queryCtrl = TextEditingController();
  String _category = '';
  String _status = '';

  /// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
  /// client-side after the hub returns results — the search RPC
  /// has no source field.
  String _source = '';
  bool _installedOnly = false;

  /// "Show installable modules only". On by default: roughly
  /// 2/3 of the seed-index entries currently carry status
  /// `planned` (no bundle to install), and pre-filter polish
  /// found that operators couldn't tell why they couldn't
  /// install half the cards. Planned modules are one chip-flip
  /// away in the filter dialog.
  bool _installableOnly = true;

  /// Tracks per-recommended-source add buttons to disable them
  /// while the request is in flight.
  final Set<String> _addingSources = <String>{};

  /// Whether the operator dismissed the editorial hero this
  /// session. Not persisted — the next Studio launch shows it
  /// again so a release-bumped story has a chance to be seen.
  bool _todayDismissed = false;

  /// The carousel of stories rendered in the hero. When an
  /// operator-accepted story exists at `~/.chain/today/active.yaml`,
  /// it is the only entry (carousel collapses to a single
  /// slide). Otherwise the curated fallback list rotates.
  late final List<TodayStoryData> _todayStories = (() {
    final loaded = TodayStoryLoader.loadOrFallback(
      _kFallbackTodayStories.first,
    );
    // When the loader fell back to the const, it returned the
    // first entry of our list — surface the whole list so the
    // operator can browse. Otherwise the loader produced a real
    // accepted story and we honour it as the only item.
    if (identical(loaded, _kFallbackTodayStories.first)) {
      return _kFallbackTodayStories;
    }
    return <TodayStoryData>[loaded];
  })();

  /// Carousel index. Wraps around modulo `_todayStories.length`.
  int _todayIndex = 0;
  late Future<List<StoreItem>> _future;

  // ── AI search state ──────────────────────────────────────
  // The Ask bar runs traditional substring search on every
  // keystroke (cheap; same as before). When the operator hits
  // Enter on a question-shaped query AND a System-AI is
  // configured, the LLM gets a structured ranking call and the
  // grid filters to its picks. Clearing the query resets both
  // surfaces back to the unfiltered view.
  bool _aiThinking = false;
  String? _aiAnswer;
  List<_AiMatch>? _aiMatches;
  Set<String>? _aiMatchedNames;
  String? _aiError;

  /// Debounce-Timer für Such-Filterung beim Tippen. Ohne
  /// Debounce flackert "Keine Treffer" zwischen Buchstaben
  /// (für 2-3 Buchstaben matched z.B. nichts, das nächste
  /// dann doch). 250ms ist comfortable für Tippen.
  Timer? _typingDebounce;

  /// Whether the operator has a System-AI configured. Drives
  /// the Ask-bar hint copy and disables the AI path when off.
  bool _systemAiEnabled = false;

  /// Active UI locale, read from the app-wide MaterialApp
  /// `locale` setting. Drives which side of the bilingual
  /// store-index entries renders (taglineEn vs taglineDe,
  /// descriptionEn vs descriptionDe). The sidebar's
  /// language toggle flips this for the whole app.
  String get _locale => Localizations.localeOf(context).languageCode;

  @override
  void initState() {
    super.initState();
    _future = _load();
    _refreshSystemAiStatus();
  }

  Future<void> _refreshSystemAiStatus() async {
    try {
      final s = await HubService.instance.systemAiStatus();
      if (!mounted) return;
      setState(() => _systemAiEnabled = s.enabled);
    } catch (_) {
      // Stay disabled on any failure — Ask bar falls back to
      // pure keyword search until next try.
    }
  }

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

  Map<String, String> _installedVersions = const {};

  Future<List<StoreItem>> _load() async {
    // Fetch the store entries and the installed modules in
    // parallel so the page paints in one round-trip. The
    // installed-version map drives the per-card "Update"
    // badge — store entries only carry `best_version` and
    // `installed: bool`, never the installed version itself.
    final results = await Future.wait([
      HubService.instance.searchStore(
        query: _queryCtrl.text.trim(),
        category: _category,
        status: _status,
        limit: 200,
      ),
      HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
    ]);
    final all = results[0] as List<StoreItem>;
    final mods = results[1] as List<ModuleSummary>;
    _installedVersions = {for (final m in mods) m.name: m.version};
    var out = all;
    if (_installedOnly) {
      out = out.where((e) => e.installed).toList();
    }
    if (_installableOnly) {
      // An already-installed item counts as "installable" for
      // this filter — the user can still update / uninstall it.
      out = out
          .where(
            (e) =>
                e.installed || e.status == 'published' || e.status == 'alpha',
          )
          .toList();
    }
    return out;
  }

  void _runSearch() {
    // Block body, so the closure's return type is `void`
    // rather than `Future`. setState() rejects closures that
    // resolve to a Future — the assignment must be synchronous
    // even though the future itself is awaited later.
    setState(() {
      _future = _load();
    });
  }

  Future<void> _openDetail(StoreItem item) async {
    final changed = await _StoreDetailSheet.show(context, item, _locale);
    if (changed) _runSearch();
  }

  Future<void> _install(StoreItem item) async {
    if (!mounted) return;
    final outcome = await showDialog<bool>(
      context: context,
      barrierDismissible: false,
      builder: (_) => _InstallProgressDialog(item: item),
    );
    if (outcome == true) _runSearch();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return Scaffold(
      backgroundColor: theme.scaffoldBackgroundColor,
      appBar: AppBar(
        // titleSpacing matches the body's horizontal padding
        // (ChainSpace.xl == 24) so the title's left edge sits
        // flush with the cards below. Default 16 left a 8-px
        // gap that read as a misaligned heading.
        titleSpacing: ChainSpace.xl,
        title: Text(l.searchGroupStore),
        actions: [
          FutureBuilder<List<StoreItem>>(
            future: _future,
            builder: (ctx, snap) {
              final raw = snap.data ?? const <StoreItem>[];
              final categories = _categoriesIn(raw);
              return Padding(
                padding: const EdgeInsets.only(right: ChainSpace.sm),
                child: _CategoryDropdown(
                  categories: categories,
                  selected: _category,
                  onSelected: (v) {
                    setState(() => _category = v);
                    _runSearch();
                  },
                ),
              );
            },
          ),
          Padding(
            padding: const EdgeInsets.only(right: ChainSpace.sm),
            child: _FilterButton(
              activeCount: _activeFilterCount(),
              onPressed: _openFilterDialog,
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(right: ChainSpace.sm),
            child: OutlinedButton.icon(
              icon: const Icon(Icons.add_business_outlined, size: 16),
              label: Text(l.storesManagerButton),
              onPressed: () => ChainStoresDialog.show(context),
              style:
                  OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
            ),
          ),
          IconButton(
            icon: const Icon(Icons.refresh, size: 18),
            tooltip: l.storeReloadTooltip,
            onPressed: _runSearch,
          ),
          // Trailing pad pulls the IconButton's outer edge in
          // to match the body's right-side padding. The icon
          // glyph itself ends up flush with the rightmost grid
          // card, not floating in space.
          const SizedBox(width: ChainSpace.md),
        ],
      ),
      body: Column(
        children: [
          // Scrollable content: editorial chrome + grid. Sits in
          // an Expanded so the bottom AskBar pins to the foot of
          // the viewport, mirroring the chat-input pattern of
          // Claude / ChatGPT / Slack.
          Expanded(
            child: Padding(
              padding: const EdgeInsets.fromLTRB(
                ChainSpace.xl,
                ChainSpace.lg,
                ChainSpace.xl,
                0,
              ),
              child: FutureBuilder<List<StoreItem>>(
                future: _future,
                builder: (context, snap) {
                  if (snap.connectionState == ConnectionState.waiting) {
                    return const Center(child: CircularProgressIndicator());
                  }
                  if (snap.hasError) {
                    return ChainEmptyState(
                      icon: Icons.cloud_off_outlined,
                      iconColor: theme.colorScheme.error,
                      title: l.hubUnreachable,
                      hint: l.hubUnreachableHint,
                      action: FilledButton.tonal(
                        onPressed: _runSearch,
                        child: Text(l.buttonRetry),
                      ),
                    );
                  }
                  final raw = snap.data ?? const <StoreItem>[];
                  final items = _applyAllFilters(raw);
                  if (items.isEmpty) {
                    return ChainEmptyState(
                      icon: Icons.search_off,
                      title: l.storeNoMatches,
                      hint: l.storeNoMatchesHint,
                      action: FilledButton.tonal(
                        onPressed: () {
                          setState(() {
                            _category = '';
                            _status = '';
                            _source = '';
                            _installedOnly = false;
                            _installableOnly = false;
                            _queryCtrl.clear();
                          });
                          _runSearch();
                        },
                        child: Text(l.storeClearFilters),
                      ),
                    );
                  }
                  // Browsing-only chrome — every editorial
                  // surface hides the moment a filter is set so
                  // the operator's query stays the focus.
                  final isBrowsing =
                      _queryCtrl.text.trim().isEmpty &&
                      _category.isEmpty &&
                      _status.isEmpty &&
                      _source.isEmpty &&
                      !_installedOnly;
                  final hasNoFederation = !raw.any((e) => e.isFederated);
                  // Today carousel stays visible until the
                  // operator explicitly dismisses it. The earlier
                  // installedCount≥3 auto-hide killed the strip
                  // for anyone past first-launch — meaning the
                  // rotating editorial stories (themes, audit,
                  // SDK, sandbox…) were never seen by experienced
                  // operators, which is exactly the audience the
                  // wider story set is for. One-click dismiss is
                  // enough opt-out.
                  final showToday = !_todayDismissed && isBrowsing;
                  // Recommended-source quick-adds are inlined in
                  // the Today footer when both apply, so the
                  // operator never sees two competing editorial
                  // cards stacked on top of each other.
                  // (Hero footer no longer carries the chips —
                  // brand-name suggestions only show in the
                  // standalone "Suggested sources" strip below
                  // and via Settings → MCP Clients.)
                  // Standalone strip only fires in the corner
                  // case: Today dismissed AND no federation.
                  // Featured strip and Today never compete —
                  // Today is the editorial hero, Featured plays
                  // second fiddle and only renders once Today is
                  // out of the way.
                  final showStandaloneRecommended =
                      isBrowsing && _todayDismissed && hasNoFederation;
                  final showFeatured =
                      isBrowsing &&
                      _todayDismissed &&
                      items.any((e) => e.featured);
                  return SingleChildScrollView(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        if (_aiThinking ||
                            _aiAnswer != null ||
                            _aiError != null) ...[
                          _AiAnswerCard(
                            thinking: _aiThinking,
                            answer: _aiAnswer,
                            matches: _aiMatches ?? const <_AiMatch>[],
                            error: _aiError,
                            onClear: _clearQuery,
                          ),
                          const SizedBox(height: ChainSpace.lg),
                        ],
                        if (showToday) ...[
                          _StoreTodayHero(
                            story:
                                _todayStories[_todayIndex %
                                    _todayStories.length],
                            locale: _locale,
                            currentIndex: _todayIndex,
                            totalCount: _todayStories.length,
                            onPrev: _todayStories.length > 1
                                ? () => setState(() {
                                    _todayIndex =
                                        (_todayIndex -
                                            1 +
                                            _todayStories.length) %
                                        _todayStories.length;
                                  })
                                : null,
                            onNext: _todayStories.length > 1
                                ? () => setState(() {
                                    _todayIndex =
                                        (_todayIndex + 1) %
                                        _todayStories.length;
                                  })
                                : null,
                            onDismiss: () =>
                                setState(() => _todayDismissed = true),
                            onCta: _ctaCallbackFor(
                              _todayStories[_todayIndex % _todayStories.length],
                            ),
                            // Per-story brand chips: only the MCP
                            // story shows DeepWiki / Semgrep
                            // quick-adds, so the chips don't read
                            // as ads on stories that aren't about
                            // sources. Standalone "Suggested
                            // sources" strip below still catches
                            // the dismissed-Today / no-federation
                            // edge case.
                            recommendedSources:
                                _todayStories[_todayIndex %
                                            _todayStories.length]
                                        .cta ==
                                    TodayCta.showSuggestedSources
                                ? _kRecommendedSources
                                : const <_RecommendedSource>[],
                            recommendedAdding: _addingSources,
                            onAddRecommended: _addRecommendedSource,
                          ),
                          const SizedBox(height: ChainSpace.lg),
                        ],
                        if (showStandaloneRecommended) ...[
                          _RecommendedSourcesStrip(
                            adding: _addingSources,
                            locale: _locale,
                            onAdd: _addRecommendedSource,
                          ),
                          const SizedBox(height: ChainSpace.lg),
                        ],
                        if (showFeatured) ...[
                          _FeaturedStrip(
                            items: items.where((e) => e.featured).toList(),
                            locale: _locale,
                            onTap: _openDetail,
                            onInstall: _install,
                          ),
                          const SizedBox(height: ChainSpace.lg),
                        ],
                        _StoreGrid(
                          items: items,
                          locale: _locale,
                          installedVersions: _installedVersions,
                          onTap: _openDetail,
                          onInstall: _install,
                        ),
                        const SizedBox(height: ChainSpace.lg),
                      ],
                    ),
                  );
                },
              ),
            ),
          ),
          // Pinned chat-style ask bar at the foot of the
          // viewport. Always visible so the operator can switch
          // from browsing to asking with one click — same shape
          // as Claude / ChatGPT / Slack composer rows.
          Container(
            decoration: BoxDecoration(
              color: theme.colorScheme.surface,
              border: Border(
                top: BorderSide(color: theme.colorScheme.outlineVariant),
              ),
            ),
            padding: const EdgeInsets.symmetric(
              horizontal: ChainSpace.xl,
              vertical: ChainSpace.md,
            ),
            child: _AskBar(
              controller: _queryCtrl,
              thinking: _aiThinking,
              aiAvailable: _systemAiEnabled,
              onSubmit: _onAskSubmit,
              onClear: _clearQuery,
              onTextChange: _onAskTyping,
            ),
          ),
        ],
      ),
    );
  }

  List<String> _categoriesIn(List<StoreItem> items) {
    final set = <String>{};
    for (final i in items) {
      if (i.category.isNotEmpty) set.add(i.category);
    }
    final list = set.toList()..sort();
    return list;
  }

  List<StoreItem> _applySourceFilter(List<StoreItem> items) {
    if (_source.isEmpty) return items;
    return items.where((e) => _sourceForItem(e) == _source).toList();
  }

  /// Source filter + AI-match overlay. AI mode narrows the grid
  /// to whatever the LLM ranked above the line so the answer
  /// card and the grid stay coherent.
  List<StoreItem> _applyAllFilters(List<StoreItem> items) {
    var out = _applySourceFilter(items);
    final ai = _aiMatchedNames;
    if (ai != null) {
      out = out.where((e) => ai.contains(e.name)).toList();
    }
    return out;
  }

  int _activeFilterCount() {
    var n = 0;
    if (_status.isNotEmpty) n++;
    if (_source.isNotEmpty) n++;
    if (_installedOnly) n++;
    // `installableOnly` defaults to ON, so we only highlight it
    // as an "active filter" when the operator turned it OFF —
    // the surprising state is the one worth flagging.
    if (!_installableOnly) n++;
    return n;
  }

  /// On every keystroke. Short keyword-shaped input runs the
  /// hub's substring search live so the grid follows along. A
  /// question-shaped input holds the grid steady until the
  /// operator submits — otherwise typing "ein Modul, das …"
  /// wipes the visible result set with every space.
  void _onAskTyping(String value) {
    if (_aiAnswer != null || _aiMatches != null) {
      setState(() {
        _aiAnswer = null;
        _aiMatches = null;
        _aiMatchedNames = null;
        _aiError = null;
      });
    }
    // Tippen läuft IMMER nur durch die lokale Substring-Suche.
    // Die KI-Suche kostet einen LLM-Roundtrip und gehört hinter
    // den expliziten Submit-Knopf — sonst feuern wir pro
    // Keystroke einen Prompt. Debounce verhindert "Keine
    // Treffer" zwischen Buchstaben.
    _typingDebounce?.cancel();
    _typingDebounce = Timer(const Duration(milliseconds: 250), () {
      if (!mounted) return;
      _runSearch();
    });
  }

  Future<void> _onAskSubmit() async {
    // Pending typing-debounce abbrechen — wir submitten jetzt
    // explizit und wollen nicht hinterher nochmal substring-
    // filter laufen lassen.
    _typingDebounce?.cancel();
    final q = _queryCtrl.text.trim();
    if (q.isEmpty) {
      _clearQuery();
      return;
    }
    // Wenn System-AI verfügbar ist, geht der explizite Submit
    // IMMER über die KI-Suche — egal ob die Eingabe wie eine
    // Frage aussieht. Das war der Bug: Nutzer tippt "tabellen"
    // + klick Suchen, erwartet KI, bekommt Substring-Filter.
    // Substring-Filter passiert weiter live beim Tippen
    // (siehe _onAskTyping).
    if (_systemAiEnabled) {
      await _runAiQuery(q);
    } else {
      _runSearch();
    }
  }

  /// Resolve a Today story's CTA descriptor into a real callback
  /// the hero card can wire to its button. `null` collapses the
  /// button out of the layout — used for narrative-only stories
  /// (audit / sandbox explainer) where there's nowhere obvious
  /// to send the operator next.
  VoidCallback? _ctaCallbackFor(TodayStoryData story) {
    switch (story.cta) {
      case TodayCta.none:
        return null;
      case TodayCta.openSettings:
        return () => ChainSettingsDialog.show(context);
      case TodayCta.filterCategory:
        if (story.ctaPayload.isEmpty) return null;
        return () {
          // Two-step refresh: setState makes the dropdown
          // header re-render with the new selection, then
          // _runSearch re-issues the backend query with
          // `category:` in its filter — without the second
          // call the grid keeps showing the unfiltered list
          // and the carousel button looked broken.
          setState(() => _category = story.ctaPayload);
          _runSearch();
        };
      case TodayCta.runQuery:
        if (story.ctaPayload.isEmpty) return null;
        return () {
          _queryCtrl.text = story.ctaPayload;
          setState(() {});
          _runSearch();
        };
      case TodayCta.showSuggestedSources:
        // Stays on this slide — the chips render in the hero
        // footer for stories with this CTA. The button just
        // scrolls focus to them; for now the chips are
        // immediately visible above the fold, so the click is
        // effectively a no-op acknowledgement. Keeping it
        // wired avoids a dead button.
        return () {};
    }
  }

  void _clearQuery() {
    setState(() {
      _queryCtrl.clear();
      _aiAnswer = null;
      _aiMatches = null;
      _aiMatchedNames = null;
      _aiError = null;
    });
    _runSearch();
  }

  Future<void> _runAiQuery(String query) async {
    setState(() {
      _aiThinking = true;
      _aiAnswer = null;
      _aiMatches = null;
      _aiMatchedNames = null;
      _aiError = null;
    });
    try {
      final all = await HubService.instance.searchStore(limit: 200);
      final modules = all
          .map(
            (s) => {
              'name': s.name,
              'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
                  ? s.taglineDe
                  : s.taglineEn,
              'category': s.category,
            },
          )
          .toList();
      final prompt = _buildAiSearchPrompt(query, modules, _locale);
      final result = await HubService.instance.askAi(prompt);
      if (!mounted) return;
      if (!result.isSuccess) {
        setState(() {
          _aiThinking = false;
          _aiError = result.text;
        });
        return;
      }
      final parsed = _parseAiSearchResponse(result.text, all);
      setState(() {
        _aiThinking = false;
        _aiAnswer = parsed.answer;
        _aiMatches = parsed.matches;
        _aiMatchedNames = parsed.matches.isEmpty
            ? null
            : parsed.matches.map((m) => m.name).toSet();
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _aiThinking = false;
        _aiError = e.toString();
      });
    }
  }

  Future<void> _openFilterDialog() async {
    final outcome = await showDialog<_FilterOutcome>(
      context: context,
      builder: (_) => _FilterDialog(
        status: _status,
        source: _source,
        installedOnly: _installedOnly,
        installableOnly: _installableOnly,
      ),
    );
    if (outcome == null) return;
    setState(() {
      _status = outcome.status;
      _source = outcome.source;
      _installedOnly = outcome.installedOnly;
      _installableOnly = outcome.installableOnly;
    });
    _runSearch();
  }

  Future<void> _addRecommendedSource(_RecommendedSource s) async {
    if (_addingSources.contains(s.name)) return;
    setState(() => _addingSources.add(s.name));
    final l = AppLocalizations.of(context)!;
    try {
      final list = await HubService.instance.addMcpClient(
        name: s.name,
        endpoint: s.endpoint,
        apiKeyEnv: '',
        description: s.descriptionEn,
      );
      final added = list.firstWhere(
        (c) => c.name == s.name,
        orElse: () => list.last,
      );
      if (!mounted) return;
      // toolCount == 0 happens when the server registered fine
      // but discovery returned nothing — typically a transport
      // mismatch or a server that wants `notifications/initialized`
      // before listing. We surface the difference so the
      // operator knows whether to celebrate or to debug.
      final msg = added.toolCount > 0
          ? l.storeProviderAddedToast(s.label, added.toolCount)
          : l.storeProviderAddedZeroToast(s.label);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(msg),
          duration: added.toolCount > 0
              ? const Duration(seconds: 4)
              : const Duration(seconds: 8),
        ),
      );
      _runSearch();
    } catch (e) {
      if (!mounted) return;
      showFaiErrorSnack(
        context,
        'store.provider.add',
        e,
        title: l.storeProviderAddFailed(s.label, ''),
      );
    } finally {
      if (mounted) setState(() => _addingSources.remove(s.name));
    }
  }
}

/// Maps a store entry to its provenance bucket: 'native' for
/// non-federated entries, 'mcp' / 'n8n' / 'temporal' for federated
/// tools that flow through one of the bridges. Used by the
/// source filter chip and the per-card provenance pill so
/// operators can tell at a glance which entries come from where.
///
/// Prefers the wire-level `sourceKind` field (0.12+); falls back
/// to the legacy name-prefix heuristic for pre-0.12 hubs that
/// don't populate it.
String _sourceForItem(StoreItem i) {
  if (i.sourceKind.isNotEmpty) {
    switch (i.sourceKind) {
      case 'bundle':
        return 'native';
      case 'mcp':
      case 'n8n':
      case 'temporal':
        return i.sourceKind;
      default:
        return 'federated';
    }
  }
  if (!i.isFederated) return 'native';
  if (i.name.startsWith('mcp.')) return 'mcp';
  if (i.name.startsWith('n8n.')) return 'n8n';
  return 'federated';
}

/// Translates a wire-level category id (`bridge`, `debug`, …)
/// into a label the operator can read. Wire ids stay in the
/// data model — only the display side gets localised. Unknown
/// ids fall through unchanged so newly-added categories don't
/// vanish until we update this map.
String _categoryDisplayName(BuildContext ctx, String wire) {
  final l = AppLocalizations.of(ctx)!;
  switch (wire) {
    case '':
      return '';
    case 'bridge':
      return l.storeCategoryBridge;
    case 'debug':
      return l.storeCategoryDebug;
    case 'text':
    case 'text-processing':
      return l.storeCategoryText;
    case 'llm':
      return l.storeCategoryLlm;
    case 'system':
      return l.storeCategorySystem;
    case 'auth':
      return l.storeCategoryAuth;
    case 'storage':
      return l.storeCategoryStorage;
    case 'channel':
      return l.storeCategoryChannel;
    case 'web':
      return l.storeCategoryWeb;
    case 'orchestrator':
      return l.storeCategoryOrchestrator;
    default:
      return wire;
  }
}

/// Same idea for the `published` / `alpha` / `planned` status
/// values. Operators read "stable / experimental / coming
/// soon"; the wire keeps the strict enum it has always had.
/// Map a permission string (`net:api.example.com`,
/// `fs.read:/etc/fai`, `env:OPENAI_API_KEY`, …) to a glyph the
/// operator can read at a glance. Mirrors the table that used
/// to live in `chain_module_sheet.dart` so the Store detail-sheet
/// can render permissions in the same shape after the Modules
/// page was dropped.
IconData _iconForPermission(String permission) {
  if (permission.startsWith('net:')) return Icons.public;
  if (permission.startsWith('fs.read:')) return Icons.folder_open;
  if (permission.startsWith('fs.write:')) return Icons.edit_note;
  if (permission.startsWith('env:')) return Icons.terminal;
  if (permission.startsWith('hub:')) return Icons.shield_outlined;
  return Icons.lock_outline;
}

String _statusDisplayName(BuildContext ctx, String wire) {
  final l = AppLocalizations.of(ctx)!;
  switch (wire) {
    case 'published':
      return l.storeStatusPublished;
    case 'alpha':
      return l.storeStatusAlpha;
    case 'planned':
      return l.storeStatusPlanned;
    default:
      return wire;
  }
}

/// Multi-line ask-and-search input. Drives the toolbar's
/// primary action: substring keyword search on every keystroke
/// (cheap, identical to the legacy `_SearchField`) plus an
/// LLM-routed "ask the AI" path when the operator submits a
/// question-shaped query and a System-AI is configured.
class _AskBar extends StatelessWidget {
  final TextEditingController controller;
  final bool thinking;
  final bool aiAvailable;
  final ValueChanged<String> onTextChange;
  final VoidCallback onSubmit;
  final VoidCallback onClear;

  const _AskBar({
    required this.controller,
    required this.thinking,
    required this.aiAvailable,
    required this.onTextChange,
    required this.onSubmit,
    required this.onClear,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return ValueListenableBuilder<TextEditingValue>(
      valueListenable: controller,
      builder: (_, value, _) {
        final empty = value.text.trim().isEmpty;
        return Container(
          padding: const EdgeInsets.symmetric(
            horizontal: ChainSpace.md,
            vertical: ChainSpace.sm,
          ),
          decoration: BoxDecoration(
            color: theme.colorScheme.surfaceContainerHigh,
            borderRadius: BorderRadius.circular(ChainRadius.md),
            border: Border.all(color: theme.colorScheme.outlineVariant),
          ),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.only(top: 6),
                child: Icon(
                  Icons.search,
                  size: 18,
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              const SizedBox(width: ChainSpace.sm),
              Expanded(
                // Cmd+Enter (or Ctrl+Enter on Linux) submits;
                // bare Enter inserts a newline so the operator
                // can write multi-line questions naturally.
                child: Shortcuts(
                  shortcuts: <LogicalKeySet, Intent>{
                    LogicalKeySet(
                      LogicalKeyboardKey.meta,
                      LogicalKeyboardKey.enter,
                    ): const _SubmitAskIntent(),
                    LogicalKeySet(
                      LogicalKeyboardKey.control,
                      LogicalKeyboardKey.enter,
                    ): const _SubmitAskIntent(),
                  },
                  child: Actions(
                    actions: <Type, Action<Intent>>{
                      _SubmitAskIntent: CallbackAction<_SubmitAskIntent>(
                        onInvoke: (_) {
                          onSubmit();
                          return null;
                        },
                      ),
                    },
                    child: TextField(
                      controller: controller,
                      autofocus: false,
                      minLines: 1,
                      maxLines: 4,
                      onChanged: onTextChange,
                      // No onSubmitted — bare Enter would
                      // collide with multi-line authoring.
                      // Submit happens via the button or
                      // Cmd/Ctrl+Enter.
                      decoration: InputDecoration(
                        isCollapsed: true,
                        contentPadding: const EdgeInsets.symmetric(vertical: 6),
                        hintText: aiAvailable
                            ? l.storeAskHint
                            : l.storeSearchHint,
                        hintMaxLines: 2,
                        hintStyle: theme.textTheme.bodyMedium?.copyWith(
                          color: theme.colorScheme.onSurfaceVariant,
                        ),
                        border: InputBorder.none,
                      ),
                      style: theme.textTheme.bodyLarge,
                    ),
                  ),
                ),
              ),
              if (!empty)
                IconButton(
                  icon: const Icon(Icons.close, size: 16),
                  visualDensity: VisualDensity.compact,
                  tooltip: l.storeAskClear,
                  onPressed: onClear,
                ),
              const SizedBox(width: ChainSpace.xs),
              FilledButton.icon(
                onPressed: thinking ? null : onSubmit,
                icon: thinking
                    ? const SizedBox(
                        width: 14,
                        height: 14,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : Icon(
                        aiAvailable ? Icons.auto_awesome : Icons.search,
                        size: 14,
                      ),
                label: Text(thinking ? l.storeAskAiThinking : l.storeAskSubmit),
                style: FilledButton.styleFrom(
                  visualDensity: VisualDensity.compact,
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

class _SubmitAskIntent extends Intent {
  const _SubmitAskIntent();
}

/// Compact Filter-popover trigger. Shows a small badge with the
/// number of active filters so the operator never has to open
/// the dialog to know what's set.
class _FilterButton extends StatelessWidget {
  final int activeCount;
  final VoidCallback onPressed;

  const _FilterButton({required this.activeCount, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return OutlinedButton.icon(
      onPressed: onPressed,
      icon: const Icon(Icons.tune, size: 14),
      label: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(l.storeFilterLabel),
          if (activeCount > 0) ...[
            const SizedBox(width: ChainSpace.xs),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
              decoration: BoxDecoration(
                color: theme.colorScheme.primary.withValues(alpha: 0.18),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '$activeCount',
                style: theme.textTheme.labelSmall?.copyWith(
                  color: theme.colorScheme.primary,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
          ],
        ],
      ),
      style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
    );
  }
}

/// Outcome of `_FilterDialog`. `null` from the dialog means
/// "operator cancelled" — caller leaves filter state untouched.
class _FilterOutcome {
  final String status;
  final String source;
  final bool installedOnly;
  final bool installableOnly;
  const _FilterOutcome({
    required this.status,
    required this.source,
    required this.installedOnly,
    required this.installableOnly,
  });
}

/// Modal that hosts the previously-inline status / source /
/// installed chips. Lives behind a button so the toolbar stays
/// quiet — operators can browse for a long time without
/// touching filters and shouldn't have a filter row eating
/// their viewport while doing so.
class _FilterDialog extends StatefulWidget {
  final String status;
  final String source;
  final bool installedOnly;
  final bool installableOnly;

  const _FilterDialog({
    required this.status,
    required this.source,
    required this.installedOnly,
    required this.installableOnly,
  });

  @override
  State<_FilterDialog> createState() => _FilterDialogState();
}

class _FilterDialogState extends State<_FilterDialog> {
  late String _status = widget.status;
  late String _source = widget.source;
  late bool _installedOnly = widget.installedOnly;
  late bool _installableOnly = widget.installableOnly;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    Widget header(String text) => Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Text(
        text.toUpperCase(),
        style: theme.textTheme.labelSmall?.copyWith(
          color: theme.colorScheme.onSurfaceVariant,
          letterSpacing: 0.6,
          fontSize: 10,
        ),
      ),
    );
    Widget chipRow(
      List<({String value, String label})> items,
      String selected,
      void Function(String) onSelect,
    ) => Wrap(
      spacing: ChainSpace.xs,
      runSpacing: ChainSpace.xs,
      children: [
        for (final i in items)
          _ChoiceChip(
            label: i.label,
            selected: selected == i.value,
            onSelected: () => onSelect(i.value),
          ),
      ],
    );
    return AlertDialog(
      title: Text(l.storeFilterLabel),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(ChainRadius.md),
      ),
      content: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 460),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            header(l.storeFilterStatusGroup),
            chipRow(
              [
                (value: '', label: l.storeStatusAll),
                (value: 'published', label: l.storeStatusPublished),
                (value: 'alpha', label: l.storeStatusAlpha),
                (value: 'planned', label: l.storeStatusPlanned),
              ],
              _status,
              (v) => setState(() => _status = v),
            ),
            const SizedBox(height: ChainSpace.lg),
            header(l.storeFilterSourceGroup),
            chipRow(
              [
                (value: '', label: l.storeSourceAll),
                (value: 'native', label: l.storeSourceNative),
                (value: 'mcp', label: l.storeSourceMcp),
                (value: 'n8n', label: l.storeSourceN8n),
                (value: 'temporal', label: l.storeSourceTemporal),
              ],
              _source,
              (v) => setState(() => _source = v),
            ),
            const SizedBox(height: ChainSpace.lg),
            header(l.storeFilterInstalledGroup),
            Wrap(
              spacing: ChainSpace.xs,
              runSpacing: ChainSpace.xs,
              children: [
                FilterChip(
                  label: Text(l.storeInstallableOnly),
                  selected: _installableOnly,
                  onSelected: (v) => setState(() => _installableOnly = v),
                  showCheckmark: true,
                ),
                FilterChip(
                  label: Text(l.storeInstalledOnly),
                  selected: _installedOnly,
                  onSelected: (v) => setState(() => _installedOnly = v),
                  showCheckmark: true,
                ),
              ],
            ),
          ],
        ),
      ),
      // A single Row is the one action child: AlertDialog wraps
      // `actions` in an OverflowBar (not a Flex), so a bare
      // `Spacer()` here throws — it needs a Flex parent. Nesting
      // the buttons in a Row gives the Spacer a valid Flex
      // ancestor and keeps Reset left / Cancel+Apply right.
      actions: [
        Row(
          children: [
            TextButton(
              onPressed: () {
                setState(() {
                  _status = '';
                  _source = '';
                  _installedOnly = false;
                  // Reset puts `installableOnly` back to its
                  // default (on) — the operator hits Reset to
                  // escape a custom-filter state and expects the
                  // default view back, not an everything-
                  // including-planned view.
                  _installableOnly = true;
                });
              },
              child: Text(l.storeFilterReset),
            ),
            const Spacer(),
            TextButton(
              onPressed: () => Navigator.pop(context, null),
              child: Text(l.buttonCancel),
            ),
            const SizedBox(width: ChainSpace.sm),
            FilledButton(
              onPressed: () => Navigator.pop(
                context,
                _FilterOutcome(
                  status: _status,
                  source: _source,
                  installedOnly: _installedOnly,
                  installableOnly: _installableOnly,
                ),
              ),
              child: Text(l.storeFilterApply),
            ),
          ],
        ),
      ],
    );
  }
}

/// One LLM-suggested module. Held as a small class (not a
/// record) because we map it through StatelessWidget params and
/// records make the type signatures noisy.
class _AiMatch {
  final String name;
  final String why;
  const _AiMatch({required this.name, required this.why});
}

/// Card that surfaces the LLM's free-form answer plus a list of
/// matched module names. Renders only when the operator is in
/// AI mode — clearing the question collapses it.
class _AiAnswerCard extends StatelessWidget {
  final bool thinking;
  final String? answer;
  final List<_AiMatch> matches;
  final String? error;
  final VoidCallback onClear;

  const _AiAnswerCard({
    required this.thinking,
    required this.answer,
    required this.matches,
    required this.error,
    required this.onClear,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final accent = error != null
        ? theme.colorScheme.error
        : theme.colorScheme.primary;
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(ChainSpace.md),
      decoration: BoxDecoration(
        color: theme.colorScheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(ChainRadius.md),
        border: Border.all(color: accent.withValues(alpha: 0.3)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(Icons.auto_awesome, size: 14, color: accent),
              const SizedBox(width: ChainSpace.xs),
              Text(
                l.storeAskAnswerLabel,
                style: theme.textTheme.labelSmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  letterSpacing: 0.6,
                  fontSize: 10,
                ),
              ),
              const Spacer(),
              IconButton(
                icon: const Icon(Icons.close, size: 14),
                visualDensity: VisualDensity.compact,
                tooltip: l.storeAskClear,
                onPressed: onClear,
              ),
            ],
          ),
          const SizedBox(height: ChainSpace.xs),
          if (thinking)
            Row(
              children: [
                const SizedBox(
                  width: 14,
                  height: 14,
                  child: CircularProgressIndicator(strokeWidth: 2),
                ),
                const SizedBox(width: ChainSpace.sm),
                Text(
                  l.storeAskAiThinking,
                  style: theme.textTheme.bodyMedium?.copyWith(
                    color: theme.colorScheme.onSurfaceVariant,
                  ),
                ),
              ],
            )
          else if (error != null)
            Text(
              error!,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.error,
              ),
            )
          else ...[
            SelectableText(
              answer ?? l.storeAskNoMatches,
              style: theme.textTheme.bodyMedium,
            ),
            if (matches.isNotEmpty) ...[
              const SizedBox(height: ChainSpace.sm),
              Text(
                l.storeAskAnswerWhy,
                style: theme.textTheme.labelSmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  letterSpacing: 0.6,
                  fontSize: 10,
                ),
              ),
              const SizedBox(height: 4),
              for (final m in matches)
                Padding(
                  padding: const EdgeInsets.only(top: 4),
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Container(
                        margin: const EdgeInsets.only(top: 6, right: 8),
                        width: 6,
                        height: 6,
                        decoration: BoxDecoration(
                          color: accent,
                          shape: BoxShape.circle,
                        ),
                      ),
                      Expanded(
                        child: Wrap(
                          spacing: ChainSpace.xs,
                          crossAxisAlignment: WrapCrossAlignment.center,
                          children: [
                            Text(
                              m.name,
                              style: ChainTheme.mono(
                                size: 12,
                                weight: FontWeight.w600,
                                color: theme.colorScheme.onSurface,
                              ),
                            ),
                            Text(
                              '— ${m.why}',
                              style: theme.textTheme.bodySmall?.copyWith(
                                color: theme.colorScheme.onSurfaceVariant,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
            ],
          ],
        ],
      ),
    );
  }
}

/// Compact category picker that lives in the AppBar's actions
/// row. Replaces the older horizontally-scrolling chip strip
/// because chips ate a full row of viewport for what is in
/// practice a one-click operation; a dropdown is faster to
/// reach and never grows past the toolbar.
class _CategoryDropdown extends StatelessWidget {
  final List<String> categories;
  final String selected;
  final ValueChanged<String> onSelected;

  const _CategoryDropdown({
    required this.categories,
    required this.selected,
    required this.onSelected,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final activeLabel = selected.isEmpty
        ? l.storeCategoryAll
        : _categoryDisplayName(context, selected);
    return PopupMenuButton<String>(
      tooltip: l.storeCategoryLabel,
      initialValue: selected,
      onSelected: onSelected,
      itemBuilder: (_) => [
        PopupMenuItem(value: '', child: Text(l.storeCategoryAll)),
        const PopupMenuDivider(),
        for (final c in categories)
          PopupMenuItem(
            value: c,
            child: Text(_categoryDisplayName(context, c)),
          ),
      ],
      child: Container(
        padding: const EdgeInsets.symmetric(
          horizontal: ChainSpace.sm,
          vertical: 6,
        ),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(ChainRadius.sm),
          border: Border.all(color: theme.colorScheme.outlineVariant),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              Icons.folder_outlined,
              size: 14,
              color: theme.colorScheme.onSurfaceVariant,
            ),
            const SizedBox(width: 6),
            Text(activeLabel, style: theme.textTheme.bodySmall),
            Icon(
              Icons.arrow_drop_down,
              size: 16,
              color: theme.colorScheme.onSurfaceVariant,
            ),
          ],
        ),
      ),
    );
  }
}

/// Builds the JSON-output prompt fed to the System-AI when the
/// operator submits a question-shaped query. Pinned to a strict
/// reply schema so the parser has stable ground to walk on; the
/// prompt also hand-encodes the operator's locale so the LLM
/// answers in the right language.
String _buildAiSearchPrompt(
  String query,
  List<Map<String, dynamic>> modules,
  String locale,
) {
  final lang = locale == 'de' ? 'German' : 'English';
  return '''You are the Ch∆In Store assistant. The operator just asked, in $lang:

"$query"

These are the modules currently in the store, as JSON:
${jsonEncode(modules)}

Reply with strict JSON only — no markdown fences, no commentary:
{
  "answer": "<one short sentence in $lang explaining the recommendation>",
  "matches": [
    {"name": "<exact module name from the list above>", "why": "<one short phrase in $lang>"}
  ]
}

Up to 5 matches, ordered by best fit first. Use only module names that appear in the list above. If nothing fits, return matches: [] and explain in answer.''';
}

/// Pulls the JSON object out of the LLM's reply. Models often
/// add chatty preamble or markdown fences; we slice from the
/// first `{` to the last `}` and try to decode that. Unknown
/// module names are dropped silently — the operator should not
/// see hallucinated entries that don't actually exist.
({String answer, List<_AiMatch> matches}) _parseAiSearchResponse(
  String text,
  List<StoreItem> all,
) {
  final start = text.indexOf('{');
  final end = text.lastIndexOf('}');
  if (start < 0 || end <= start) {
    return (answer: text.trim(), matches: const <_AiMatch>[]);
  }
  try {
    final dynamic decoded = jsonDecode(text.substring(start, end + 1));
    if (decoded is! Map) {
      return (answer: text.trim(), matches: const <_AiMatch>[]);
    }
    final answer = (decoded['answer'] ?? '').toString();
    final matches = <_AiMatch>[];
    final knownNames = all.map((e) => e.name).toSet();
    final list = (decoded['matches'] as List?) ?? const [];
    for (final m in list) {
      if (m is Map) {
        final name = (m['name'] ?? '').toString();
        final why = (m['why'] ?? '').toString();
        if (knownNames.contains(name)) {
          matches.add(_AiMatch(name: name, why: why));
        }
      }
    }
    return (answer: answer, matches: matches);
  } catch (_) {
    return (answer: text.trim(), matches: const <_AiMatch>[]);
  }
}

class _ChoiceChip extends StatelessWidget {
  final String label;
  final bool selected;
  final VoidCallback onSelected;

  const _ChoiceChip({
    required this.label,
    required this.selected,
    required this.onSelected,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(right: ChainSpace.xs),
      child: ChoiceChip(
        label: Text(label),
        selected: selected,
        onSelected: (_) => onSelected(),
        showCheckmark: false,
      ),
    );
  }
}

/// Responsive grid — wraps to as many columns as the viewport
/// allows. Cards are click-to-expand; install is also a top-
/// level action so single-click installs are still possible.
class _StoreGrid extends StatelessWidget {
  final List<StoreItem> items;
  final String locale;

  /// Map of installed module name → installed version. Cards
  /// compare against `bestVersion` to render an "Update" badge
  /// when the store offers something newer.
  final Map<String, String> installedVersions;
  final ValueChanged<StoreItem> onTap;
  final ValueChanged<StoreItem> onInstall;

  const _StoreGrid({
    required this.items,
    required this.locale,
    required this.installedVersions,
    required this.onTap,
    required this.onInstall,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        const minCardWidth = 360.0;
        final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4);
        // shrinkWrap + NeverScrollable lets the grid sit inside
        // the page-level SingleChildScrollView so editorial
        // chrome and the grid scroll as one continuous surface
        // (App-Store / Play-Store behaviour). Without this, the
        // inner grid claims its own scroll viewport and the
        // outer Column overflows on small windows.
        return GridView.builder(
          padding: EdgeInsets.zero,
          shrinkWrap: true,
          physics: const NeverScrollableScrollPhysics(),
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: cols,
            mainAxisSpacing: ChainSpace.md,
            crossAxisSpacing: ChainSpace.md,
            mainAxisExtent: 168,
          ),
          itemCount: items.length,
          itemBuilder: (context, i) => _StoreCard(
            item: items[i],
            locale: locale,
            installedVersion: installedVersions[items[i].name],
            onTap: () => onTap(items[i]),
            onInstall: () => onInstall(items[i]),
          ),
        );
      },
    );
  }
}

/// Horizontally-scrolling band of editorial picks, rendered
/// only when the operator hasn't filtered the result set.
/// Each tile is a hero version of the regular store card —
/// bigger icon, gradient backdrop, prominent install button.
/// Curated quarterly via the bundled `seed.yaml` `featured:`
/// list so the choice can be reviewed in code review.
class _FeaturedStrip extends StatelessWidget {
  final List<StoreItem> items;
  final String locale;
  final ValueChanged<StoreItem> onTap;
  final ValueChanged<StoreItem> onInstall;

  const _FeaturedStrip({
    required this.items,
    required this.locale,
    required this.onTap,
    required this.onInstall,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            Icon(Icons.star, size: 14, color: ChainColors.success),
            const SizedBox(width: 6),
            Text(
              l.storeFeatured,
              style: theme.textTheme.labelSmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
                letterSpacing: 0.6,
                fontSize: 10,
              ),
            ),
            const SizedBox(width: ChainSpace.sm),
            Text(
              l.storeFeaturedHint,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
          ],
        ),
        const SizedBox(height: ChainSpace.sm),
        SizedBox(
          // Tiles render larger than regular grid cards
          // (which are 168 × ~360) so the editorial picks
          // visibly stand out. Same hierarchy you see on app
          // stores: hero band before the dense list.
          height: 220,
          child: ListView.separated(
            scrollDirection: Axis.horizontal,
            itemCount: items.length,
            separatorBuilder: (_, _) => const SizedBox(width: ChainSpace.md),
            itemBuilder: (context, i) {
              final item = items[i];
              return SizedBox(
                width: 480,
                child: _FeaturedTile(
                  item: item,
                  locale: locale,
                  onTap: () => onTap(item),
                  onInstall: () => onInstall(item),
                ),
              );
            },
          ),
        ),
      ],
    );
  }
}

class _FeaturedTile extends StatelessWidget {
  final StoreItem item;
  final String locale;
  final VoidCallback onTap;
  final VoidCallback onInstall;

  const _FeaturedTile({
    required this.item,
    required this.locale,
    required this.onTap,
    required this.onInstall,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final tagline = locale == 'de' && item.taglineDe.isNotEmpty
        ? item.taglineDe
        : item.taglineEn;
    final installable = item.status == 'published' || item.status == 'alpha';
    return Material(
      color: theme.colorScheme.surfaceContainer,
      borderRadius: BorderRadius.circular(ChainRadius.md),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(ChainRadius.md),
        child: Container(
          padding: const EdgeInsets.all(ChainSpace.lg),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(ChainRadius.md),
            gradient: LinearGradient(
              colors: [
                theme.colorScheme.primary.withValues(alpha: 0.14),
                theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
              ],
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
            ),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  CircleAvatar(
                    radius: 24,
                    backgroundColor: theme.colorScheme.primary.withValues(
                      alpha: 0.18,
                    ),
                    child: Icon(
                      _iconForCategory(item.category),
                      size: 26,
                      color: theme.colorScheme.primary,
                    ),
                  ),
                  const SizedBox(width: ChainSpace.md),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          item.name,
                          style: theme.textTheme.titleLarge?.copyWith(
                            fontWeight: FontWeight.w700,
                          ),
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                        ),
                        if (item.category.isNotEmpty)
                          Text(
                            _categoryDisplayName(context, item.category),
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                            style: theme.textTheme.bodySmall?.copyWith(
                              color: theme.colorScheme.onSurfaceVariant,
                            ),
                          ),
                      ],
                    ),
                  ),
                  Icon(Icons.star, size: 18, color: ChainColors.success),
                ],
              ),
              const SizedBox(height: ChainSpace.md),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    if (item.isFederated)
                      // Federated items get tagline copy straight
                      // from the upstream MCP / n8n server, which
                      // is English regardless of Studio's locale.
                      // The [EN] pill is the honest signal until
                      // the planned `studio.translate` plugin
                      // rewrites it on the fly.
                      Padding(
                        padding: const EdgeInsets.only(bottom: 4),
                        child: ChainEnBadge.forContext(context),
                      ),
                    Expanded(
                      child: Text(
                        tagline.isEmpty ? l.storeNoTagline : tagline,
                        style: theme.textTheme.bodyLarge,
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: ChainSpace.sm),
              Row(
                children: [
                  if (item.bestVersion.isNotEmpty)
                    Tooltip(
                      // Federated upstream services (MCP, n8n,
                      // Temporal workflows) don't honour semver,
                      // so their version pill carries a "~"
                      // prefix + tooltip noting that the value
                      // is advisory only.
                      message: item.isFederated
                          ? l.storeAdvisoryVersionTooltip
                          : '',
                      child: ChainPill(
                        label: item.isFederated
                            ? '~v${item.bestVersion}'
                            : 'v${item.bestVersion}',
                        tone: ChainPillTone.neutral,
                        monospace: true,
                      ),
                    ),
                  const SizedBox(width: ChainSpace.xs),
                  if (item.status.isNotEmpty)
                    ChainPill(
                      label: _statusDisplayName(context, item.status),
                      tone: _toneForStatus(item.status),
                    ),
                  const Spacer(),
                  if (item.installed)
                    ChainPill(
                      label: l.storePillInstalled,
                      tone: ChainPillTone.success,
                      icon: Icons.check,
                    )
                  else if (installable)
                    FilledButton.icon(
                      onPressed: onInstall,
                      icon: const Icon(Icons.download, size: 16),
                      label: Text(l.buttonInstall),
                    )
                  else
                    // Status is `planned` (or another non-installable
                    // wire value). Without an explicit pill here the
                    // featured tile would just go silent, leaving the
                    // operator wondering why the card has no action.
                    ChainPill(
                      label: l.storePillComingSoon,
                      tone: ChainPillTone.neutral,
                      icon: Icons.schedule,
                    ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _StoreCard extends StatefulWidget {
  final StoreItem item;
  final String locale;

  /// Installed version for this name, when the operator has it.
  /// `null` for not-installed entries.
  final String? installedVersion;
  final VoidCallback onTap;
  final VoidCallback onInstall;

  const _StoreCard({
    required this.item,
    required this.locale,
    required this.installedVersion,
    required this.onTap,
    required this.onInstall,
  });

  @override
  State<_StoreCard> createState() => _StoreCardState();
}

class _StoreCardState extends State<_StoreCard> {
  bool _hovered = false;

  StoreItem get item => widget.item;
  String get locale => widget.locale;
  String? get installedVersion => widget.installedVersion;
  VoidCallback get onTap => widget.onTap;
  VoidCallback get onInstall => widget.onInstall;

  /// True iff the operator has this module installed AND the
  /// store-index offers a strictly-newer best_version. Uses a
  /// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
  bool get _hasUpdate {
    final installed = installedVersion;
    final best = item.bestVersion;
    if (installed == null || best.isEmpty) return false;
    if (installed == best) return false;
    return _versionCmp(best, installed) > 0;
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final tagline = locale == 'de' && item.taglineDe.isNotEmpty
        ? item.taglineDe
        : item.taglineEn;
    final installable = item.status == 'published' || item.status == 'alpha';
    // Dim planned/non-installable cards so the eye lands on
    // actionable cards first. We don't disable interaction — the
    // detail sheet still opens — but the visual hierarchy is
    // honest about what the operator can act on.
    final notInstallable = !installable && !item.installed;
    return Opacity(
      opacity: notInstallable ? 0.6 : 1.0,
      // Hover-lift: a 2px rise plus shadow bump signals the card
      // is clickable without a heavy hover fill. Resting state
      // carries the low elevation so cards read as physical.
      child: MouseRegion(
        onEnter: (_) => setState(() => _hovered = true),
        onExit: (_) => setState(() => _hovered = false),
        child: AnimatedContainer(
          duration: ChainMotion.base,
          curve: ChainMotion.easing,
          transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(ChainRadius.md),
            boxShadow: _hovered
                ? ChainElevation.medium(theme.brightness)
                : ChainElevation.low(theme.brightness),
          ),
          child: Material(
            color: theme.colorScheme.surfaceContainer,
            borderRadius: BorderRadius.circular(ChainRadius.md),
            child: InkWell(
              onTap: onTap,
              borderRadius: BorderRadius.circular(ChainRadius.md),
              child: Padding(
                padding: const EdgeInsets.all(ChainSpace.md),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Row(
                      children: [
                        CircleAvatar(
                          radius: 18,
                          backgroundColor: theme.colorScheme.primary.withValues(
                            alpha: 0.12,
                          ),
                          child: Icon(
                            _iconForCategory(item.category),
                            size: 18,
                            color: theme.colorScheme.primary,
                          ),
                        ),
                        const SizedBox(width: ChainSpace.sm),
                        Expanded(
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text(
                                item.name,
                                style: theme.textTheme.titleSmall?.copyWith(
                                  fontWeight: FontWeight.w600,
                                ),
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                              ),
                              Text(
                                item.category.isEmpty
                                    ? '—'
                                    : _categoryDisplayName(
                                        context,
                                        item.category,
                                      ),
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                                style: theme.textTheme.bodySmall?.copyWith(
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                              ),
                            ],
                          ),
                        ),
                        if (_hasUpdate)
                          Tooltip(
                            message: l.storeUpdateTooltip(
                              installedVersion ?? '?',
                              item.bestVersion,
                            ),
                            child: ChainPill(
                              label: l.storePillUpdate,
                              tone: ChainPillTone.warning,
                              icon: Icons.system_update_alt,
                            ),
                          )
                        else if (item.installed)
                          ChainPill(
                            label: l.storePillInstalled,
                            tone: ChainPillTone.success,
                            icon: Icons.check,
                          )
                        else if (item.status.isNotEmpty)
                          ChainPill(
                            label: _statusDisplayName(context, item.status),
                            tone: _toneForStatus(item.status),
                          ),
                      ],
                    ),
                    const SizedBox(height: ChainSpace.sm),
                    Expanded(
                      child: Text(
                        tagline.isEmpty ? l.storeNoTagline : tagline,
                        style: theme.textTheme.bodySmall,
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                    const SizedBox(height: ChainSpace.sm),
                    Row(
                      children: [
                        if (item.bestVersion.isNotEmpty) ...[
                          Tooltip(
                            message: item.isFederated
                                ? l.storeAdvisoryVersionTooltip
                                : '',
                            child: ChainPill(
                              label: item.isFederated
                                  ? '~v${item.bestVersion}'
                                  : 'v${item.bestVersion}',
                              tone: ChainPillTone.neutral,
                              monospace: true,
                            ),
                          ),
                          const SizedBox(width: ChainSpace.xs),
                        ],
                        _ProvenancePill(item: item),
                        const Spacer(),
                        if (_hasUpdate)
                          FilledButton.icon(
                            onPressed: onInstall,
                            icon: const Icon(Icons.system_update_alt, size: 14),
                            label: Text(l.storeUpdateButton(item.bestVersion)),
                            style: FilledButton.styleFrom(
                              visualDensity: VisualDensity.compact,
                            ),
                          )
                        else if (item.installed)
                          TextButton.icon(
                            onPressed: onTap,
                            icon: const Icon(Icons.info_outline, size: 14),
                            label: Text(l.buttonDetails),
                          )
                        else if (installable)
                          FilledButton.icon(
                            onPressed: onInstall,
                            icon: const Icon(Icons.download, size: 14),
                            label: Text(l.buttonInstall),
                            style: FilledButton.styleFrom(
                              visualDensity: VisualDensity.compact,
                            ),
                          )
                        else
                          OutlinedButton.icon(
                            onPressed: onTap,
                            icon: const Icon(Icons.info_outline, size: 14),
                            label: Text(l.buttonDetails),
                            style: OutlinedButton.styleFrom(
                              visualDensity: VisualDensity.compact,
                            ),
                          ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// Per-module detail sheet. Renders the full bilingual
/// description, the capabilities + services the module
/// requires, plus repository / docs links. Returns `true` when
/// state changed (install / uninstall) so the caller refreshes.
class _StoreDetailSheet extends StatefulWidget {
  final StoreItem item;
  final String locale;

  const _StoreDetailSheet({required this.item, required this.locale});

  static Future<bool> show(
    BuildContext context,
    StoreItem item,
    String locale,
  ) async {
    final r = await showModalBottomSheet<bool>(
      context: context,
      isScrollControlled: true,
      backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
      elevation: 8,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.md)),
      ),
      builder: (_) => _StoreDetailSheet(item: item, locale: locale),
    );
    return r ?? false;
  }

  @override
  State<_StoreDetailSheet> createState() => _StoreDetailSheetState();
}

class _StoreDetailSheetState extends State<_StoreDetailSheet> {
  /// Locale comes from MaterialApp via `Localizations.localeOf`;
  /// the per-sheet state from before is no longer needed.
  String get _locale => Localizations.localeOf(context).languageCode;
  bool _busy = false;
  String? _toast;

  /// `null` while the initial probe is in flight; non-null once
  /// `readInstalledModuleDocs` has returned. The probe is cheap —
  /// a single file stat on disk — so we run it eagerly when the
  /// sheet opens for an installed module instead of gating it
  /// behind a "Load docs" click.
  ({String state, String text, String sourcePath})? _docsResult;
  bool _docsProbed = false;

  /// Live module-info for installed entries: declared
  /// permissions and on-disk directory. Pulled lazily so
  /// not-installed entries don't run an unnecessary RPC. Stays
  /// null when the fetch fails — the corresponding sections
  /// just hide rather than show a spinner inside a hover sheet.
  ModuleDetail? _moduleDetail;

  @override
  void initState() {
    super.initState();
    if (widget.item.installed) {
      _loadModuleDetail();
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Locale is read off `Localizations`, which is only ready in
    // didChangeDependencies, not initState.
    if (widget.item.installed && !_docsProbed) {
      _docsProbed = true;
      _probeInstalledDocs();
    }
  }

  Future<void> _probeInstalledDocs() async {
    final locale = Localizations.localeOf(context).languageCode;
    try {
      final r = await HubService.instance.readInstalledModuleDocs(
        widget.item.name,
        locale: locale,
      );
      if (!mounted) return;
      setState(() => _docsResult = r);
    } catch (_) {
      // Treat probe failures as "no docs available" — the section
      // simply hides, the rest of the sheet still works.
      if (!mounted) return;
      setState(
        () => _docsResult = (state: 'no_docs', text: '', sourcePath: ''),
      );
    }
  }

  Future<void> _loadModuleDetail() async {
    try {
      final detail = await HubService.instance.moduleInfo(widget.item.name);
      if (!mounted) return;
      setState(() => _moduleDetail = detail);
    } catch (_) {
      // Silent — sections stay hidden, the sheet still works.
    }
  }

  Future<void> _install() async {
    setState(() {
      _busy = true;
      _toast = null;
    });
    try {
      final r = await HubService.instance.installModule(
        source: widget.item.name,
      );
      if (!mounted) return;
      final l = AppLocalizations.of(context)!;
      setState(() {
        _busy = false;
        _toast = l.storeInstalledToast(r.name, r.version);
      });
      Navigator.pop(context, true);
    } catch (e) {
      if (!mounted) return;
      final l = AppLocalizations.of(context)!;
      setState(() {
        _busy = false;
        _toast = l.storeInstallFailedToast(e.toString());
      });
    }
  }

  Future<void> _uninstall() async {
    final l = AppLocalizations.of(context)!;
    // Multi-version aware: ask which version to remove when
    // more than one is installed side-by-side. Otherwise the
    // hub picks "highest version" and the operator can be
    // surprised by what disappeared.
    final versions = await HubService.instance.installedVersions(
      widget.item.name,
    );
    if (!mounted) return;
    String? targetVersion;
    if (versions.length > 1) {
      targetVersion = await showDialog<String>(
        context: context,
        builder: (ctx) => _StoreUninstallVersionPickerDialog(
          moduleName: widget.item.name,
          versions: versions,
        ),
      );
      if (targetVersion == null || !mounted) return;
    }
    final ok = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l.storeUninstallTitle),
        content: Text(l.storeUninstallBody(widget.item.name)),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false),
            child: Text(l.buttonCancel),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(ctx, true),
            style: FilledButton.styleFrom(
              backgroundColor: Theme.of(ctx).colorScheme.error,
              foregroundColor: Theme.of(ctx).colorScheme.onError,
            ),
            child: Text(l.buttonUninstall),
          ),
        ],
      ),
    );
    if (ok != true || !mounted) return;
    setState(() {
      _busy = true;
      _toast = null;
    });
    try {
      final r = await HubService.instance.uninstallModule(
        widget.item.name,
        version: targetVersion ?? '',
      );
      if (!mounted) return;
      final l2 = AppLocalizations.of(context)!;
      setState(() {
        _busy = false;
        _toast = l2.storeUninstalledToast(r.name, r.version);
      });
      Navigator.pop(context, true);
    } catch (e) {
      if (!mounted) return;
      final l2 = AppLocalizations.of(context)!;
      setState(() {
        _busy = false;
        _toast = l2.storeUninstallFailedToast(e.toString());
      });
    }
  }

  Future<void> _openRepository() async {
    if (widget.item.repository.isEmpty) return;
    final r = await SystemActions.openInOs(widget.item.repository);
    if (!mounted) return;
    if (!r.ok) {
      showFaiErrorSnack(
        context,
        'store.repository.open',
        r.stderr.isEmpty ? 'open failed' : r.stderr,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final item = widget.item;
    final tagline = _locale == 'de' && item.taglineDe.isNotEmpty
        ? item.taglineDe
        : item.taglineEn;
    final description = _locale == 'de' && item.descriptionDe.isNotEmpty
        ? item.descriptionDe
        : item.descriptionEn;
    final installable = item.status == 'published' || item.status == 'alpha';
    final maxHeight = MediaQuery.of(context).size.height * 0.85;

    return ConstrainedBox(
      constraints: BoxConstraints(maxHeight: maxHeight),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: const EdgeInsets.symmetric(vertical: ChainSpace.sm),
            child: Container(
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                color: theme.colorScheme.outlineVariant,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
          ),
          Flexible(
            child: SingleChildScrollView(
              padding: const EdgeInsets.fromLTRB(
                ChainSpace.xxl,
                ChainSpace.md,
                ChainSpace.xxl,
                ChainSpace.xxl,
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      _ModuleIcon(
                        iconUrl: item.iconUrl,
                        category: item.category,
                        radius: 28,
                      ),
                      const SizedBox(width: ChainSpace.lg),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              item.name,
                              style: theme.textTheme.headlineSmall?.copyWith(
                                fontWeight: FontWeight.w600,
                              ),
                            ),
                            const SizedBox(height: 2),
                            Wrap(
                              spacing: ChainSpace.xs,
                              runSpacing: 4,
                              children: [
                                if (item.bestVersion.isNotEmpty)
                                  Tooltip(
                                    message: item.isFederated
                                        ? l.storeAdvisoryVersionTooltip
                                        : '',
                                    child: ChainPill(
                                      label: item.isFederated
                                          ? '~v${item.bestVersion}'
                                          : 'v${item.bestVersion}',
                                      tone: ChainPillTone.neutral,
                                      monospace: true,
                                    ),
                                  ),
                                if (item.status.isNotEmpty)
                                  ChainPill(
                                    label: _statusDisplayName(
                                      context,
                                      item.status,
                                    ),
                                    tone: _toneForStatus(item.status),
                                  ),
                                if (item.category.isNotEmpty)
                                  ChainPill(
                                    label: _categoryDisplayName(
                                      context,
                                      item.category,
                                    ),
                                    tone: ChainPillTone.neutral,
                                  ),
                                if (item.installed)
                                  ChainPill(
                                    label: l.storePillInstalled,
                                    tone: ChainPillTone.success,
                                    icon: Icons.check,
                                  ),
                                if (item.license.isNotEmpty)
                                  ChainPill(
                                    label: item.license,
                                    tone: ChainPillTone.neutral,
                                  ),
                              ],
                            ),
                          ],
                        ),
                      ),
                      // Locale follows the app-wide setting
                      // from the sidebar; the detail sheet
                      // does not host its own toggle anymore.
                    ],
                  ),
                  const SizedBox(height: ChainSpace.lg),
                  if (tagline.isNotEmpty) ...[
                    Text(
                      tagline,
                      style: theme.textTheme.titleMedium?.copyWith(
                        color: theme.colorScheme.onSurfaceVariant,
                      ),
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (description.isNotEmpty) ...[
                    if (item.isFederated) ...[
                      // Description copy was supplied by the
                      // upstream server, not by Studio's
                      // localisation pipeline — flag it.
                      Padding(
                        padding: const EdgeInsets.only(bottom: 6),
                        child: ChainEnBadge.forContext(context),
                      ),
                    ],
                    SelectableText(
                      description,
                      style: theme.textTheme.bodyMedium,
                    ),
                    const SizedBox(height: ChainSpace.xl),
                  ],
                  if (item.tags.isNotEmpty) ...[
                    _SectionHeader(l.storeSectionTags),
                    const SizedBox(height: ChainSpace.sm),
                    Wrap(
                      spacing: ChainSpace.xs,
                      runSpacing: ChainSpace.xs,
                      children: [
                        for (final t in item.tags)
                          ChainPill(label: t, tone: ChainPillTone.neutral),
                      ],
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (item.requiresCapabilities.isNotEmpty) ...[
                    _SectionHeader(l.storeSectionRequiredCapabilities),
                    const SizedBox(height: ChainSpace.sm),
                    Wrap(
                      spacing: ChainSpace.xs,
                      runSpacing: ChainSpace.xs,
                      children: [
                        for (final c in item.requiresCapabilities)
                          ChainPill(
                            label: c,
                            tone: ChainPillTone.accent,
                            monospace: true,
                          ),
                      ],
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (item.requiresServices.isNotEmpty) ...[
                    _SectionHeader(l.storeSectionRequiredHostServices),
                    const SizedBox(height: ChainSpace.sm),
                    Wrap(
                      spacing: ChainSpace.xs,
                      runSpacing: ChainSpace.xs,
                      children: [
                        for (final s in item.requiresServices)
                          ChainPill(
                            label: s,
                            tone: ChainPillTone.warning,
                            monospace: true,
                          ),
                      ],
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  // Live-from-hub sections for installed
                  // modules. Folded in here in v0.35.0 when the
                  // standalone Modules page got dropped — its
                  // unique value (declared permissions, on-disk
                  // directory) belongs in the same surface
                  // operators already use for everything else
                  // about a module.
                  if (item.installed && _moduleDetail != null) ...[
                    _SectionHeader(l.storeSectionPermissions),
                    const SizedBox(height: ChainSpace.sm),
                    if (_moduleDetail!.permissions.isEmpty)
                      Text(
                        l.storeSectionPermissionsNone,
                        style: theme.textTheme.bodySmall?.copyWith(
                          color: theme.colorScheme.onSurfaceVariant,
                          fontStyle: FontStyle.italic,
                        ),
                      )
                    else
                      Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          for (final p in _moduleDetail!.permissions)
                            Padding(
                              padding: const EdgeInsets.symmetric(vertical: 2),
                              child: Row(
                                children: [
                                  Icon(
                                    _iconForPermission(p),
                                    size: 14,
                                    color: theme.colorScheme.onSurfaceVariant,
                                  ),
                                  const SizedBox(width: ChainSpace.sm),
                                  SelectableText(
                                    p,
                                    style: ChainTheme.mono(
                                      size: 12,
                                      color: theme.colorScheme.onSurface,
                                    ),
                                  ),
                                ],
                              ),
                            ),
                        ],
                      ),
                    const SizedBox(height: ChainSpace.lg),
                    _SectionHeader(l.storeSectionDirectory),
                    const SizedBox(height: ChainSpace.sm),
                    SelectableText(
                      _moduleDetail!.directory,
                      style: ChainTheme.mono(
                        size: 11,
                        color: theme.colorScheme.onSurfaceVariant,
                      ),
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (item.screenshotUrls.isNotEmpty) ...[
                    _SectionHeader(l.storeSectionScreenshots),
                    const SizedBox(height: ChainSpace.sm),
                    SizedBox(
                      height: 220,
                      child: ListView.separated(
                        scrollDirection: Axis.horizontal,
                        itemCount: item.screenshotUrls.length,
                        separatorBuilder: (_, _) =>
                            const SizedBox(width: ChainSpace.sm),
                        itemBuilder: (context, i) =>
                            _Screenshot(url: item.screenshotUrls[i]),
                      ),
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (_docsResult?.state == 'found') ...[
                    _SectionHeader(l.storeSectionDocumentation),
                    const SizedBox(height: ChainSpace.sm),
                    _DocsPanel(text: _docsResult!.text),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (item.repository.isNotEmpty) ...[
                    _SectionHeader(l.storeSectionSource),
                    const SizedBox(height: ChainSpace.sm),
                    InkWell(
                      onTap: _openRepository,
                      child: Row(
                        children: [
                          const Icon(Icons.open_in_new, size: 14),
                          const SizedBox(width: ChainSpace.xs),
                          Flexible(
                            child: Text(
                              item.repository,
                              style: ChainTheme.mono(
                                size: 11,
                                color: theme.colorScheme.primary,
                              ),
                              overflow: TextOverflow.ellipsis,
                            ),
                          ),
                        ],
                      ),
                    ),
                    const SizedBox(height: ChainSpace.lg),
                  ],
                  if (_toast != null) ...[
                    Container(
                      padding: const EdgeInsets.all(ChainSpace.sm),
                      decoration: BoxDecoration(
                        color: theme.colorScheme.surfaceContainerHigh,
                        borderRadius: BorderRadius.circular(ChainRadius.sm),
                        border: Border.all(
                          color: theme.colorScheme.outlineVariant,
                        ),
                      ),
                      child: SelectableText(
                        _toast!,
                        style: ChainTheme.mono(size: 11),
                      ),
                    ),
                    const SizedBox(height: ChainSpace.md),
                  ],
                  Row(
                    children: [
                      const Spacer(),
                      if (item.installed) ...[
                        OutlinedButton.icon(
                          onPressed: _busy ? null : _uninstall,
                          icon: _busy
                              ? const SizedBox(
                                  width: 14,
                                  height: 14,
                                  child: CircularProgressIndicator(
                                    strokeWidth: 2,
                                  ),
                                )
                              : const Icon(Icons.delete_outline, size: 16),
                          label: Text(l.buttonUninstall),
                          style: OutlinedButton.styleFrom(
                            foregroundColor: theme.colorScheme.error,
                            side: BorderSide(
                              color: theme.colorScheme.error.withValues(
                                alpha: 0.5,
                              ),
                            ),
                          ),
                        ),
                      ] else if (installable)
                        FilledButton.icon(
                          onPressed: _busy ? null : _install,
                          icon: _busy
                              ? const SizedBox(
                                  width: 14,
                                  height: 14,
                                  child: CircularProgressIndicator(
                                    strokeWidth: 2,
                                  ),
                                )
                              : const Icon(Icons.download, size: 16),
                          label: Text(
                            _busy ? l.storeInstallingButton : l.buttonInstall,
                          ),
                        )
                      else ...[
                        // Inline hint replaces the previous
                        // tooltip-only affordance: the operator
                        // sees the reason without having to hover.
                        Expanded(
                          child: Container(
                            padding: const EdgeInsets.symmetric(
                              horizontal: ChainSpace.md,
                              vertical: ChainSpace.sm,
                            ),
                            decoration: BoxDecoration(
                              color: theme.colorScheme.surfaceContainer,
                              borderRadius: BorderRadius.circular(ChainRadius.sm),
                              border: Border.all(
                                color: theme.colorScheme.outlineVariant,
                              ),
                            ),
                            child: Row(
                              children: [
                                Icon(
                                  Icons.schedule,
                                  size: 16,
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                                const SizedBox(width: ChainSpace.sm),
                                Expanded(
                                  child: Text(
                                    l.storeNotInstallableInline(item.status),
                                    style: theme.textTheme.bodySmall?.copyWith(
                                      color: theme.colorScheme.onSurfaceVariant,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ),
                      ],
                    ],
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _SectionHeader extends StatelessWidget {
  final String text;
  const _SectionHeader(this.text);

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Text(
      text.toUpperCase(),
      style: theme.textTheme.labelSmall?.copyWith(
        color: theme.colorScheme.onSurfaceVariant,
        letterSpacing: 0.6,
        fontSize: 10,
      ),
    );
  }
}

class _InstallProgressDialog extends StatefulWidget {
  final StoreItem item;

  const _InstallProgressDialog({required this.item});

  @override
  State<_InstallProgressDialog> createState() => _InstallProgressDialogState();
}

class _InstallProgressDialogState extends State<_InstallProgressDialog> {
  late final Future<({String name, String version})> _future;

  @override
  void initState() {
    super.initState();
    // Capability-name install: hub resolves the wasm_url from the
    // bundled store-index. No source prompt needed for entries
    // whose seed.yaml already has wasm_url.
    _future = HubService.instance.installModule(source: widget.item.name);
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return AlertDialog(
      title: Text(l.storeInstallProgressTitle(widget.item.name)),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(ChainRadius.md),
      ),
      content: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
        child: FutureBuilder<({String name, String version})>(
          future: _future,
          builder: (context, snap) {
            if (snap.connectionState == ConnectionState.waiting) {
              return Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const SizedBox(height: ChainSpace.md),
                  const CircularProgressIndicator(),
                  const SizedBox(height: ChainSpace.md),
                  Text(
                    l.storeInstallProgressBody,
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              );
            }
            if (snap.hasError) {
              return SingleChildScrollView(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(
                      Icons.error_outline,
                      color: theme.colorScheme.error,
                      size: 32,
                    ),
                    const SizedBox(height: ChainSpace.md),
                    ChainErrorBox(
                      error: snap.error,
                      isError: true,
                      maxHeight: 200,
                    ),
                  ],
                ),
              );
            }
            final r = snap.data!;
            return Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(
                  Icons.check_circle_outline,
                  size: 32,
                  color: ChainColors.success,
                ),
                const SizedBox(height: ChainSpace.md),
                Text(
                  l.storeInstalledToast(r.name, r.version),
                  style: theme.textTheme.titleMedium,
                ),
              ],
            );
          },
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context, true),
          child: Text(l.buttonClose),
        ),
      ],
    );
  }
}

ChainPillTone _toneForStatus(String s) {
  switch (s) {
    case 'published':
      return ChainPillTone.success;
    case 'alpha':
      return ChainPillTone.warning;
    case 'planned':
      return ChainPillTone.neutral;
    default:
      return ChainPillTone.neutral;
  }
}

IconData _iconForCategory(String category) {
  switch (category) {
    case 'llm':
      return Icons.psychology_outlined;
    case 'text':
    case 'text-processing':
      return Icons.description_outlined;
    case 'system':
      return Icons.settings_outlined;
    case 'orchestrator':
      return Icons.account_tree_outlined;
    case 'debug':
      return Icons.bug_report_outlined;
    case 'auth':
      return Icons.lock_outline;
    case 'storage':
      return Icons.storage_outlined;
    case 'channel':
      return Icons.forum_outlined;
    default:
      return Icons.extension_outlined;
  }
}

/// Renders the module's inline `MODULE.md` (or its localized
/// sibling) in the same theme as the rest of Studio. The markdown
/// body is supplied by the parent — `_DocsPanel` itself stays a
/// pure renderer with no async / no error handling.
///
/// Blockquote + table-cell decoration is overridden because
/// flutter_markdown's default styles hardcode
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
class _DocsPanel extends StatelessWidget {
  final String text;

  const _DocsPanel({required this.text});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    // No maxHeight + NeverScrollableScrollPhysics so the docs
    // section participates in the outer sheet's scroll instead
    // of opening its own viewport. The previous nested-scroll
    // form trapped the reader on a 480 px window with two
    // scrollbars fighting for the wheel.
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(ChainSpace.md),
      decoration: BoxDecoration(
        color: theme.colorScheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(ChainRadius.sm),
        border: Border.all(color: theme.colorScheme.outlineVariant),
      ),
      child: Markdown(
        data: text,
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        selectable: true,
        onTapLink: (text, href, title) async {
          if (href == null || href.isEmpty) return;
          await SystemActions.openInOs(href);
        },
        styleSheet: ChainTheme.markdownStyle(theme),
      ),
    );
  }
}

/// Lightweight semver compare used by the "update available"
/// badge. Returns negative if a < b, 0 if equal, positive if
/// a > b. Splits on '.' and compares integer chunks; falls back
/// to lexicographic compare on non-numeric segments so
/// `0.10.0-rc.1` still sorts sensibly. Good enough for the
/// store's coarse "is there a newer publish" question — the
/// hub does its own strict resolution at install time.
int _versionCmp(String a, String b) {
  final aParts = a.split('.');
  final bParts = b.split('.');
  final n = aParts.length > bParts.length ? aParts.length : bParts.length;
  for (var i = 0; i < n; i++) {
    final ap = i < aParts.length ? aParts[i] : '0';
    final bp = i < bParts.length ? bParts[i] : '0';
    final ai = int.tryParse(ap);
    final bi = int.tryParse(bp);
    if (ai != null && bi != null) {
      if (ai != bi) return ai.compareTo(bi);
    } else {
      final cmp = ap.compareTo(bp);
      if (cmp != 0) return cmp;
    }
  }
  return 0;
}

/// Module icon: renders the explicit icon URL when set, falls
/// back to the category-derived `Icons.X` glyph in a colored
/// circle when the URL is empty or fails to load. Wraps the
/// network image in `frameBuilder` so a load failure doesn't
/// leave a broken-image rectangle in the hero header.
class _ModuleIcon extends StatelessWidget {
  final String iconUrl;
  final String category;
  final double radius;

  const _ModuleIcon({
    required this.iconUrl,
    required this.category,
    required this.radius,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final fallback = CircleAvatar(
      radius: radius,
      backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12),
      child: Icon(
        _iconForCategory(category),
        size: radius * 1.0,
        color: theme.colorScheme.primary,
      ),
    );
    if (iconUrl.isEmpty) return fallback;
    return ClipOval(
      child: SizedBox(
        width: radius * 2,
        height: radius * 2,
        child: Image.network(
          iconUrl,
          fit: BoxFit.cover,
          errorBuilder: (_, _, _) => fallback,
          loadingBuilder: (_, child, progress) =>
              progress == null ? child : fallback,
        ),
      ),
    );
  }
}

/// One screenshot tile in the detail-sheet strip. Click opens
/// the image full-size via the OS handler — keeps Studio
/// chromeless. Renders a placeholder on load failure rather
/// than the default broken-image rectangle.
class _Screenshot extends StatelessWidget {
  final String url;
  const _Screenshot({required this.url});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Material(
      color: theme.colorScheme.surfaceContainerHigh,
      borderRadius: BorderRadius.circular(ChainRadius.sm),
      child: InkWell(
        onTap: () => SystemActions.openInOs(url),
        borderRadius: BorderRadius.circular(ChainRadius.sm),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(ChainRadius.sm),
          child: SizedBox(
            width: 320,
            child: Image.network(
              url,
              fit: BoxFit.cover,
              errorBuilder: (_, _, _) => _ScreenshotPlaceholder(
                label: 'image unavailable',
                detail: url,
              ),
              loadingBuilder: (_, child, progress) {
                if (progress == null) return child;
                return const _ScreenshotPlaceholder(
                  label: 'loading…',
                  detail: '',
                );
              },
            ),
          ),
        ),
      ),
    );
  }
}

class _ScreenshotPlaceholder extends StatelessWidget {
  final String label;
  final String detail;
  const _ScreenshotPlaceholder({required this.label, required this.detail});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Container(
      color: theme.colorScheme.surfaceContainer,
      child: Center(
        child: Padding(
          padding: const EdgeInsets.all(ChainSpace.md),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(
                Icons.image_outlined,
                size: 32,
                color: theme.colorScheme.onSurfaceVariant,
              ),
              const SizedBox(height: 4),
              Text(
                label,
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              if (detail.isNotEmpty) ...[
                const SizedBox(height: 2),
                Text(
                  detail,
                  style: ChainTheme.mono(
                    size: 9,
                    color: theme.colorScheme.onSurfaceVariant,
                  ),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ],
          ),
        ),
      ),
    );
  }
}

/// Compiled-in fallback stories shown when
/// `~/.chain/today/active.yaml` is absent or fails schema
/// validation. Operator can override at any time without
/// recompiling Studio — see docs/today-pipeline.md. When the
/// operator accepts a story, that single story becomes the
/// active item and the carousel collapses to a single slide.
///
/// **Editorial discipline:** every entry here points at a
/// concrete store / federation item the operator can act on,
/// not abstract platform philosophy. Architecture education
/// (sandbox model, hash-chained audit, air-gap posture) lives
/// on the dedicated Welcome page, not in the store carousel.
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
  TodayStoryData(
    badgeEn: 'MCP',
    badgeDe: 'MCP',
    titleEn: 'Plug in any MCP server',
    titleDe: 'Jeden MCP-Server einbinden',
    bodyEn:
        'Ch∆In speaks the Model Context Protocol out of the box. Add a public server (DeepWiki, Semgrep) or a private one — its tools show up in this grid next to the built-in modules. Same install button, same audit trail.',
    bodyDe:
        'Ch∆In spricht das Model Context Protocol von Haus aus. Einen öffentlichen Server (DeepWiki, Semgrep) oder einen eigenen hinzufügen — seine Tools tauchen in diesem Grid neben den eingebauten Modulen auf. Gleicher Install-Button, gleiche Audit-Spur.',
    ctaLabelEn: 'Browse suggested sources',
    ctaLabelDe: 'Empfohlene Quellen ansehen',
    icon: Icons.hub_outlined,
    cta: TodayCta.showSuggestedSources,
  ),
  TodayStoryData(
    badgeEn: 'FLOWS',
    badgeDe: 'FLOWS',
    titleEn: 'Flows chain modules deterministically',
    titleDe: 'Flows verknüpfen Module deterministisch',
    bodyEn:
        'A flow is a YAML file with three keys — inputs, steps, outputs. Each step calls one module. Every run lands in the audit log with a verifiable hash chain. Build flows graphically or write the YAML directly — either way, the run is reproducible.',
    bodyDe:
        'Ein Flow ist eine YAML-Datei mit drei Schlüsseln — inputs, steps, outputs. Jeder Schritt ruft ein Modul auf. Jeder Lauf landet im Audit-Log mit verifizierbarer Hash-Kette. Flows grafisch bauen oder direkt YAML schreiben — der Lauf bleibt reproduzierbar.',
    ctaLabelEn: 'Show featured flows',
    ctaLabelDe: 'Featured-Flows zeigen',
    icon: Icons.account_tree_outlined,
    cta: TodayCta.runQuery,
    ctaPayload: 'hello',
  ),
  TodayStoryData(
    badgeEn: 'THEMES',
    badgeDe: 'THEMES',
    titleEn: 'Make Studio yours — pick a theme',
    titleDe: 'Studio nach deinem Geschmack — Theme wählen',
    bodyEn:
        'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio plugins re-skin the whole app at runtime. Or pick a single accent colour and let Material 3 derive the rest. Browse the studio-plugin category for the full set.',
    bodyDe:
        'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio-Plugins themen die App zur Laufzeit komplett um. Oder eine einzelne Akzentfarbe wählen und Material 3 leitet den Rest ab. Die Kategorie Studio-Plugin zeigt das volle Set.',
    ctaLabelEn: 'Show themes',
    ctaLabelDe: 'Themes anzeigen',
    icon: Icons.palette_outlined,
    cta: TodayCta.filterCategory,
    ctaPayload: 'studio-plugin',
  ),
  TodayStoryData(
    badgeEn: 'TEXT TOOLS',
    badgeDe: 'TEXT-TOOLS',
    titleEn: 'Extract, anonymise, translate, summarise',
    titleDe: 'Extrahieren, anonymisieren, übersetzen, zusammenfassen',
    bodyEn:
        'The text-processing bundle is Ch∆In\'s document-analysis backbone. PDF and DOCX in, plain text out. Then anonymise PII, translate, or summarise — every step audit-grade, every Ollama call recorded with model digest.',
    bodyDe:
        'Das Text-Processing-Bündel ist Ch∆Ins Dokumentenanalyse-Rückgrat. PDF und DOCX rein, Klartext raus. Dann PII anonymisieren, übersetzen oder zusammenfassen — jeder Schritt audit-tauglich, jeder Ollama-Aufruf mit Modell-Digest protokolliert.',
    ctaLabelEn: 'Show text modules',
    ctaLabelDe: 'Text-Module zeigen',
    icon: Icons.article_outlined,
    cta: TodayCta.filterCategory,
    ctaPayload: 'text-processing',
  ),
  TodayStoryData(
    badgeEn: 'SANDBOX',
    badgeDe: 'SANDBOX',
    titleEn: 'Every module declares its permissions',
    titleDe: 'Jedes Modul deklariert seine Berechtigungen',
    bodyEn:
        'Ch∆In modules are WASM components. They run in a sandbox with no ambient authority — every filesystem path, every network host, every env var has to be listed in module.yaml. The hub enforces them. No declaration, no access.',
    bodyDe:
        'Ch∆In-Module sind WASM-Komponenten. Sie laufen in einer Sandbox ohne Ambient-Authority — jeder Dateipfad, jeder Netzwerk-Host, jede Env-Var muss in module.yaml stehen. Der Hub setzt es durch. Keine Deklaration, kein Zugriff.',
    ctaLabelEn: 'Review permissions',
    ctaLabelDe: 'Berechtigungen prüfen',
    icon: Icons.shield_outlined,
    cta: TodayCta.openSettings,
  ),
  TodayStoryData(
    badgeEn: 'AUDIT',
    badgeDe: 'AUDIT',
    titleEn: 'Tamper-evident hash chain — built in',
    titleDe: 'Manipulationssicher per Hash-Kette — eingebaut',
    bodyEn:
        'Every flow run, every install, every approval lands in ~/.chain/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.',
    bodyDe:
        'Jeder Flow-Lauf, jede Installation, jede Freigabe landet in ~/.chain/audit/ als hash-verkettetes Event-Log. Jede spätere Änderung bricht die Kette. CRA-tauglich von Haus aus — kein Compliance-Produkt zum Aufkaufen nötig.',
    ctaLabelEn: '',
    ctaLabelDe: '',
    icon: Icons.timeline_outlined,
    cta: TodayCta.none,
  ),
  TodayStoryData(
    badgeEn: 'TEST FIRST',
    badgeDe: 'ZUERST TESTEN',
    titleEn: 'chain doctor — diagnose before you ship',
    titleDe: 'chain doctor — diagnostizieren bevor ausgerollt wird',
    bodyEn:
        'Before installing a module on a production hub, run `chain doctor` in the CLI. It validates manifest, signatures, declared permissions against the operator policy ceiling, and reports anything the hub would reject — no surprises at install time.',
    bodyDe:
        'Bevor ein Modul auf einem Produktions-Hub installiert wird, in der CLI `chain doctor` laufen lassen. Validiert Manifest, Signaturen und deklarierte Berechtigungen gegen die Operator-Policy-Decke — keine Überraschungen beim Install.',
    ctaLabelEn: '',
    ctaLabelDe: '',
    icon: Icons.health_and_safety_outlined,
    cta: TodayCta.none,
  ),
  TodayStoryData(
    badgeEn: 'BUILD ONE',
    badgeDe: 'EIGENES MODUL',
    titleEn: 'Build a module in a single Rust file',
    titleDe: 'Ein Modul in einer einzigen Rust-Datei bauen',
    bodyEn:
        'fai-module-sdk gives you a #[fai::module] macro and a Cargo target. Declare inputs / outputs in module.yaml, write the function, cargo build, drop the wasm in ~/.chain/modules. The hub picks it up at next list. Polyglot SDKs follow in Phase 1.',
    bodyDe:
        'fai-module-sdk liefert ein #[fai::module]-Makro und ein Cargo-Target. Inputs / Outputs in module.yaml deklarieren, Funktion schreiben, cargo build, das wasm in ~/.chain/modules legen. Der Hub erkennt es beim nächsten Listing. Polyglott-SDKs folgen in Phase 1.',
    ctaLabelEn: '',
    ctaLabelDe: '',
    icon: Icons.code_outlined,
    cta: TodayCta.none,
  ),
];

/// Editorial hero strip — the first surface a browsing operator
/// sees. Plays the role of Apple's Today tab: one curated
/// story, gradient backdrop, narrative copy over feature
/// bullets. Auto-hides whenever a filter is active so a
/// purposeful search isn't pushed below the fold.
class _StoreTodayHero extends StatelessWidget {
  final TodayStoryData story;
  final String locale;

  /// Carousel position (0-based) and total slide count. Drives
  /// the dot indicator and the visibility of the prev/next
  /// arrows. When `totalCount == 1` the chrome collapses to a
  /// single-card layout — operator-accepted stories never wear
  /// carousel dressing.
  final int currentIndex;
  final int totalCount;
  final VoidCallback? onPrev;
  final VoidCallback? onNext;
  final VoidCallback onDismiss;

  /// CTA action — null hides the button. Wired by the parent
  /// because navigation targets live in the store-page state,
  /// not in const story data.
  final VoidCallback? onCta;

  /// Inline one-click "+ Add public source" chips rendered in
  /// the hero footer. Empty list hides the row — the parent
  /// passes [_kRecommendedSources] only when the store has zero
  /// federated entries, so we never duplicate the standalone
  /// strip.
  final List<_RecommendedSource> recommendedSources;
  final Set<String> recommendedAdding;
  final void Function(_RecommendedSource)? onAddRecommended;

  const _StoreTodayHero({
    required this.story,
    required this.locale,
    required this.currentIndex,
    required this.totalCount,
    required this.onPrev,
    required this.onNext,
    required this.onDismiss,
    required this.onCta,
    this.recommendedSources = const <_RecommendedSource>[],
    this.recommendedAdding = const <String>{},
    this.onAddRecommended,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final isDe = locale == 'de';
    final badge = isDe ? story.badgeDe : story.badgeEn;
    final title = isDe ? story.titleDe : story.titleEn;
    final body = isDe ? story.bodyDe : story.bodyEn;
    final ctaLabel = isDe ? story.ctaLabelDe : story.ctaLabelEn;
    return Container(
      width: double.infinity,
      // Min-height keeps the carousel slides at a consistent
      // size so the surrounding layout doesn't jump when the
      // operator clicks the prev/next chevrons. Sized to fit
      // the longest story (≈ 6 body lines + title + CTA + chip
      // row) without leaving empty space on shorter slides.
      constraints: const BoxConstraints(minHeight: 240),
      padding: const EdgeInsets.fromLTRB(
        ChainSpace.xl,
        ChainSpace.lg,
        ChainSpace.md,
        ChainSpace.lg,
      ),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(ChainRadius.md),
        gradient: LinearGradient(
          colors: [
            theme.colorScheme.primary.withValues(alpha: 0.16),
            theme.colorScheme.primary.withValues(alpha: 0.04),
            theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
          ],
          stops: const [0.0, 0.55, 1.0],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        border: Border.all(
          color: theme.colorScheme.primary.withValues(alpha: 0.25),
        ),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            padding: const EdgeInsets.all(ChainSpace.md),
            decoration: BoxDecoration(
              color: theme.colorScheme.primary.withValues(alpha: 0.14),
              shape: BoxShape.circle,
            ),
            child: Icon(story.icon, size: 24, color: theme.colorScheme.primary),
          ),
          const SizedBox(width: ChainSpace.lg),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Wrap(
                  spacing: ChainSpace.sm,
                  runSpacing: 4,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: [
                    ChainPill(label: l.storeTodayBadge, tone: ChainPillTone.accent),
                    Text(
                      badge,
                      style: theme.textTheme.labelSmall?.copyWith(
                        color: theme.colorScheme.onSurfaceVariant,
                        letterSpacing: 0.6,
                        fontSize: 10,
                      ),
                    ),
                    Text(
                      '· ${l.storeTodayDeck}',
                      style: theme.textTheme.labelSmall?.copyWith(
                        color: theme.colorScheme.onSurfaceVariant,
                        fontStyle: FontStyle.italic,
                        fontSize: 10,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: ChainSpace.sm),
                Text(
                  title,
                  maxLines: 3,
                  overflow: TextOverflow.ellipsis,
                  style: theme.textTheme.headlineSmall?.copyWith(
                    fontWeight: FontWeight.w700,
                    height: 1.15,
                  ),
                ),
                const SizedBox(height: ChainSpace.sm),
                Text(
                  body,
                  maxLines: 6,
                  overflow: TextOverflow.ellipsis,
                  style: theme.textTheme.bodyMedium?.copyWith(
                    color: theme.colorScheme.onSurfaceVariant,
                    height: 1.45,
                  ),
                ),
                if (onCta != null || recommendedSources.isNotEmpty) ...[
                  const SizedBox(height: ChainSpace.md),
                  Wrap(
                    spacing: ChainSpace.sm,
                    runSpacing: ChainSpace.xs,
                    crossAxisAlignment: WrapCrossAlignment.center,
                    children: [
                      if (onCta != null)
                        FilledButton.tonalIcon(
                          onPressed: onCta,
                          icon: const Icon(Icons.arrow_forward, size: 14),
                          label: Text(ctaLabel),
                          style: FilledButton.styleFrom(
                            visualDensity: VisualDensity.compact,
                          ),
                        ),
                      for (final s in recommendedSources)
                        ActionChip(
                          avatar: recommendedAdding.contains(s.name)
                              ? const SizedBox(
                                  width: 12,
                                  height: 12,
                                  child: CircularProgressIndicator(
                                    strokeWidth: 2,
                                  ),
                                )
                              : Icon(s.icon, size: 14),
                          label: Text('+ ${s.label}'),
                          onPressed:
                              recommendedAdding.contains(s.name) ||
                                  onAddRecommended == null
                              ? null
                              : () => onAddRecommended!(s),
                        ),
                    ],
                  ),
                ],
              ],
            ),
          ),
          // Right-edge column: dismiss + (when more than one
          // slide exists) a carousel control row. Stacking them
          // vertically keeps the hero's main row tidy whatever
          // the slide count.
          Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              IconButton(
                icon: const Icon(Icons.close, size: 16),
                visualDensity: VisualDensity.compact,
                tooltip: l.storeTodayDismissTooltip,
                onPressed: onDismiss,
              ),
              if (totalCount > 1) ...[
                const SizedBox(height: ChainSpace.sm),
                Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    IconButton(
                      icon: const Icon(Icons.chevron_left, size: 18),
                      visualDensity: VisualDensity.compact,
                      onPressed: onPrev,
                    ),
                    Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 4),
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          for (var i = 0; i < totalCount; i++)
                            Container(
                              width: 6,
                              height: 6,
                              margin: const EdgeInsets.symmetric(horizontal: 2),
                              decoration: BoxDecoration(
                                shape: BoxShape.circle,
                                color: i == currentIndex
                                    ? theme.colorScheme.primary
                                    : theme.colorScheme.outlineVariant,
                              ),
                            ),
                        ],
                      ),
                    ),
                    IconButton(
                      icon: const Icon(Icons.chevron_right, size: 18),
                      visualDensity: VisualDensity.compact,
                      onPressed: onNext,
                    ),
                  ],
                ),
              ],
            ],
          ),
        ],
      ),
    );
  }
}

/// Curated public MCP server that the operator can register
/// with one click — no Node, no API key, no subprocess. Fields
/// are kept const so the strip can be a `const` widget tree.
class _RecommendedSource {
  /// Stable lowercase id used as the MCP-client name. Becomes
  /// the prefix of every synthetic store entry the server emits
  /// (`mcp.<name>.<tool>`).
  final String name;

  /// Display label shown on the card.
  final String label;
  final IconData icon;

  /// HTTPS Streamable-HTTP endpoint. No auth required.
  final String endpoint;

  /// Bilingual description used as the MCP-client `description`
  /// field — keeps the audit log in the operator's locale.
  final String descriptionEn;
  final String descriptionDe;

  const _RecommendedSource({
    required this.name,
    required this.label,
    required this.icon,
    required this.endpoint,
    required this.descriptionEn,
    required this.descriptionDe,
  });
}

/// Curated public MCP servers offered as one-click adds. Two
/// guardrails apply when picking entries: outbound HTTPS only
/// (no subprocess, no npm), and a small-enough tool surface
/// that the store stays browsable after the add (Useful AI was
/// considered but its 340+ tools would drown the index).
const _kRecommendedSources = <_RecommendedSource>[
  _RecommendedSource(
    name: 'deepwiki',
    label: 'DeepWiki',
    icon: Icons.menu_book_outlined,
    endpoint: 'https://mcp.deepwiki.com/mcp',
    descriptionEn: 'GitHub repo documentation search (AI-powered).',
    descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).',
  ),
  _RecommendedSource(
    name: 'semgrep',
    label: 'Semgrep',
    icon: Icons.security,
    endpoint: 'https://mcp.semgrep.ai/mcp',
    descriptionEn: 'Security scanning for code vulnerabilities.',
    descriptionDe: 'Security-Scanning für Code-Schwachstellen.',
  ),
];

/// Recommended-sources strip — replaces the older Settings nudge.
/// Renders one card per [_kRecommendedSources] entry with a
/// single `[+ Add]` button so the operator can fill the store
/// without leaving the Store page. Hides as soon as any
/// federated entry exists.
class _RecommendedSourcesStrip extends StatelessWidget {
  final Set<String> adding;
  final String locale;
  final void Function(_RecommendedSource) onAdd;

  const _RecommendedSourcesStrip({
    required this.adding,
    required this.locale,
    required this.onAdd,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(ChainSpace.md),
      decoration: BoxDecoration(
        color: theme.colorScheme.surfaceContainer,
        borderRadius: BorderRadius.circular(ChainRadius.md),
        border: Border.all(color: theme.colorScheme.outlineVariant),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(
                Icons.hub_outlined,
                size: 18,
                color: theme.colorScheme.primary,
              ),
              const SizedBox(width: ChainSpace.sm),
              Text(
                l.storeRecommendedTitle,
                style: theme.textTheme.titleSmall?.copyWith(
                  fontWeight: FontWeight.w600,
                ),
              ),
            ],
          ),
          const SizedBox(height: 4),
          Text(
            l.storeRecommendedBody,
            style: theme.textTheme.bodySmall?.copyWith(
              color: theme.colorScheme.onSurfaceVariant,
            ),
          ),
          const SizedBox(height: ChainSpace.md),
          Wrap(
            spacing: ChainSpace.md,
            runSpacing: ChainSpace.sm,
            children: [
              for (final s in _kRecommendedSources)
                _RecommendedSourceCard(
                  source: s,
                  locale: locale,
                  busy: adding.contains(s.name),
                  onAdd: () => onAdd(s),
                ),
            ],
          ),
        ],
      ),
    );
  }
}

class _RecommendedSourceCard extends StatelessWidget {
  final _RecommendedSource source;
  final String locale;
  final bool busy;
  final VoidCallback onAdd;

  const _RecommendedSourceCard({
    required this.source,
    required this.locale,
    required this.busy,
    required this.onAdd,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn;
    return SizedBox(
      width: 320,
      child: Container(
        padding: const EdgeInsets.all(ChainSpace.sm),
        decoration: BoxDecoration(
          color: theme.colorScheme.surfaceContainerHigh,
          borderRadius: BorderRadius.circular(ChainRadius.sm),
          border: Border.all(color: theme.colorScheme.outlineVariant),
        ),
        child: Row(
          children: [
            Icon(source.icon, size: 18, color: theme.colorScheme.primary),
            const SizedBox(width: ChainSpace.sm),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    source.label,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: theme.textTheme.bodyMedium?.copyWith(
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  Text(
                    desc,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: ChainSpace.sm),
            Tooltip(
              message: busy
                  ? l.storeRecommendedAdding
                  : l.storeRecommendedAddTooltip,
              child: IconButton.filledTonal(
                onPressed: busy ? null : onAdd,
                icon: busy
                    ? const SizedBox(
                        width: 14,
                        height: 14,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Icon(Icons.add, size: 16),
                visualDensity: VisualDensity.compact,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Small color-coded chip that names the entry's source: native
/// (muted), MCP (warning tone), or n8n (accent). The on-card
/// position lets the operator triage at a glance who emitted the
/// capability — the same role the "verified" badge plays in
/// commercial app stores.
class _ProvenancePill extends StatelessWidget {
  final StoreItem item;

  const _ProvenancePill({required this.item});

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    final source = _sourceForItem(item);
    switch (source) {
      case 'mcp':
        return ChainPill(
          label: l.storeProvenanceMcp(item.provider),
          tone: ChainPillTone.warning,
          monospace: true,
          icon: Icons.hub_outlined,
        );
      case 'n8n':
        return ChainPill(
          label: l.storeProvenanceN8n(item.provider),
          tone: ChainPillTone.accent,
          monospace: true,
          icon: Icons.account_tree_outlined,
        );
      case 'temporal':
        return ChainPill(
          label: l.storeProvenanceTemporal(item.provider),
          tone: ChainPillTone.accent,
          monospace: true,
          icon: Icons.timer_outlined,
        );
      default:
        // Native is the default: no badge needed. Keeps the
        // card visually quiet — only foreign code (mcp / n8n)
        // earns the provenance pill, the same way a "verified"
        // badge is opt-in in commercial app stores.
        return const SizedBox.shrink();
    }
  }
}

/// Store-side version picker. Same shape as the module-sheet
/// version picker but lives here so the Store page doesn't take
/// a dependency on the module-sheet's private widgets. The
/// duplication is small (one dialog), and keeping them
/// separate lets each surface evolve its copy independently.
class _StoreUninstallVersionPickerDialog extends StatefulWidget {
  final String moduleName;
  final List<String> versions;

  const _StoreUninstallVersionPickerDialog({
    required this.moduleName,
    required this.versions,
  });

  @override
  State<_StoreUninstallVersionPickerDialog> createState() =>
      _StoreUninstallVersionPickerDialogState();
}

class _StoreUninstallVersionPickerDialogState
    extends State<_StoreUninstallVersionPickerDialog> {
  late String _picked;

  @override
  void initState() {
    super.initState();
    _picked = widget.versions.last;
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    return AlertDialog(
      title: Text(l.uninstallVersionPickerTitle),
      content: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(l.uninstallVersionPickerBody(widget.moduleName)),
          const SizedBox(height: ChainSpace.md),
          RadioGroup<String>(
            groupValue: _picked,
            onChanged: (val) {
              if (val != null) setState(() => _picked = val);
            },
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                for (final v in widget.versions)
                  RadioListTile<String>(
                    value: v,
                    title: Text(
                      'v$v',
                      style: const TextStyle(fontFamily: 'monospace'),
                    ),
                    dense: true,
                    contentPadding: EdgeInsets.zero,
                  ),
              ],
            ),
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text(l.buttonCancel),
        ),
        FilledButton(
          onPressed: () => Navigator.pop(context, _picked),
          child: Text(l.uninstallVersionPickerContinue),
        ),
      ],
    );
  }
}
