// 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 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:flutter_driver/src/extension/wait_conditions.dart';
import 'package:flutter_test/flutter_test.dart';

import 'diagnostics_tree.dart';
import 'error.dart';
import 'find.dart';
import 'frame_sync.dart';
import 'geometry.dart';
import 'gesture.dart';
import 'health.dart';
import 'layer_tree.dart';
import 'message.dart';
import 'render_tree.dart';
import 'request_data.dart';
import 'semantics.dart';
import 'text.dart';
import 'wait.dart';

/// A factory which creates [Finder]s from [SerializableFinder]s.
mixin CreateFinderFactory {
  /// Creates the flutter widget finder from [SerializableFinder].
  Finder createFinder(SerializableFinder finder) {
    final String finderType = finder.finderType;
    switch (finderType) {
      case 'ByText':
        return _createByTextFinder(finder as ByText);
      case 'ByTooltipMessage':
        return _createByTooltipMessageFinder(finder as ByTooltipMessage);
      case 'BySemanticsLabel':
        return _createBySemanticsLabelFinder(finder as BySemanticsLabel);
      case 'ByValueKey':
        return _createByValueKeyFinder(finder as ByValueKey);
      case 'ByType':
        return _createByTypeFinder(finder as ByType);
      case 'PageBack':
        return _createPageBackFinder();
      case 'Ancestor':
        return _createAncestorFinder(finder as Ancestor);
      case 'Descendant':
        return _createDescendantFinder(finder as Descendant);
      default:
        throw DriverError('Unsupported search specification type $finderType');
    }
  }

  Finder _createByTextFinder(ByText arguments) {
    return find.text(arguments.text);
  }

  Finder _createByTooltipMessageFinder(ByTooltipMessage arguments) {
    return find.byElementPredicate((Element element) {
      final Widget widget = element.widget;
      if (widget is Tooltip) {
        return widget.message == arguments.text;
      }
      return false;
    }, description: 'widget with text tooltip "${arguments.text}"');
  }

  Finder _createBySemanticsLabelFinder(BySemanticsLabel arguments) {
    return find.byElementPredicate((Element element) {
      if (element is! RenderObjectElement) {
        return false;
      }
      final String? semanticsLabel = element.renderObject.debugSemantics?.label;
      if (semanticsLabel == null) {
        return false;
      }
      final Pattern label = arguments.label;
      return label is RegExp
          ? label.hasMatch(semanticsLabel)
          : label == semanticsLabel;
    }, description: 'widget with semantic label "${arguments.label}"');
  }

  Finder _createByValueKeyFinder(ByValueKey arguments) {
    switch (arguments.keyValueType) {
      case 'int':
        return find.byKey(ValueKey<int>(arguments.keyValue as int));
      case 'String':
        return find.byKey(ValueKey<String>(arguments.keyValue as String));
      default:
        throw 'Unsupported ByValueKey type: ${arguments.keyValueType}';
    }
  }

  Finder _createByTypeFinder(ByType arguments) {
    return find.byElementPredicate((Element element) {
      return element.widget.runtimeType.toString() == arguments.type;
    }, description: 'widget with runtimeType "${arguments.type}"');
  }

  Finder _createPageBackFinder() {
    return find.byElementPredicate((Element element) {
      final Widget widget = element.widget;
      if (widget is Tooltip) {
        return widget.message == 'Back';
      }
      if (widget is CupertinoNavigationBarBackButton) {
        return true;
      }
      return false;
    }, description: 'Material or Cupertino back button');
  }

  Finder _createAncestorFinder(Ancestor arguments) {
    final Finder finder = find.ancestor(
      of: createFinder(arguments.of),
      matching: createFinder(arguments.matching),
      matchRoot: arguments.matchRoot,
    );
    return arguments.firstMatchOnly ? finder.first : finder;
  }

  Finder _createDescendantFinder(Descendant arguments) {
    final Finder finder = find.descendant(
      of: createFinder(arguments.of),
      matching: createFinder(arguments.matching),
      matchRoot: arguments.matchRoot,
    );
    return arguments.firstMatchOnly ? finder.first : finder;
  }
}

/// A factory for [Command] handlers.
mixin CommandHandlerFactory {
  /// With [_frameSync] enabled, Flutter Driver will wait to perform an action
  /// until there are no pending frames in the app under test.
  bool _frameSync = true;

  /// Gets [DataHandler] for result delivery.
  @protected
  DataHandler? getDataHandler() => null;

  /// Registers text input emulation.
  @protected
  void registerTextInput() {
    _testTextInput.register();
  }

  final TestTextInput _testTextInput = TestTextInput();

  /// Deserializes the finder from JSON generated by [Command.serialize] or [CommandWithTarget.serialize].
  Future<Result> handleCommand(Command command, WidgetController prober, CreateFinderFactory finderFactory) {
    switch(command.kind) {
      case 'get_health': return _getHealth(command);
      case 'get_layer_tree': return _getLayerTree(command);
      case 'get_render_tree': return _getRenderTree(command);
      case 'enter_text': return _enterText(command);
      case 'get_text': return _getText(command, finderFactory);
      case 'request_data': return _requestData(command);
      case 'scroll': return _scroll(command, prober, finderFactory);
      case 'scrollIntoView': return _scrollIntoView(command, finderFactory);
      case 'set_frame_sync': return _setFrameSync(command);
      case 'set_semantics': return _setSemantics(command);
      case 'set_text_entry_emulation': return _setTextEntryEmulation(command);
      case 'tap': return _tap(command, prober, finderFactory);
      case 'waitFor': return _waitFor(command, finderFactory);
      case 'waitForAbsent': return _waitForAbsent(command, finderFactory);
      case 'waitForCondition': return _waitForCondition(command);
      case 'waitUntilNoTransientCallbacks': return _waitUntilNoTransientCallbacks(command);
      case 'waitUntilNoPendingFrame': return _waitUntilNoPendingFrame(command);
      case 'waitUntilFirstFrameRasterized': return _waitUntilFirstFrameRasterized(command);
      case 'get_semantics_id': return _getSemanticsId(command, finderFactory);
      case 'get_offset': return _getOffset(command, finderFactory);
      case 'get_diagnostics_tree': return _getDiagnosticsTree(command, finderFactory);
    }

    throw DriverError('Unsupported command kind ${command.kind}');
  }

  Future<Health> _getHealth(Command command) async => const Health(HealthStatus.ok);

  Future<LayerTree> _getLayerTree(Command command) async {
    return LayerTree(RendererBinding.instance?.renderView.debugLayer?.toStringDeep());
  }

  Future<RenderTree> _getRenderTree(Command command) async {
    return RenderTree(RendererBinding.instance?.renderView.toStringDeep());
  }

  Future<Result> _enterText(Command command) async {
    if (!_testTextInput.isRegistered) {
      throw 'Unable to fulfill `FlutterDriver.enterText`. Text emulation is '
            'disabled. You can enable it using `FlutterDriver.setTextEntryEmulation`.';
    }
    final EnterText enterTextCommand = command as EnterText;
    _testTextInput.enterText(enterTextCommand.text);
    return Result.empty;
  }

  Future<RequestDataResult> _requestData(Command command) async {
    final RequestData requestDataCommand = command as RequestData;
    final DataHandler? dataHandler = getDataHandler();
    return RequestDataResult(dataHandler == null
      ? 'No requestData Extension registered'
      : await dataHandler(requestDataCommand.message));
  }

  Future<Result> _setFrameSync(Command command) async {
    final SetFrameSync setFrameSyncCommand = command as SetFrameSync;
    _frameSync = setFrameSyncCommand.enabled;
    return Result.empty;
  }

  Future<Result> _tap(Command command, WidgetController prober, CreateFinderFactory finderFactory) async {
    final Tap tapCommand = command as Tap;
    final Finder computedFinder = await waitForElement(
      finderFactory.createFinder(tapCommand.finder).hitTestable(),
    );
    await prober.tap(computedFinder);
    return Result.empty;
  }

  Future<Result> _waitFor(Command command, CreateFinderFactory finderFactory) async {
    final WaitFor waitForCommand = command as WaitFor;
    await waitForElement(finderFactory.createFinder(waitForCommand.finder));
    return Result.empty;
  }

  Future<Result> _waitForAbsent(Command command, CreateFinderFactory finderFactory) async {
    final WaitForAbsent waitForAbsentCommand = command as WaitForAbsent;
    await waitForAbsentElement(finderFactory.createFinder(waitForAbsentCommand.finder));
    return Result.empty;
  }

  Future<Result> _waitForCondition(Command command) async {
    assert(command != null);
    final WaitForCondition waitForConditionCommand = command as WaitForCondition;
    final WaitCondition condition = deserializeCondition(waitForConditionCommand.condition);
    await condition.wait();
    return Result.empty;
  }

  @Deprecated(
    'This method has been deprecated in favor of _waitForCondition. '
    'This feature was deprecated after v1.9.3.'
  )
  Future<Result> _waitUntilNoTransientCallbacks(Command command) async {
    if (SchedulerBinding.instance!.transientCallbackCount != 0)
      await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0);
    return Result.empty;
  }

  /// Returns a future that waits until no pending frame is scheduled (frame is synced).
  ///
  /// Specifically, it checks:
  /// * Whether the count of transient callbacks is zero.
  /// * Whether there's no pending request for scheduling a new frame.
  ///
  /// We consider the frame is synced when both conditions are met.
  ///
  /// This method relies on a Flutter Driver mechanism called "frame sync",
  /// which waits for transient animations to finish. Persistent animations will
  /// cause this to wait forever.
  ///
  /// If a test needs to interact with the app while animations are running, it
  /// should avoid this method and instead disable the frame sync using
  /// `set_frame_sync` method. See [FlutterDriver.runUnsynchronized] for more
  /// details on how to do this. Note, disabling frame sync will require the
  /// test author to use some other method to avoid flakiness.
  ///
  /// This method has been deprecated in favor of [_waitForCondition].
  @Deprecated(
    'This method has been deprecated in favor of _waitForCondition. '
    'This feature was deprecated after v1.9.3.'
  )
  Future<Result> _waitUntilNoPendingFrame(Command command) async {
    await _waitUntilFrame(() {
      return SchedulerBinding.instance!.transientCallbackCount == 0
          && !SchedulerBinding.instance!.hasScheduledFrame;
    });
    return Result.empty;
  }

  Future<GetSemanticsIdResult> _getSemanticsId(Command command, CreateFinderFactory finderFactory) async {
    final GetSemanticsId semanticsCommand = command as GetSemanticsId;
    final Finder target = await waitForElement(finderFactory.createFinder(semanticsCommand.finder));
    final Iterable<Element> elements = target.evaluate();
    if (elements.length > 1) {
      throw StateError('Found more than one element with the same ID: $elements');
    }
    final Element element = elements.single;
    RenderObject? renderObject = element.renderObject;
    SemanticsNode? node;
    while (renderObject != null && node == null) {
      node = renderObject.debugSemantics;
      renderObject = renderObject.parent as RenderObject?;
    }
    if (node == null)
      throw StateError('No semantics data found');
    return GetSemanticsIdResult(node.id);
  }

  Future<GetOffsetResult> _getOffset(Command command, CreateFinderFactory finderFactory) async {
    final GetOffset getOffsetCommand = command as GetOffset;
    final Finder finder = await waitForElement(finderFactory.createFinder(getOffsetCommand.finder));
    final Element element = finder.evaluate().single;
    final RenderBox box = (element.renderObject as RenderBox?)!;
    Offset localPoint;
    switch (getOffsetCommand.offsetType) {
      case OffsetType.topLeft:
        localPoint = Offset.zero;
        break;
      case OffsetType.topRight:
        localPoint = box.size.topRight(Offset.zero);
        break;
      case OffsetType.bottomLeft:
        localPoint = box.size.bottomLeft(Offset.zero);
        break;
      case OffsetType.bottomRight:
        localPoint = box.size.bottomRight(Offset.zero);
        break;
      case OffsetType.center:
        localPoint = box.size.center(Offset.zero);
        break;
    }
    final Offset globalPoint = box.localToGlobal(localPoint);
    return GetOffsetResult(dx: globalPoint.dx, dy: globalPoint.dy);
  }

  Future<DiagnosticsTreeResult> _getDiagnosticsTree(Command command, CreateFinderFactory finderFactory) async {
    final GetDiagnosticsTree diagnosticsCommand = command as GetDiagnosticsTree;
    final Finder finder = await waitForElement(finderFactory.createFinder(diagnosticsCommand.finder));
    final Element element = finder.evaluate().single;
    DiagnosticsNode diagnosticsNode;
    switch (diagnosticsCommand.diagnosticsType) {
      case DiagnosticsType.renderObject:
        diagnosticsNode = element.renderObject!.toDiagnosticsNode();
        break;
      case DiagnosticsType.widget:
        diagnosticsNode = element.toDiagnosticsNode();
        break;
    }
    return DiagnosticsTreeResult(diagnosticsNode.toJsonMap(DiagnosticsSerializationDelegate(
      subtreeDepth: diagnosticsCommand.subtreeDepth,
      includeProperties: diagnosticsCommand.includeProperties,
    )));
  }

  Future<Result> _scroll(Command command, WidgetController _prober, CreateFinderFactory finderFactory) async {
    final Scroll scrollCommand = command as Scroll;
    final Finder target = await waitForElement(finderFactory.createFinder(scrollCommand.finder));
    final int totalMoves = scrollCommand.duration.inMicroseconds * scrollCommand.frequency ~/ Duration.microsecondsPerSecond;
    final Offset delta = Offset(scrollCommand.dx, scrollCommand.dy) / totalMoves.toDouble();
    final Duration pause = scrollCommand.duration ~/ totalMoves;
    final Offset startLocation = _prober.getCenter(target);
    Offset currentLocation = startLocation;
    final TestPointer pointer = TestPointer(1);
    _prober.binding.handlePointerEvent(pointer.down(startLocation));
    await Future<void>.value(); // so that down and move don't happen in the same microtask
    for (int moves = 0; moves < totalMoves; moves += 1) {
      currentLocation = currentLocation + delta;
      _prober.binding.handlePointerEvent(pointer.move(currentLocation));
      await Future<void>.delayed(pause);
    }
    _prober.binding.handlePointerEvent(pointer.up());

    return Result.empty;
  }

  Future<Result> _scrollIntoView(Command command, CreateFinderFactory finderFactory) async {
    final ScrollIntoView scrollIntoViewCommand = command as ScrollIntoView;
    final Finder target = await waitForElement(finderFactory.createFinder(scrollIntoViewCommand.finder));
    await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: scrollIntoViewCommand.alignment);
    return Result.empty;
  }

  Future<GetTextResult> _getText(Command command, CreateFinderFactory finderFactory) async {
    final GetText getTextCommand = command as GetText;
    final Finder target = await waitForElement(finderFactory.createFinder(getTextCommand.finder));

    final Widget widget = target.evaluate().single.widget;
    String? text;

    if (widget.runtimeType == Text) {
      text = (widget as Text).data;
    } else if (widget.runtimeType == RichText) {
      final RichText richText = widget as RichText;
      text = richText.text.toPlainText(
        includeSemanticsLabels: false,
        includePlaceholders: false,
      );
    } else if (widget.runtimeType == TextField) {
      text = (widget as TextField).controller?.text;
    } else if (widget.runtimeType == TextFormField) {
      text = (widget as TextFormField).controller?.text;
    } else if (widget.runtimeType == EditableText) {
      text = (widget as EditableText).controller.text;
    }

    if (text == null) {
      throw UnsupportedError('Type ${widget.runtimeType.toString()} is currently not supported by getText');
    }

    return GetTextResult(text);
  }

  Future<Result> _setTextEntryEmulation(Command command) async {
    final SetTextEntryEmulation setTextEntryEmulationCommand = command as SetTextEntryEmulation;
    if (setTextEntryEmulationCommand.enabled) {
      _testTextInput.register();
    } else {
      _testTextInput.unregister();
    }
    return Result.empty;
  }

  SemanticsHandle? _semantics;
  bool get _semanticsIsEnabled => RendererBinding.instance!.pipelineOwner.semanticsOwner != null;

  Future<SetSemanticsResult> _setSemantics(Command command) async {
    final SetSemantics setSemanticsCommand = command as SetSemantics;
    final bool semanticsWasEnabled = _semanticsIsEnabled;
    if (setSemanticsCommand.enabled && _semantics == null) {
      _semantics = RendererBinding.instance!.pipelineOwner.ensureSemantics();
      if (!semanticsWasEnabled) {
        // wait for the first frame where semantics is enabled.
        final Completer<void> completer = Completer<void>();
        SchedulerBinding.instance!.addPostFrameCallback((Duration d) {
          completer.complete();
        });
        await completer.future;
      }
    } else if (!setSemanticsCommand.enabled && _semantics != null) {
      _semantics!.dispose();
      _semantics = null;
    }
    return SetSemanticsResult(semanticsWasEnabled != _semanticsIsEnabled);
  }

  // This can be used to wait for the first frame being rasterized during app launch.
  @Deprecated(
    'This method has been deprecated in favor of _waitForCondition. '
    'This feature was deprecated after v1.9.3.'
  )
  Future<Result> _waitUntilFirstFrameRasterized(Command command) async {
    await WidgetsBinding.instance!.waitUntilFirstFrameRasterized;
    return Result.empty;
  }

  /// Runs `finder` repeatedly until it finds one or more [Element]s.
  Future<Finder> waitForElement(Finder finder) async {
    if (_frameSync)
      await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0);

    await _waitUntilFrame(() => finder.evaluate().isNotEmpty);

    if (_frameSync)
      await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0);

    return finder;
  }

  /// Runs `finder` repeatedly until it finds zero [Element]s.
  Future<Finder> waitForAbsentElement(Finder finder) async {
    if (_frameSync)
      await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0);

    await _waitUntilFrame(() => finder.evaluate().isEmpty);

    if (_frameSync)
      await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0);

    return finder;
  }

  // Waits until at the end of a frame the provided [condition] is [true].
  Future<void> _waitUntilFrame(bool Function() condition, [ Completer<void>? completer ]) {
    completer ??= Completer<void>();
    if (!condition()) {
      SchedulerBinding.instance!.addPostFrameCallback((Duration timestamp) {
        _waitUntilFrame(condition, completer);
      });
    } else {
      completer.complete();
    }
    return completer.future;
  }
}
