// 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:async';
import 'dart:convert';

import 'package:android_semantics_testing/android_semantics_testing.dart';
import 'package:android_semantics_testing/main.dart' as app;
import 'package:android_semantics_testing/test_constants.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

// The accessibility focus actions are added when a semantics node receives or
// lose accessibility focus. This test ignores these actions since it is hard to
// predict which node has the accessibility focus after a screen changes.
const List<AndroidSemanticsAction> ignoredAccessibilityFocusActions = <AndroidSemanticsAction>[
  AndroidSemanticsAction.accessibilityFocus,
  AndroidSemanticsAction.clearAccessibilityFocus,
];

const MethodChannel kSemanticsChannel = MethodChannel('semantics');

Future<void> setClipboard(String message) async {
  final Completer<void> completer = Completer<void>();
  Future<void> completeSetClipboard([Object? _]) async {
    await kSemanticsChannel.invokeMethod<dynamic>('setClipboard', <String, dynamic>{
      'message': message,
    });
    completer.complete();
  }
  if (SchedulerBinding.instance.hasScheduledFrame) {
    SchedulerBinding.instance.addPostFrameCallback(completeSetClipboard);
  } else {
    completeSetClipboard();
  }
  await completer.future;
}

Future<AndroidSemanticsNode> getSemantics(Finder finder, WidgetTester tester) async {
  final int id = tester.getSemantics(finder).id;
  final Completer<String> completer = Completer<String>();
  Future<void> completeSemantics([Object? _]) async {
    final dynamic result = await kSemanticsChannel.invokeMethod<dynamic>('getSemanticsNode', <String, dynamic>{
      'id': id,
    });
    completer.complete(json.encode(result));
  }
  if (SchedulerBinding.instance.hasScheduledFrame) {
    SchedulerBinding.instance.addPostFrameCallback(completeSemantics);
  } else {
    completeSemantics();
  }
  return AndroidSemanticsNode.deserialize(await completer.future);
}

Future<void> main() async {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('AccessibilityBridge', () {
    group('TextField', () {
      Future<void> prepareTextField(WidgetTester tester) async {
        app.main();
        await tester.pumpAndSettle();
        await tester.tap(find.text(textFieldRoute));
        await tester.pumpAndSettle();

        // The text selection menu and related semantics vary depending on if
        // the clipboard contents are pasteable. Copy some text into the
        // clipboard to make sure these tests always run with pasteable content
        // in the clipboard.
        // Ideally this should test the case where there is nothing on the
        // clipboard as well, but there is no reliable way to clear the
        // clipboard on Android devices.
        await setClipboard('Hello World');
        await tester.pumpAndSettle();
      }

      testWidgets('TextField has correct Android semantics', (WidgetTester tester) async {
        final Finder normalTextField = find.descendant(
          of: find.byKey(const ValueKey<String>(normalTextFieldKeyValue)),
          matching: find.byType(EditableText),
        );

        await prepareTextField(tester);
        expect(
          await getSemantics(normalTextField, tester),
          hasAndroidSemantics(
            className: AndroidClassName.editText,
            isEditable: true,
            isFocusable: true,
            isFocused: false,
            isPassword: false,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
            // We can't predict the a11y focus when the screen changes.
            ignoredActions: ignoredAccessibilityFocusActions,
          ),
        );
        await tester.tap(normalTextField);
        await tester.pumpAndSettle();

        expect(
          await getSemantics(normalTextField, tester),
          hasAndroidSemantics(
            className: AndroidClassName.editText,
            isFocusable: true,
            isFocused: true,
            isEditable: true,
            isPassword: false,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
              AndroidSemanticsAction.paste,
              AndroidSemanticsAction.setSelection,
              AndroidSemanticsAction.setText,
            ],
            // We can't predict the a11y focus when the screen changes.
            ignoredActions: ignoredAccessibilityFocusActions,
          ),
        );

        await tester.enterText(normalTextField, 'hello world');
        await tester.pumpAndSettle();

        expect(
          await getSemantics(normalTextField, tester),
          hasAndroidSemantics(
            text: 'hello world',
            className: AndroidClassName.editText,
            isFocusable: true,
            isFocused: true,
            isEditable: true,
            isPassword: false,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
              AndroidSemanticsAction.paste,
              AndroidSemanticsAction.setSelection,
              AndroidSemanticsAction.setText,
              AndroidSemanticsAction.previousAtMovementGranularity,
            ],
            // We can't predict the a11y focus when the screen changes.
            ignoredActions: ignoredAccessibilityFocusActions,
          ),
        );
      }, timeout: Timeout.none);

      testWidgets('password TextField has correct Android semantics', (WidgetTester tester) async {
        final Finder passwordTextField = find.descendant(
          of: find.byKey(const ValueKey<String>(passwordTextFieldKeyValue)),
          matching: find.byType(EditableText),
        );

        await prepareTextField(tester);
        expect(
          await getSemantics(passwordTextField, tester),
          hasAndroidSemantics(
            className: AndroidClassName.editText,
            isEditable: true,
            isFocusable: true,
            isFocused: false,
            isPassword: true,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
            // We can't predict the a11y focus when the screen changes.
            ignoredActions: ignoredAccessibilityFocusActions,
          ),
        );

        await tester.tap(passwordTextField);
        await tester.pumpAndSettle();

        expect(
          await getSemantics(passwordTextField, tester),
          hasAndroidSemantics(
            className: AndroidClassName.editText,
            isFocusable: true,
            isFocused: true,
            isEditable: true,
            isPassword: true,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
              AndroidSemanticsAction.paste,
              AndroidSemanticsAction.setSelection,
              AndroidSemanticsAction.setText,
            ],
            // We can't predict the a11y focus when the screen changes.
            ignoredActions: ignoredAccessibilityFocusActions,
          ),
        );

        await tester.enterText(passwordTextField, 'hello world');
        await tester.pumpAndSettle();

        expect(
          await getSemantics(passwordTextField, tester),
          hasAndroidSemantics(
            text: '\u{2022}' * ('hello world'.length),
            className: AndroidClassName.editText,
            isFocusable: true,
            isFocused: true,
            isEditable: true,
            isPassword: true,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
              AndroidSemanticsAction.paste,
              AndroidSemanticsAction.setSelection,
              AndroidSemanticsAction.setText,
              AndroidSemanticsAction.previousAtMovementGranularity,
            ],
            // We can't predict the a11y focus when the screen changes.
            ignoredActions: ignoredAccessibilityFocusActions,
          ),
        );
      }, timeout: Timeout.none);
    });

    group('SelectionControls', () {
      Future<void> prepareSelectionControls(WidgetTester tester) async {
        app.main();
        await tester.pumpAndSettle();
        await tester.tap(find.text(selectionControlsRoute));
        await tester.pumpAndSettle();
      }

      testWidgets('Checkbox has correct Android semantics', (WidgetTester tester) async {
        final Finder checkbox = find.byKey(const ValueKey<String>(checkboxKeyValue));
        final Finder disabledCheckbox = find.byKey(const ValueKey<String>(disabledCheckboxKeyValue));

        await prepareSelectionControls(tester);
        expect(
          await getSemantics(checkbox, tester),
          hasAndroidSemantics(
            className: AndroidClassName.checkBox,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await tester.tap(checkbox);
        await tester.pumpAndSettle();

        expect(
          await getSemantics(checkbox, tester),
          hasAndroidSemantics(
            className: AndroidClassName.checkBox,
            isChecked: true,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
        expect(
          await getSemantics(disabledCheckbox, tester),
          hasAndroidSemantics(
            className: AndroidClassName.checkBox,
            isCheckable: true,
            isEnabled: false,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: const <AndroidSemanticsAction>[],
          ),
        );
      }, timeout: Timeout.none);

      testWidgets('Radio has correct Android semantics', (WidgetTester tester) async {
        final Finder radio = find.byKey(const ValueKey<String>(radio2KeyValue));

        await prepareSelectionControls(tester);
        expect(
          await getSemantics(radio, tester),
          hasAndroidSemantics(
            className: AndroidClassName.radio,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await tester.tap(radio);
        await tester.pumpAndSettle();

        expect(
          await getSemantics(radio, tester),
          hasAndroidSemantics(
            className: AndroidClassName.radio,
            isChecked: true,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
      }, timeout: Timeout.none);

      testWidgets('Switch has correct Android semantics', (WidgetTester tester) async {
        final Finder switchFinder = find.byKey(const ValueKey<String>(switchKeyValue));

        await prepareSelectionControls(tester);
        expect(
          await getSemantics(switchFinder, tester),
          hasAndroidSemantics(
            className: AndroidClassName.toggleSwitch,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await tester.tap(switchFinder);
        await tester.pumpAndSettle();

        expect(
          await getSemantics(switchFinder, tester),
          hasAndroidSemantics(
            className: AndroidClassName.toggleSwitch,
            isChecked: true,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
      }, timeout: Timeout.none);

      // Regression test for https://github.com/flutter/flutter/issues/20820.
      testWidgets('Switch can be labeled', (WidgetTester tester) async {
        final Finder switchFinder = find.byKey(const ValueKey<String>(labeledSwitchKeyValue));

        await prepareSelectionControls(tester);
        expect(
          await getSemantics(switchFinder, tester),
          hasAndroidSemantics(
            className: AndroidClassName.toggleSwitch,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            contentDescription: switchLabel,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
      }, timeout: Timeout.none);
    });

    group('Popup Controls', () {
      Future<void> preparePopupControls(WidgetTester tester) async {
        app.main();
        await tester.pumpAndSettle();
        await tester.tap(find.text(popupControlsRoute));
        await tester.pumpAndSettle();
      }

      testWidgets('Popup Menu has correct Android semantics', (WidgetTester tester) async {
        final Finder popupButton = find.byKey(const ValueKey<String>(popupButtonKeyValue));

        await preparePopupControls(tester);
        expect(
          await getSemantics(popupButton, tester),
          hasAndroidSemantics(
            className: AndroidClassName.button,
            isChecked: false,
            isCheckable: false,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await tester.tap(popupButton);
        await tester.pumpAndSettle();

        try {
          for (final String item in popupItems) {
            expect(
              await getSemantics(find.byKey(ValueKey<String>('$popupKeyValue.$item')), tester),
              hasAndroidSemantics(
                className: AndroidClassName.button,
                isChecked: false,
                isCheckable: false,
                isEnabled: true,
                isFocusable: true,
                ignoredActions: ignoredAccessibilityFocusActions,
                actions: <AndroidSemanticsAction>[
                  AndroidSemanticsAction.click,
                ],
              ),
              reason: "Popup $item doesn't have the right semantics",
            );
          }
          await tester.tap(find.byKey(ValueKey<String>('$popupKeyValue.${popupItems.first}')));
          await tester.pumpAndSettle();

          // Pop up the menu again, to verify that TalkBack gets the right answer
          // more than just the first time.
          await tester.tap(popupButton);
          await tester.pumpAndSettle();

          for (final String item in popupItems) {
            expect(
              await getSemantics(find.byKey(ValueKey<String>('$popupKeyValue.$item')), tester),
              hasAndroidSemantics(
                className: AndroidClassName.button,
                isChecked: false,
                isCheckable: false,
                isEnabled: true,
                isFocusable: true,
                ignoredActions: ignoredAccessibilityFocusActions,
                actions: <AndroidSemanticsAction>[
                  AndroidSemanticsAction.click,
                ],
              ),
              reason: "Popup $item doesn't have the right semantics the second time",
            );
          }
        } finally {
          await tester.tap(find.byKey(ValueKey<String>('$popupKeyValue.${popupItems.first}')));
        }
      }, timeout: Timeout.none);

      testWidgets('Dropdown Menu has correct Android semantics', (WidgetTester tester) async {
        final Finder dropdownButton = find.byKey(const ValueKey<String>(dropdownButtonKeyValue));

        await preparePopupControls(tester);
        expect(
          await getSemantics(dropdownButton, tester),
          hasAndroidSemantics(
            className: AndroidClassName.button,
            isChecked: false,
            isCheckable: false,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await tester.tap(dropdownButton);
        await tester.pumpAndSettle();

        try {
          for (final String item in popupItems) {
            // There are two copies of each item, so we want to find the version
            // that is in the overlay, not the one in the dropdown.
            expect(
              await getSemantics(
                find.descendant(
                  of: find.byType(Scrollable),
                  matching: find.byKey(ValueKey<String>('$dropdownKeyValue.$item')),
                ),
                tester,
              ),
              hasAndroidSemantics(
                className: AndroidClassName.view,
                isChecked: false,
                isCheckable: false,
                isEnabled: true,
                isFocusable: true,
                ignoredActions: ignoredAccessibilityFocusActions,
                actions: <AndroidSemanticsAction>[
                  AndroidSemanticsAction.click,
                ],
              ),
              reason: "Dropdown $item doesn't have the right semantics",
            );
          }
          await tester.tap(
            find.descendant(
              of: find.byType(Scrollable),
              matching: find.byKey(ValueKey<String>('$dropdownKeyValue.${popupItems.first}')),
            ),
          );
          await tester.pumpAndSettle();

          // Pop up the dropdown again, to verify that TalkBack gets the right answer
          // more than just the first time.
          await tester.tap(dropdownButton);
          await tester.pumpAndSettle();

          for (final String item in popupItems) {
            // There are two copies of each item, so we want to find the version
            // that is in the overlay, not the one in the dropdown.
            expect(
              await getSemantics(
                find.descendant(
                  of: find.byType(Scrollable),
                  matching: find.byKey(ValueKey<String>('$dropdownKeyValue.$item')),
                ),
                tester,
              ),
              hasAndroidSemantics(
                className: AndroidClassName.view,
                isChecked: false,
                isCheckable: false,
                isEnabled: true,
                isFocusable: true,
                ignoredActions: ignoredAccessibilityFocusActions,
                actions: <AndroidSemanticsAction>[
                  AndroidSemanticsAction.click,
                ],
              ),
              reason: "Dropdown $item doesn't have the right semantics the second time.",
            );
          }
        } finally {
          await tester.tap(
            find.descendant(
              of: find.byType(Scrollable),
              matching: find.byKey(ValueKey<String>('$dropdownKeyValue.${popupItems.first}')),
            ),
          );
        }
      }, timeout: Timeout.none);

      testWidgets('Modal alert dialog has correct Android semantics', (WidgetTester tester) async {
        final Finder alertButton = find.byKey(const ValueKey<String>(alertButtonKeyValue));

        await preparePopupControls(tester);
        expect(
          await getSemantics(alertButton, tester),
          hasAndroidSemantics(
            className: AndroidClassName.button,
            isChecked: false,
            isCheckable: false,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await tester.tap(alertButton);
        await tester.pumpAndSettle();

        try {
          expect(
            await getSemantics(find.byKey(const ValueKey<String>('$alertKeyValue.OK')), tester),
            hasAndroidSemantics(
              className: AndroidClassName.button,
              isChecked: false,
              isCheckable: false,
              isEnabled: true,
              isFocusable: true,
              ignoredActions: ignoredAccessibilityFocusActions,
              actions: <AndroidSemanticsAction>[
                AndroidSemanticsAction.click,
              ],
            ),
            reason: "Alert OK button doesn't have the right semantics",
          );

          for (final String item in <String>['Title', 'Body1', 'Body2']) {
            expect(
              await getSemantics(find.byKey(ValueKey<String>('$alertKeyValue.$item')), tester),
              hasAndroidSemantics(
                className: AndroidClassName.view,
                isChecked: false,
                isCheckable: false,
                isEnabled: true,
                isFocusable: true,
                ignoredActions: ignoredAccessibilityFocusActions,
                actions: <AndroidSemanticsAction>[],
              ),
              reason: "Alert $item button doesn't have the right semantics",
            );
          }

          await tester.tap(find.byKey(const ValueKey<String>('$alertKeyValue.OK')));
          await tester.pumpAndSettle();

          // Pop up the alert again, to verify that TalkBack gets the right answer
          // more than just the first time.
          await tester.tap(alertButton);
          await tester.pumpAndSettle();

          expect(
            await getSemantics(find.byKey(const ValueKey<String>('$alertKeyValue.OK')), tester),
            hasAndroidSemantics(
              className: AndroidClassName.button,
              isChecked: false,
              isCheckable: false,
              isEnabled: true,
              isFocusable: true,
              ignoredActions: ignoredAccessibilityFocusActions,
              actions: <AndroidSemanticsAction>[
                AndroidSemanticsAction.click,
              ],
            ),
            reason: "Alert OK button doesn't have the right semantics",
          );

          for (final String item in <String>['Title', 'Body1', 'Body2']) {
            expect(
              await getSemantics(find.byKey(ValueKey<String>('$alertKeyValue.$item')), tester),
              hasAndroidSemantics(
                className: AndroidClassName.view,
                isChecked: false,
                isCheckable: false,
                isEnabled: true,
                isFocusable: true,
                ignoredActions: ignoredAccessibilityFocusActions,
                actions: <AndroidSemanticsAction>[],
              ),
              reason: "Alert $item button doesn't have the right semantics",
            );
          }
        } finally {
          await tester.tap(find.byKey(const ValueKey<String>('$alertKeyValue.OK')));
        }
      }, timeout: Timeout.none);
    });

    group('Headings', () {
      Future<void> prepareHeading(WidgetTester tester) async {
        app.main();
        await tester.pumpAndSettle();
        await tester.tap(find.text(headingsRoute));
        await tester.pumpAndSettle();
      }

      testWidgets('AppBar title has correct Android heading semantics', (WidgetTester tester) async {
        await prepareHeading(tester);
        expect(
          await getSemantics(find.byKey(const ValueKey<String>(appBarTitleKeyValue)), tester),
          hasAndroidSemantics(isHeading: true),
        );
      }, timeout: Timeout.none);

      testWidgets('body text does not have Android heading semantics', (WidgetTester tester) async {
        await prepareHeading(tester);
        expect(
          await getSemantics(find.byKey(const ValueKey<String>(bodyTextKeyValue)), tester),
          hasAndroidSemantics(isHeading: false),
        );
      }, timeout: Timeout.none);
    });
  });
}
