// 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:io' as io;

import 'package:android_semantics_testing/android_semantics_testing.dart';
import 'package:android_semantics_testing/test_constants.dart';
import 'package:flutter_driver/flutter_driver.dart';
import 'package:path/path.dart' as path;
import 'package:pub_semver/pub_semver.dart';
import 'package:test/test.dart' hide isInstanceOf;

// 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,
];

String adbPath() {
  final String androidHome = io.Platform.environment['ANDROID_HOME'] ?? io.Platform.environment['ANDROID_SDK_ROOT'];
  if (androidHome == null) {
    return 'adb';
  } else {
    return path.join(androidHome, 'platform-tools', 'adb');
  }
}

void main() {
  group('AccessibilityBridge', () {
    FlutterDriver driver;
    Future<AndroidSemanticsNode> getSemantics(SerializableFinder finder) async {
      final int id = await driver.getSemanticsId(finder);
      final String data = await driver.requestData('getSemanticsNode#$id');
      return AndroidSemanticsNode.deserialize(data);
    }

    // The version of TalkBack running on the device.
    Version talkbackVersion;

    Future<Version> getTalkbackVersion() async {
      final io.ProcessResult result = await io.Process.run(adbPath(), const <String>[
        'shell',
        'dumpsys',
        'package',
        'com.google.android.marvin.talkback',
      ]);
      if (result.exitCode != 0) {
        throw Exception('Failed to get TalkBack version: ${result.stdout as String}\n${result.stderr as String}');
      }
      final List<String> lines = (result.stdout as String).split('\n');
      String version;
      for (final String line in lines) {
        if (line.contains('versionName')) {
          version = line.replaceAll(RegExp(r'\s*versionName='), '');
          break;
        }
      }
      if (version == null) {
        throw Exception('Unable to determine TalkBack version.');
      }

      // Android doesn't quite use semver, so convert the version string to semver form.
      final RegExp startVersion = RegExp(r'(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(\.(?<build>\d+))?');
      final RegExpMatch match = startVersion.firstMatch(version);
      if (match == null) {
        return Version(0, 0, 0);
      }
      return Version(
        int.parse(match.namedGroup('major')),
        int.parse(match.namedGroup('minor')),
        int.parse(match.namedGroup('patch')),
        build: match.namedGroup('build'),
      );
    }

    setUpAll(() async {
      driver = await FlutterDriver.connect();
      talkbackVersion ??= await getTalkbackVersion();
      print('TalkBack version is $talkbackVersion');

      // Say the magic words..
      final io.Process run = await io.Process.start(adbPath(), const <String>[
        'shell',
        'settings',
        'put',
        'secure',
        'enabled_accessibility_services',
        'com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService',
      ]);
      await run.exitCode;
    });

    tearDownAll(() async {
      // ... And turn it off again
      final io.Process run = await io.Process.start(adbPath(), const <String>[
        'shell',
        'settings',
        'put',
        'secure',
        'enabled_accessibility_services',
        'null',
      ]);
      await run.exitCode;
      driver?.close();
    });

    group('TextField', () {
      setUpAll(() async {
        await driver.tap(find.text(textFieldRoute));
        // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28
        await Future<void>.delayed(const Duration(milliseconds: 500));

        // 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 driver.requestData('setClipboard#Hello World');
        await Future<void>.delayed(const Duration(milliseconds: 500));
      });

      test('TextField has correct Android semantics', () async {
        final SerializableFinder normalTextField = find.descendant(
          of: find.byValueKey(normalTextFieldKeyValue),
          matching: find.byType('Semantics'),
          firstMatchOnly: true,
        );
        expect(
          await getSemantics(normalTextField),
          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 driver.tap(normalTextField);
        // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28
        await Future<void>.delayed(const Duration(milliseconds: 500));

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

        await driver.enterText('hello world');
        // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28
        await Future<void>.delayed(const Duration(milliseconds: 500));

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

      test('password TextField has correct Android semantics', () async {
        final SerializableFinder passwordTextField = find.descendant(
          of: find.byValueKey(passwordTextFieldKeyValue),
          matching: find.byType('Semantics'),
          firstMatchOnly: true,
        );
        expect(
          await getSemantics(passwordTextField),
          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 driver.tap(passwordTextField);
        // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28
        await Future<void>.delayed(const Duration(milliseconds: 500));

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

        await driver.enterText('hello world');
        // Delay for TalkBack to update focus as of November 2019 with Pixel 3 and Android API 28
        await Future<void>.delayed(const Duration(milliseconds: 500));

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

      tearDownAll(() async {
        await driver.tap(find.byValueKey('back'));
      });
    });

    group('SelectionControls', () {
      setUpAll(() async {
        await driver.tap(find.text(selectionControlsRoute));
      });

      test('Checkbox has correct Android semantics', () async {
        Future<AndroidSemanticsNode> getCheckboxSemantics(String key) async {
          return getSemantics(find.byValueKey(key));
        }
        expect(
          await getCheckboxSemantics(checkboxKeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.checkBox,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await driver.tap(find.byValueKey(checkboxKeyValue));

        expect(
          await getCheckboxSemantics(checkboxKeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.checkBox,
            isChecked: true,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
        expect(
          await getCheckboxSemantics(disabledCheckboxKeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.checkBox,
            isCheckable: true,
            isEnabled: false,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: const <AndroidSemanticsAction>[],
          ),
        );
      }, timeout: Timeout.none);
      test('Radio has correct Android semantics', () async {
        Future<AndroidSemanticsNode> getRadioSemantics(String key) async {
          return getSemantics(find.byValueKey(key));
        }
        expect(
          await getRadioSemantics(radio2KeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.radio,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await driver.tap(find.byValueKey(radio2KeyValue));

        expect(
          await getRadioSemantics(radio2KeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.radio,
            isChecked: true,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
      }, timeout: Timeout.none);
      test('Switch has correct Android semantics', () async {
        Future<AndroidSemanticsNode> getSwitchSemantics(String key) async {
          return getSemantics(find.byValueKey(key));
        }
        expect(
          await getSwitchSemantics(switchKeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.toggleSwitch,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await driver.tap(find.byValueKey(switchKeyValue));

        expect(
          await getSwitchSemantics(switchKeyValue),
          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.
      test('Switch can be labeled', () async {
        Future<AndroidSemanticsNode> getSwitchSemantics(String key) async {
          return getSemantics(find.byValueKey(key));
        }
        expect(
          await getSwitchSemantics(labeledSwitchKeyValue),
          hasAndroidSemantics(
            className: AndroidClassName.toggleSwitch,
            isChecked: false,
            isCheckable: true,
            isEnabled: true,
            isFocusable: true,
            contentDescription: switchLabel,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );
      }, timeout: Timeout.none);

      tearDownAll(() async {
        await driver.tap(find.byValueKey('back'));
      });
    });

    group('Popup Controls', () {
      setUpAll(() async {
        await driver.tap(find.text(popupControlsRoute));
      });

      test('Popup Menu has correct Android semantics', () async {
        expect(
          await getSemantics(find.byValueKey(popupButtonKeyValue)),
          hasAndroidSemantics(
            className: AndroidClassName.button,
            isChecked: false,
            isCheckable: false,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await driver.tap(find.byValueKey(popupButtonKeyValue));
        try {
          // We have to wait wall time here because we're waiting for TalkBack to
          // catch up.
          await Future<void>.delayed(const Duration(milliseconds: 1500));

          for (final String item in popupItems) {
            expect(
                await getSemantics(find.byValueKey('$popupKeyValue.$item')),
                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 driver.tap(find.byValueKey('$popupKeyValue.${popupItems.first}'));

          // Pop up the menu again, to verify that TalkBack gets the right answer
          // more than just the first time.
          await driver.tap(find.byValueKey(popupButtonKeyValue));
          await Future<void>.delayed(const Duration(milliseconds: 1500));

          for (final String item in popupItems) {
            expect(
                await getSemantics(find.byValueKey('$popupKeyValue.$item')),
                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 driver.tap(find.byValueKey('$popupKeyValue.${popupItems.first}'));
        }
      }, timeout: Timeout.none);

      test('Dropdown Menu has correct Android semantics', () async {
        expect(
          await getSemantics(find.byValueKey(dropdownButtonKeyValue)),
          hasAndroidSemantics(
            className: AndroidClassName.button,
            isChecked: false,
            isCheckable: false,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await driver.tap(find.byValueKey(dropdownButtonKeyValue));
        try {
          await Future<void>.delayed(const Duration(milliseconds: 1500));

          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.byValueKey('$dropdownKeyValue.$item'),
                )),
                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 driver.tap(
            find.descendant(
              of: find.byType('Scrollable'),
              matching: find.byValueKey('$dropdownKeyValue.${popupItems.first}'),
            ),
          );

          // Pop up the dropdown again, to verify that TalkBack gets the right answer
          // more than just the first time.
          await driver.tap(find.byValueKey(dropdownButtonKeyValue));
          await Future<void>.delayed(const Duration(milliseconds: 1500));

          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.byValueKey('$dropdownKeyValue.$item'),
                )),
                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 driver.tap(
            find.descendant(
              of: find.byType('Scrollable'),
              matching: find.byValueKey('$dropdownKeyValue.${popupItems.first}'),
            ),
          );
        }
      }, timeout: Timeout.none);

      test('Modal alert dialog has correct Android semantics', () async {
        expect(
          await getSemantics(find.byValueKey(alertButtonKeyValue)),
          hasAndroidSemantics(
            className: AndroidClassName.button,
            isChecked: false,
            isCheckable: false,
            isEnabled: true,
            isFocusable: true,
            ignoredActions: ignoredAccessibilityFocusActions,
            actions: <AndroidSemanticsAction>[
              AndroidSemanticsAction.click,
            ],
          ),
        );

        await driver.tap(find.byValueKey(alertButtonKeyValue));
        try {
          await Future<void>.delayed(const Duration(milliseconds: 1500));

          expect(
              await getSemantics(find.byValueKey('$alertKeyValue.OK')),
              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.byValueKey('$alertKeyValue.$item')),
                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 driver.tap(find.byValueKey('$alertKeyValue.OK'));

          // Pop up the alert again, to verify that TalkBack gets the right answer
          // more than just the first time.
          await driver.tap(find.byValueKey(alertButtonKeyValue));
          await Future<void>.delayed(const Duration(milliseconds: 1500));

          expect(
              await getSemantics(find.byValueKey('$alertKeyValue.OK')),
              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.byValueKey('$alertKeyValue.$item')),
                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 driver.tap(find.byValueKey('$alertKeyValue.OK'));
        }
      }, timeout: Timeout.none);

      tearDownAll(() async {
        await Future<void>.delayed(const Duration(milliseconds: 500));
        await driver.tap(find.byValueKey('back'));
      });
    });

    group('Headings', () {
      setUpAll(() async {
        await driver.tap(find.text(headingsRoute));
      });

      test('AppBar title has correct Android heading semantics', () async {
        expect(
          await getSemantics(find.byValueKey(appBarTitleKeyValue)),
          hasAndroidSemantics(isHeading: true),
        );
      }, timeout: Timeout.none);

      test('body text does not have Android heading semantics', () async {
        expect(
          await getSemantics(find.byValueKey(bodyTextKeyValue)),
          hasAndroidSemantics(isHeading: false),
        );
      }, timeout: Timeout.none);

      tearDownAll(() async {
        await driver.tap(find.byValueKey('back'));
      });
    });

  });
}