// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:ui' show SemanticsRole;

import 'package:collection/collection.dart';
import 'package:flutter/services.dart';

import 'actions.dart';
import 'basic.dart';
import 'binding.dart';
import 'focus_manager.dart';
import 'focus_traversal.dart';
import 'framework.dart';
import 'shortcuts.dart';

/// A group for radios.
///
/// This widget treats all radios, such as [RawRadio], [Radio], [CupertinoRadio]
/// in the sub tree with the same type T as a group. Radios with different types
/// are not included in the group.
///
/// This widget handles the group value for the radios in the subtree with the
/// same value type.
///
/// Using this widget also provides keyboard navigation and semantics for the
/// radio buttons that matches [APG](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
///
/// The keyboard behaviors are:
/// * Tab and Shift+Tab: moves focus into and out of radio group. When focus
///   moves into a radio group and a radio button is select, focus is set on
///   selected button. Otherwise, it focus the first radio button in reading
///   order.
/// * Space: toggle the selection on the focused radio button.
/// * Right and down arrow key: move selection to next radio button in the group
///   in reading order.
/// * Left and up arrow key: move selection to previous radio button in the
///   group in reading order.
///
/// Arrow keys will wrap around if it reach the first or last radio in the
/// group.
///
/// {@tool dartpad}
/// Here is an example of RadioGroup widget.
///
/// Try using tab, arrow keys, and space to see how the widget responds.
///
/// ** See code in examples/api/lib/widgets/radio_group/radio_group.0.dart **
/// {@end-tool}
class RadioGroup<T> extends StatefulWidget {
  /// Creates a radio group.
  ///
  /// The `groupValue` set the selection on a subtree radio with the same
  /// [RawRadio.value].
  ///
  /// The `onChanged` is called when the selection has changed in the subtree
  /// radios.
  const RadioGroup({super.key, this.groupValue, required this.onChanged, required this.child});

  /// The selected value under this radio group.
  ///
  /// [RawRadio] under this radio group where its [RawRadio.value] equals to this
  /// value will be selected.
  final T? groupValue;

  /// Called when selection has changed.
  ///
  /// The value can be null when unselect the [RawRadio] with
  /// [RawRadio.toggleable] set to true.
  final ValueChanged<T?> onChanged;

  /// {@macro flutter.widgets.ProxyWidget.child}
  final Widget child;

  /// Gets the [RadioGroupRegistry] from the above the context.
  ///
  /// This registers a dependencies on the context that it causes rebuild
  /// if [RadioGroupRegistry] has changed or its
  /// [RadioGroupRegistry.groupValue] has changed.
  static RadioGroupRegistry<T>? maybeOf<T>(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<_RadioGroupStateScope<T>>()?.state;
  }

  @override
  State<StatefulWidget> createState() => _RadioGroupState<T>();
}

class _RadioGroupState<T> extends State<RadioGroup<T>> implements RadioGroupRegistry<T> {
  late final Map<ShortcutActivator, Intent> _radioGroupShortcuts = <ShortcutActivator, Intent>{
    const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(_selectPreviousRadio),
    const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(_selectNextRadio),
    const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(_selectNextRadio),
    const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(_selectPreviousRadio),
    const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(_toggleFocusedRadio),
  };

  late final _RadioGroupShortcutManager<T> _radioGroupShortcutManager =
      _RadioGroupShortcutManager<T>(shortcuts: _radioGroupShortcuts, state: this);

  final Set<RadioClient<T>> _radios = <RadioClient<T>>{};

  bool _debugHasScheduledSingleSelectionCheck = false;

  /// Schedules a check for the next frame to verify that there is only one
  /// radio with the group value.
  bool _debugScheduleSingleSelectionCheck() {
    if (_debugHasScheduledSingleSelectionCheck) {
      return true;
    }
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _debugHasScheduledSingleSelectionCheck = false;
      if (!mounted || _debugCheckOnlySingleSelection()) {
        return;
      }
      throw FlutterError(
        "RadioGroupPolicy can't be used for a radio group that allows multiple selection.",
      );
    }, debugLabel: 'RadioGroup.singleSelectionCheck');
    _debugHasScheduledSingleSelectionCheck = true;
    return true;
  }

  bool _debugCheckOnlySingleSelection() {
    return _radios.where((RadioClient<T> radio) => radio.radioValue == groupValue).length < 2;
  }

  @override
  T? get groupValue => widget.groupValue;

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

  @override
  void registerClient(RadioClient<T> radio) {
    _radios.add(radio);
    assert(_debugScheduleSingleSelectionCheck());
  }

  @override
  void unregisterClient(RadioClient<T> radio) => _radios.remove(radio);

  void _toggleFocusedRadio() {
    final RadioClient<T>? radio = _radios.firstWhereOrNull(
      (RadioClient<T> radio) => radio.focusNode.hasFocus,
    );
    if (radio == null) {
      return;
    }
    if (radio.radioValue != widget.groupValue) {
      onChanged(radio.radioValue);
      return;
    }

    if (radio.tristate) {
      onChanged(null);
    }
  }

  @override
  ValueChanged<T?> get onChanged => widget.onChanged;

  void _selectNextRadio() => _selectRadioInDirection(true);

  void _selectPreviousRadio() => _selectRadioInDirection(false);

  void _selectRadioInDirection(bool forward) {
    if (_radios.length < 2) {
      return;
    }
    final FocusNode? currentFocus = _radios
        .firstWhereOrNull((RadioClient<T> radio) => radio.focusNode.hasFocus)
        ?.focusNode;
    if (currentFocus == null) {
      // The focused node is either a non interactive radio or other controls.
      return;
    }
    final List<FocusNode> sorted = ReadingOrderTraversalPolicy.sort(
      _radios
          .where((RadioClient<T> radio) => radio.enabled)
          .map<FocusNode>((RadioClient<T> radio) => radio.focusNode),
    ).toList();
    assert(sorted.isNotEmpty);
    final Iterable<FocusNode> nodesInEffectiveOrder = forward ? sorted : sorted.reversed;

    final Iterator<FocusNode> iterator = nodesInEffectiveOrder.iterator;
    FocusNode? nextFocus;
    while (iterator.moveNext()) {
      if (iterator.current == currentFocus) {
        if (iterator.moveNext()) {
          nextFocus = iterator.current;
        }
        break;
      }
    }
    // Current focus is at the end, the next focus should wrap around.
    nextFocus ??= nodesInEffectiveOrder.first;
    final RadioClient<T> radioToSelect = _radios.firstWhere(
      (RadioClient<T> radio) => radio.focusNode == nextFocus,
    );
    onChanged(radioToSelect.radioValue);
    nextFocus.requestFocus();
  }

  @override
  Widget build(BuildContext context) {
    assert(_debugScheduleSingleSelectionCheck());

    return Semantics(
      container: true,
      role: SemanticsRole.radioGroup,
      child: Shortcuts.manager(
        manager: _radioGroupShortcutManager,
        child: FocusTraversalGroup(
          policy: _SkipUnselectedRadioPolicy<T>(_radios, widget.groupValue),
          child: _RadioGroupStateScope<T>(
            state: this,
            groupValue: widget.groupValue,
            child: widget.child,
          ),
        ),
      ),
    );
  }
}

class _RadioGroupShortcutManager<T> extends ShortcutManager {
  _RadioGroupShortcutManager({required super.shortcuts, required this.state});

  final _RadioGroupState<T> state;

  @override
  KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
    final bool radioHasFocus = state._radios.any(
      (RadioClient<T> radio) => radio.focusNode.hasFocus,
    );
    if (!radioHasFocus) {
      // Ignore the event if no radio is focused. This prevents this handler
      // from unintentionally consuming an event meant for a non-radio widget
      // that currently has focus.
      return KeyEventResult.ignored;
    }
    return super.handleKeypress(context, event);
  }
}

class _RadioGroupStateScope<T> extends InheritedWidget {
  const _RadioGroupStateScope({required this.state, required this.groupValue, required super.child})
    : super();
  final _RadioGroupState<T> state;
  // Need to include group value to notify listener when group value changes.
  final T? groupValue;

  @override
  bool updateShouldNotify(covariant _RadioGroupStateScope<T> oldWidget) {
    return state != oldWidget.state || groupValue != oldWidget.groupValue;
  }
}

/// An abstract interface for registering a group of radios.
///
/// Use [registerClient] or [unregisterClient] to handle registrations of radios.
///
/// The registry manages the group value for the radios. The radio needs to call
/// [onChanged] to notify the group value needs to be changed.
abstract class RadioGroupRegistry<T> {
  /// The group value for the group.
  T? get groupValue;

  /// Registers a radio client.
  ///
  /// The subclass provides additional features, such as keyboard navigation
  /// for the registered clients.
  void registerClient(RadioClient<T> radio);

  /// Unregisters a radio client.
  void unregisterClient(RadioClient<T> radio);

  /// Notifies the registry that the a radio is selected or unselected.
  ValueChanged<T?> get onChanged;
}

/// A client for a [RadioGroupRegistry].
///
/// This is typically mixed with a [State].
///
/// To register to a [RadioGroupRegistry], assign the registry to [registry].
///
/// To unregister from previous [RadioGroupRegistry], either assign a different
/// value to [registry] or set it to null.
mixin RadioClient<T> {
  /// Whether this radio support toggles.
  ///
  /// Used by registry to provide additional feature such as keyboard support.
  bool get tristate;

  /// This value this radio represents.
  ///
  /// Used by registry to provide additional feature such as keyboard support.
  T get radioValue;

  /// Whether this radio is enabled.
  ///
  /// If false, the registry skips this client when handling keyboard
  /// navigation.
  bool get enabled;

  /// Focus node for this radio.
  ///
  /// Used by registry to provide additional feature such as keyboard support.
  FocusNode get focusNode;

  /// The [RadioGroupRegistry] this client register to.
  ///
  /// Setting this property automatically register to the new value and
  /// unregister the old value.
  ///
  /// This should set to null when dispose.
  RadioGroupRegistry<T>? get registry => _registry;
  RadioGroupRegistry<T>? _registry;
  set registry(RadioGroupRegistry<T>? newRegistry) {
    if (_registry != newRegistry) {
      _registry?.unregisterClient(this);
    }
    _registry = newRegistry;
    _registry?.registerClient(this);
  }
}

/// A traversal policy that is the same as [ReadingOrderTraversalPolicy] except
/// it skips nodes of unselected radio button if there is one selected radio
/// button.
///
/// If none of the radio is selected, this defaults to
/// [ReadingOrderTraversalPolicy] for all nodes.
///
/// This policy is to ensure when tabbing into a radio group, it will only focus
/// the current selected radio button and prevent focus from reaching unselected
/// ones.
class _SkipUnselectedRadioPolicy<T> extends ReadingOrderTraversalPolicy {
  _SkipUnselectedRadioPolicy(this.radios, this.groupValue);
  final Set<RadioClient<T>> radios;
  final T? groupValue;

  bool _radioSelected(RadioClient<T> radio) => radio.radioValue == groupValue;

  @override
  Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) {
    final Iterable<FocusNode> nodesInReadOrder = super.sortDescendants(descendants, currentNode);
    RadioClient<T>? selected = radios.firstWhereOrNull(_radioSelected);

    if (selected == null) {
      // None of the radio are selected. Select the first radio in read order.
      final radioFocusNodes = <FocusNode, RadioClient<T>>{};
      for (final RadioClient<T> radio in radios) {
        radioFocusNodes[radio.focusNode] = radio;
      }

      for (final node in nodesInReadOrder) {
        selected = radioFocusNodes[node];
        if (selected != null) {
          break;
        }
      }
    }

    if (selected == null) {
      // None of the radio is selected or focusable, defaults to reading order
      return nodesInReadOrder;
    }

    // Nodes that are not selected AND not currently focused, since we can't
    // remove the focused node from the sorted result.
    final Set<FocusNode> nodeToSkip = radios
        .where((RadioClient<T> radio) => selected != radio && radio.focusNode != currentNode)
        .map<FocusNode>((RadioClient<T> radio) => radio.focusNode)
        .toSet();
    final Iterable<FocusNode> skipsNonSelected = descendants.where(
      (FocusNode node) => !nodeToSkip.contains(node),
    );
    return super.sortDescendants(skipsNonSelected, currentNode);
  }
}
