// 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:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/src/foundation/constants.dart';
import 'package:flutter_test/flutter_test.dart';

class _TestSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
  _TestSliverPersistentHeaderDelegate({
    required this.minExtent,
    required this.maxExtent,
    required this.child,
    this.vsync = const TestVSync(),
  });

  final Widget child;

  @override
  final double maxExtent;

  @override
  final double minExtent;

  @override
  final TickerProvider? vsync;

  @override
  final PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration = const PersistentHeaderShowOnScreenConfiguration();

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;

  @override
  bool shouldRebuild(_TestSliverPersistentHeaderDelegate oldDelegate) => true;
}

void main() {
  const TextStyle textStyle = TextStyle();
  const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
    final FocusNode focusNode = FocusNode();

  testWidgets('tapping on a partly visible editable brings it fully on screen', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    final TextEditingController controller = TextEditingController();

    await tester.pumpWidget(MaterialApp(
      home: Center(
        child: SizedBox(
          height: 300.0,
          child: ListView(
            controller: scrollController,
            children: <Widget>[
              EditableText(
                backgroundCursorColor: Colors.grey,
                controller: controller,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
              const SizedBox(
                height: 350.0,
              ),
            ],
          ),
        ),
      ),
    ));

    // Scroll the EditableText half off screen.
    final RenderBox render = tester.renderObject(find.byType(EditableText));
    scrollController.jumpTo(render.size.height / 2);
    await tester.pumpAndSettle();
    expect(scrollController.offset, render.size.height / 2);

    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
  });

  testWidgets('tapping on a partly visible editable brings it fully on screen with scrollInsets', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    final TextEditingController controller = TextEditingController();
    final FocusNode focusNode = FocusNode();

    await tester.pumpWidget(MaterialApp(
      home: Center(
        child: SizedBox(
          height: 300.0,
          child: ListView(
            controller: scrollController,
            children: <Widget>[
              const SizedBox(
                height: 200.0,
              ),
              EditableText(
                backgroundCursorColor: Colors.grey,
                scrollPadding: const EdgeInsets.all(50.0),
                controller: controller,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
              const SizedBox(
                height: 850.0,
              ),
            ],
          ),
        ),
      ),
    ));

    // Scroll the EditableText half off screen.
    final RenderBox render = tester.renderObject(find.byType(EditableText));
    scrollController.jumpTo(200 + render.size.height / 2);
    await tester.pumpAndSettle();
    expect(scrollController.offset, 200 + render.size.height / 2);

    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    // Container above the text is 200 in height, the scrollInsets are 50
    // Tolerance of 5 units (The actual value was 152.0 in the current tests instead of 150.0)
    expect(scrollController.offset, lessThan(200.0 - 50.0 + 5.0));
    expect(scrollController.offset, greaterThan(200.0 - 50.0 - 5.0));
  });

  testWidgets('editable comes back on screen when entering text while it is off-screen', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController(initialScrollOffset: 100.0);
    final TextEditingController controller = TextEditingController();
    final FocusNode focusNode = FocusNode();

    await tester.pumpWidget(MaterialApp(
      home: Center(
        child: SizedBox(
          height: 300.0,
          child: ListView(
            controller: scrollController,
            children: <Widget>[
              const SizedBox(
                height: 350.0,
              ),
              EditableText(
                backgroundCursorColor: Colors.grey,
                controller: controller,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
              const SizedBox(
                height: 350.0,
              ),
            ],
          ),
        ),
      ),
    ));

    // Focus the EditableText and scroll it off screen.
    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    expect(focusNode.hasFocus, isTrue);
    scrollController.jumpTo(0.0);
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(find.byType(EditableText), findsNothing);

    // Entering text brings it back on screen.
    tester.testTextInput.enterText('Hello');
    await tester.pumpAndSettle();
    expect(scrollController.offset, greaterThan(0.0));
    expect(find.byType(EditableText), findsOneWidget);
  });

  testWidgets('entering text does not scroll when scrollPhysics.allowImplicitScrolling = false', (WidgetTester tester) async {
    // regression test for https://github.com/flutter/flutter/issues/19523

    final ScrollController scrollController = ScrollController(initialScrollOffset: 100.0);
    final TextEditingController controller = TextEditingController();
    final FocusNode focusNode = FocusNode();

    await tester.pumpWidget(MaterialApp(
      home: Center(
        child: SizedBox(
          height: 300.0,
          child: ListView(
            physics: const NoImplicitScrollPhysics(),
            controller: scrollController,
            children: <Widget>[
              const SizedBox(
                height: 350.0,
              ),
              EditableText(
                backgroundCursorColor: Colors.grey,
                controller: controller,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
              const SizedBox(
                height: 350.0,
              ),
            ],
          ),
        ),
      ),
    ));

    // Focus the EditableText and scroll it off screen.
    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    expect(focusNode.hasFocus, isTrue);
    scrollController.jumpTo(0.0);
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(find.byType(EditableText), findsNothing);

    // Entering text brings it not back on screen.
    tester.testTextInput.enterText('Hello');
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(find.byType(EditableText), findsNothing);
  });

  testWidgets('entering text does not scroll a surrounding PageView', (WidgetTester tester) async {
    // regression test for https://github.com/flutter/flutter/issues/19523

    final TextEditingController textController = TextEditingController();
    final PageController pageController = PageController(initialPage: 1);

    await tester.pumpWidget(
      MaterialApp(
        home: MediaQuery(
          data: const MediaQueryData(),
          child: Directionality(
            textDirection: TextDirection.ltr,
            child: Material(
              child: PageView(
                controller: pageController,
                children: <Widget>[
                  Container(
                    color: Colors.red,
                  ),
                  ColoredBox(
                    color: Colors.green,
                    child: TextField(
                      controller: textController,
                    ),
                  ),
                  Container(
                    color: Colors.red,
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );

    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    expect(textController.text, '');
    tester.testTextInput.enterText('H');
    final int frames = await tester.pumpAndSettle();

    // The text input should not trigger any animations, which would indicate
    // that the surrounding PageView is incorrectly scrolling back-and-forth.
    expect(frames, 1);

    expect(textController.text, 'H');
  });

  testWidgets('focused multi-line editable scrolls caret back into view when typing', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    final TextEditingController controller = TextEditingController();
    final FocusNode focusNode = FocusNode();
    controller.text = 'Start\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nEnd';

    await tester.pumpWidget(MaterialApp(
      home: Center(
        child: SizedBox(
          height: 300.0,
          child: ListView(
            controller: scrollController,
            children: <Widget>[
              EditableText(
                backgroundCursorColor: Colors.grey,
                maxLines: null, // multiline
                controller: controller,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
            ],
          ),
        ),
      ),
    ));

    // Bring keyboard up and verify that end of EditableText is not on screen.
    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    scrollController.jumpTo(0.0);
    await tester.pumpAndSettle();
    final RenderBox render = tester.renderObject(find.byType(EditableText));
    expect(render.size.height, greaterThan(500.0));
    expect(scrollController.offset, 0.0);

    // Enter text at end, which is off-screen.
    final String textToEnter = '${controller.text} HELLO';
    tester.testTextInput.updateEditingValue(TextEditingValue(
      text: textToEnter,
      selection: TextSelection.collapsed(offset: textToEnter.length),
    ));
    await tester.pumpAndSettle();

    // Caret scrolls into view.
    expect(find.byType(EditableText), findsOneWidget);
    expect(render.size.height, greaterThan(500.0));
    expect(scrollController.offset, greaterThan(0.0));
  });

  testWidgets('scrolls into view with scrollInserts after the keyboard pops up', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    final TextEditingController controller = TextEditingController();
    final FocusNode focusNode = FocusNode();

    const Key container = Key('container');

    await tester.pumpWidget(MaterialApp(
      home: Align(
        alignment: Alignment.bottomCenter,
        child: SizedBox(
          height: 300.0,
          child: ListView(
            controller: scrollController,
            children: <Widget>[
              const SizedBox(
                key: container,
                height: 200.0,
              ),
              EditableText(
                backgroundCursorColor: Colors.grey,
                scrollPadding: const EdgeInsets.only(bottom: 300.0),
                controller: controller,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
              const SizedBox(
                height: 400.0,
              ),
            ],
          ),
        ),
      ),
    ));

    expect(scrollController.offset, 0.0);

    await tester.showKeyboard(find.byType(EditableText));
    await tester.pumpAndSettle();
    expect(scrollController.offset, greaterThan(0.0));
    expect(find.byKey(container), findsNothing);
  });

  testWidgets(
    'A pinned persistent header should not scroll when its descendant EditableText gains focus',
    (WidgetTester tester) async {
      // Regression test for https://github.com/flutter/flutter/issues/25507.
      ScrollController controller;
      final TextEditingController textEditingController = TextEditingController();
      final FocusNode focusNode = FocusNode();

      const Key headerKey = Key('header');
      await tester.pumpWidget(
        MaterialApp(
          home: Center(
            child: SizedBox(
              height: 600.0,
              width: 600.0,
              child: CustomScrollView(
                controller: controller = ScrollController(),
                slivers: List<Widget>.generate(50, (int i) {
                  return i == 10
                  ? SliverPersistentHeader(
                    pinned: true,
                    delegate: _TestSliverPersistentHeaderDelegate(
                      minExtent: 50,
                      maxExtent: 50,
                      child: Container(
                        alignment: Alignment.topCenter,
                        child: EditableText(
                          key: headerKey,
                          backgroundCursorColor: Colors.grey,
                          controller: textEditingController,
                          focusNode: focusNode,
                          style: textStyle,
                          cursorColor: cursorColor,
                        ),
                      ),
                    ),
                  )
                  : SliverToBoxAdapter(
                    child: SizedBox(
                      height: 100.0,
                      child: Text('Tile $i'),
                    ),
                  );
                }),
              ),
            ),
          ),
        ),
      );

      // The persistent header should now be pinned at the top.
      controller.jumpTo(100.0 * 15);
      await tester.pumpAndSettle();
      expect(controller.offset, 100.0 * 15);

      focusNode.requestFocus();
      await tester.pumpAndSettle();
      // The scroll offset should remain the same.
      expect(controller.offset, 100.0 * 15);
    },
  );

  testWidgets(
    'A pinned persistent header should not scroll when its descendant EditableText gains focus (no animation)',
    (WidgetTester tester) async {
      // Regression test for https://github.com/flutter/flutter/issues/25507.
      ScrollController controller;
      final TextEditingController textEditingController = TextEditingController();
      final FocusNode focusNode = FocusNode();

      const Key headerKey = Key('header');
      await tester.pumpWidget(
        MaterialApp(
          home: Center(
            child: SizedBox(
              height: 600.0,
              width: 600.0,
              child: CustomScrollView(
                controller: controller = ScrollController(),
                slivers: List<Widget>.generate(50, (int i) {
                  return i == 10
                    ? SliverPersistentHeader(
                      pinned: true,
                      delegate: _TestSliverPersistentHeaderDelegate(
                        minExtent: 50,
                        maxExtent: 50,
                        vsync: null,
                        child: Container(
                          alignment: Alignment.topCenter,
                          child: EditableText(
                            key: headerKey,
                            backgroundCursorColor: Colors.grey,
                            controller: textEditingController,
                            focusNode: focusNode,
                            style: textStyle,
                            cursorColor: cursorColor,
                          ),
                        ),
                      ),
                    )
                    : SliverToBoxAdapter(
                      child: SizedBox(
                        height: 100.0,
                        child: Text('Tile $i'),
                      ),
                    );
                }),
              ),
            ),
          ),
        ),
      );

      // The persistent header should now be pinned at the top.
      controller.jumpTo(100.0 * 15);
      await tester.pumpAndSettle();
      expect(controller.offset, 100.0 * 15);

      focusNode.requestFocus();
      await tester.pumpAndSettle();
      // The scroll offset should remain the same.
      expect(controller.offset, 100.0 * 15);
    },
  );

  void testShowCaretOnScreen({ required bool readOnly }) {
    group('EditableText._showCaretOnScreen, readOnly=$readOnly', () {
      final TextEditingController textEditingController = TextEditingController();
      final TextInputFormatter rejectEverythingFormatter = TextInputFormatter.withFunction((TextEditingValue old, TextEditingValue value) => old);

      bool isCaretOnScreen(WidgetTester tester) {
        final EditableTextState state = tester.state<EditableTextState>(
          find.byType(EditableText, skipOffstage: false),
        );
        final RenderEditable renderEditable = state.renderEditable;
        final Rect localRect = renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base);
        final Offset caretOrigin = renderEditable.localToGlobal(localRect.topLeft);
        final Rect caretRect = caretOrigin & localRect.size;
        return const Rect.fromLTWH(0, 0,  800, 600).intersect(caretRect) == caretRect;
      }

      Widget buildEditableText({
        required bool rejectUserInputs,
        ScrollController? scrollController,
        ScrollController? editableScrollController,
      }) {
        return MaterialApp(
          home: Scaffold(
            body: ListView(
              controller: scrollController,
              cacheExtent: 1000,
              children: <Widget>[
                // The text field is not fully visible.
                const SizedBox(height: 599),
                EditableText(
                  backgroundCursorColor: Colors.grey,
                  controller: textEditingController,
                  scrollController: editableScrollController,
                  inputFormatters: <TextInputFormatter>[if (rejectUserInputs) rejectEverythingFormatter],
                  focusNode: focusNode,
                  style: textStyle,
                  cursorColor: cursorColor,
                  readOnly: readOnly,
                ),
              ],
            ),
          ),
        );
      }

      testWidgets('focus-triggered showCaretOnScreen', (WidgetTester tester) async {
        textEditingController.text = 'a' * 100;
        textEditingController.selection = const TextSelection.collapsed(offset: 100);
        final ScrollController scrollController = ScrollController();
        final ScrollController editableScrollController = ScrollController();

        await tester.pumpWidget(
          buildEditableText(
            rejectUserInputs: false,
            scrollController: scrollController,
            editableScrollController: editableScrollController,
          ),
        );

        focusNode.requestFocus();
        await tester.pumpAndSettle();

        if (kIsWeb) {
          await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
          await tester.pump();
        }

        // On web, the entire field is selected, and only part of that selection
        // is visible on the screen.
        expect(isCaretOnScreen(tester), !readOnly && !kIsWeb);
        expect(scrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
        expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
      });

      testWidgets('selection-triggered showCaretOnScreen: virtual keyboard', (WidgetTester tester) async {
        textEditingController.text = 'a' * 100;
        textEditingController.selection = const TextSelection.collapsed(offset: 80);
        final ScrollController scrollController = ScrollController();
        final ScrollController editableScrollController = ScrollController();

        await tester.pumpWidget(
          buildEditableText(
            rejectUserInputs: false,
            scrollController: scrollController,
            editableScrollController: editableScrollController,
          ),
        );

        focusNode.requestFocus();
        await tester.pumpAndSettle();

        // Ensure the caret is not fully visible and the text field is focused.
        scrollController.jumpTo(0);
        editableScrollController.jumpTo(0);
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), isFalse);

        final EditableTextState state = tester.state<EditableTextState>(
          find.byType(EditableText, skipOffstage: false),
        );

        // Change the selection. Show caret on screen when readyOnly is true,
        // as a read-only text field rejects everything from the software
        // keyboard (except for web).
        state.updateEditingValue(state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)));
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), !readOnly || kIsWeb);
        expect(scrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
        expect(editableScrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));

        // Reject user input.
        await tester.pumpWidget(
          buildEditableText(
            rejectUserInputs: true,
            scrollController: scrollController,
            editableScrollController: editableScrollController,
          ),
        );

        // Ensure the caret is not fully visible and the text field is focused.
        scrollController.jumpTo(0);
        editableScrollController.jumpTo(0);
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), isFalse);

        state.updateEditingValue(state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)));
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), !readOnly || kIsWeb);
        expect(scrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
        expect(editableScrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
      });

      testWidgets('selection-triggered showCaretOnScreen: text selection delegate', (WidgetTester tester) async {
        textEditingController.text = 'a' * 100;
        textEditingController.selection = const TextSelection.collapsed(offset: 80);
        final ScrollController scrollController = ScrollController();
        final ScrollController editableScrollController = ScrollController();

        await tester.pumpWidget(
          buildEditableText(
            rejectUserInputs: false,
            scrollController: scrollController,
            editableScrollController: editableScrollController,
          ),
        );

        focusNode.requestFocus();
        await tester.pumpAndSettle();

        // Ensure the caret is not fully visible and the text field is focused.
        scrollController.jumpTo(0);
        editableScrollController.jumpTo(0);
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), isFalse);

        final EditableTextState state = tester.state<EditableTextState>(
          find.byType(EditableText, skipOffstage: false),
        );

        // Change the selection. Show caret on screen even when readyOnly is
        // false.
        state.userUpdateTextEditingValue(
          state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)),
          null,
        );
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), isTrue);
        expect(scrollController.offset, greaterThan(0.0));
        expect(editableScrollController.offset, greaterThan(0.0));

        // Rejects user input.
        await tester.pumpWidget(
          buildEditableText(
            rejectUserInputs: true,
            scrollController: scrollController,
            editableScrollController: editableScrollController,
          ),
        );

        // Ensure the caret is not fully visible and the text field is focused.
        scrollController.jumpTo(0);
        editableScrollController.jumpTo(0);
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), isFalse);

        state.userUpdateTextEditingValue(
          state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)),
          null,
        );
        await tester.pumpAndSettle();
        expect(isCaretOnScreen(tester), isTrue);
        expect(scrollController.offset, greaterThan(0.0));
        expect(editableScrollController.offset, greaterThan(0.0));
      });

      // Regression text for https://github.com/flutter/flutter/pull/74722.
      testWidgets('does NOT randomly trigger when cursor blinks', (WidgetTester tester) async {
        textEditingController.text = 'a' * 100;
        textEditingController.selection = const TextSelection.collapsed(offset: 0);
        final ScrollController editableScrollController = ScrollController();
        final bool deterministicCursor = EditableText.debugDeterministicCursor;
        EditableText.debugDeterministicCursor = false;

        await tester.pumpWidget(
          MaterialApp(
            home: Scaffold(
              body: EditableText(
                backgroundCursorColor: Colors.grey,
                controller: textEditingController,
                scrollController: editableScrollController,
                focusNode: focusNode,
                style: textStyle,
                cursorColor: cursorColor,
              ),
            ),
          ),
        );

        final EditableTextState state = tester.state<EditableTextState>(
          find.byType(EditableText, skipOffstage: false),
        );

        // Ensure the text was initially visible.
        expect(isCaretOnScreen(tester), true);
        expect(editableScrollController.offset, 0.0);

        // Change the text but keep the cursor location.
        state.updateEditingValue(textEditingController.value.copyWith(
          text: 'a' * 101,
        ));

        await tester.pumpAndSettle();

        // The caret should stay where it was, since the selection didn't change.
        expect(isCaretOnScreen(tester), true);
        expect(editableScrollController.offset, 0.0);

        // Now move to hide the cursor.
        editableScrollController.jumpTo(100.0);

        // Does not trigger showCaretOnScreen.
        await tester.pump();
        await tester.pumpAndSettle();
        expect(editableScrollController.offset, 100.0);
        expect(isCaretOnScreen(tester), isFalse);

        EditableText.debugDeterministicCursor = deterministicCursor;
      });
    });
  }

  testShowCaretOnScreen(readOnly: true);
  testShowCaretOnScreen(readOnly: false);
}

class NoImplicitScrollPhysics extends AlwaysScrollableScrollPhysics {
  const NoImplicitScrollPhysics({ super.parent });

  @override
  bool get allowImplicitScrolling => false;

  @override
  NoImplicitScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return NoImplicitScrollPhysics(parent: buildParent(ancestor));
  }
}
