// 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 'package:test/test.dart' hide isInstanceOf;

import 'common.dart';
import 'constants.dart';

/// Matches an [AndroidSemanticsNode].
///
/// Any properties which aren't supplied are ignored during the comparison,
/// with the exception of `isHeading`. The heading property is not available
/// on all versions of Android. If it is not available on the tested version,
/// it will be match whatever it is compared against.
///
/// This matcher is intended to compare the accessibility values generated by
/// the Android accessibility bridge, and not the semantics object created by
/// the Flutter framework.
Matcher hasAndroidSemantics({
  String? text,
  String? contentDescription,
  String? className,
  int? id,
  Rect? rect,
  Size? size,
  List<AndroidSemanticsAction>? actions,
  List<AndroidSemanticsAction>? ignoredActions,
  List<AndroidSemanticsNode>? children,
  bool? isChecked,
  bool? isCheckable,
  bool? isEditable,
  bool? isEnabled,
  bool? isFocusable,
  bool? isFocused,
  bool? isHeading,
  bool? isPassword,
  bool? isLongClickable,
}) {
  return _AndroidSemanticsMatcher(
    text: text,
    contentDescription: contentDescription,
    className: className,
    rect: rect,
    size: size,
    id: id,
    actions: actions,
    ignoredActions: ignoredActions,
    isChecked: isChecked,
    isCheckable: isCheckable,
    isEditable: isEditable,
    isEnabled: isEnabled,
    isFocusable: isFocusable,
    isFocused: isFocused,
    isHeading: isHeading,
    isPassword: isPassword,
    isLongClickable: isLongClickable,
  );
}

class _AndroidSemanticsMatcher extends Matcher {
  _AndroidSemanticsMatcher({
    this.text,
    this.contentDescription,
    this.className,
    this.id,
    this.actions,
    this.ignoredActions,
    this.rect,
    this.size,
    this.isChecked,
    this.isCheckable,
    this.isEnabled,
    this.isEditable,
    this.isFocusable,
    this.isFocused,
    this.isHeading,
    this.isPassword,
    this.isLongClickable,
  }) : assert(
         ignoredActions == null || actions != null,
         'actions must not be null if ignoredActions is not null',
       ),
       assert(ignoredActions == null || !actions!.any(ignoredActions.contains));

  final String? text;
  final String? className;
  final String? contentDescription;
  final int? id;
  final List<AndroidSemanticsAction>? actions;
  final List<AndroidSemanticsAction>? ignoredActions;
  final Rect? rect;
  final Size? size;
  final bool? isChecked;
  final bool? isCheckable;
  final bool? isEditable;
  final bool? isEnabled;
  final bool? isFocusable;
  final bool? isFocused;
  final bool? isHeading;
  final bool? isPassword;
  final bool? isLongClickable;

  @override
  Description describe(Description description) {
    description.add('AndroidSemanticsNode');
    if (text != null) {
      description.add(' with text: $text');
    }
    if (contentDescription != null) {
      description.add('with contentDescription $contentDescription');
    }
    if (className != null) {
      description.add(' with className: $className');
    }
    if (id != null) {
      description.add(' with id: $id');
    }
    if (actions != null) {
      description.add(' with actions: $actions');
    }
    if (ignoredActions != null) {
      description.add(' with ignoredActions: $ignoredActions');
    }
    if (rect != null) {
      description.add(' with rect: $rect');
    }
    if (size != null) {
      description.add(' with size: $size');
    }
    if (isChecked != null) {
      description.add(' with flag isChecked: $isChecked');
    }
    if (isEditable != null) {
      description.add(' with flag isEditable: $isEditable');
    }
    if (isEnabled != null) {
      description.add(' with flag isEnabled: $isEnabled');
    }
    if (isFocusable != null) {
      description.add(' with flag isFocusable: $isFocusable');
    }
    if (isFocused != null) {
      description.add(' with flag isFocused: $isFocused');
    }
    if (isHeading != null) {
      description.add(' with flag isHeading: $isHeading');
    }
    if (isPassword != null) {
      description.add(' with flag isPassword: $isPassword');
    }
    if (isLongClickable != null) {
      description.add(' with flag isLongClickable: $isLongClickable');
    }
    return description;
  }

  @override
  bool matches(covariant AndroidSemanticsNode item, Map<dynamic, dynamic> matchState) {
    if (text != null && text != item.text) {
      return _failWithMessage('Expected text: $text', matchState);
    }
    if (contentDescription != null && contentDescription != item.contentDescription) {
      return _failWithMessage('Expected contentDescription: $contentDescription', matchState);
    }
    if (className != null && className != item.className) {
      return _failWithMessage('Expected className: $className', matchState);
    }
    if (id != null && id != item.id) {
      return _failWithMessage('Expected id: $id', matchState);
    }
    if (rect != null && rect != item.getRect()) {
      return _failWithMessage('Expected rect: $rect', matchState);
    }
    if (size != null && size != item.getSize()) {
      return _failWithMessage('Expected size: $size', matchState);
    }
    if (actions != null) {
      final List<AndroidSemanticsAction> itemActions = item.getActions();
      if (ignoredActions != null) {
        itemActions.removeWhere(ignoredActions!.contains);
      }
      if (!unorderedEquals(actions!).matches(itemActions, matchState)) {
        final List<String> actionsString =
            actions!.map<String>((AndroidSemanticsAction action) => action.toString()).toList()
              ..sort();
        final List<String> itemActionsString =
            itemActions.map<String>((AndroidSemanticsAction action) => action.toString()).toList()
              ..sort();
        final Set<String> unexpectedInString = itemActionsString.toSet().difference(
          actionsString.toSet(),
        );
        final Set<String> missingInString = actionsString.toSet().difference(
          itemActionsString.toSet(),
        );
        if (missingInString.isEmpty && unexpectedInString.isEmpty) {
          return true;
        }
        return _failWithMessage(
          'Expected actions: $actionsString\nActual actions: $itemActionsString\nUnexpected: $unexpectedInString\nMissing: $missingInString',
          matchState,
        );
      }
    }
    if (isChecked != null && isChecked != item.isChecked) {
      return _failWithMessage('Expected isChecked: $isChecked', matchState);
    }
    if (isCheckable != null && isCheckable != item.isCheckable) {
      return _failWithMessage('Expected isCheckable: $isCheckable', matchState);
    }
    if (isEditable != null && isEditable != item.isEditable) {
      return _failWithMessage('Expected isEditable: $isEditable', matchState);
    }
    if (isEnabled != null && isEnabled != item.isEnabled) {
      return _failWithMessage('Expected isEnabled: $isEnabled', matchState);
    }
    if (isFocusable != null && isFocusable != item.isFocusable) {
      return _failWithMessage('Expected isFocusable: $isFocusable', matchState);
    }
    if (isFocused != null && isFocused != item.isFocused) {
      return _failWithMessage('Expected isFocused: $isFocused', matchState);
    }
    // Heading is not available in all Android versions, so match anything if it is not set by the platform
    if (isHeading != null && isHeading != item.isHeading && item.isHeading != null) {
      return _failWithMessage('Expected isHeading: $isHeading', matchState);
    }
    if (isPassword != null && isPassword != item.isPassword) {
      return _failWithMessage('Expected isPassword: $isPassword', matchState);
    }
    if (isLongClickable != null && isLongClickable != item.isLongClickable) {
      return _failWithMessage('Expected longClickable: $isLongClickable', matchState);
    }
    return true;
  }

  @override
  Description describeMismatch(
    dynamic item,
    Description mismatchDescription,
    Map<dynamic, dynamic> matchState,
    bool verbose,
  ) {
    final String? failure = matchState['failure'] as String?;
    return mismatchDescription.add(
      failure ?? 'hasAndroidSemantics matcher does not complete successfully',
    );
  }

  bool _failWithMessage(String value, Map<dynamic, dynamic> matchState) {
    matchState['failure'] = value;
    return false;
  }
}
