import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';

import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart';
import '../utils/platform_detector.dart';
import 'app_icon.dart';
import 'clickable_cursor.dart';
import 'listenable_selector.dart';

bool _keyboardTextWarmedUp = false;

/// One-shot warm-up of the keyboard's text layout caches.
///
/// The first keyboard open lays out ~60 key labels in one frame; on low-end
/// TVs the first paragraph alone measured 130ms+ (cold font/shaping caches)
/// and the full first open ~315ms. Laying the keyboard's glyph set out once
/// during startup idle moves that cost off the first real open. Subsequent
/// calls are no-ops.
void warmUpTvVirtualKeyboardText(BuildContext context) {
  if (_keyboardTextWarmedUp || !PlatformDetector.isTV()) return;
  _keyboardTextWarmedUp = true;
  // Matches the key cap style (titleLarge w800, see _buildKey) at the sizes
  // the metrics clamp to; shaping caches are per font/size.
  final baseStyle = Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800);
  const samples = [
    'abcdefghijklmnopqrstuvwxyz',
    'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
    '0123456789 @#_/:=&-+()[]{}<>!?\'".,;*%\\^~|',
    'Space Del Line Shift Symbols Done Cancel Clear Search Next Go',
  ];
  for (final fontSize in const [18.0, 22.0]) {
    final style = baseStyle?.copyWith(fontSize: fontSize);
    for (final sample in samples) {
      final painter = TextPainter(
        text: TextSpan(text: sample, style: style),
        textDirection: TextDirection.ltr,
      )..layout();
      painter.dispose();
    }
  }
}

/// Handle to a TV virtual keyboard pushed by [showTvVirtualKeyboard].
class TvVirtualKeyboardHandle {
  TvVirtualKeyboardHandle._(this._navigator, this._route);

  final NavigatorState _navigator;
  final DialogRoute<void> _route;

  /// Completes when the keyboard leaves the navigator — submit, cancel,
  /// barrier/back dismiss, or [close].
  Future<void> get closed => _route.popped;

  /// Dismiss the keyboard if it is still up. No-ops once the route is gone
  /// or the navigator is being torn down.
  void close() {
    if (!_navigator.mounted) return;
    if (_route.isCurrent) {
      _navigator.pop();
    } else if (_route.isActive) {
      // removeRoute also completes route.popped, which backs [closed].
      _navigator.removeRoute(_route);
    }
  }
}

TvVirtualKeyboardHandle? showTvVirtualKeyboard({
  required BuildContext context,
  required TextEditingController controller,
  String? hintText,
  TextInputType? keyboardType,
  TextInputAction? textInputAction,
  List<TextInputFormatter>? inputFormatters,
  bool obscureText = false,
  int? maxLength,
  int? maxLines,
  ValueChanged<String>? onChanged,
  ValueChanged<String>? onSubmitted,
  VoidCallback? onAction,
}) {
  if (!PlatformDetector.isTV()) return null;

  // A hand-built DialogRoute instead of showDialog so the caller gets a
  // handle it can close when the owning field unmounts. Use the nearest
  // navigator so profile-owned keyboards are disposed with their profile
  // session.
  final navigator = Navigator.of(context);
  final route = DialogRoute<void>(
    context: context,
    barrierDismissible: true,
    barrierColor: Colors.black.withValues(alpha: 0.1),
    useSafeArea: false,
    themes: InheritedTheme.capture(from: context, to: navigator.context),
    traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop,
    builder: (context) => _TvVirtualKeyboardDialog(
      controller: controller,
      hintText: hintText,
      keyboardType: keyboardType,
      textInputAction: textInputAction,
      inputFormatters: inputFormatters,
      obscureText: obscureText,
      maxLength: maxLength,
      maxLines: maxLines,
      onChanged: onChanged,
      onSubmitted: onSubmitted,
      onAction: onAction,
    ),
  );
  unawaited(navigator.push(route));
  return TvVirtualKeyboardHandle._(navigator, route);
}

enum _TvKeyType { spacer, character, shift, symbols, space, newline, backspace, clear, cancel, done }

class _TvKey {
  final String label;
  final String value;
  final _TvKeyType type;
  final IconData? icon;

  const _TvKey.spacer() : label = '', value = '', type = _TvKeyType.spacer, icon = null;
  const _TvKey.character(this.value) : label = value, type = _TvKeyType.character, icon = null;
  const _TvKey.action(this.label, this.type, {this.icon}) : value = '';
}

typedef _TvKeyboardSelection = ({int row, int column});

class _TvVirtualKeyboardDialog extends StatefulWidget {
  final TextEditingController controller;
  final String? hintText;
  final TextInputType? keyboardType;
  final TextInputAction? textInputAction;
  final List<TextInputFormatter>? inputFormatters;
  final bool obscureText;
  final int? maxLength;
  final int? maxLines;
  final ValueChanged<String>? onChanged;
  final ValueChanged<String>? onSubmitted;
  final VoidCallback? onAction;

  const _TvVirtualKeyboardDialog({
    required this.controller,
    this.hintText,
    this.keyboardType,
    this.textInputAction,
    this.inputFormatters,
    this.obscureText = false,
    this.maxLength,
    this.maxLines,
    this.onChanged,
    this.onSubmitted,
    this.onAction,
  });

  @override
  State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState();
}

class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> {
  final _focusNode = FocusNode(debugLabel: 'TvVirtualKeyboard');
  final ValueNotifier<_TvKeyboardSelection> _selection = ValueNotifier((row: 0, column: 0));
  List<List<_TvKey>> _rows = const [];
  Locale? _rowsLocale;
  bool _shiftEnabled = false;
  bool _symbolsPage = false;

  int get _row => _selection.value.row;
  int get _column => _selection.value.column;
  int get _gridColumnCount => _isNumberKeyboard ? 3 : 12;

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

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final locale = Localizations.maybeLocaleOf(context);
    if (_rows.isNotEmpty && locale == _rowsLocale) return;
    _rowsLocale = locale;
    _refreshRows(resetSelection: _rows.isEmpty);
  }

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

  void _refreshRows({bool resetSelection = false}) {
    _rows = _symbolsPage ? _buildSymbolRows() : _buildMainRows();
    final current = _selection.value;
    final row = resetSelection ? 0 : current.row.clamp(0, _rows.length - 1);
    final column = resetSelection ? (_firstFocusableColumn(row) ?? 0) : _nearestFocusableColumn(row, current.column);
    _selection.value = (row: row, column: column);
  }

  bool get _isNumberKeyboard {
    final type = widget.keyboardType;
    return type?.index == TextInputType.number.index || type?.index == TextInputType.phone.index;
  }

  bool get _isMultiline {
    final type = widget.keyboardType;
    return type?.index == TextInputType.multiline.index || (widget.maxLines != null && widget.maxLines != 1);
  }

  List<List<_TvKey>> _buildMainRows() {
    if (_isNumberKeyboard) {
      return [
        _characters('123'),
        _characters('456'),
        _characters('789'),
        [
          _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded),
          const _TvKey.character('0'),
          const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded),
        ],
        [
          _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded),
          const _TvKey.character('.'),
          _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
        ],
      ];
    }

    final actionRow = [
      const _TvKey.action('Space', _TvKeyType.space, icon: Symbols.space_bar_rounded),
      const _TvKey.character('@'),
      const _TvKey.character('#'),
      const _TvKey.character('_'),
      const _TvKey.character('/'),
      const _TvKey.character(':'),
      const _TvKey.character('='),
      _isMultiline
          ? const _TvKey.action('Line', _TvKeyType.newline, icon: Symbols.keyboard_return_rounded)
          : const _TvKey.character('&'),
      _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded),
      _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded),
      _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
    ];

    return [
      [const _TvKey.spacer(), ..._characters('1234567890'), const _TvKey.spacer()],
      [const _TvKey.spacer(), ..._characters('qwertyuiop'), const _TvKey.spacer()],
      [const _TvKey.spacer(), ..._characters('asdfghjkl'), const _TvKey.character("'"), const _TvKey.spacer()],
      [
        const _TvKey.action('', _TvKeyType.symbols, icon: Symbols.functions_rounded),
        _TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift_rounded),
        ..._characters('zxcvbnm.-'),
        const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded),
      ],
      [const _TvKey.spacer(), ...actionRow],
    ];
  }

  List<List<_TvKey>> _buildSymbolRows() {
    return [
      [
        const _TvKey.action('ABC', _TvKeyType.symbols),
        ..._symbols(['!', '?', r'$', '%', '^', '*', '+', '=', '~']),
        const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded),
        const _TvKey.spacer(),
      ],
      [
        const _TvKey.spacer(),
        ..._symbols(['`', r'\', '|', ';', ':', '"', "'", '<', '>']),
        const _TvKey.spacer(),
        const _TvKey.spacer(),
      ],
      [
        const _TvKey.spacer(),
        ..._symbols(['[', ']', '{', '}', '(', ')', ',', '.', '-']),
        const _TvKey.spacer(),
        const _TvKey.spacer(),
      ],
      [
        const _TvKey.spacer(),
        const _TvKey.spacer(),
        const _TvKey.action('Space', _TvKeyType.space, icon: Symbols.space_bar_rounded),
        const _TvKey.character('@'),
        const _TvKey.character('#'),
        const _TvKey.character('_'),
        const _TvKey.character('/'),
        _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded),
        _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded),
        _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
        const _TvKey.spacer(),
        const _TvKey.spacer(),
      ],
    ];
  }

  List<_TvKey> _characters(String chars) {
    return chars
        .split('')
        .map((c) {
          final code = c.codeUnitAt(0);
          final shifted = _shiftEnabled && code >= 0x61 && code <= 0x7a ? c.toUpperCase() : c;
          return _TvKey.character(shifted);
        })
        .toList(growable: false);
  }

  List<_TvKey> _symbols(List<String> symbols) {
    return symbols.map((symbol) => _TvKey.character(symbol)).toList(growable: false);
  }

  String _doneLabel() {
    switch (widget.textInputAction) {
      case TextInputAction.search:
        return t.common.search;
      case TextInputAction.next:
        return t.companionRemote.remote.next;
      case TextInputAction.go:
        return t.common.submit;
      default:
        return t.common.ok;
    }
  }

  IconData _doneIcon() {
    switch (widget.textInputAction) {
      case TextInputAction.search:
        return Symbols.search_rounded;
      case TextInputAction.next:
        return Symbols.arrow_forward_rounded;
      case TextInputAction.go:
        return Symbols.keyboard_double_arrow_right_rounded;
      default:
        return Symbols.check_rounded;
    }
  }

  KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
    final key = event.logicalKey;

    if (key.isBackKey) {
      if (event is KeyUpEvent) Navigator.of(context).pop();
      return KeyEventResult.handled;
    }
    if (event.isTvSelectEvent) {
      return handleOneShotSelect(event, () => _activate(_rows[_row][_column]));
    }

    if (event is KeyDownEvent || event is KeyRepeatEvent) {
      if (_handlePhysicalKeyboardTextInput(event)) return KeyEventResult.handled;

      if (key == LogicalKeyboardKey.backspace) {
        _backspace();
        return KeyEventResult.handled;
      }
      if (key == LogicalKeyboardKey.delete) {
        _deleteForward();
        return KeyEventResult.handled;
      }

      if (event.isPhysicalKeyboardEnter) {
        if (_isMultiline) {
          _insert('\n');
        } else if (event is KeyDownEvent) {
          _submit();
        }
        return KeyEventResult.handled;
      }

      if (key.isUpKey) {
        _moveVertical(-1);
        return KeyEventResult.handled;
      }
      if (key.isDownKey) {
        _moveVertical(1);
        return KeyEventResult.handled;
      }
      if (key.isLeftKey) {
        _moveHorizontal(-1);
        return KeyEventResult.handled;
      }
      if (key.isRightKey) {
        _moveHorizontal(1);
        return KeyEventResult.handled;
      }

      final character = event.character;
      if (character != null && character.isNotEmpty && !key.isNavigationKey) {
        _insert(character);
        return KeyEventResult.handled;
      }
    }

    return KeyEventResult.ignored;
  }

  bool _handlePhysicalKeyboardTextInput(KeyEvent event) {
    if (!event.isPhysicalKeyboardEvent) return false;

    final key = event.logicalKey;
    if (key == LogicalKeyboardKey.backspace) {
      _backspace();
      _dismissForPhysicalKeyboardInput();
      return true;
    }
    if (key == LogicalKeyboardKey.delete) {
      _deleteForward();
      _dismissForPhysicalKeyboardInput();
      return true;
    }
    if (event.isPhysicalKeyboardEnter && _isMultiline) {
      _insert('\n');
      _dismissForPhysicalKeyboardInput();
      return true;
    }

    final character = event.character;
    if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) {
      _insert(character);
      _dismissForPhysicalKeyboardInput();
      return true;
    }
    return false;
  }

  bool _isControlCharacter(String text) {
    return text.runes.every((codeUnit) => codeUnit < 0x20 || codeUnit == 0x7f);
  }

  void _dismissForPhysicalKeyboardInput() {
    Navigator.of(context).pop();
  }

  void _moveHorizontal(int delta) {
    var nextColumn = _nextFocusableColumn(_row, _column + delta, delta);
    if (nextColumn == null) {
      final wrapStart = delta > 0 ? 0 : _rows[_row].length - 1;
      nextColumn = _nextFocusableColumn(_row, wrapStart, delta);
    }
    if (nextColumn == null) return;
    final column = nextColumn;
    _selection.value = (row: _row, column: column);
  }

  void _moveVertical(int delta) {
    final rows = _rows;
    final nextRow = (_row + delta) % rows.length;
    final nextColumn = _nearestFocusableColumn(nextRow, _column);
    _selection.value = (row: nextRow, column: nextColumn);
  }

  int? _firstFocusableColumn(int row) {
    final rows = _rows;
    if (row < 0 || row >= rows.length) return null;
    final index = rows[row].indexWhere(_isFocusableKey);
    return index == -1 ? null : index;
  }

  int? _nextFocusableColumn(int row, int column, int delta) {
    final rows = _rows;
    if (row < 0 || row >= rows.length) return null;
    for (var c = column; c >= 0 && c < rows[row].length; c += delta) {
      if (_isFocusableKey(rows[row][c])) return c;
    }
    return null;
  }

  int _nearestFocusableColumn(int row, int preferredColumn) {
    final rows = _rows;
    if (row < 0 || row >= rows.length) return preferredColumn;
    final keys = rows[row];
    if (preferredColumn >= 0 && preferredColumn < keys.length && _isFocusableKey(keys[preferredColumn])) {
      return preferredColumn;
    }
    for (var offset = 1; offset < keys.length; offset++) {
      final right = preferredColumn + offset;
      if (right >= 0 && right < keys.length && _isFocusableKey(keys[right])) return right;
      final left = preferredColumn - offset;
      if (left >= 0 && left < keys.length && _isFocusableKey(keys[left])) return left;
    }
    return _firstFocusableColumn(row) ?? 0;
  }

  bool _isFocusableKey(_TvKey key) => key.type != _TvKeyType.spacer;

  void _activate(_TvKey key) {
    switch (key.type) {
      case _TvKeyType.spacer:
        return;
      case _TvKeyType.character:
        _insert(key.value);
        return;
      case _TvKeyType.shift:
        setState(() {
          _shiftEnabled = !_shiftEnabled;
          _refreshRows();
        });
        return;
      case _TvKeyType.symbols:
        _toggleSymbolsPage();
        return;
      case _TvKeyType.space:
        _insert(' ');
        return;
      case _TvKeyType.newline:
        _insert('\n');
        return;
      case _TvKeyType.backspace:
        _backspace();
        return;
      case _TvKeyType.clear:
        _replace(TextEditingValue.empty);
        return;
      case _TvKeyType.cancel:
        Navigator.of(context).pop();
        return;
      case _TvKeyType.done:
        _submit();
        return;
    }
  }

  void _toggleSymbolsPage() {
    setState(() {
      _symbolsPage = !_symbolsPage;
      _refreshRows();
    });
  }

  void _submit() {
    final text = widget.controller.text;
    final onSubmitted = widget.onSubmitted;
    final onAction = widget.onAction;
    Navigator.of(context).pop();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (onSubmitted != null) {
        onSubmitted(text);
      } else {
        onAction?.call();
      }
    });
  }

  void _insert(String text) {
    final value = widget.controller.value;
    final (:start, :end) = _selectionRangeForEdit(value);
    final newText = value.text.replaceRange(start, end, text);
    _replace(
      value.copyWith(
        text: newText,
        selection: TextSelection.collapsed(offset: start + text.length),
        composing: TextRange.empty,
      ),
    );
  }

  void _backspace() {
    final value = widget.controller.value;
    final (:start, :end) = _selectionRangeForEdit(value);
    if (start == end && start == 0) return;

    final codeUnitRange = start == end ? TextRange(start: start - 1, end: start) : TextRange(start: start, end: end);
    final range = expandToGraphemeRange(value.text, codeUnitRange);
    if (range.isCollapsed) return;
    _replace(
      value.copyWith(
        text: value.text.replaceRange(range.start, range.end, ''),
        selection: TextSelection.collapsed(offset: range.start),
        composing: TextRange.empty,
      ),
    );
  }

  void _deleteForward() {
    final value = widget.controller.value;
    final (:start, :end) = _selectionRangeForEdit(value);
    if (start == end && start >= value.text.length) return;

    final codeUnitRange = start == end ? TextRange(start: start, end: start + 1) : TextRange(start: start, end: end);
    final range = expandToGraphemeRange(value.text, codeUnitRange);
    if (range.isCollapsed) return;
    _replace(
      value.copyWith(
        text: value.text.replaceRange(range.start, range.end, ''),
        selection: TextSelection.collapsed(offset: range.start),
        composing: TextRange.empty,
      ),
    );
  }

  ({int start, int end}) _selectionRangeForEdit(TextEditingValue value) {
    final selection = value.selection;
    if (!selection.isValid) return (start: value.text.length, end: value.text.length);
    if (selection.isCollapsed && selection.baseOffset == 0 && value.text.isNotEmpty) {
      return (start: value.text.length, end: value.text.length);
    }
    return (
      start: selection.start < selection.end ? selection.start : selection.end,
      end: selection.start > selection.end ? selection.start : selection.end,
    );
  }

  void _replace(TextEditingValue nextValue) {
    final previousValue = widget.controller.value;
    var formattedValue = nextValue;
    final maxLength = widget.maxLength;
    final formatters = [
      ...?widget.inputFormatters,
      if (maxLength != null && maxLength > 0) LengthLimitingTextInputFormatter(maxLength),
    ];
    for (final formatter in formatters) {
      formattedValue = formatter.formatEditUpdate(previousValue, formattedValue);
    }
    widget.controller.value = formattedValue;
    if (formattedValue.text != previousValue.text) {
      widget.onChanged?.call(formattedValue.text);
    }
  }

  @override
  Widget build(BuildContext context) {
    final media = MediaQuery.of(context);
    final metrics = _metricsFor(media.size);
    final colorScheme = Theme.of(context).colorScheme;

    return Dialog(
      key: const Key('tv_virtual_keyboard_dialog'),
      alignment: .bottomCenter,
      insetPadding: .only(
        left: metrics.edgeInset,
        right: metrics.edgeInset,
        top: media.padding.top + 48,
        bottom: media.padding.bottom + metrics.bottomInset,
      ),
      backgroundColor: Colors.transparent,
      elevation: 0,
      child: Focus(
        focusNode: _focusNode,
        onKeyEvent: _handleKey,
        child: Container(
          key: const Key('tv_virtual_keyboard_panel'),
          constraints: BoxConstraints(maxWidth: metrics.panelWidth),
          padding: .all(metrics.panelPadding),
          decoration: BoxDecoration(
            color: colorScheme.surface.withValues(alpha: 0.96),
            borderRadius: BorderRadius.circular(metrics.panelRadius),
          ),
          child: SizedBox(
            width: metrics.gridWidth,
            child: Column(
              mainAxisSize: .min,
              crossAxisAlignment: .stretch,
              children: [
                ValueListenableBuilder<TextEditingValue>(
                  valueListenable: widget.controller,
                  builder: (context, value, _) => _buildPreview(context, value.text, metrics),
                ),
                SizedBox(height: metrics.previewGap),
                for (var row = 0; row < _rows.length; row++) ...[
                  _buildRow(context, row, metrics),
                  if (row != _rows.length - 1) SizedBox(height: metrics.rowGap),
                ],
              ],
            ),
          ),
        ),
      ),
    );
  }

  _TvKeyboardMetrics _metricsFor(Size size) {
    final columns = _gridColumnCount;
    var keySize = (size.height * 0.061).clamp(36.0, 52.0).toDouble();
    var keyGap = (keySize * 0.09).clamp(3.0, 6.0).toDouble();
    var panelPadding = (keySize * 0.24).clamp(8.0, 14.0).toDouble();
    final edgeInset = (size.width * 0.035).clamp(20.0, 56.0).toDouble();

    final availableWidth = size.width - edgeInset * 2 - panelPadding * 2;
    final maxKeySize = (availableWidth - keyGap * (columns - 1)) / columns;
    final widthBoundKeySize = maxKeySize.clamp(24.0, 52.0).toDouble();
    if (keySize > widthBoundKeySize) keySize = widthBoundKeySize;
    keyGap = (keySize * 0.09).clamp(3.0, 6.0).toDouble();
    panelPadding = (keySize * 0.24).clamp(8.0, 14.0).toDouble();
    final gridWidth = keySize * columns + keyGap * (columns - 1);
    return _TvKeyboardMetrics(
      keySize: keySize,
      keyGap: keyGap,
      rowGap: keyGap,
      panelPadding: panelPadding,
      edgeInset: edgeInset,
      bottomInset: (size.height * 0.025).clamp(12.0, 28.0).toDouble(),
      gridWidth: gridWidth,
      panelWidth: gridWidth + panelPadding * 2,
      panelRadius: (keySize * 0.55).clamp(18.0, 28.0).toDouble(),
      keyRadius: (keySize * 0.28).clamp(10.0, 16.0).toDouble(),
      previewHeight: (_isMultiline ? keySize * 1.45 : keySize).clamp(40.0, 76.0).toDouble(),
      previewGap: (keySize * 0.18).clamp(6.0, 12.0).toDouble(),
      previewRadius: (keySize * 0.36).clamp(12.0, 18.0).toDouble(),
      iconSize: (keySize * 0.58).clamp(20.0, 30.0).toDouble(),
      keyFontSize: (keySize * 0.52).clamp(17.0, 26.0).toDouble(),
    );
  }

  Widget _buildPreview(BuildContext context, String text, _TvKeyboardMetrics metrics) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;
    final isEmpty = text.isEmpty;
    final displayText = widget.obscureText && !isEmpty ? List.filled(text.length, '*').join() : text;
    final previewText = isEmpty ? (widget.hintText ?? '') : displayText;
    final multiline = _isMultiline;
    final style = (multiline ? theme.textTheme.titleMedium : theme.textTheme.titleLarge)?.copyWith(
      color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
      fontSize: (metrics.keyFontSize * 1.08).clamp(16.0, 24.0).toDouble(),
    );

    return Container(
      height: metrics.previewHeight,
      alignment: .centerLeft,
      padding: .symmetric(horizontal: metrics.keySize * 0.3),
      decoration: BoxDecoration(
        color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
        borderRadius: BorderRadius.circular(metrics.previewRadius),
      ),
      child: multiline
          ? SingleChildScrollView(reverse: true, child: Text(previewText, maxLines: 3, style: style))
          : SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              reverse: true,
              child: Text(previewText, maxLines: 1, style: style),
            ),
    );
  }

  Widget _buildRow(BuildContext context, int row, _TvKeyboardMetrics metrics) {
    return Row(
      mainAxisAlignment: .center,
      children: [
        for (var column = 0; column < _rows[row].length; column++) ...[
          _buildKey(context, _rows[row][column], row, column, metrics),
          if (column != _rows[row].length - 1) SizedBox(width: metrics.keyGap),
        ],
      ],
    );
  }

  Widget _buildKey(BuildContext context, _TvKey key, int row, int column, _TvKeyboardMetrics metrics) {
    if (key.type == _TvKeyType.spacer) {
      return SizedBox(width: metrics.keySize, height: metrics.keySize);
    }

    return ListenableSelector<bool>(
      listenable: _selection,
      selector: () => _selection.value == (row: row, column: column),
      builder: (context, selected, _) {
        final colorScheme = Theme.of(context).colorScheme;
        final active = key.type == _TvKeyType.shift && _shiftEnabled;
        final background = selected
            ? colorScheme.primary
            : active
            ? colorScheme.secondaryContainer
            : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88);
        final foreground = selected
            ? colorScheme.onPrimary
            : active
            ? colorScheme.onSecondaryContainer
            : colorScheme.onSurface;

        return ClickableCursor(
          child: GestureDetector(
            onTap: () {
              _selection.value = (row: row, column: column);
              _activate(key);
            },
            child: AnimatedContainer(
              duration: const Duration(milliseconds: 120),
              width: metrics.keySize,
              height: metrics.keySize,
              alignment: .center,
              decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(metrics.keyRadius)),
              child: Padding(
                padding: .symmetric(horizontal: metrics.keySize * 0.04),
                child: _buildKeyContent(context, key, foreground, metrics),
              ),
            ),
          ),
        );
      },
    );
  }

  Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground, _TvKeyboardMetrics metrics) {
    final icon = key.icon;
    if (icon != null) {
      return AppIcon(
        icon,
        color: foreground,
        size: key.type == _TvKeyType.space ? metrics.iconSize * 1.12 : metrics.iconSize,
      );
    }

    return FittedBox(
      fit: BoxFit.scaleDown,
      child: Text(
        key.label,
        maxLines: 1,
        style: Theme.of(
          context,
        ).textTheme.titleLarge?.copyWith(color: foreground, fontSize: metrics.keyFontSize, fontWeight: .w800),
      ),
    );
  }
}

class _TvKeyboardMetrics {
  final double keySize;
  final double keyGap;
  final double rowGap;
  final double panelPadding;
  final double edgeInset;
  final double bottomInset;
  final double gridWidth;
  final double panelWidth;
  final double panelRadius;
  final double keyRadius;
  final double previewHeight;
  final double previewGap;
  final double previewRadius;
  final double iconSize;
  final double keyFontSize;

  const _TvKeyboardMetrics({
    required this.keySize,
    required this.keyGap,
    required this.rowGap,
    required this.panelPadding,
    required this.edgeInset,
    required this.bottomInset,
    required this.gridWidth,
    required this.panelWidth,
    required this.panelRadius,
    required this.keyRadius,
    required this.previewHeight,
    required this.previewGap,
    required this.previewRadius,
    required this.iconSize,
    required this.keyFontSize,
  });
}
