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

/// Flutter code sample for [SelectionArea].

void main() => runApp(const SelectionAreaColorTextRedExampleApp());

class SelectionAreaColorTextRedExampleApp extends StatelessWidget {
  const SelectionAreaColorTextRedExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

typedef LocalSpanRange = ({int startOffset, int endOffset});

class _MyHomePageState extends State<MyHomePage> {
  final SelectionListenerNotifier _selectionNotifier =
      SelectionListenerNotifier();
  final ContextMenuController _menuController = ContextMenuController();
  final GlobalKey<SelectionAreaState> selectionAreaKey =
      GlobalKey<SelectionAreaState>();

  // The data of the top level TextSpans. Each TextSpan is mapped to a LocalSpanRange,
  // which is the range the textspan covers relative to the SelectionListener it is under.
  Map<LocalSpanRange, TextSpan> dataSourceMap = <LocalSpanRange, TextSpan>{};
  // The data of the bulleted list contained within a WidgetSpan. Each bullet is mapped
  // to a LocalSpanRange, being the range the bullet covers relative to the SelectionListener
  // it is under.
  Map<LocalSpanRange, TextSpan> bulletSourceMap = <LocalSpanRange, TextSpan>{};
  Map<int, Map<LocalSpanRange, TextSpan>> widgetSpanMaps =
      <int, Map<LocalSpanRange, TextSpan>>{};
  // The origin data used to restore the demo to its initial state.
  late final Map<LocalSpanRange, TextSpan> originSourceData;
  late final Map<LocalSpanRange, TextSpan> originBulletSourceData;

  void _initData() {
    const String bulletListTitle = 'This is some bulleted list:\n';
    final List<String> bullets = <String>[
      for (int i = 1; i <= 7; i += 1) '• Bullet $i',
    ];
    final TextSpan bulletedList = TextSpan(
      text: bulletListTitle,
      children: <InlineSpan>[
        WidgetSpan(
          child: Column(
            children: <Widget>[
              for (final String bullet in bullets)
                Padding(
                  padding: const EdgeInsets.only(left: 20.0),
                  child: Text(bullet),
                ),
            ],
          ),
        ),
      ],
    );

    int currentOffset = 0;
    // Map bulleted list span to a local range using its concrete length calculated
    // from the length of its title and each individual bullet.
    dataSourceMap[(
          startOffset: currentOffset,
          endOffset: bulletListTitle.length + bullets.join().length,
        )] =
        bulletedList;
    currentOffset += bulletListTitle.length;
    widgetSpanMaps[currentOffset] = bulletSourceMap;
    // Map individual bullets to a local range.
    for (final String bullet in bullets) {
      bulletSourceMap[(
        startOffset: currentOffset,
        endOffset: currentOffset + bullet.length,
      )] = TextSpan(
        text: bullet,
      );
      currentOffset += bullet.length;
    }

    const TextSpan secondTextParagraph = TextSpan(
      text: 'This is some text in a text widget.',
      children: <InlineSpan>[
        TextSpan(text: ' This is some more text in the same text widget.'),
      ],
    );
    const TextSpan thirdTextParagraph = TextSpan(
      text: 'This is some text in another text widget.',
    );
    // Map second and third paragraphs to local ranges.
    dataSourceMap[(
          startOffset: currentOffset,
          endOffset:
              currentOffset +
              secondTextParagraph
                  .toPlainText(includeSemanticsLabels: false)
                  .length,
        )] =
        secondTextParagraph;
    currentOffset += secondTextParagraph
        .toPlainText(includeSemanticsLabels: false)
        .length;
    dataSourceMap[(
          startOffset: currentOffset,
          endOffset:
              currentOffset +
              thirdTextParagraph
                  .toPlainText(includeSemanticsLabels: false)
                  .length,
        )] =
        thirdTextParagraph;

    // Save the origin data so we can revert our changes.
    originSourceData = <LocalSpanRange, TextSpan>{};
    for (final MapEntry<LocalSpanRange, TextSpan> entry
        in dataSourceMap.entries) {
      originSourceData[entry.key] = entry.value;
    }
    originBulletSourceData = <LocalSpanRange, TextSpan>{};
    for (final MapEntry<LocalSpanRange, TextSpan> entry
        in bulletSourceMap.entries) {
      originBulletSourceData[entry.key] = entry.value;
    }
  }

  void _handleSelectableRegionStatusChanged(
    SelectableRegionSelectionStatus status,
  ) {
    if (_menuController.isShown) {
      ContextMenuController.removeAny();
    }
    if (_selectionNotifier.selection.status != SelectionStatus.uncollapsed ||
        status != SelectableRegionSelectionStatus.finalized) {
      return;
    }
    if (selectionAreaKey.currentState == null ||
        !selectionAreaKey.currentState!.mounted ||
        selectionAreaKey
                .currentState!
                .selectableRegion
                .contextMenuAnchors
                .secondaryAnchor ==
            null) {
      return;
    }
    final SelectedContentRange? selectedContentRange =
        _selectionNotifier.selection.range;
    if (selectedContentRange == null) {
      return;
    }
    _menuController.show(
      context: context,
      contextMenuBuilder: (BuildContext context) {
        return TapRegion(
          onTapOutside: (PointerDownEvent event) {
            if (_menuController.isShown) {
              ContextMenuController.removeAny();
            }
          },
          child: AdaptiveTextSelectionToolbar.buttonItems(
            buttonItems: <ContextMenuButtonItem>[
              ContextMenuButtonItem(
                onPressed: () {
                  ContextMenuController.removeAny();
                  _colorSelectionRed(
                    selectedContentRange,
                    dataMap: dataSourceMap,
                    coloringChildSpan: false,
                  );
                  selectionAreaKey.currentState!.selectableRegion
                      .clearSelection();
                },
                label: 'Color Text Red',
              ),
            ],
            anchors: TextSelectionToolbarAnchors(
              primaryAnchor: selectionAreaKey
                  .currentState!
                  .selectableRegion
                  .contextMenuAnchors
                  .secondaryAnchor!,
            ),
          ),
        );
      },
    );
  }

  void _colorSelectionRed(
    SelectedContentRange selectedContentRange, {
    required Map<LocalSpanRange, TextSpan> dataMap,
    required bool coloringChildSpan,
  }) {
    for (final MapEntry<LocalSpanRange, TextSpan> entry in dataMap.entries) {
      final LocalSpanRange entryLocalRange = entry.key;
      final int normalizedStartOffset = min(
        selectedContentRange.startOffset,
        selectedContentRange.endOffset,
      );
      final int normalizedEndOffset = max(
        selectedContentRange.startOffset,
        selectedContentRange.endOffset,
      );
      if (normalizedStartOffset > entryLocalRange.endOffset) {
        continue;
      }
      if (normalizedEndOffset < entryLocalRange.startOffset) {
        continue;
      }
      // The selection details is covering the current entry so let's color the range red.
      final TextSpan rawSpan = entry.value;
      // Determine local ranges relative to rawSpan.
      final int clampedLocalStart =
          normalizedStartOffset < entryLocalRange.startOffset
          ? entryLocalRange.startOffset
          : normalizedStartOffset;
      final int clampedLocalEnd =
          normalizedEndOffset > entryLocalRange.endOffset
          ? entryLocalRange.endOffset
          : normalizedEndOffset;
      final int startOffset = (clampedLocalStart - entryLocalRange.startOffset)
          .abs();
      final int endOffset =
          startOffset + (clampedLocalEnd - clampedLocalStart).abs();
      final List<InlineSpan> beforeSelection = <InlineSpan>[];
      final List<InlineSpan> insideSelection = <InlineSpan>[];
      final List<InlineSpan> afterSelection = <InlineSpan>[];
      int count = 0;
      rawSpan.visitChildren((InlineSpan child) {
        if (child is TextSpan) {
          final String? rawText = child.text;
          if (rawText != null) {
            if (count < startOffset) {
              final int newStart = min(startOffset - count, rawText.length);
              final int globalNewStart = count + newStart;
              // Collect spans before selection.
              beforeSelection.add(
                TextSpan(
                  style: child.style,
                  text: rawText.substring(0, newStart),
                ),
              );
              // Check if this span also contains the selection.
              if (globalNewStart == startOffset && newStart < rawText.length) {
                final int newStartAfterSelection = min(
                  newStart + (endOffset - startOffset),
                  rawText.length,
                );
                final int globalNewStartAfterSelection =
                    count + newStartAfterSelection;
                insideSelection.add(
                  TextSpan(
                    style: const TextStyle(
                      color: Colors.red,
                    ).merge(entry.value.style),
                    text: rawText.substring(newStart, newStartAfterSelection),
                  ),
                );
                // Check if this span contains content after the selection.
                if (globalNewStartAfterSelection == endOffset &&
                    newStartAfterSelection < rawText.length) {
                  afterSelection.add(
                    TextSpan(
                      style: child.style,
                      text: rawText.substring(newStartAfterSelection),
                    ),
                  );
                }
              }
            } else if (count >= endOffset) {
              // Collect spans after selection.
              afterSelection.add(TextSpan(style: child.style, text: rawText));
            } else {
              // Collect spans inside selection.
              final int newStart = min(endOffset - count, rawText.length);
              final int globalNewStart = count + newStart;
              insideSelection.add(
                TextSpan(
                  style: const TextStyle(color: Colors.red),
                  text: rawText.substring(0, newStart),
                ),
              );
              // Check if this span contains content after the selection.
              if (globalNewStart == endOffset && newStart < rawText.length) {
                afterSelection.add(
                  TextSpan(
                    style: child.style,
                    text: rawText.substring(newStart),
                  ),
                );
              }
            }
            count += rawText.length;
          }
        } else if (child is WidgetSpan) {
          if (!widgetSpanMaps.containsKey(count)) {
            // We have arrived at a WidgetSpan but it is unaccounted for.
            return true;
          }
          final Map<LocalSpanRange, TextSpan> widgetSpanSourceMap =
              widgetSpanMaps[count]!;
          if (count < startOffset &&
              count +
                      (widgetSpanSourceMap.keys.last.endOffset -
                              widgetSpanSourceMap.keys.first.startOffset)
                          .abs() <
                  startOffset) {
            // When the count is less than the startOffset and we are at a widgetspan
            // it is still possible that the startOffset is somewhere within the widgetspan,
            // so we should try to color the selection red for the widgetspan.
            //
            // If the calculated widgetspan length would not extend the count past the
            // startOffset then add this widgetspan to the beforeSelection, and
            // continue walking the tree.
            beforeSelection.add(child);
            count +=
                (widgetSpanSourceMap.keys.last.endOffset -
                        widgetSpanSourceMap.keys.first.startOffset)
                    .abs();
            return true;
          } else if (count >= endOffset) {
            afterSelection.add(child);
            count +=
                (widgetSpanSourceMap.keys.last.endOffset -
                        widgetSpanSourceMap.keys.first.startOffset)
                    .abs();
            return true;
          }
          // Update widgetspan data.
          _colorSelectionRed(
            selectedContentRange,
            dataMap: widgetSpanSourceMap,
            coloringChildSpan: true,
          );
          // Re-create widgetspan.
          if (count == 28) {
            // The index where the bulleted list begins.
            insideSelection.add(
              WidgetSpan(
                child: Column(
                  children: <Widget>[
                    for (final MapEntry<LocalSpanRange, TextSpan> entry
                        in widgetSpanSourceMap.entries)
                      Padding(
                        padding: const EdgeInsets.only(left: 20.0),
                        child: Text.rich(widgetSpanSourceMap[entry.key]!),
                      ),
                  ],
                ),
              ),
            );
          }
          count +=
              (widgetSpanSourceMap.keys.last.endOffset -
                      widgetSpanSourceMap.keys.first.startOffset)
                  .abs();
          return true;
        }
        return true;
      });
      dataMap[entry.key] = TextSpan(
        style: dataMap[entry.key]!.style,
        children: <InlineSpan>[
          ...beforeSelection,
          ...insideSelection,
          ...afterSelection,
        ],
      );
    }
    // Avoid clearing the selection and setting the state
    // before we have colored all parts of the selection.
    if (!coloringChildSpan) {
      setState(() {});
    }
  }

  @override
  void initState() {
    super.initState();
    _initData();
  }

  @override
  void dispose() {
    _selectionNotifier.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: SelectionArea(
        key: selectionAreaKey,
        child: MySelectableTextColumn(
          selectionNotifier: _selectionNotifier,
          dataSourceMap: dataSourceMap,
          onChanged: _handleSelectableRegionStatusChanged,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            // Resets the state to the origin data.
            for (final MapEntry<LocalSpanRange, TextSpan> entry
                in originSourceData.entries) {
              dataSourceMap[entry.key] = entry.value;
            }
            for (final MapEntry<LocalSpanRange, TextSpan> entry
                in originBulletSourceData.entries) {
              bulletSourceMap[entry.key] = entry.value;
            }
          });
        },
        child: const Icon(Icons.undo),
      ),
    );
  }
}

class MySelectableTextColumn extends StatefulWidget {
  const MySelectableTextColumn({
    super.key,
    required this.selectionNotifier,
    required this.dataSourceMap,
    required this.onChanged,
  });

  final SelectionListenerNotifier selectionNotifier;
  final Map<LocalSpanRange, TextSpan> dataSourceMap;
  final ValueChanged<SelectableRegionSelectionStatus> onChanged;

  @override
  State<MySelectableTextColumn> createState() => _MySelectableTextColumnState();
}

class _MySelectableTextColumnState extends State<MySelectableTextColumn> {
  ValueListenable<SelectableRegionSelectionStatus>? _selectableRegionScope;

  void _handleOnSelectableRegionChanged() {
    if (_selectableRegionScope == null) {
      return;
    }
    widget.onChanged.call(_selectableRegionScope!.value);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged);
    _selectableRegionScope = SelectableRegionSelectionStatusScope.maybeOf(
      context,
    );
    _selectableRegionScope?.addListener(_handleOnSelectableRegionChanged);
  }

  @override
  void dispose() {
    _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged);
    _selectableRegionScope = null;
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SelectionListener(
      selectionNotifier: widget.selectionNotifier,
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            for (final MapEntry<LocalSpanRange, TextSpan> entry
                in widget.dataSourceMap.entries)
              Text.rich(entry.value),
          ],
        ),
      ),
    );
  }
}
