// ChainSystemAiEditor — modal for configuring the hub-internal
// System AI from inside Studio. Mirrors the YAML block but with
// provider presets, in-place explainers, and a "Test connection"
// button. On save, calls HubAdmin.UpdateSystemAi which both
// persists to ~/.chain/config.yaml and hot-reloads the live hub.

import 'package:flutter/material.dart';

import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'chain_error_box.dart';
import 'chain_pill.dart';

/// Provider preset metadata kept in sync with
/// `chain_hub::operator_config::SystemLlmProvider`. Anything that
/// is operator-visible (label, description, default endpoint,
/// suggested env-var) lives here so the dropdown self-explains
/// without needing to read the source.
class _ProviderPreset {
  final String wire;
  final String label;
  final String defaultEndpoint;
  final String defaultApiKeyEnv;
  final String description;
  final String modelHint;

  const _ProviderPreset({
    required this.wire,
    required this.label,
    required this.defaultEndpoint,
    required this.defaultApiKeyEnv,
    required this.description,
    required this.modelHint,
  });

  static const all = <_ProviderPreset>[
    _ProviderPreset(
      wire: 'ollama',
      label: 'Ollama',
      defaultEndpoint: 'http://127.0.0.1:11434/v1',
      defaultApiKeyEnv: '',
      description:
          'Local `ollama serve`. Models stay on your machine. No API key needed.',
      modelHint: 'gemma3:4b · llama3.2:3b · qwen2.5-coder:7b',
    ),
    _ProviderPreset(
      wire: 'openai',
      label: 'OpenAI',
      defaultEndpoint: 'https://api.openai.com/v1',
      defaultApiKeyEnv: 'OPENAI_API_KEY',
      description:
          'OpenAI hosted API. Requires an API key. Data leaves your machine.',
      modelHint: 'gpt-4o-mini · gpt-4o · o1-mini',
    ),
    _ProviderPreset(
      wire: 'lmstudio',
      label: 'LM Studio',
      defaultEndpoint: 'http://127.0.0.1:1234/v1',
      defaultApiKeyEnv: '',
      description:
          'Local LM Studio server. Models stay on your machine. No API key needed.',
      modelHint: 'whichever model you loaded in LM Studio',
    ),
    _ProviderPreset(
      wire: 'vllm',
      label: 'vLLM (self-hosted)',
      defaultEndpoint: '',
      defaultApiKeyEnv: '',
      description:
          'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.',
      modelHint: 'depends on what your vLLM has loaded',
    ),
    _ProviderPreset(
      wire: 'custom',
      label: 'Custom (OpenAI-compatible)',
      defaultEndpoint: '',
      defaultApiKeyEnv: '',
      description:
          'Anything else that speaks OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).',
      modelHint: '',
    ),
  ];

  static _ProviderPreset byWire(String wire) {
    return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
  }

  /// Localized help body for this provider. Falls back to the
  /// const English [description] for any future wire that has no
  /// ARB key yet.
  String descriptionFor(AppLocalizations l) {
    switch (wire) {
      case 'ollama':
        return l.providerDescOllama;
      case 'openai':
        return l.providerDescOpenai;
      case 'lmstudio':
        return l.providerDescLmstudio;
      case 'vllm':
        return l.providerDescVllm;
      case 'custom':
        return l.providerDescCustom;
      default:
        return description;
    }
  }
}

class ChainSystemAiEditor extends StatefulWidget {
  final SystemAiStatus initial;

  const ChainSystemAiEditor({super.key, required this.initial});

  /// Convenience launcher. Returns the new status when the
  /// operator saved, null on cancel.
  static Future<SystemAiStatus?> show(
    BuildContext context,
    SystemAiStatus initial,
  ) {
    return showDialog<SystemAiStatus>(
      context: context,
      builder: (_) => ChainSystemAiEditor(initial: initial),
    );
  }

  @override
  State<ChainSystemAiEditor> createState() => _FaiSystemAiEditorState();
}

class _FaiSystemAiEditorState extends State<ChainSystemAiEditor> {
  late _ProviderPreset _preset;
  late final TextEditingController _endpoint;
  late final TextEditingController _model;
  late final TextEditingController _apiKeyEnv;
  late String _privacyMode;
  bool _saving = false;
  bool _testing = false;
  bool _loadingModels = false;
  bool _pulling = false;
  List<String>? _models;
  String? _modelsError;
  AskAiResult? _testResult;
  String? _error;
  HardwareSnapshot? _hw;
  CuratedSnapshot? _curated;
  Map<String, CuratedModelInfo> _curatedById = const {};

  @override
  void initState() {
    super.initState();
    final init = widget.initial;
    _preset = _ProviderPreset.byWire(
      init.provider.isEmpty ? 'ollama' : init.provider,
    );
    _endpoint = TextEditingController(
      text: init.endpoint.isEmpty ? _preset.defaultEndpoint : init.endpoint,
    );
    _model = TextEditingController(text: init.model);
    _apiKeyEnv = TextEditingController(
      text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv,
    );
    _privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
    // Pull the model list immediately so the operator sees the
    // available models when the dialog opens — no Refresh click
    // needed. Fail-quiet: if the provider isn't running, the
    // dropdown stays empty and the operator can fill the field
    // manually or hit Refresh later.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_endpoint.text.trim().isNotEmpty) {
        _refreshModels();
      }
      _loadHardwareAndCurated();
    });
  }

  Future<void> _loadHardwareAndCurated() async {
    try {
      final results = await Future.wait([
        HubService.instance.hardwareInfo(),
        HubService.instance.listSystemAiCuratedModels(),
      ]);
      if (!mounted) return;
      final hw = results[0] as HardwareSnapshot;
      final curated = results[1] as CuratedSnapshot;
      setState(() {
        _hw = hw;
        _curated = curated;
        _curatedById = {for (final m in curated.models) m.id: m};
      });
    } catch (_) {
      // Non-fatal: editor still works using the size-only heuristic.
    }
  }

  @override
  void dispose() {
    _endpoint.dispose();
    _model.dispose();
    _apiKeyEnv.dispose();
    super.dispose();
  }

  void _onProviderChanged(_ProviderPreset p) {
    setState(() {
      _preset = p;
      // Only auto-fill the field when the operator hasn't typed
      // anything custom yet (i.e. it still matches a different
      // preset's default or is empty).
      if (_endpoint.text.isEmpty || _isAnyDefaultEndpoint(_endpoint.text)) {
        _endpoint.text = p.defaultEndpoint;
      }
      if (_apiKeyEnv.text.isEmpty || _isAnyDefaultApiKeyEnv(_apiKeyEnv.text)) {
        _apiKeyEnv.text = p.defaultApiKeyEnv;
      }
      _testResult = null;
      _models = null;
      _modelsError = null;
    });
    // New provider → new model list. Fire a refresh so the
    // chips update before the operator wonders what's available.
    if (_endpoint.text.trim().isNotEmpty) {
      _refreshModels();
    }
  }

  bool _isAnyDefaultEndpoint(String t) =>
      _ProviderPreset.all.any((p) => p.defaultEndpoint == t);
  bool _isAnyDefaultApiKeyEnv(String t) =>
      _ProviderPreset.all.any((p) => p.defaultApiKeyEnv == t);

  Future<void> _save({bool keepOpen = false}) async {
    setState(() {
      _saving = true;
      _error = null;
    });
    try {
      final status = await HubService.instance.updateSystemAi(
        provider: _preset.wire,
        endpoint: _endpoint.text.trim(),
        model: _model.text.trim(),
        apiKeyEnv: _apiKeyEnv.text.trim(),
        privacyMode: _privacyMode,
      );
      if (!mounted) return;
      if (keepOpen) {
        setState(() => _saving = false);
      } else {
        Navigator.pop(context, status);
      }
    } catch (e) {
      setState(() {
        _saving = false;
        _error = e.toString();
      });
    }
  }

  Future<void> _clearCache() async {
    try {
      final purged = await HubService.instance.clearSystemLlmCache();
      if (!mounted) return;
      final l = AppLocalizations.of(context)!;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(l.systemAiCacheClearedToast(purged))),
      );
      setState(() {
        // Force a rebuild that hides the row; cheap.
      });
    } catch (e) {
      if (!mounted) return;
      showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e);
    }
  }

  Future<void> _test() async {
    setState(() {
      _testing = true;
      _testResult = null;
      _error = null;
    });
    try {
      // Pass the current form values directly — Test must work
      // *before* Save so the operator can validate without
      // committing.
      final r = await HubService.instance.testSystemAi(
        provider: _preset.wire,
        endpoint: _endpoint.text.trim(),
        model: _model.text.trim(),
        apiKeyEnv: _apiKeyEnv.text.trim(),
        privacyMode: _privacyMode,
      );
      if (!mounted) return;
      setState(() {
        _testing = false;
        _testResult = r;
      });
    } catch (e) {
      setState(() {
        _testing = false;
        _error = e.toString();
      });
    }
  }

  Future<void> _refreshModels() async {
    setState(() {
      _loadingModels = true;
      _models = null;
      _modelsError = null;
    });
    final r = await HubService.instance.listSystemAiModels(
      provider: _preset.wire,
      endpoint: _endpoint.text.trim(),
      apiKeyEnv: _apiKeyEnv.text.trim(),
    );
    if (!mounted) return;
    setState(() {
      _loadingModels = false;
      if (r.errorKind.isEmpty) {
        _models = r.ids;
      } else {
        _modelsError = r.text;
      }
    });
  }

  Future<void> _pullModel() async {
    final wanted = _model.text.trim();
    final l = AppLocalizations.of(context)!;
    if (wanted.isEmpty) {
      setState(() => _error = l.systemAiPullEmptyError);
      return;
    }
    setState(() {
      _pulling = true;
      _error = null;
    });
    final r = await HubService.instance.pullSystemAiModel(
      endpoint: _endpoint.text.trim(),
      model: wanted,
      apiKeyEnv: _apiKeyEnv.text.trim(),
    );
    if (!mounted) return;
    setState(() {
      _pulling = false;
      if (r.errorKind.isNotEmpty) {
        _error = l.systemAiPullFailedError(r.text);
      } else {
        _error = null;
      }
    });
    if (r.errorKind.isEmpty) {
      // Refresh the model list so the just-pulled model appears
      // in the dropdown immediately.
      await _refreshModels();
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return AlertDialog(
      title: Text(l.systemAiTitle),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(ChainRadius.md),
      ),
      contentPadding: const EdgeInsets.symmetric(
        horizontal: ChainSpace.xl,
        vertical: ChainSpace.lg,
      ),
      content: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600),
        child: SingleChildScrollView(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                l.systemAiIntro,
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              if (_hw != null) ...[
                const SizedBox(height: ChainSpace.sm),
                _HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
              ],
              const SizedBox(height: ChainSpace.lg),
              _ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
              const SizedBox(height: 4),
              Text(
                _preset.descriptionFor(l),
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              const SizedBox(height: ChainSpace.md),
              TextField(
                controller: _endpoint,
                style: ChainTheme.mono(size: 12),
                decoration: InputDecoration(
                  labelText: l.systemAiEndpointLabel,
                  border: const OutlineInputBorder(),
                  isDense: true,
                ),
              ),
              const SizedBox(height: ChainSpace.md),
              _ModelPicker(
                controller: _model,
                preset: _preset,
                models: _models,
                modelsError: _modelsError,
                loading: _loadingModels,
                pulling: _pulling,
                hw: _hw,
                curatedById: _curatedById,
                onRefresh: _saving || _testing || _loadingModels || _pulling
                    ? null
                    : _refreshModels,
                onPull: _saving || _testing || _loadingModels || _pulling
                    ? null
                    : _pullModel,
              ),
              const SizedBox(height: ChainSpace.md),
              TextField(
                controller: _apiKeyEnv,
                style: ChainTheme.mono(size: 12),
                decoration: InputDecoration(
                  labelText: _preset.wire == 'openai'
                      ? l.systemAiApiKeyRequired
                      : l.systemAiApiKeyOptional,
                  hintText: 'OPENAI_API_KEY',
                  prefixText: '\$',
                  border: const OutlineInputBorder(),
                  isDense: true,
                ),
              ),
              const SizedBox(height: 4),
              Text(
                l.systemAiApiKeyDisclaimer('<name>'),
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              const SizedBox(height: ChainSpace.lg),
              _PrivacyModeChips(
                value: _privacyMode,
                onChanged: (v) => setState(() => _privacyMode = v),
              ),
              if (widget.initial.cacheCount > 0) ...[
                const SizedBox(height: ChainSpace.md),
                _CacheStatusRow(
                  cacheCount: widget.initial.cacheCount,
                  onClear: _saving || _testing ? null : _clearCache,
                ),
              ],
              if (_error != null) ...[
                const SizedBox(height: ChainSpace.md),
                // Copyable — every error the operator sees must be
                // selectable + one-tap copyable (ChainErrorBox), never
                // a bare Text they can't paste back.
                ChainErrorBox(text: _error!, isError: true, maxHeight: 200),
              ],
              if (_testResult != null) ...[
                const SizedBox(height: ChainSpace.md),
                _TestResultPanel(result: _testResult!),
              ],
            ],
          ),
        ),
      ),
      actions: [
        TextButton(
          onPressed: _saving || _testing
              ? null
              : () => Navigator.pop(context, null),
          child: Text(l.buttonCancel),
        ),
        OutlinedButton.icon(
          onPressed: _saving || _testing ? null : _test,
          icon: const Icon(Icons.play_arrow, size: 16),
          label: Text(_testing ? l.systemAiTesting : l.systemAiTestConnection),
        ),
        FilledButton(
          onPressed: _saving || _testing ? null : () => _save(),
          child: _saving
              ? const SizedBox(
                  width: 16,
                  height: 16,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : Text(l.systemAiSave),
        ),
      ],
    );
  }
}

class _ProviderDropdown extends StatelessWidget {
  final _ProviderPreset value;
  final void Function(_ProviderPreset) onChanged;

  const _ProviderDropdown({required this.value, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    return DropdownButtonFormField<String>(
      initialValue: value.wire,
      isDense: true,
      decoration: InputDecoration(
        labelText: l.systemAiProviderLabel,
        border: const OutlineInputBorder(),
        isDense: true,
      ),
      items: _ProviderPreset.all
          .map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
          .toList(),
      onChanged: (v) {
        if (v == null) return;
        onChanged(_ProviderPreset.byWire(v));
      },
    );
  }
}

class _PrivacyModeChips extends StatelessWidget {
  final String value;
  final ValueChanged<String> onChanged;

  const _PrivacyModeChips({required this.value, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final modes = <(String, String, String)>[
      ('off', l.systemAiPrivacyOffLabel, l.systemAiPrivacyOffDesc),
      (
        'redacted',
        l.systemAiPrivacyRedactedLabel,
        l.systemAiPrivacyRedactedDesc,
      ),
      ('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
    ];
    // Material 3 deprecated per-Radio `groupValue` / `onChanged`
    // after Flutter 3.32. The modern pattern wraps the radios in
    // a RadioGroup<T> ancestor that owns the selection + change
    // callback; the individual Radio widgets only declare their
    // value. Same UX, no deprecation warnings.
    return RadioGroup<String>(
      groupValue: value,
      onChanged: (v) {
        if (v != null) onChanged(v);
      },
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l.systemAiPrivacyHeader,
            style: theme.textTheme.labelSmall?.copyWith(
              color: theme.colorScheme.onSurfaceVariant,
              letterSpacing: 0.6,
              fontSize: 10,
            ),
          ),
          const SizedBox(height: 4),
          for (final (wire, label, desc) in modes)
            InkWell(
              onTap: () => onChanged(wire),
              borderRadius: BorderRadius.circular(ChainRadius.sm),
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  vertical: ChainSpace.sm,
                  horizontal: ChainSpace.sm,
                ),
                child: Row(
                  children: [
                    Radio<String>(value: wire),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(label, style: theme.textTheme.bodyMedium),
                          Text(
                            desc,
                            style: theme.textTheme.bodySmall?.copyWith(
                              color: theme.colorScheme.onSurfaceVariant,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _TestResultPanel extends StatelessWidget {
  final AskAiResult result;

  const _TestResultPanel({required this.result});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final ok = result.isSuccess;
    final color = ok ? ChainColors.success : theme.colorScheme.error;
    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: color.withValues(alpha: 0.3)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(
                ok ? Icons.check_circle_outline : Icons.error_outline,
                size: 14,
                color: color,
              ),
              const SizedBox(width: ChainSpace.xs),
              Text(
                ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
                style: theme.textTheme.bodyMedium?.copyWith(color: color),
              ),
              const Spacer(),
              if (ok && result.latencyMs > 0)
                ChainPill(
                  label: '${result.latencyMs} ms',
                  tone: ChainPillTone.neutral,
                  monospace: true,
                ),
            ],
          ),
          const SizedBox(height: 4),
          // Success → the reply preview (selectable). Failure → the
          // raw error in a ChainErrorBox with an explicit copy button,
          // so the operator can paste the exact failure back.
          if (ok)
            SelectableText(
              l.systemAiReplyPrefix(result.text),
              style:
                  ChainTheme.mono(size: 11, color: theme.colorScheme.onSurface),
            )
          else
            ChainErrorBox(text: result.text, isError: true, maxHeight: 200),
          if (!ok && result.fixHint(l).isNotEmpty) ...[
            const SizedBox(height: ChainSpace.xs),
            Text(
              result.fixHint(l),
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurface,
              ),
            ),
          ],
        ],
      ),
    );
  }
}

class _ModelPicker extends StatelessWidget {
  final TextEditingController controller;
  final _ProviderPreset preset;
  final List<String>? models;
  final String? modelsError;
  final bool loading;
  final bool pulling;

  /// Detected host hardware. Used by [_suitabilityFor] to mark
  /// curated models as "recommended" when their `min_hw_tier` is
  /// at or below the operator's tier. Null while the hub call is
  /// in flight or unreachable — chips fall back to the size-only
  /// heuristic.
  final HardwareSnapshot? hw;

  /// Curated DB indexed by model id. Empty while the hub call is
  /// in flight; chips then fall back to the size-only heuristic.
  final Map<String, CuratedModelInfo> curatedById;
  final VoidCallback? onRefresh;
  final VoidCallback? onPull;

  const _ModelPicker({
    required this.controller,
    required this.preset,
    required this.models,
    required this.modelsError,
    required this.loading,
    required this.pulling,
    required this.hw,
    required this.curatedById,
    required this.onRefresh,
    required this.onPull,
  });

  bool get _isOllama => preset.wire == 'ollama';

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(
              child: ValueListenableBuilder<TextEditingValue>(
                valueListenable: controller,
                builder: (_, _, _) => TextField(
                  controller: controller,
                  style: ChainTheme.mono(size: 12),
                  decoration: InputDecoration(
                    labelText: l.systemAiModelLabel,
                    hintText: preset.modelHint.isEmpty
                        ? l.systemAiModelHintFallback
                        : preset.modelHint,
                    helperText: controller.text.trim().isEmpty
                        ? (_isOllama
                              ? l.systemAiModelHelperOllama
                              : l.systemAiModelHelperBase)
                        : null,
                    helperMaxLines: 2,
                    border: const OutlineInputBorder(),
                    isDense: true,
                  ),
                ),
              ),
            ),
            const SizedBox(width: ChainSpace.sm),
            OutlinedButton.icon(
              onPressed: onRefresh,
              icon: loading
                  ? const SizedBox(
                      width: 14,
                      height: 14,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Icon(Icons.refresh, size: 16),
              label: Text(l.systemAiRefresh),
            ),
            if (_isOllama) ...[
              const SizedBox(width: ChainSpace.sm),
              OutlinedButton.icon(
                onPressed: onPull,
                icon: pulling
                    ? const SizedBox(
                        width: 14,
                        height: 14,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Icon(Icons.download, size: 16),
                label: Text(pulling ? l.systemAiPulling : l.systemAiPull),
              ),
            ],
          ],
        ),
        if (modelsError != null) ...[
          const SizedBox(height: 4),
          Text(
            l.systemAiCouldNotListModels(modelsError!),
            style: theme.textTheme.bodySmall?.copyWith(
              color: theme.colorScheme.error,
            ),
          ),
        ],
        if (models != null) ...[
          const SizedBox(height: ChainSpace.sm),
          if (models!.isEmpty)
            Text(
              _isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            )
          else ...[
            Wrap(
              spacing: ChainSpace.xs,
              runSpacing: ChainSpace.xs,
              children: [
                for (final id in _sortedBySuitability(
                  models!,
                  hw: hw,
                  curatedById: curatedById,
                ))
                  _ModelChip(
                    id: id,
                    suitability: _suitabilityFor(
                      id,
                      hw: hw,
                      curatedById: curatedById,
                    ),
                    curated: curatedById[id.trim().toLowerCase()],
                    onTap: () => controller.text = id,
                  ),
              ],
            ),
            const SizedBox(height: 6),
            _SuitabilityLegend(hw: hw),
          ],
        ],
      ],
    );
  }
}

/// Heuristic suitability rating for a model id, used to colour
/// the chips in the model picker. Pure id-based — no /api/show
/// round-trip; works for Ollama tags (`gemma3:4b`), OpenAI names
/// (`gpt-4o-mini`), Anthropic-style (`claude-haiku-4-5`).
enum _Suitability {
  recommended, // tested with Ch∆In System AI
  balanced, // 4-8B params or known-good cloud model
  small, // <4B params; quality may be insufficient
  large, // 9-15B; slower but ok
  huge, // >15B; usually too slow on a developer laptop
  unknown, // can't tell from the id
}

extension _SuitabilityCopy on _Suitability {
  String labelFor(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    switch (this) {
      case _Suitability.recommended:
        return l.systemAiSuitabilityRecommended;
      case _Suitability.balanced:
        return l.systemAiSuitabilityBalanced;
      case _Suitability.small:
        return l.systemAiSuitabilitySmall;
      case _Suitability.large:
        return l.systemAiSuitabilityLarge;
      case _Suitability.huge:
        return l.systemAiSuitabilityHuge;
      case _Suitability.unknown:
        return l.systemAiSuitabilityUnknown;
    }
  }

  Color color(ColorScheme cs) {
    switch (this) {
      case _Suitability.recommended:
        return ChainColors.success;
      case _Suitability.balanced:
        return cs.primary;
      case _Suitability.small:
        return ChainColors.warning;
      case _Suitability.large:
        return ChainColors.warning;
      case _Suitability.huge:
        return cs.error;
      case _Suitability.unknown:
        return cs.outline;
    }
  }
}

/// Suitability rating for a model id. Curated DB + detected
/// hardware win over the size-only heuristic when both are
/// available; otherwise we fall back to parsing the param count
/// out of the id.
///
/// Lookup order:
/// 1. Curated entry → use `quality_tier` AND compare
///    `min_hw_tier` against detected hardware. Only `good` or
///    `excellent` curated entries that the hardware can actually
///    run get the green star.
/// 2. Size-only heuristic — same fallback logic as before.
_Suitability _suitabilityFor(
  String rawId, {
  HardwareSnapshot? hw,
  Map<String, CuratedModelInfo> curatedById = const {},
}) {
  final id = rawId.trim().toLowerCase();
  final curated = curatedById[id];
  if (curated != null) {
    final hwOk = hw == null || hw.rank < 0 || curated.minHwRank <= hw.rank;
    if ((curated.qualityTier == 'good' || curated.qualityTier == 'excellent') &&
        hwOk) {
      return _Suitability.recommended;
    }
    if (curated.qualityTier == 'good' || curated.qualityTier == 'excellent') {
      // Curated quality is fine, but hardware is below `min_hw_tier`.
      // Mark as large (orange) to signal "may be slow on this host".
      return _Suitability.large;
    }
    // Curated as `basic` — quality may be insufficient.
    return _Suitability.small;
  }
  // Fallback: try to extract a parameter count from common naming
  // conventions: `gemma3:4b`, `llama3.2-70b-instruct`, `qwen-1.5b`,
  // `:13b`, `-3.2b`, etc. Match a number (with optional decimal)
  // followed by `b`, bordered by a non-letter on both sides.
  final m = RegExp(r'(?<![a-z])(\d+(?:\.\d+)?)\s*b(?![a-z])').firstMatch(id);
  if (m != null) {
    final n = double.tryParse(m.group(1)!);
    if (n != null) {
      if (n < 3) return _Suitability.small;
      if (n <= 8) return _Suitability.balanced;
      if (n <= 15) return _Suitability.large;
      return _Suitability.huge;
    }
  }
  // OpenAI / Anthropic without size in the name — known-family
  // heuristics.
  if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
    return _Suitability.balanced;
  }
  if (id.contains('opus') || id.contains('o1') || id.contains('large')) {
    return _Suitability.large;
  }
  return _Suitability.unknown;
}

/// Sort by suitability tier first (recommended → balanced →
/// large → small → huge → unknown), then alphabetically — so
/// the operator's eye lands on the green chips immediately.
List<String> _sortedBySuitability(
  List<String> ids, {
  HardwareSnapshot? hw,
  Map<String, CuratedModelInfo> curatedById = const {},
}) {
  int rank(_Suitability s) {
    switch (s) {
      case _Suitability.recommended:
        return 0;
      case _Suitability.balanced:
        return 1;
      case _Suitability.large:
        return 2;
      case _Suitability.small:
        return 3;
      case _Suitability.huge:
        return 4;
      case _Suitability.unknown:
        return 5;
    }
  }

  final out = [...ids];
  out.sort((a, b) {
    final ra = rank(_suitabilityFor(a, hw: hw, curatedById: curatedById));
    final rb = rank(_suitabilityFor(b, hw: hw, curatedById: curatedById));
    if (ra != rb) return ra.compareTo(rb);
    return a.compareTo(b);
  });
  return out;
}

class _ModelChip extends StatelessWidget {
  final String id;
  final _Suitability suitability;

  /// Curated entry for this id, when one exists. Drives the
  /// tooltip — operators get the editorial note ("Solid 4B
  /// all-rounder…") rather than just the suitability label.
  final CuratedModelInfo? curated;
  final VoidCallback onTap;

  const _ModelChip({
    required this.id,
    required this.suitability,
    required this.curated,
    required this.onTap,
  });

  String _tooltipFor(BuildContext context) {
    final lines = <String>[suitability.labelFor(context)];
    final c = curated;
    if (c != null) {
      lines.add(
        '${c.qualityTier} · ${c.paramsB > 0 ? "${_fmtParams(c.paramsB)} params · " : ""}'
        '${c.contextK}K ctx · min hw: ${c.minHwTier}',
      );
      if (c.notes.isNotEmpty) lines.add(c.notes);
      if (c.license.isNotEmpty) lines.add('license: ${c.license}');
    }
    return lines.join('\n');
  }

  static String _fmtParams(double b) {
    if (b == b.roundToDouble()) return '${b.toInt()}B';
    return '${b.toStringAsFixed(1)}B';
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final color = suitability.color(theme.colorScheme);
    return Tooltip(
      message: _tooltipFor(context),
      child: ActionChip(
        avatar: Icon(
          suitability == _Suitability.recommended ? Icons.star : Icons.circle,
          size: suitability == _Suitability.recommended ? 14 : 9,
          color: color,
        ),
        label: Text(id, style: ChainTheme.mono(size: 11)),
        side: BorderSide(color: color.withValues(alpha: 0.4)),
        onPressed: onTap,
      ),
    );
  }
}

/// Compact banner that shows the detected hardware tier plus the
/// curated DB's `last_reviewed` date, so the operator knows which
/// "recommended" judgement they are reading.
class _HardwareBanner extends StatelessWidget {
  final HardwareSnapshot hw;
  final String? lastReviewed;

  const _HardwareBanner({required this.hw, required this.lastReviewed});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    final lr = (lastReviewed != null && lastReviewed!.isNotEmpty)
        ? l.systemAiHwReviewed(lastReviewed!)
        : '';
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 6),
      decoration: BoxDecoration(
        color: theme.colorScheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(ChainRadius.sm),
        border: Border.all(color: theme.colorScheme.outlineVariant),
      ),
      child: Row(
        children: [
          Icon(
            Icons.memory,
            size: 13,
            color: theme.colorScheme.onSurfaceVariant,
          ),
          const SizedBox(width: 6),
          Expanded(
            child: Text(
              '${l.systemAiHwDetected(hw.summary)}$lr',
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _SuitabilityLegend extends StatelessWidget {
  final HardwareSnapshot? hw;
  const _SuitabilityLegend({required this.hw});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    Widget dot(Color c, String label) => Padding(
      padding: const EdgeInsets.only(right: ChainSpace.md),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.circle, size: 8, color: c),
          const SizedBox(width: 4),
          Text(
            label,
            style: theme.textTheme.bodySmall?.copyWith(
              color: theme.colorScheme.onSurfaceVariant,
              fontSize: 11,
            ),
          ),
        ],
      ),
    );
    final tierTag = (hw == null || hw!.tier == 'unknown')
        ? ''
        : ' (${hw!.tier})';
    return Wrap(
      crossAxisAlignment: WrapCrossAlignment.center,
      children: [
        Padding(
          padding: const EdgeInsets.only(right: ChainSpace.md),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(Icons.star, size: 11, color: ChainColors.success),
              const SizedBox(width: 4),
              Text(
                l.systemAiLegendRecommended(tierTag),
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  fontSize: 11,
                ),
              ),
            ],
          ),
        ),
        dot(theme.colorScheme.primary, l.systemAiLegendBalanced),
        dot(ChainColors.warning, l.systemAiLegendSmallLarge),
        dot(theme.colorScheme.error, l.systemAiLegendHuge),
        dot(theme.colorScheme.outline, l.systemAiLegendUnknown),
      ],
    );
  }
}

/// Displays the System-AI cache count plus a "Clear cache"
/// button. Hidden when the cache is empty so the dialog stays
/// quiet for fresh installs. Lives inline so the operator sees
/// the cache exists, what it costs, and how to drop it without
/// reading docs.
class _CacheStatusRow extends StatelessWidget {
  final int cacheCount;
  final VoidCallback? onClear;

  const _CacheStatusRow({required this.cacheCount, required this.onClear});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l = AppLocalizations.of(context)!;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 6),
      decoration: BoxDecoration(
        color: theme.colorScheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(ChainRadius.sm),
        border: Border.all(color: theme.colorScheme.outlineVariant),
      ),
      child: Row(
        children: [
          Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
          const SizedBox(width: 6),
          Expanded(
            child: Text(
              l.systemAiCacheRow(cacheCount),
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
          ),
          TextButton.icon(
            onPressed: onClear,
            icon: const Icon(Icons.delete_outline, size: 14),
            label: Text(l.systemAiCacheClear),
          ),
        ],
      ),
    );
  }
}
