import "package:flutter/material.dart";
import "package:shared_preferences/shared_preferences.dart";

import "../format.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../theme.dart";
import "car_view_sheet.dart" show arrangeKeys;
import "charger_import_sheet.dart";

/// Charging & map screen, mirroring the web Charging.vue. Two tabs split the
/// public charging network (a presentational discovery map + demo session +
/// nearby public stations) from the user's own chargers and their real control.
///
/// There is no live public-charging API yet — the session and station lists are
/// placeholders, exactly as on the web. The home tab is not: those are chargers
/// the user imported from a service they connected, and the cards beside them
/// drive a real charger through the Anker Solix control endpoints once a control
/// mode is picked in Settings → Integrations.

/// The tabs this page can show, in the order they appear when the account has no
/// arrangement of its own. Mirrors ALL_CHARGER_TABS in the web Charging.vue; the
/// server rejects nothing here, but a key outside this set is dropped on read.
const List<String> kChargerTabKeys = ["public", "home"];

/// The home tab's cards, in their default order. Controls first, because acting
/// on the charger is what the page is opened for; the connection below it,
/// because it is touched once and then left alone.
const List<String> kChargerCardKeys = ["control", "connection", "readings", "info"];

/// Which cards are folded away, kept on the device rather than on the profile:
/// it is a per-device reading habit, not an account setting. Keyed by card, so a
/// fold outlives switching charger.
const String _kCollapsedKey = "cc_chargingCollapsed";

/// The serial the control cards are pointed at. Remembered so the page comes
/// back up on the charger it was left on, the way the web does.
const String _kSerialKey = "cc_ctlSerial";

class ChargingScreen extends StatefulWidget {
  const ChargingScreen({super.key});
  @override
  State<ChargingScreen> createState() => _ChargingScreenState();
}

/// A public charging station, tone-coded by availability. Presentational demo
/// data — there is no public-network API to fill it from yet.
class _Station {
  final String id;
  final String name;
  final String dist;
  final int kw;
  final String conn;
  final int avail;
  final int total;
  final String price;
  final String tone; // "good" | "due" | "fault"
  const _Station(this.id, this.name, this.dist, this.kw, this.conn, this.avail,
      this.total, this.price, this.tone);
}

class _ChargingScreenState extends State<ChargingScreen> {
  /// The tab bar's arrangement, and the home cards'. Both live on the profile —
  /// they are layout choices that should follow the account, the way the garage
  /// order does — so they arrive with /me and are written back with it.
  List<String> _tabKeys = List.of(kChargerTabKeys);
  List<String> _cardKeys = List.of(kChargerCardKeys);
  String? _orderError;

  int _tab = 0;
  String _selectedStation = "sc";

  static const List<_Station> _stations = [
    _Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"),
    _Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"),
    _Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"),
  ];

  @override
  void initState() {
    super.initState();
    _loadArrangement();
  }

  /// The arrangement arrives with the profile. A failure is silent: the page is
  /// perfectly usable in its default order, and an error banner about it would
  /// be about nothing the user asked for.
  Future<void> _loadArrangement() async {
    try {
      final me = await apiClient.getMe();
      if (!mounted) return;
      setState(() {
        _tabKeys = arrangeKeys(kChargerTabKeys, me.chargerTabOrder);
        _cardKeys = arrangeKeys(kChargerCardKeys, me.chargerCardOrder);
      });
    } catch (_) {
      // Keep the defaults.
    }
  }

  Future<void> _arrange() async {
    final result = await showChargingArrangeSheet(
      context,
      tabs: _tabKeys,
      cards: _cardKeys,
    );
    if (result == null || !mounted) return;

    // The tab in view is followed through the rearrangement rather than left at
    // its index: moving a tab should not switch which one you are reading.
    final current = _tabKeys[_tab.clamp(0, _tabKeys.length - 1)];
    setState(() {
      _tabKeys = result.tabs;
      _cardKeys = result.cards;
      _tab = result.tabs.indexOf(current).clamp(0, result.tabs.length - 1);
      _orderError = null;
    });
    try {
      await apiClient.updateMe({
        "chargerTabOrder": result.tabs,
        "chargerCardOrder": result.cards,
      });
    } catch (e) {
      // It didn't stick; say so and put the stored arrangement back rather than
      // leaving the page showing an order the server does not have.
      if (!mounted) return;
      setState(() => _orderError = "$e");
      _loadArrangement();
    }
  }

  Color _toneColor(String tone) {
    switch (tone) {
      case "due":
        return DriverVault.warning;
      case "fault":
        return DriverVault.danger;
      default:
        return DriverVault.success;
    }
  }

  String _stationStatus(_Station s) => s.avail == 0
      ? t("charging.stations.full")
      : t("charging.stations.free", params: {"avail": s.avail, "total": s.total});

  Widget _tabBody(String key, {required bool active}) {
    switch (key) {
      case "home":
        return _HomeTab(key: const ValueKey("home"), cardKeys: _cardKeys, active: active);
      default:
        return _PublicTab(
          key: const ValueKey("public"),
          stations: _stations,
          selected: _selectedStation,
          onSelect: (id) => setState(() => _selectedStation = id),
          toneColor: _toneColor,
          statusFor: _stationStatus,
        );
    }
  }

  @override
  Widget build(BuildContext context) {
    final muted = DriverVault.muted(context);
    final index = _tab.clamp(0, _tabKeys.length - 1);
    return Scaffold(
      appBar: AppBar(
        title: Text(t("charging.title")),
        actions: [
          IconButton(
            icon: const Icon(Icons.swap_vert),
            tooltip: t("charging.arrange.title"),
            onPressed: _arrange,
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(12),
        children: [
          // Eyebrow + title header, matching the web layout.
          Text(t("charging.eyebrow"),
              style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
                  .copyWith(letterSpacing: 2.2)),
          const SizedBox(height: 2),
          Text(t("charging.title"),
              style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
          const SizedBox(height: 16),

          if (_orderError != null)
            Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: Text(_orderError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
            ),

          // Tabs: public network vs. the user's own chargers.
          _TabBar(
            tabs: [for (final key in _tabKeys) t("charging.tabs.$key")],
            index: index,
            onChanged: (i) => setState(() => _tab = i),
          ),
          const SizedBox(height: 16),

          // IndexedStack keeps each tab's state alive across switches (v-show parity).
          // Keyed, so rearranging the bar moves the tabs rather than rebuilding
          // whichever widget now sits at that index from scratch.
          IndexedStack(
            index: index,
            children: [
              for (var i = 0; i < _tabKeys.length; i++)
                _tabBody(_tabKeys[i], active: i == index),
            ],
          ),
          const SizedBox(height: 24),
        ],
      ),
    );
  }
}

// --- Arranging the page ------------------------------------------------------

/// What the arrange sheet returns: the two arrangements, saved together.
class ChargingArrangement {
  final List<String> tabs;
  final List<String> cards;
  const ChargingArrangement(this.tabs, this.cards);
}

/// Rearranges the charging tabs and the home tab's cards.
///
/// The web drags the tab bar and the card headings themselves; on a touch screen
/// the tab bar owns that gesture and a card heading is a fold toggle, so both
/// arrangements are made here instead, with a handle — exactly the trade the
/// car's view picker makes.
Future<ChargingArrangement?> showChargingArrangeSheet(
  BuildContext context, {
  required List<String> tabs,
  required List<String> cards,
}) {
  return showModalBottomSheet<ChargingArrangement>(
    context: context,
    isScrollControlled: true,
    builder: (_) => _ChargingArrangeSheet(tabs: tabs, cards: cards),
  );
}

class _ChargingArrangeSheet extends StatefulWidget {
  final List<String> tabs;
  final List<String> cards;
  const _ChargingArrangeSheet({required this.tabs, required this.cards});
  @override
  State<_ChargingArrangeSheet> createState() => _ChargingArrangeSheetState();
}

class _ChargingArrangeSheetState extends State<_ChargingArrangeSheet> {
  late final List<String> _tabs = List.of(widget.tabs);
  late final List<String> _cards = List.of(widget.cards);

  /// The card's own heading, so the sheet names each row the way the page does.
  String _cardLabel(String key) => switch (key) {
        "control" => t("charging.control.title"),
        "connection" => t("charging.control.connectionTitle"),
        "readings" => t("charging.modbus.title"),
        _ => t("charging.info.title"),
      };

  @override
  Widget build(BuildContext context) {
    final muted = DriverVault.muted(context);
    return Padding(
      padding: EdgeInsets.only(
        left: 16,
        right: 16,
        top: 16,
        bottom: DriverVault.sheetBottomInset(context),
      ),
      child: SingleChildScrollView(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(t("charging.arrange.title"),
                style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
            const SizedBox(height: 4),
            Text(t("car.viewPicker.reorderHint"), style: TextStyle(fontSize: 12, color: muted)),
            const SizedBox(height: 16),
            _section(t("charging.arrange.tabs")),
            _reorder(_tabs, (k) => t("charging.tabs.$k"),
                (from, to) => setState(() => _tabs.insert(to, _tabs.removeAt(from)))),
            const SizedBox(height: 16),
            _section(t("charging.arrange.cards")),
            _reorder(_cards, _cardLabel,
                (from, to) => setState(() => _cards.insert(to, _cards.removeAt(from)))),
            const SizedBox(height: 16),
            Row(children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: () => Navigator.pop(context),
                  child: Text(t("common.cancel")),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: FilledButton(
                  onPressed: () => Navigator.pop(context, ChargingArrangement(_tabs, _cards)),
                  child: Text(t("common.save")),
                ),
              ),
            ]),
          ],
        ),
      ),
    );
  }

  Widget _section(String label) => Padding(
        padding: const EdgeInsets.only(bottom: 6),
        child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
      );

  /// A short reorderable list of labelled rows, each dragged by its own handle.
  /// shrinkWrap because the sheet scrolls, not the list.
  Widget _reorder(List<String> keys, String Function(String) label,
      void Function(int, int) onMove) {
    return ReorderableListView(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      buildDefaultDragHandles: false,
      onReorderItem: onMove,
      children: [
        for (var i = 0; i < keys.length; i++)
          ListTile(
            key: ValueKey(keys[i]),
            dense: true,
            contentPadding: EdgeInsets.zero,
            title: Text(label(keys[i]), style: const TextStyle(fontSize: 14)),
            trailing: ReorderableDragStartListener(
              index: i,
              child: Padding(
                padding: const EdgeInsets.all(8),
                child: Icon(Icons.drag_handle, color: DriverVault.muted(context)),
              ),
            ),
          ),
      ],
    );
  }
}

// --- Shared: underline tab bar (matches Settings tabs) ----------------------

class _TabBar extends StatelessWidget {
  final List<String> tabs;
  final int index;
  final ValueChanged<int> onChanged;
  const _TabBar({required this.tabs, required this.index, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    final accent = Theme.of(context).colorScheme.primary;
    final muted = DriverVault.muted(context);
    final border = DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100;
    return Container(
      decoration: BoxDecoration(border: Border(bottom: BorderSide(color: border))),
      child: Row(
        children: [
          for (var i = 0; i < tabs.length; i++)
            GestureDetector(
              onTap: () => onChanged(i),
              behavior: HitTestBehavior.opaque,
              child: Container(
                padding: const EdgeInsets.only(right: 20, bottom: 10, top: 2),
                decoration: BoxDecoration(
                  border: Border(
                    bottom: BorderSide(
                      color: i == index ? accent : Colors.transparent,
                      width: 2,
                    ),
                  ),
                ),
                child: Text(
                  tabs[i],
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w600,
                    color: i == index ? (DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900) : muted,
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

/// A titled bordered card, matching the web's dh-card.
class _Card extends StatelessWidget {
  final EdgeInsetsGeometry padding;
  final Widget child;
  const _Card({this.padding = const EdgeInsets.all(16), required this.child});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 0,
      margin: EdgeInsets.zero,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(DriverVault.radiusControl),
        side: BorderSide(color: Theme.of(context).dividerColor),
      ),
      child: Padding(padding: padding, child: child),
    );
  }
}

/// A card whose heading folds it away. The column runs long once the readings
/// are in it, and which cards are folded is remembered per device.
class _FoldCard extends StatelessWidget {
  final String title;
  final bool open;
  final VoidCallback onToggle;

  /// Rides in the header, so it is still readable with the card folded — the
  /// connection badge is the one thing worth seeing either way.
  final Widget? badge;

  /// Sits beside the heading rather than inside the fold, so it can be pressed
  /// without unfolding anything.
  final Widget? action;

  final List<Widget> children;

  const _FoldCard({
    required this.title,
    required this.open,
    required this.onToggle,
    required this.children,
    this.badge,
    this.action,
  });

  @override
  Widget build(BuildContext context) {
    return _Card(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Expanded(
                child: GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onTap: onToggle,
                  child: Row(
                    children: [
                      Expanded(
                        child: Text(title,
                            style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
                      ),
                      if (badge != null) ...[badge!, const SizedBox(width: 8)],
                    ],
                  ),
                ),
              ),
              if (action != null) action!,
              // The fold arrow belongs hard against the right edge, past the
              // action, so a card with a header button is not the one whose
              // arrow sits somewhere else. It folds the card like the heading.
              GestureDetector(
                behavior: HitTestBehavior.opaque,
                onTap: onToggle,
                child: Icon(open ? Icons.expand_more : Icons.chevron_right,
                    size: 20, color: DriverVault.muted(context)),
              ),
            ],
          ),
          if (open) ...children,
        ],
      ),
    );
  }
}

/// The green/amber "Connected"/"Not connected" style pill.
class _StatusBadge extends StatelessWidget {
  final String label;
  final bool ok;
  const _StatusBadge({required this.label, required this.ok});

  @override
  Widget build(BuildContext context) {
    final dark = DriverVault.isDark(context);
    final fg = ok ? DriverVault.success : DriverVault.warning;
    final bg = ok
        ? (dark ? DriverVault.successSoftDark : DriverVault.successSoft)
        : (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft);
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
      decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)),
      child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)),
    );
  }
}

/// A neutral pill, for a fact rather than a state — which service a charger came
/// from, say.
class _NeutralBadge extends StatelessWidget {
  final String label;
  const _NeutralBadge({required this.label});

  @override
  Widget build(BuildContext context) {
    final dark = DriverVault.isDark(context);
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
      decoration: BoxDecoration(
        color: dark ? DriverVault.darkSunken : DriverVault.ink50,
        borderRadius: BorderRadius.circular(999),
      ),
      child: Text(label,
          style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w600,
              color: DriverVault.muted(context))),
    );
  }
}

// --- A station row, shared by both station lists -----------------------------

class _StationTile extends StatelessWidget {
  final _Station station;
  final bool selected;
  final VoidCallback onTap;
  final Color tone;
  final String status;
  const _StationTile({
    required this.station,
    required this.selected,
    required this.onTap,
    required this.tone,
    required this.status,
  });

  @override
  Widget build(BuildContext context) {
    final muted = DriverVault.muted(context);
    final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(DriverVault.radiusControl),
      child: Container(
        padding: const EdgeInsets.all(10),
        decoration: BoxDecoration(
          color: selected ? DriverVault.brandTint(context) : null,
          borderRadius: BorderRadius.circular(DriverVault.radiusControl),
        ),
        child: Row(
          children: [
            Container(
              width: 36,
              height: 36,
              decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)),
              child: Icon(Icons.bolt, size: 18, color: tone),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(station.name,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
                  const SizedBox(height: 2),
                  Text("${station.dist} · ${station.kw} kW · ${station.conn}",
                      style: DriverVault.mono(context, size: 11, color: muted)),
                ],
              ),
            ),
            const SizedBox(width: 8),
            Column(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                Text(status, style: DriverVault.mono(context, size: 11, weight: FontWeight.w500, color: tone)),
                const SizedBox(height: 2),
                Text(station.price, style: DriverVault.mono(context, size: 11, color: muted)),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

/// A section wrapper with an eyebrow header and a list of public stations.
class _StationList extends StatelessWidget {
  final String heading;
  final List<_Station> stations;
  final String selected;
  final ValueChanged<String> onSelect;
  final Color Function(String) toneColor;
  final String Function(_Station) statusFor;
  const _StationList({
    required this.heading,
    required this.stations,
    required this.selected,
    required this.onSelect,
    required this.toneColor,
    required this.statusFor,
  });

  @override
  Widget build(BuildContext context) {
    final muted = DriverVault.muted(context);
    return _Card(
      padding: const EdgeInsets.all(8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(6, 6, 6, 8),
            child: Text(
              "$heading · ${t("charging.stations.count", params: {"n": stations.length}, n: stations.length)}",
              style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
                  .copyWith(letterSpacing: 1.4),
            ),
          ),
          for (final s in stations)
            _StationTile(
              station: s,
              selected: selected == s.id,
              onTap: () => onSelect(s.id),
              tone: toneColor(s.tone),
              status: statusFor(s),
            ),
        ],
      ),
    );
  }
}

// --- Public tab: discovery map + demo session + public station list ----------

class _PublicTab extends StatefulWidget {
  final List<_Station> stations;
  final String selected;
  final ValueChanged<String> onSelect;
  final Color Function(String) toneColor;
  final String Function(_Station) statusFor;
  const _PublicTab({
    super.key,
    required this.stations,
    required this.selected,
    required this.onSelect,
    required this.toneColor,
    required this.statusFor,
  });

  @override
  State<_PublicTab> createState() => _PublicTabState();
}

class _PublicTabState extends State<_PublicTab> {
  // The demo session card is presentational, so its running/stopped state is
  // purely local.
  bool _charging = true;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        _MapPanel(
          stations: widget.stations,
          selected: widget.selected,
          onSelect: widget.onSelect,
          toneColor: widget.toneColor,
        ),
        const SizedBox(height: 16),
        _SessionCard(charging: _charging, onStop: () => setState(() => _charging = false)),
        const SizedBox(height: 16),
        _StationList(
          heading: t("charging.stations.heading"),
          stations: widget.stations,
          selected: widget.selected,
          onSelect: widget.onSelect,
          toneColor: widget.toneColor,
          statusFor: widget.statusFor,
        ),
      ],
    );
  }
}

/// A stylized discovery map with a "you are here" marker and charger pins,
/// standing in for a real map the way the web's CSS grid panel does.
class _MapPanel extends StatelessWidget {
  final List<_Station> stations;
  final String selected;
  final ValueChanged<String> onSelect;
  final Color Function(String) toneColor;
  const _MapPanel({
    required this.stations,
    required this.selected,
    required this.onSelect,
    required this.toneColor,
  });

  // Pin positions as fractions of the panel, mirroring the web's x/y percents.
  static const _pos = {
    "sc": Offset(0.47, 0.34),
    "evgo": Offset(0.26, 0.60),
    "cp": Offset(0.70, 0.64),
  };

  @override
  Widget build(BuildContext context) {
    final dark = DriverVault.isDark(context);
    final base = dark ? DriverVault.darkSunken : DriverVault.ink25;
    final grid = dark ? Colors.white.withValues(alpha: 0.04) : const Color(0x0B0F1E3D);
    return ClipRRect(
      borderRadius: BorderRadius.circular(DriverVault.radiusCard),
      child: Container(
        height: 320,
        decoration: BoxDecoration(
          color: base,
          border: Border.all(color: Theme.of(context).dividerColor),
          borderRadius: BorderRadius.circular(DriverVault.radiusCard),
        ),
        child: LayoutBuilder(
          builder: (context, c) {
            final w = c.maxWidth, h = c.maxHeight;
            return Stack(
              children: [
                // Grid lines.
                CustomPaint(size: Size(w, h), painter: _GridPainter(grid)),
                // Roads.
                Positioned(left: 0, right: 0, top: h * 0.52, child: Container(height: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))),
                Positioned(top: 0, bottom: 0, left: w * 0.58, child: Container(width: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))),
                // Legend chip.
                Positioned(
                  left: 12,
                  top: 12,
                  child: Container(
                    padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
                    decoration: BoxDecoration(
                      color: Theme.of(context).cardColor.withValues(alpha: 0.9),
                      borderRadius: BorderRadius.circular(999),
                      border: Border.all(color: Theme.of(context).dividerColor),
                    ),
                    child: Row(mainAxisSize: MainAxisSize.min, children: [
                      const Icon(Icons.map_outlined, size: 13, color: DriverVault.brand600),
                      const SizedBox(width: 5),
                      Text(t("charging.liveMap"),
                          style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
                              .copyWith(letterSpacing: 1.4)),
                    ]),
                  ),
                ),
                // You are here.
                Positioned(
                  left: w * 0.5 - 20,
                  top: h * 0.5 - 20,
                  child: Tooltip(
                    message: t("charging.youAreHere"),
                    child: Container(
                      width: 40,
                      height: 40,
                      decoration: BoxDecoration(color: DriverVault.brand600.withValues(alpha: 0.16), shape: BoxShape.circle),
                      child: Center(
                        child: Container(
                          width: 14,
                          height: 14,
                          decoration: BoxDecoration(
                            color: DriverVault.brand600,
                            shape: BoxShape.circle,
                            border: Border.all(color: Colors.white, width: 2),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
                // Charger pins.
                for (final s in stations)
                  if (_pos[s.id] != null)
                    Positioned(
                      left: w * _pos[s.id]!.dx - (selected == s.id ? 18 : 14),
                      top: h * _pos[s.id]!.dy - (selected == s.id ? 36 : 28),
                      child: _Pin(
                        selected: selected == s.id,
                        color: toneColor(s.tone),
                        label: "${s.avail}/${s.total} · ${s.kw} kW",
                        onTap: () => onSelect(s.id),
                      ),
                    ),
              ],
            );
          },
        ),
      ),
    );
  }
}

class _Pin extends StatelessWidget {
  final bool selected;
  final Color color;
  final String label;
  final VoidCallback onTap;
  const _Pin({required this.selected, required this.color, required this.label, required this.onTap});

  @override
  Widget build(BuildContext context) {
    final size = selected ? 36.0 : 28.0;
    return GestureDetector(
      onTap: onTap,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (selected)
            Container(
              margin: const EdgeInsets.only(bottom: 6),
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
              decoration: BoxDecoration(
                color: Theme.of(context).cardColor,
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Theme.of(context).dividerColor),
              ),
              child: Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600)),
            ),
          Container(
            width: size,
            height: size,
            decoration: BoxDecoration(
              color: color,
              border: Border.all(color: Colors.white, width: 2),
              borderRadius: const BorderRadius.only(
                topLeft: Radius.circular(999),
                topRight: Radius.circular(999),
                bottomRight: Radius.circular(999),
                bottomLeft: Radius.circular(3),
              ),
            ),
            child: Icon(Icons.bolt, color: Colors.white, size: selected ? 18 : 14),
          ),
        ],
      ),
    );
  }
}

/// Faint grid lines behind the map, echoing the web's repeating gradients.
class _GridPainter extends CustomPainter {
  final Color color;
  _GridPainter(this.color);
  @override
  void paint(Canvas canvas, Size size) {
    final p = Paint()
      ..color = color
      ..strokeWidth = 1;
    const step = 44.0;
    for (double x = 0; x < size.width; x += step) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
    }
    for (double y = 0; y < size.height; y += step) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
    }
  }

  @override
  bool shouldRepaint(covariant _GridPainter old) => old.color != color;
}

/// The presentational "Charging now" session card (dark brand background).
class _SessionCard extends StatelessWidget {
  final bool charging;
  final VoidCallback onStop;
  const _SessionCard({required this.charging, required this.onStop});

  @override
  Widget build(BuildContext context) {
    const from = 62, to = 80;
    final metrics = [
      (t("charging.session.rate"), "142 kW"),
      (t("charging.session.added"), "+29 km"),
      (t("charging.session.cost"), "5,80 €"),
      (t("charging.session.done"), "~18 min"),
    ];
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: DriverVault.brand900,
        borderRadius: BorderRadius.circular(DriverVault.radiusCard),
      ),
      child: charging
          ? Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(mainAxisSize: MainAxisSize.min, children: [
                  Container(width: 6, height: 6, decoration: const BoxDecoration(color: DriverVault.success, shape: BoxShape.circle)),
                  const SizedBox(width: 8),
                  Text("${t("charging.session.chargingNow")} · Model Y",
                      style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300)
                          .copyWith(letterSpacing: 1.4)),
                ]),
                const SizedBox(height: 12),
                RichText(
                  text: TextSpan(
                    style: DriverVault.mono(context, size: 34, weight: FontWeight.w500, color: Colors.white),
                    children: [
                      const TextSpan(text: "$from"),
                      TextSpan(
                        text: " % → $to%",
                        style: DriverVault.mono(context, size: 15, weight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.7)),
                      ),
                    ],
                  ),
                ),
                const SizedBox(height: 14),
                ClipRRect(
                  borderRadius: BorderRadius.circular(999),
                  child: LinearProgressIndicator(
                    value: from / 100,
                    minHeight: 8,
                    backgroundColor: Colors.white.withValues(alpha: 0.15),
                    valueColor: const AlwaysStoppedAnimation(DriverVault.brand400),
                  ),
                ),
                const SizedBox(height: 16),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    for (final m in metrics)
                      Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(m.$1,
                              style: DriverVault.mono(context, size: 9, weight: FontWeight.w500, color: DriverVault.brand300)
                                  .copyWith(letterSpacing: 1.2)),
                          const SizedBox(height: 2),
                          Text(m.$2, style: DriverVault.mono(context, size: 14, weight: FontWeight.w500, color: Colors.white)),
                        ],
                      ),
                  ],
                ),
                const SizedBox(height: 16),
                SizedBox(
                  width: double.infinity,
                  child: OutlinedButton(
                    onPressed: onStop,
                    style: OutlinedButton.styleFrom(
                      foregroundColor: Colors.white,
                      backgroundColor: Colors.white.withValues(alpha: 0.10),
                      side: BorderSide(color: Colors.white.withValues(alpha: 0.20)),
                    ),
                    child: Text(t("charging.session.stop")),
                  ),
                ),
              ],
            )
          : Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(mainAxisSize: MainAxisSize.min, children: [
                  Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.4), shape: BoxShape.circle)),
                  const SizedBox(width: 8),
                  Text(t("charging.session.idle"),
                      style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300)
                          .copyWith(letterSpacing: 1.4)),
                ]),
                const SizedBox(height: 12),
                Text(t("charging.session.idleHint"),
                    style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))),
              ],
            ),
    );
  }
}

// --- Home tab: the user's own chargers + real control ------------------------

/// The home half of the page: the cards that act on one charger, in whatever
/// order the account arranged them, and below them the list of chargers the user
/// has imported.
///
/// One State for the lot, mirroring the web view: the cards all read the same
/// charger, and picking one in the list is what points the control cards at it.
class _HomeTab extends StatefulWidget {
  final List<String> cardKeys;

  /// Whether this is the tab being read. Reachability costs a round trip to each
  /// connected service, so it is asked for when the question is actually being
  /// asked — not because an IndexedStack built both tabs.
  final bool active;

  const _HomeTab({super.key, required this.cardKeys, required this.active});
  @override
  State<_HomeTab> createState() => _HomeTabState();
}

class _HomeTabState extends State<_HomeTab> {
  // --- control ---
  final _serial = TextEditingController();
  final _host = TextEditingController();
  final _port = TextEditingController(text: "502");
  final _resetPassword = TextEditingController();

  /// The chargers on the linked Anker account. With them the serial is a pick
  /// from a list; without them (account not linked, or the cloud unreachable)
  /// the field stays a plain text box so a serial can still be typed by hand.
  List<AnkerCharger> _accountChargers = const [];

  /// Typing the serial rather than picking it. A dropdown can only show a serial
  /// it has an option for, so a remembered serial the account does not report —
  /// a charger imported before the account was linked, one the cloud is quiet
  /// about today — would render as a blank field with no way to read or fix it.
  /// Falling back to the text box shows the serial that is actually in force.
  bool _manualSerial = false;

  String _mode = "off"; // effective control mode (off | mqtt | modbus | own | proxy)
  AnkerControl? _ctl;
  String? _ctlError;
  String _busy = ""; // action name currently in flight
  double _limitAmps = 16;
  bool _resetPrompt = false;
  bool _savingAddress = false;

  // --- the user's own chargers ---
  List<HomeCharger> _homeChargers = const [];
  String? _homeError;

  /// The charger services this user could import from. Importing only makes
  /// sense once one is connected, so the button appears only then — the same
  /// rule the garage's import follows — and the list doubles as the id → label
  /// map the information card names a charger's origin with.
  List<ChargerProvider> _providers = const [];

  /// Live reachability, keyed by the provider's own id for the charger. The
  /// record says what a charger is; only the service it came from knows whether
  /// it is reachable right now, so that half is asked for separately and held
  /// beside the records rather than in them.
  Map<String, ProviderCharger> _live = const {};
  bool _liveLoading = false;
  bool _liveLoaded = false;

  String _selected = ""; // the charger the cards are about
  String _removing = "";

  /// The account's per-charger views, kept by serial: four endpoints answer only
  /// when a serial is named, so they are asked for the charger being looked at
  /// and remembered, rather than asked again on every glance.
  final Map<String, ChargerDetails> _details = {};
  String _detailsLoading = "";

  /// Which cards are folded, read once and written on every toggle.
  Set<String> _collapsed = <String>{};

  bool get _active => _mode != "off";
  bool get _connected => _ctl?.connected == true;
  bool get _isModbus => _mode == "modbus";

  /// The Anker cloud path: both ends meet at the broker the charger already
  /// talks to, so there is nothing to address and nothing to install — only an
  /// account to be signed in to. It is the mode for a charger somewhere else.
  bool get _isCloud => _mode == "mqtt";

  /// Those two read the charger itself and answer with its own snapshot, where
  /// OCPP answers with the session our CSMS holds. Anything both can report is
  /// named the same in both, so one set of readouts serves them; what each can
  /// be told still differs, which is what the buttons below branch on.
  bool get _readsDevice => _isModbus || _isCloud;

  /// The charger's snapshot, whichever transport read it.
  ChargerStatus? get _dev => _ctl?.device;
  bool get _canImport => _providers.any((p) => p.connected);

  bool get _serialInList =>
      _accountChargers.any((c) => c.sn == _serial.text.trim());

  /// A dropdown can only show a serial it has an option for, so one the account
  /// does not report — a charger picked from the list below, imported before the
  /// account was linked — falls back to the text box rather than rendering as a
  /// blank field with no way to read or fix it.
  bool get _pickingFromList =>
      _accountChargers.isNotEmpty && !_manualSerial && (_serial.text.trim().isEmpty || _serialInList);

  @override
  void initState() {
    super.initState();
    _init();
  }

  @override
  void didUpdateWidget(_HomeTab old) {
    super.didUpdateWidget(old);
    // Switching to this tab is the moment reachability is being asked about.
    if (widget.active && !old.active) _loadLive();
  }

  @override
  void dispose() {
    _serial.dispose();
    _host.dispose();
    _port.dispose();
    _resetPassword.dispose();
    super.dispose();
  }

  Future<void> _init() async {
    final prefs = await SharedPreferences.getInstance();
    if (!mounted) return;
    _serial.text = prefs.getString(_kSerialKey) ?? "";
    setState(() => _collapsed = (prefs.getStringList(_kCollapsedKey) ?? const []).toSet());

    await _loadHomeChargers();
    await _loadProviders();
    if (widget.active) await _loadLive();
    await _loadMode();
    if (_active) await _loadAccountChargers();
    await _refresh();
  }

  // --- folding ---

  bool _isOpen(String id) => !_collapsed.contains(id);

  Future<void> _toggleCard(String id) async {
    setState(() {
      if (_collapsed.contains(id)) {
        _collapsed.remove(id);
      } else {
        _collapsed.add(id);
      }
    });
    final prefs = await SharedPreferences.getInstance();
    await prefs.setStringList(_kCollapsedKey, _collapsed.toList());
  }

  // --- loading ---

  Future<void> _loadMode() async {
    try {
      final v = await apiClient.getAnkerSolix();
      if (mounted) setState(() => _mode = v.controlMode);
    } catch (_) {
      if (mounted) setState(() => _mode = "off");
    }
  }

  Future<void> _loadAccountChargers() async {
    try {
      final res = await apiClient.listAnkerChargers();
      if (!mounted) return;
      setState(() {
        _accountChargers = res.chargers;
        // Nothing chosen yet: start on the first charger the account reports.
        if (_serial.text.trim().isEmpty && _accountChargers.isNotEmpty) {
          _serial.text = _accountChargers.first.sn;
        }
        // Decided when the list arrives rather than on every keystroke:
        // recomputing it as the serial is typed would swap the text box for a
        // dropdown mid-word, the moment what had been typed happened to match.
        _manualSerial = _accountChargers.isNotEmpty && !_serialInList;
      });
    } catch (_) {
      if (mounted) setState(() => _accountChargers = const []);
    }
  }

  Future<void> _loadHomeChargers() async {
    try {
      final list = await apiClient.listHomeChargers();
      if (!mounted) return;
      setState(() {
        _homeChargers = list;
        _homeError = null;
        if (_selected.isEmpty && list.isNotEmpty) _selected = list.first.id;
      });
    } catch (e) {
      if (mounted) setState(() => _homeError = "$e");
    }
  }

  Future<void> _loadProviders() async {
    try {
      final list = await apiClient.listChargerProviders();
      if (mounted) setState(() => _providers = list);
    } catch (_) {
      if (mounted) setState(() => _providers = const []);
    }
  }

  /// Asking costs a round trip to each connected service, so it happens when the
  /// home tab is first built — the moment the question is being asked — and on
  /// demand after that.
  Future<void> _loadLive({bool force = false}) async {
    final connected = _providers.where((p) => p.connected).toList();
    if (_liveLoading || connected.isEmpty || !mounted) return;
    if (_liveLoaded && !force) return; // switching tabs is not a new question
    setState(() => _liveLoading = true);
    final live = <String, ProviderCharger>{};
    await Future.wait(connected.map((p) async {
      try {
        final res = await apiClient.listProviderChargers(p.id);
        for (final c in res.chargers) {
          live[c.id] = c;
        }
      } catch (_) {
        // A service that will not answer leaves its chargers unknown rather
        // than offline — this page cannot tell those two apart.
      }
    }));
    if (!mounted) return;
    setState(() {
      _live = live;
      _liveLoaded = true;
      _liveLoading = false;
    });
    // The card's other half: the views that answer per charger rather than per
    // account. Refreshing the card refreshes both.
    _loadDetails(force: force);
  }

  /// The per-charger views for the charger on screen. A view the account cannot
  /// read is not an error to put on the page — the rows above still say
  /// everything the inventory knew.
  Future<void> _loadDetails({bool force = false}) async {
    final c = _selectedCharger;
    final sn = c == null
        ? ""
        : (c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial);
    if (sn.isEmpty || c!.provider != "anker-solix") return;
    if (_detailsLoading == sn || (_details.containsKey(sn) && !force)) return;
    _detailsLoading = sn;
    try {
      final res = await apiClient.getAnkerChargerDetails(sn);
      if (mounted) setState(() => _details[sn] = res);
    } catch (_) {
      // Left absent rather than shown as a failure.
    } finally {
      _detailsLoading = "";
    }
  }

  /// The views to draw for the charger on screen, in the order the account
  /// answered them.
  List<ChargerDetailView> _detailViews(HomeCharger c) {
    final sn = c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial;
    return _details[sn]?.views ?? const [];
  }

  Future<void> _refresh() async {
    if (!mounted) return;
    final sn = _serial.text.trim();
    if (sn.isEmpty) {
      setState(() => _ctl = null);
      return;
    }
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_kSerialKey, sn);
    if (!mounted) return;
    setState(() => _ctlError = null);
    try {
      final c = await apiClient.getAnkerControl(sn);
      if (!mounted) return;
      setState(() {
        _ctl = c;
        // The saved address belongs to the charger, not to the form: switching
        // chargers has to bring its own address along rather than leave the
        // previous one sitting in the field, where saving would move it to the
        // wrong charger.
        _host.text = c.modbusHost;
        _port.text = "${c.modbusPort}";
      });
    } catch (e) {
      if (mounted) {
        setState(() {
          _ctlError = "$e";
          _ctl = null;
        });
      }
    }
  }

  // --- acting on the charger ---

  Future<void> _action(String action, [Map<String, dynamic> body = const {}]) async {
    final sn = _serial.text.trim();
    if (sn.isEmpty) return;
    setState(() {
      _busy = action;
      _ctlError = null;
    });
    try {
      await apiClient.ankerControlAction(sn, action, body);
      await _refresh();
    } catch (e) {
      if (mounted) setState(() => _ctlError = "$e");
    } finally {
      if (mounted) setState(() => _busy = "");
    }
  }

  Future<void> _confirmReset() async {
    if (_resetPassword.text.isEmpty) return;
    setState(() => _resetPrompt = false);
    await _action("reset", {"hard": false, "confirm": true, "password": _resetPassword.text});
    _resetPassword.clear();
  }

  /// One control for both directions, so a serial that is not on the account is
  /// never a dead end and the list is never the only option.
  void _toggleSerialEntry() {
    final toList = !_pickingFromList;
    setState(() => _manualSerial = !toList);
    // Returning to a list that does not hold this serial would blank the
    // dropdown again, which is the thing being fixed; land on one it does hold.
    if (toList && !_serialInList && _accountChargers.isNotEmpty) {
      _serial.text = _accountChargers.first.sn;
      _refresh();
    }
  }

  Future<void> _saveAddress() async {
    final sn = _serial.text.trim();
    final host = _host.text.trim();
    if (sn.isEmpty || host.isEmpty) return;
    setState(() {
      _savingAddress = true;
      _ctlError = null;
    });
    try {
      await apiClient.ankerControlAddress(sn, host, int.tryParse(_port.text.trim()) ?? 502);
      await _refresh();
    } catch (e) {
      if (mounted) setState(() => _ctlError = "$e");
    } finally {
      if (mounted) setState(() => _savingAddress = false);
    }
  }

  Future<void> _forgetAddress() async {
    final sn = _serial.text.trim();
    if (sn.isEmpty) return;
    if (!await _confirm(t("charging.control.forgetAddressConfirm"))) return;
    setState(() {
      _savingAddress = true;
      _ctlError = null;
    });
    try {
      await apiClient.ankerControlForgetAddress(sn);
      await _refresh();
    } catch (e) {
      if (mounted) setState(() => _ctlError = "$e");
    } finally {
      if (mounted) setState(() => _savingAddress = false);
    }
  }

  Future<bool> _confirm(String message) async {
    final ok = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        content: Text(message),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
          FilledButton(
            style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
            onPressed: () => Navigator.pop(ctx, true),
            child: Text(t("common.confirm")),
          ),
        ],
      ),
    );
    return ok == true;
  }

  // --- the user's own chargers ---

  /// The live half for one imported charger, or null when the service it came
  /// from says nothing about it (disconnected since the import, or a charger
  /// added by hand). Both services we speak to id a charger by its serial, so
  /// the serial is a sound fallback for a record imported before the provider
  /// link was stored.
  ProviderCharger? _liveFor(HomeCharger c) =>
      _live[c.providerChargerId] ?? _live[c.serial];

  /// The card shows one charger: whichever is picked in the list below it. Until
  /// something is picked that is the first one — a card that says nothing until
  /// tapped would be a worse first impression than the charger most people have
  /// only one of.
  HomeCharger? get _selectedCharger {
    for (final c in _homeChargers) {
      if (c.id == _selected) return c;
    }
    return _homeChargers.isEmpty ? null : _homeChargers.first;
  }

  /// A charger picked here becomes the one the control cards drive.
  void _select(HomeCharger c) {
    setState(() => _selected = c.id);
    _loadDetails();
    if (c.serial.isEmpty) return;
    _serial.text = c.serial;
    _refresh();
  }

  Future<void> _import() async {
    final charger = await showChargerImportSheet(context);
    if (charger == null || !mounted) return;
    setState(() => _homeChargers = [..._homeChargers, charger]);
    _select(charger);
    _loadLive(force: true);
  }

  Future<void> _remove(HomeCharger c) async {
    if (!await _confirm(t("charging.home.removeConfirm", params: {"name": c.name}))) return;
    setState(() {
      _removing = c.id;
      _homeError = null;
    });
    try {
      await apiClient.deleteHomeCharger(c.id);
      if (mounted) {
        setState(() => _homeChargers = _homeChargers.where((x) => x.id != c.id).toList());
      }
    } catch (e) {
      if (mounted) setState(() => _homeError = "$e");
    } finally {
      if (mounted) setState(() => _removing = "");
    }
  }

  String _providerLabel(String id) {
    for (final p in _providers) {
      if (p.id == id) return p.label;
    }
    return id;
  }

  /// The cloud's own slug for what the charger is doing (charging, standby, …),
  /// translated. The vocabulary is the integration's, so the wording lives with
  /// it in Settings rather than being said twice.
  String _stateLabel(String slug) {
    if (slug.isEmpty) return "";
    final key = "settings.integrations.states.$slug";
    final label = t(key);
    return label == key ? slug.replaceAll("_", " ") : label;
  }

  /// How the charger is registered on the account, in the service's own terms.
  String _sourcesLabel(List<String> sources) => sources.map((src) {
        final key = "charging.info.sourceNames.$src";
        final label = t(key);
        return label == key ? src : label;
      }).join(" · ");

  @override
  Widget build(BuildContext context) {
    final cards = <String, Widget?>{
      "control": _active && _connected ? _controlCard(context) : null,
      "connection": _active ? _connectionCard(context) : null,
      "readings": _readsDevice && _connected ? _readingsCard(context) : null,
      "info": _infoCard(context),
    };

    return Column(
      children: [
        for (final key in widget.cardKeys)
          if (cards[key] != null) ...[cards[key]!, const SizedBox(height: 16)],
        _chargerListCard(context),
      ],
    );
  }

  // --- the cards -------------------------------------------------------------

  /// Acting on the charger. It appears only once there is a connection to act
  /// over, which the card below it is where you set up.
  Widget _controlCard(BuildContext context) {
    final muted = DriverVault.muted(context);
    return _FoldCard(
      title: t("charging.control.title"),
      open: _isOpen("control"),
      onToggle: () => _toggleCard("control"),
      children: [
        const SizedBox(height: 12),
        Row(children: [
          Expanded(child: _MetricTile(value: _ctl!.statusLabel, label: t("charging.control.status"))),
          const SizedBox(width: 8),
          Expanded(
            child: _MetricTile(
              value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh",
              label: t("charging.control.meter"),
            ),
          ),
        ]),
        // A charger told to start can sit in "preparing" for a good while, and
        // these say why: it is waiting for a plug, or counting down a start
        // delay. Only the cloud transport can see them.
        if (_countdown("plugCountdownSeconds") != null ||
            _countdown("startCountdownSeconds") != null) ...[
          const SizedBox(height: 8),
          Row(children: [
            if (_countdown("plugCountdownSeconds") != null)
              Expanded(
                child: _MetricTile(
                  value: _countdown("plugCountdownSeconds")!,
                  label: t("charging.modbus.plugCountdown"),
                ),
              ),
            if (_countdown("plugCountdownSeconds") != null &&
                _countdown("startCountdownSeconds") != null)
              const SizedBox(width: 8),
            if (_countdown("startCountdownSeconds") != null)
              Expanded(
                child: _MetricTile(
                  value: _countdown("startCountdownSeconds")!,
                  label: t("charging.modbus.startCountdown"),
                ),
              ),
          ]),
        ],
        const SizedBox(height: 12),
        Row(children: [
          Expanded(
            child: FilledButton(
              onPressed: _busy == "start" ? null : () => _action("start"),
              child: Text(t("charging.control.start")),
            ),
          ),
          const SizedBox(width: 8),
          Expanded(
            child: OutlinedButton(
              onPressed: _busy == "stop" ? null : () => _action("stop"),
              child: Text(t("charging.control.stop")),
            ),
          ),
        ]),
        const SizedBox(height: 12),
        Row(children: [
          Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)),
          const Spacer(),
          Text("${_limitAmps.round()} A",
              style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)),
        ]),
        Slider(
          value: _limitAmps,
          min: 6,
          max: 32,
          divisions: 26,
          label: "${_limitAmps.round()} A",
          onChanged: (v) => setState(() => _limitAmps = v),
        ),
        Row(children: [
          Expanded(
            child: OutlinedButton(
              onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}),
              child: Text(t("charging.control.applyLimit")),
            ),
          ),
          // Clearing a limit is an OCPP command. Both of the transports that
          // talk to the charger itself take an explicit ceiling, so there is
          // nothing for them to clear to.
          if (!_readsDevice) ...[
            const SizedBox(width: 8),
            Expanded(
              child: OutlinedButton(
                onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"),
                child: Text(t("charging.control.clearLimit")),
              ),
            ),
          ],
        ]),

        // Boost lasts for the current session only, and is a command the
        // charger itself takes — over the register map or over the cloud, but
        // never over OCPP.
        if (_readsDevice) ...[
          const SizedBox(height: 12),
          SizedBox(
            width: double.infinity,
            child: OutlinedButton(
              onPressed: _busy == "boost" ? null : () => _action("boost", {"on": true}),
              child: Text(t("charging.control.boost")),
            ),
          ),
        ],

        // Skipping a start delay is only offered while one is running, and only
        // the cloud transport knows that it is.
        if (_dev?.modeOptions.contains("skip_delay") ?? false) ...[
          const SizedBox(height: 12),
          SizedBox(
            width: double.infinity,
            child: OutlinedButton(
              onPressed: _busy == "skip-delay" ? null : () => _action("skip-delay"),
              child: Text(t("charging.control.skipDelay")),
            ),
          ),
        ],

        // Rebooting the charger: an OCPP reset, or the cloud's own restart
        // message, which is the way to reach a charger that is on neither a CSMS
        // nor the LAN. No register does it, so Modbus is the one mode without
        // the button.
        if (!_readsDevice || _isCloud) ...[
          const SizedBox(height: 12),
          if (!_resetPrompt)
            SizedBox(
              width: double.infinity,
              child: OutlinedButton(
                onPressed: _busy == "reset"
                    ? null
                    : () => setState(() {
                          _ctlError = null;
                          _resetPassword.clear();
                          _resetPrompt = true;
                        }),
                child: Text(t("charging.control.reset")),
              ),
            )
          else
            // Step-up: a destructive reset requires re-entering the password.
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
                borderRadius: BorderRadius.circular(DriverVault.radiusControl),
                border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(t("charging.control.resetConfirm"),
                      style: const TextStyle(
                          fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)),
                  // Over the cloud there is nothing to confirm it with: the
                  // charger that would answer is the one rebooting.
                  if (_isCloud) ...[
                    const SizedBox(height: 4),
                    Text(t("charging.control.restartCloudHint"),
                        style: const TextStyle(fontSize: 11, color: DriverVault.danger)),
                  ],
                  const SizedBox(height: 8),
                  TextField(
                    controller: _resetPassword,
                    obscureText: true,
                    decoration: InputDecoration(
                      border: const OutlineInputBorder(),
                      isDense: true,
                      hintText: t("charging.control.resetPassword"),
                    ),
                    onSubmitted: (_) => _confirmReset(),
                  ),
                  const SizedBox(height: 8),
                  Row(children: [
                    Expanded(
                      child: OutlinedButton(
                        onPressed: () => setState(() {
                          _resetPrompt = false;
                          _resetPassword.clear();
                        }),
                        child: Text(t("common.cancel")),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: FilledButton(
                        style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
                        onPressed: _busy == "reset" ? null : _confirmReset,
                        child: Text(t("charging.control.reset")),
                      ),
                    ),
                  ]),
                ],
              ),
            ),
        ],
      ],
    );
  }

  /// Which charger, and how to reach it. Below the controls: it is touched once
  /// and then left alone.
  Widget _connectionCard(BuildContext context) {
    final muted = DriverVault.muted(context);
    final sn = _serial.text.trim();
    return _FoldCard(
      title: t("charging.control.connectionTitle"),
      open: _isOpen("connection"),
      onToggle: () => _toggleCard("connection"),
      // Whether the charger is reachable is the one thing worth seeing with the
      // card folded, so the badge rides in the header.
      badge: _StatusBadge(
        label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"),
        ok: _connected,
      ),
      children: [
        const SizedBox(height: 12),
        Row(children: [
          Expanded(
            child: _pickingFromList
                ? DropdownButtonFormField<String>(
                    // initialValue is read once, so the field is rebuilt when the
                    // serial changes from elsewhere — picking a charger below.
                    key: ValueKey(sn),
                    initialValue: _serialInList ? sn : null,
                    isExpanded: true,
                    decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
                    items: [
                      for (final c in _accountChargers)
                        DropdownMenuItem(
                          value: c.sn,
                          child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn,
                              overflow: TextOverflow.ellipsis),
                        ),
                    ],
                    onChanged: (picked) {
                      if (picked == null) return;
                      setState(() => _serial.text = picked);
                      _refresh();
                    },
                  )
                : TextField(
                    controller: _serial,
                    autocorrect: false,
                    decoration: InputDecoration(
                      border: const OutlineInputBorder(),
                      isDense: true,
                      hintText: t("charging.control.serialPlaceholder"),
                    ),
                    onSubmitted: (_) => _refresh(),
                  ),
          ),
          const SizedBox(width: 8),
          OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))),
        ]),

        // Only worth offering when there is a list to switch to or from.
        if (_accountChargers.isNotEmpty)
          Align(
            alignment: Alignment.centerLeft,
            child: TextButton(
              onPressed: _toggleSerialEntry,
              child: Text(
                _pickingFromList
                    ? t("charging.control.enterSerial")
                    : t("charging.control.pickSerial"),
                style: TextStyle(fontSize: 12, color: muted),
              ),
            ),
          ),

        // Modbus mode dials the charger, so it needs the charger's address on
        // this network rather than a token installed into the charger.
        if (_isModbus) ...[
          const SizedBox(height: 8),
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
              borderRadius: BorderRadius.circular(DriverVault.radiusControl),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(t("charging.control.address"),
                    style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
                const SizedBox(height: 8),
                Row(children: [
                  Expanded(
                    child: TextField(
                      controller: _host,
                      autocorrect: false,
                      keyboardType: TextInputType.url,
                      decoration: InputDecoration(
                        border: const OutlineInputBorder(),
                        isDense: true,
                        hintText: t("charging.control.addressPlaceholder"),
                      ),
                      onChanged: (_) => setState(() {}),
                      onSubmitted: (_) => _saveAddress(),
                    ),
                  ),
                  const SizedBox(width: 8),
                  SizedBox(
                    width: 92,
                    child: TextField(
                      controller: _port,
                      keyboardType: TextInputType.number,
                      decoration: InputDecoration(
                        border: const OutlineInputBorder(),
                        isDense: true,
                        labelText: t("charging.control.addressPort"),
                      ),
                      onChanged: (_) => setState(() {}),
                    ),
                  ),
                ]),
                const SizedBox(height: 6),
                Text(t("charging.control.addressHint"),
                    style: TextStyle(fontSize: 11, color: muted)),
                const SizedBox(height: 8),
                Row(children: [
                  Expanded(
                    child: FilledButton(
                      onPressed: _host.text.trim().isEmpty || !_addressChanged || _savingAddress
                          ? null
                          : _saveAddress,
                      child: Text(t("charging.control.saveAddress")),
                    ),
                  ),
                  if ((_ctl?.modbusHost ?? "").isNotEmpty) ...[
                    const SizedBox(width: 8),
                    OutlinedButton(
                      onPressed: _savingAddress ? null : _forgetAddress,
                      child: Text(t("charging.control.forgetAddress")),
                    ),
                  ],
                ]),
              ],
            ),
          ),
        ],

        // The cloud path has nothing to set up: the account is the credential,
        // and it is entered in Settings. What it does have to say is what the
        // charger reports about its own local side, which is the address the
        // Modbus mode would otherwise have to be told.
        if (_isCloud) ...[
          const SizedBox(height: 8),
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
              borderRadius: BorderRadius.circular(DriverVault.radiusControl),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(t("charging.control.cloudNote"), style: TextStyle(fontSize: 11, color: muted)),
                if (_localAccess != null) ...[
                  const SizedBox(height: 6),
                  Text(
                    t("charging.control.cloudLocalFound", params: {"address": _localAccess!}),
                    style: DriverVault.mono(context, size: 11),
                  ),
                ],
              ],
            ),
          ),
        ],

        // Why there is nothing to control yet. Reading the charger directly, the
        // server has already tried and says what it found, which beats a generic
        // hint.
        if (!_connected)
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: Text(
              (_ctl?.detail.isNotEmpty ?? false) ? _ctl!.detail : t(_hintKey),
              style: TextStyle(fontSize: 12, color: muted),
            ),
          ),
        if (_ctlError != null)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: Text(_ctlError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
          ),
      ],
    );
  }

  /// Saving is only worth offering when there is something to save that is not
  /// already saved, so the button goes quiet once the form matches the server.
  bool get _addressChanged =>
      _host.text.trim() != (_ctl?.modbusHost ?? "") ||
      (int.tryParse(_port.text.trim()) ?? 502) != (_ctl?.modbusPort ?? 502);

  /// A countdown the charger is running, as minutes and seconds. Only shown
  /// while it is actually running: zero is not a countdown, it is the absence
  /// of one.
  String? _countdown(String key) {
    final v = _dev?.integer(key);
    if (v == null || v <= 0) return null;
    final m = v ~/ 60;
    return m > 0 ? "$m min ${v % 60} s" : "$v s";
  }

  /// The charger's own local address, when the cloud snapshot carries it: the
  /// address the Modbus mode has to be given by hand, discovered instead.
  String? get _localAccess {
    final local = _dev?.local ?? const {};
    final host = local["host"];
    if (local["modbusEnabled"] != true || host is! String || host.isEmpty) return null;
    final port = local["port"];
    return port is num && port != 502 ? "$host:${port.toInt()}" : host;
  }

  /// Why nothing is connected yet, in the terms of the transport in force.
  String get _hintKey {
    if (_isModbus) return "charging.control.modbusHint";
    if (_isCloud) return "charging.control.cloudHint";
    return "charging.control.connectHint";
  }

  /// What the charger reports about itself, over whichever transport reads it.
  /// Its own card rather than a tail on the control one: control is for acting
  /// on the charger, and this is a long read that pushed the buttons off screen.
  Widget _readingsCard(BuildContext context) {
    final s = _dev;
    return _FoldCard(
      title: t("charging.modbus.title"),
      open: _isOpen("readings"),
      onToggle: () => _toggleCard("readings"),
      children: [
        const SizedBox(height: 12),
        if (s != null) ..._readingSections(context, s),
      ],
    );
  }

  List<Widget> _readingSections(BuildContext context, ChargerStatus s) {
    final phases = _phaseRows(s);
    final live = _liveRows(s);
    final settings = _settingRows(s);
    final local = _localRows(s);
    final device = _deviceRows(s);
    final extra = _extraRows(s);
    final alarms = _alarmWords(s);

    return [
      if (phases.isNotEmpty) ...[
        _ReadingSection(
          heading: t("charging.modbus.phases"),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // Three phases against five measurements is a matrix, not a list;
              // a table says that. It scrolls sideways rather than wrapping,
              // which would break the columns apart.
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Table(
                  defaultColumnWidth: const IntrinsicColumnWidth(),
                  children: [
                    TableRow(children: [
                      _th(context, t("charging.modbus.phase"), left: true),
                      _th(context, t("charging.modbus.voltage")),
                      _th(context, t("charging.modbus.current")),
                      _th(context, t("charging.modbus.activePower")),
                      if (_phasesHaveVA(s)) _th(context, t("charging.modbus.reactivePower")),
                      if (_phasesHaveVA(s)) _th(context, t("charging.modbus.apparentPower")),
                      if (_phasesHaveSessionWh(s)) _th(context, t("charging.modbus.sessionEnergy")),
                    ]),
                    for (final row in phases)
                      TableRow(children: [
                        _td(context, row[0], left: true),
                        for (var i = 1; i < row.length; i++) _td(context, row[i]),
                      ]),
                  ],
                ),
              ),
              if (_lineVoltages(s).isNotEmpty)
                Padding(
                  padding: const EdgeInsets.only(top: 8),
                  child: Text(
                    "${t("charging.modbus.lineToLine")}: ${_lineVoltages(s).join(" · ")}",
                    style: DriverVault.mono(context, size: 11, color: DriverVault.muted(context)),
                  ),
                ),
            ],
          ),
        ),
        const SizedBox(height: 8),
      ],
      if (live.isNotEmpty) ...[
        _ReadingSection(heading: t("charging.modbus.live"), child: _PairList(rows: live)),
        const SizedBox(height: 8),
      ],
      if (settings.isNotEmpty) ...[
        _ReadingSection(heading: t("charging.modbus.settings"), child: _PairList(rows: settings)),
        const SizedBox(height: 8),
      ],
      // The charger's own LAN side, which only the cloud transport can report:
      // whether its Modbus server is on, and where.
      if (local.isNotEmpty) ...[
        _ReadingSection(heading: t("charging.modbus.localTitle"), child: _PairList(rows: local)),
        const SizedBox(height: 8),
      ],
      if (device.isNotEmpty) ...[
        _ReadingSection(heading: t("charging.modbus.device"), child: _PairList(rows: device)),
        const SizedBox(height: 8),
      ],
      // What the charger sends beyond what we model. It appears only when there
      // is something in it, so a transport that reports nothing unnamed draws no
      // empty block.
      if (extra.isNotEmpty) ...[
        _ReadingSection(
          heading: t("charging.modbus.extra"),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              _PairList(rows: extra, breakLong: true),
              const SizedBox(height: 6),
              Text(t("charging.modbus.extraHint"),
                  style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
            ],
          ),
        ),
        const SizedBox(height: 8),
      ],
      // Alarms, when any word is non-zero. Which register is set is reportable
      // even though the bit list is not published.
      if (alarms.isNotEmpty)
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft,
            borderRadius: BorderRadius.circular(DriverVault.radiusControl),
            border: Border.all(color: DriverVault.warning.withValues(alpha: 0.4)),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(t("charging.modbus.alarms"),
                  style: DriverVault.mono(context,
                          size: 10, weight: FontWeight.w600, color: DriverVault.warning)
                      .copyWith(letterSpacing: 1.4)),
              const SizedBox(height: 6),
              Text(alarms.join("   "), style: DriverVault.mono(context, size: 12)),
              const SizedBox(height: 4),
              Text(t("charging.modbus.alarmsHint"),
                  style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
            ],
          ),
        ),
    ];
  }

  Widget _th(BuildContext context, String label, {bool left = false}) => Padding(
        padding: const EdgeInsets.fromLTRB(0, 2, 12, 4),
        child: Text(label,
            textAlign: left ? TextAlign.left : TextAlign.right,
            style: TextStyle(
                fontSize: 11, fontWeight: FontWeight.w500, color: DriverVault.muted(context))),
      );

  Widget _td(BuildContext context, String value, {bool left = false}) => Padding(
        padding: const EdgeInsets.fromLTRB(0, 3, 12, 3),
        child: Text(value,
            textAlign: left ? TextAlign.left : TextAlign.right,
            style: DriverVault.mono(context,
                size: 12, color: left ? DriverVault.muted(context) : null)),
      );

  // --- reading the snapshot ---
  //
  // The local path reports far more than the OCPP one: one poll carries
  // metering, the control settings and the charger's identity. Shown as a flat
  // list that would be a wall of forty numbers, so it is sorted the way it gets
  // asked about — what the charger is doing, what it is set to, and what it is.
  //
  // Pairs with no value drop out: a charger on older firmware, or one that
  // refused the control block, should show a shorter list rather than a column
  // of dashes.

  String? _unit(num? v, int digits, String unit) =>
      v == null ? null : "${v.toStringAsFixed(digits)} $unit";

  String? _yesNo(bool? v) => v == null ? null : (v ? t("common.yes") : t("common.no"));

  /// An enum the charger reports as a number, named through the catalogue so it
  /// translates; an unlisted value falls back to the number rather than a blank.
  String? _enumLabel(String prefix, int? v) {
    if (v == null) return null;
    final key = "charging.modbus.$prefix$v";
    final label = t(key);
    return label == key ? "$v" : label;
  }

  /// The operational mode the charger is in, in the integration's own
  /// vocabulary. Only the cloud transport derives it.
  String? _modeLabel(String slug) {
    if (slug.isEmpty) return null;
    final key = "settings.integrations.modes.$slug";
    final label = t(key);
    return label == key ? slug.replaceAll("_", " ") : label;
  }

  /// The charging window the charger's schedule allows, when it reports one.
  String? _window(ChargerStatus s) {
    final from = s.settings["weekStart"];
    final to = s.settings["weekEnd"];
    if (from is! String || to is! String || from.isEmpty || to.isEmpty) return null;
    return "$from–$to";
  }

  String? _sessionLength(int? seconds) {
    if (seconds == null) return null;
    final h = seconds ~/ 3600;
    final m = (seconds % 3600) ~/ 60;
    return h > 0 ? "$h h $m min" : "$m min";
  }

  List<(String, String)> _rows(List<(String, String?)> pairs) => [
        for (final (key, value) in pairs)
          if (value != null && value.isNotEmpty) (t("charging.modbus.$key"), value),
      ];

  List<(String, String)> _liveRows(ChargerStatus s) {
    final r1 = s.number("relay1TempC");
    final r2 = s.number("relay2TempC");
    return _rows([
      ("mode", _modeLabel(s.text("mode"))),
      ("power", _unit(s.number("powerTotal"), 0, "W")),
      ("sessionDuration", _sessionLength(s.integer("sessionSeconds"))),
      ("plugCountdown", _countdown("plugCountdownSeconds")),
      ("startCountdown", _countdown("startCountdownSeconds")),
      ("cpSignal", s.text("cpSignalDesc")),
      ("cpVoltage", _unit(s.number("cpVoltage"), 2, "V")),
      ("phaseMode", _enumLabel("phaseMode", s.integer("phaseMode"))),
      (
        "relayTemps",
        r1 != null && r2 != null
            ? "${r1.toStringAsFixed(1)} / ${r2.toStringAsFixed(1)} °C"
            : _unit(r1, 1, "°C")
      ),
      ("pwm", _yesNo(s.flag("pwmEnabled"))),
      ("plugged", _yesNo(s.flag("plugged"))),
      // Where the charge is coming from. The reference marks this reading
      // uncertain, and an unlisted value falls back to its number rather than
      // borrowing the name of a neighbouring one.
      ("chargingSource", _enumLabel("chargingSource", s.integer("chargingSource"))),
      ("chargingWindow", _sessionLength(s.integer("chargingWindowSeconds"))),
      ("sessionStarted", _unixTime(s.number("sessionStartedAt"))),
      ("orderId", _plain(s.integer("orderId"))),
      // Two streams, two clocks: telemetry flows only inside a trigger window,
      // the settings arrive with a command. A reading is worth as much as its
      // age, so each half says when it last spoke.
      ("liveStream", _yesNo(s.flag("live"))),
      ("telemetryAt", _stamp(s.text("telemetryAt"))),
      ("settingsAt", _stamp(s.text("settingsAt"))),
    ]);
  }

  String? _plain(int? v) => v == null ? null : "$v";

  /// A cloud timestamp, as the charger sends it: an ISO instant on the two
  /// stream clocks, whole unix seconds on the session's start.
  String? _stamp(String iso) =>
      iso.isEmpty ? null : formatDateTime(DateTime.tryParse(iso)?.toLocal());

  String? _unixTime(double? seconds) => seconds == null || seconds <= 0
      ? null
      : formatDateTime(DateTime.fromMillisecondsSinceEpoch((seconds * 1000).round()));

  List<(String, String)> _settingRows(ChargerStatus s) {
    final timeout = s.settingInt("timeoutSeconds");
    final led = s.integer("ledBrightness");
    return _rows([
      ("maxCurrentSet", _unit(s.setting("maxCurrentA"), 1, "A")),
      ("timeout", timeout == null ? null : "$timeout s"),
      ("phaseSetting", _enumLabel("phaseSet", s.settingInt("phaseSetting"))),
      // The transports name the same thing differently: the control block has a
      // boost register that was written, the cloud reports a boost that is
      // running. Either answers "is it boosting".
      ("boostSet", _yesNo(s.settingFlag("boost") ?? s.flag("boostMode"))),
      ("autoStart", _yesNo(s.settingFlag("autoStart"))),
      ("scheduleWindow", _window(s)),
      ("lastCommand", _enumLabel("command", s.settingInt("lastCommand"))),
      ("chargingMode", _enumLabel("chargingMode", s.integer("chargingMode"))),
      ("loadBalancing", _yesNo(s.flag("loadBalancing"))),
      ("solarBalancing", _yesNo(s.flag("solarBalancing"))),
      ("ledBrightness", led == null ? null : "$led %"),
      // The rest of the settings group, which only the cloud transport reports:
      // the register map has no address for any of them.
      ("plugLock", _yesNo(s.settingFlag("plugLock"))),
      ("autoRestart", _yesNo(s.settingFlag("autoRestart"))),
      ("randomDelay", _yesNo(s.settingFlag("randomDelay"))),
      ("scheduleEnabled", _yesNo(s.settingFlag("scheduleEnabled"))),
      ("scheduleMode", _enumLabel("scheduleMode", s.settingInt("scheduleMode"))),
      ("weekendWindow", _clockWindow(s, "weekendStart", "weekendEnd")),
      ("weekendMode", _enumLabel("weekendMode", s.settingInt("weekendMode"))),
      ("lightOff", _yesNo(s.settingFlag("lightOffSchedule"))),
      ("lightOffWindow", _clockWindow(s, "lightOffStart", "lightOffEnd")),
      ("mainBreakerLimit", _unit(s.setting("mainBreakerLimitA"), 0, "A")),
      ("solarChargeMode", _enumLabel("solarMode", s.settingInt("solarChargeMode"))),
      ("solarMinCurrent", _unit(s.setting("solarMinCurrentA"), 0, "A")),
      ("autoPhaseSwitching", _yesNo(s.settingFlag("autoPhaseSwitching"))),
      ("swipeUp", _enumLabel("gesture", s.integer("swipeUpMode"))),
      ("swipeDown", _enumLabel("gesture", s.integer("swipeDownMode"))),
      ("smartTouch", _enumLabel("touch", s.integer("smartTouchMode"))),
      // What the two balancing features watch. The reference has not pinned
      // down what the two modes and the flag select, so they are shown as the
      // numbers they are rather than under names that would imply we knew.
      ("loadBalanceMeter", s.text("loadBalanceMonitorSN")),
      ("loadBalanceMonitorMode", _plain(s.integer("loadBalanceMonitorMode"))),
      ("loadBalanceMeterFlag", _plain(s.integer("loadBalanceMeterFlag"))),
      ("solarMonitor", s.text("solarMonitorSN")),
      ("solarMonitoringMode", _plain(s.integer("solarMonitoringMode"))),
    ]);
  }

  /// One of the charger's four time windows, when both of its ends arrived.
  String? _clockWindow(ChargerStatus s, String fromKey, String toKey) {
    final from = s.settings[fromKey];
    final to = s.settings[toKey];
    if (from is! String || to is! String || from.isEmpty || to.isEmpty) return null;
    return "$from–$to";
  }

  /// What the charger says about its own LAN side. The cloud transport is the
  /// only one that can answer it — a charger whose Modbus server is off is a
  /// charger the Modbus transport cannot ask.
  List<(String, String)> _localRows(ChargerStatus s) {
    final local = s.local;
    final enabled = local["modbusEnabled"];
    final host = local["host"];
    final port = local["port"];
    final timeout = local["timeoutSeconds"];
    return _rows([
      ("modbusServer", _yesNo(enabled is bool ? enabled : null)),
      ("modbusAddress", host is String ? host : null),
      ("modbusPort", port == null ? null : "$port"),
      ("modbusTimeout", timeout == null ? null : "$timeout s"),
    ]);
  }

  /// The current range as the charger reports it: both bounds when it sends
  /// both, and the one it does send otherwise — "up to 32 A" is a fact, and
  /// dropping the row because the other half is missing hides it.
  String? _currentRange(int? min, int? max) {
    if (min != null && max != null) return "$min–$max A";
    if (max != null) return "${t("charging.modbus.upTo")} $max A";
    if (min != null) return "${t("charging.modbus.from")} $min A";
    return null;
  }

  List<(String, String)> _deviceRows(ChargerStatus s) {
    final product = s.integer("productNumber");
    final min = s.integer("minCurrentA");
    final max = s.integer("maxCurrentA");
    return _rows([
      ("model", s.text("model")),
      ("serial", s.text("serial")),
      ("firmware", s.text("firmware")),
      ("controllerVersion", s.text("controllerVersion")),
      ("hardware", s.text("hardware")),
      ("productNumber", product == null ? null : "$product"),
      ("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")),
      // Either bound on its own is still a bound worth reading; only a charger
      // that reports neither has nothing to say here.
      ("currentRange", _currentRange(min, max)),
      ("ocppLink", _enumLabel("ocpp", s.integer("ocppStatus"))),
      ("mqttLink", _enumLabel("mqtt", s.integer("mqttStatus"))),
    ]);
  }

  /// Everything the charger sends that none of the blocks above has a name for.
  /// Over the cloud a message carries more fields than this integration models,
  /// and some of them no published map names at all — those arrive keyed by the
  /// message and the field byte they came in ("0410.c9"). Shown raw: no unit, no
  /// scaling, no translation, because a factor and a label are part of a meaning
  /// Anker does not publish. Named leftovers sort first, the byte-keyed ones
  /// after, so the readable half is not buried under hex.
  List<(String, String)> _extraRows(ChargerStatus s) {
    final extra = s.extra;
    final keys = extra.keys.toList()
      ..sort((a, b) {
        final raw = (a.contains(".") ? 1 : 0) - (b.contains(".") ? 1 : 0);
        return raw != 0 ? raw : a.compareTo(b);
      });
    return [for (final key in keys) (key, "${extra[key]}")];
  }

  /// Reactive and apparent power are registers of their own, and the cloud has
  /// no message carrying either — so on that transport the two columns could
  /// only ever be three dashes each. They appear when the charger actually
  /// reports them, which also covers a Modbus charger that leaves them out.
  bool _phasesHaveVA(ChargerStatus s) => [1, 2, 3]
      .any((n) => s.raw["reactiveL$n"] != null || s.raw["apparentL$n"] != null);

  /// The per-phase matrix, or nothing at all when the charger reported none of
  /// it. A cell with no reading is a dash, so the columns still line up.
  List<List<String>> _phaseRows(ChargerStatus s) {
    String cell(double? v, int digits, String unit) =>
        v == null ? "—" : "${v.toStringAsFixed(digits)} $unit";
    final any = ["voltageL1", "currentL1", "powerL1"].any((k) => s.raw[k] != null);
    if (!any) return const [];
    final va = _phasesHaveVA(s);
    final wh = _phasesHaveSessionWh(s);
    return [
      for (final n in [1, 2, 3])
        [
          "L$n",
          cell(s.number("voltageL$n"), 1, "V"),
          cell(s.number("currentL$n"), 2, "A"),
          cell(s.number("powerL$n"), 0, "W"),
          if (va) cell(s.number("reactiveL$n"), 0, "var"),
          if (va) cell(s.number("apparentL$n"), 0, "VA"),
          if (wh) cell(s.number("sessionWhL$n"), 0, "Wh"),
        ],
    ];
  }

  /// The session's energy per phase is a cloud reading — a session is a cloud
  /// idea, and no register counts one — so the column joins the matrix when the
  /// charger reports it rather than standing as three dashes on Modbus.
  bool _phasesHaveSessionWh(ChargerStatus s) =>
      [1, 2, 3].any((n) => s.raw["sessionWhL$n"] != null);

  /// Line-to-line voltages only mean anything on a three-phase supply, so they
  /// are shown when the charger reports one rather than as three more zeroes.
  List<String> _lineVoltages(ChargerStatus s) {
    final pairs = [
      ("L1–L2", s.number("voltageL1L2")),
      ("L2–L3", s.number("voltageL2L3")),
      ("L3–L1", s.number("voltageL3L1")),
    ];
    return [
      for (final (name, v) in pairs)
        if (v != null && v > 10) "$name ${v.toStringAsFixed(1)} V",
    ];
  }

  List<String> _alarmWords(ChargerStatus s) {
    if (!s.alarm) return const [];
    final words = s.alarms;
    return [
      for (var i = 0; i < words.length; i++)
        if (words[i] != 0)
          "${t("charging.modbus.alarmWord", params: {"n": i + 1})} "
              "0x${words[i].toRadixString(16).toUpperCase().padLeft(4, "0")}",
    ];
  }

  /// Charger information: everything the record holds about the picked charger.
  /// It stands on its own — control needs a control mode, but what the charger
  /// *is* is known either way, so with control off this card is what fills the
  /// column instead of a bare hint.
  Widget _infoCard(BuildContext context) {
    final muted = DriverVault.muted(context);
    final charger = _selectedCharger;
    return _FoldCard(
      title: t("charging.info.title"),
      open: _isOpen("info"),
      onToggle: () => _toggleCard("info"),
      action: _homeChargers.isEmpty
          ? null
          : TextButton(
              onPressed: _liveLoading ? null : () => _loadLive(force: true),
              child: Text(_liveLoading ? t("common.loading") : t("charging.info.refresh")),
            ),
      children: [
        if (charger != null) ...[
          const SizedBox(height: 12),
          Row(children: [
            // The product shot the app shows for this model, when the account
            // sent one. Decorative: the name beside it says everything the
            // picture does.
            if ((_liveFor(charger)?.imageUrl ?? "").isNotEmpty) ...[
              Image.network(_liveFor(charger)!.imageUrl,
                  width: 28,
                  height: 28,
                  fit: BoxFit.contain,
                  errorBuilder: (_, __, ___) => const SizedBox.shrink()),
              const SizedBox(width: 8),
            ],
            Expanded(
              child: Text(charger.name,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
            ),
            // Reachability, said either way. A charger the service says nothing
            // about stays silent: unknown is not offline.
            if (_liveFor(charger)?.online == true) ...[
              _StatusBadge(label: t("charging.info.online"), ok: true),
              const SizedBox(width: 6),
            ] else if (_liveFor(charger)?.online == false) ...[
              _StatusBadge(label: t("charging.info.offline"), ok: false),
              const SizedBox(width: 6),
            ],
            if (charger.provider.isNotEmpty)
              _NeutralBadge(label: _providerLabel(charger.provider)),
          ]),
          const SizedBox(height: 8),
          // A box per group, the way the readings card draws its own: one list
          // of everything the service knows is harder to find a field in than
          // several short ones under headings.
          for (final (heading, rows) in _infoGroups(charger)) ...[
            _ReadingSection(heading: heading, child: _PairList(rows: rows, breakLong: true)),
            const SizedBox(height: 8),
          ],
          // The rest of what the service knows, in the service's own words. It
          // appears only when there is something in it, so a charger the cloud
          // says nothing more about stays quiet.
          if (_attrRows(charger).isNotEmpty) ...[
            _ReadingSection(
              heading: t("charging.info.rawTitle"),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _PairList(rows: _attrRows(charger), breakLong: true),
                  const SizedBox(height: 6),
                  Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)),
                ],
              ),
            ),
            const SizedBox(height: 8),
          ],
          // The views that answer per charger rather than per account. Each says
          // what it knows, why it could not be read — an account that is not the
          // charger's owner cannot read the cards, which is a fact about the
          // account rather than a failure — or that it answered with nothing,
          // which is equally an answer: a standalone charger has no station
          // record and no site.
          for (final view in _detailViews(charger)) ...[
            _ReadingSection(
              heading: t("charging.info.views.${view.id}"),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  if (view.error.isNotEmpty)
                    Text(view.error, style: TextStyle(fontSize: 11, color: muted))
                  else if (view.rows.isEmpty)
                    Text(t("charging.info.viewEmpty"), style: TextStyle(fontSize: 11, color: muted))
                  else
                    _PairList(rows: view.rows, breakLong: true),
                  if (view.note.isNotEmpty) ...[
                    const SizedBox(height: 6),
                    Text(view.note,
                        style: DriverVault.mono(context, size: 11, color: muted)),
                  ],
                ],
              ),
            ),
            const SizedBox(height: 8),
          ],
        ],
        if (_homeChargers.isEmpty)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: Text(t("charging.info.empty"), style: TextStyle(fontSize: 12, color: muted)),
          ),
        // Control mode off: say why the card above is missing, here, where there
        // is now something to read it against.
        if (!_active)
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: Text(t("charging.stations.noControlHint"),
                style: TextStyle(fontSize: 12, color: muted)),
          ),
      ],
    );
  }

  /// Everything the card can say about one charger, in groups: what the box is,
  /// what it is doing, how it is connected, and how it sits on the account. Each
  /// group gets a box of its own, the way the readings card draws its groups.
  /// Every row is drawn every time, a field nothing supplied included: which
  /// fields a charger has an answer for is itself worth seeing, and a row that
  /// comes and goes with the data makes two chargers impossible to read against
  /// each other. Nothing to say is said with a dash.
  List<(String, List<(String, String)>)> _infoGroups(HomeCharger c) {
    final live = _liveFor(c);
    final groups = <(String, List<(String, String)>)>[
      ("device", [
        ("vendor", c.vendor),
        ("model", c.model),
        ("firmware", live?.firmware ?? ""),
        ("serial", c.serial),
        ("power", c.powerKw > 0 ? "${c.powerKw} kW" : ""),
        ("connector", c.connector),
      ]),
      ("status", [
        ("state", _stateLabel(live?.status ?? "")),
        // Relayed as the service words it — the unit is upstream's, so putting
        // one on it here would be inventing it.
        ("chargePower", live?.power ?? ""),
        ("ocpp", live?.ocppLabel ?? ""),
      ]),
      // The box on the wall, as opposed to the charging: the networks it is on.
      ("network", [
        ("wifiName", live?.wifiName ?? ""),
        ("wifiMac", live?.wifiMac ?? ""),
        ("signal", live?.wifiRssi == null ? "" : "${live!.wifiRssi} dBm"),
        ("bleMac", live?.bleMac ?? ""),
        ("relatedBy", (live?.relatedBy ?? const []).join(" · ")),
      ]),
      // Where it thinks it is, and how the account came to know it.
      ("account", [
        ("site", c.siteName.isNotEmpty ? c.siteName : (live?.siteName ?? "")),
        ("siteId", live?.siteId ?? ""),
        ("sources", _sourcesLabel(live?.sources ?? const [])),
        ("timeZone", live?.timeZone ?? ""),
        (
          "linked",
          live?.linkedAt == null
              ? ""
              : formatDateTime(
                  DateTime.fromMillisecondsSinceEpoch((live!.linkedAt! * 1000).round()))
        ),
        ("providerId", c.providerChargerId),
        ("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())),
      ]),
    ];
    return [
      for (final (id, pairs) in groups)
        (
          t("charging.info.groups.$id"),
          [
            for (final (key, value) in pairs)
              (t("charging.info.$key"), value.isEmpty ? "—" : value),
          ],
        ),
    ];
  }

  /// Everything else the service said about this charger, under its own field
  /// names. The rows above are the ones DriverVault has a name for; these are
  /// the remainder — the service documents none of them, so its own key is the
  /// only honest label, and renaming one here would be inventing a meaning for
  /// it. Sorted so the same charger reads the same way on every refresh.
  List<(String, String)> _attrRows(HomeCharger c) {
    final attrs = _liveFor(c)?.attrs ?? const <String, String>{};
    final keys = attrs.keys.toList()..sort();
    return [for (final key in keys) (key, attrs[key]!)];
  }

  /// The user's own chargers, and the import that fills the list.
  Widget _chargerListCard(BuildContext context) {
    final muted = DriverVault.muted(context);
    return _Card(
      padding: const EdgeInsets.all(8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(6, 6, 6, 4),
            child: Row(children: [
              Expanded(
                child: Text(
                  "${t("charging.stations.homeHeading")} · "
                  "${t("charging.home.count", params: {"n": _homeChargers.length}, n: _homeChargers.length)}",
                  style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
                      .copyWith(letterSpacing: 1.4),
                ),
              ),
              if (_canImport)
                TextButton(onPressed: _import, child: Text(t("charging.home.import"))),
            ]),
          ),
          for (final c in _homeChargers) _chargerRow(context, c),

          // Nothing imported yet: say what this list is for and offer the import.
          if (_homeChargers.isEmpty)
            Padding(
              padding: const EdgeInsets.fromLTRB(6, 4, 6, 8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(t("charging.home.empty"), style: TextStyle(fontSize: 13, color: muted)),
                  const SizedBox(height: 10),
                  if (_canImport)
                    SizedBox(
                      width: double.infinity,
                      child: FilledButton(
                        onPressed: _import,
                        child: Text(t("charging.home.import")),
                      ),
                    )
                  else
                    Text(t("charging.home.connectFirst"),
                        style: TextStyle(fontSize: 12, color: muted)),
                ],
              ),
            ),

          if (_homeError != null)
            Padding(
              padding: const EdgeInsets.fromLTRB(6, 4, 6, 8),
              child: Text(_homeError!,
                  style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
            ),
        ],
      ),
    );
  }

  Widget _chargerRow(BuildContext context, HomeCharger c) {
    final muted = DriverVault.muted(context);
    final selected = _selectedCharger?.id == c.id;
    final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
    // The colour of the list icon: the same green and amber the badges in the
    // information card use, so the list can be read at a glance without opening
    // anything. A charger the service says nothing about stays muted — unknown
    // is not offline, and a green bolt for it would be a claim.
    final online = _liveFor(c)?.online;
    final tone = online == true
        ? DriverVault.success
        : online == false
            ? DriverVault.warning
            : muted;
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 2),
      child: Row(
        children: [
          Expanded(
            child: InkWell(
              onTap: () => _select(c),
              borderRadius: BorderRadius.circular(DriverVault.radiusControl),
              child: Container(
                padding: const EdgeInsets.all(10),
                decoration: BoxDecoration(
                  color: selected ? DriverVault.brandTint(context) : null,
                  borderRadius: BorderRadius.circular(DriverVault.radiusControl),
                ),
                child: Row(children: [
                  Container(
                    width: 36,
                    height: 36,
                    decoration:
                        BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)),
                    child: Icon(Icons.bolt, size: 18, color: tone),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(c.name,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                            style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
                        if (c.subtitle.isNotEmpty) ...[
                          const SizedBox(height: 2),
                          Text(c.subtitle,
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                              style: DriverVault.mono(context, size: 11, color: muted)),
                        ],
                      ],
                    ),
                  ),
                ]),
              ),
            ),
          ),
          IconButton(
            icon: const Icon(Icons.close, size: 18),
            color: muted,
            tooltip: t("charging.home.remove"),
            onPressed: _removing == c.id ? null : () => _remove(c),
          ),
        ],
      ),
    );
  }
}

/// One block of the readings card: an eyebrow heading over a sunken panel.
class _ReadingSection extends StatelessWidget {
  final String heading;
  final Widget child;
  const _ReadingSection({required this.heading, required this.child});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
        borderRadius: BorderRadius.circular(DriverVault.radiusControl),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(heading,
              style: DriverVault.mono(context,
                      size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
                  .copyWith(letterSpacing: 1.4)),
          const SizedBox(height: 8),
          child,
        ],
      ),
    );
  }
}

/// Label/value rows: the label muted on the left, the value right-aligned in the
/// mono face the rest of the app reads numbers in.
class _PairList extends StatelessWidget {
  final List<(String, String)> rows;

  /// Let a long value wrap rather than clip. Serials and site ids are long
  /// enough to need it; a reading never is.
  final bool breakLong;

  const _PairList({required this.rows, this.breakLong = false});

  @override
  Widget build(BuildContext context) {
    final muted = DriverVault.muted(context);
    return Column(
      children: [
        for (final (label, value) in rows)
          Padding(
            padding: const EdgeInsets.only(bottom: 3),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(
                  flex: 4,
                  child: Text(label, style: TextStyle(fontSize: 11, color: muted)),
                ),
                const SizedBox(width: 10),
                Expanded(
                  flex: 5,
                  child: Text(
                    value,
                    textAlign: TextAlign.right,
                    maxLines: breakLong ? 3 : 1,
                    overflow: TextOverflow.ellipsis,
                    style: DriverVault.mono(context, size: 11),
                  ),
                ),
              ],
            ),
          ),
      ],
    );
  }
}

/// A small sunken metric tile (connector status / energy).
class _MetricTile extends StatelessWidget {
  final String value;
  final String label;
  const _MetricTile({required this.value, required this.label});

  @override
  Widget build(BuildContext context) {
    final muted = DriverVault.muted(context);
    final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(DriverVault.radiusControl)),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(value, style: DriverVault.mono(context, size: 14, weight: FontWeight.w600)),
          const SizedBox(height: 2),
          Text(label, style: TextStyle(fontSize: 11, color: muted)),
        ],
      ),
    );
  }
}
