// Copyright 2015 The Chromium 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/rendering.dart' show RenderEditable, SelectionChangedHandler, RenderEditablePaintOffsetNeededCallback;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart';

import 'basic.dart';
import 'focus.dart';
import 'framework.dart';
import 'media_query.dart';
import 'scroll_behavior.dart';
import 'scrollable.dart';
import 'text_selection.dart';

export 'package:flutter/painting.dart' show TextSelection;
export 'package:flutter/services.dart' show TextInputType;

const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500);

TextSelection _getTextSelectionFromEditingState(TextEditingState state) {
  return new TextSelection(
    baseOffset: state.selectionBase,
    extentOffset: state.selectionExtent,
    affinity: state.selectionAffinity,
    isDirectional: state.selectionIsDirectional,
  );
}

InputValue _getInputValueFromEditingState(TextEditingState state) {
  return new InputValue(
    text: state.text,
    selection: _getTextSelectionFromEditingState(state),
    composing: new TextRange(start: state.composingBase, end: state.composingExtent),
  );
}

TextEditingState _getTextEditingStateFromInputValue(InputValue value) {
  return new TextEditingState(
    text: value.text,
    selectionBase: value.selection.baseOffset,
    selectionExtent: value.selection.extentOffset,
    selectionAffinity: value.selection.affinity,
    selectionIsDirectional: value.selection.isDirectional,
    composingBase: value.composing.start,
    composingExtent: value.composing.end,
  );
}

/// Configuration information for an input field.
///
/// An [InputValue] contains the text for the input field as well as the
/// selection extent and the composing range.
class InputValue {
  // TODO(abarth): This class is really the same as TextEditingState.
  // We should merge them into one object.

  /// Creates configuration information for an input field
  ///
  /// The selection and composing range must be within the text.
  ///
  /// The [text], [selection], and [composing] arguments must not be null but
  /// each have default values.
  const InputValue({
    this.text: '',
    this.selection: const TextSelection.collapsed(offset: -1),
    this.composing: TextRange.empty
  });

  /// The current text being edited.
  final String text;

  /// The range of text that is currently selected.
  final TextSelection selection;

  /// The range of text that is still being composed.
  final TextRange composing;

  /// An input value that corresponds to the empty string with no selection and no composing range.
  static const InputValue empty = const InputValue();

  @override
  String toString() => '$runtimeType(text: \u2524$text\u251C, selection: $selection, composing: $composing)';

  @override
  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
    if (other is! InputValue)
      return false;
    InputValue typedOther = other;
    return typedOther.text == text
        && typedOther.selection == selection
        && typedOther.composing == composing;
  }

  @override
  int get hashCode => hashValues(
    text.hashCode,
    selection.hashCode,
    composing.hashCode
  );

  /// Creates a copy of this input value but with the given fields replaced with the new values.
  InputValue copyWith({
    String text,
    TextSelection selection,
    TextRange composing
  }) {
    return new InputValue (
      text: text ?? this.text,
      selection: selection ?? this.selection,
      composing: composing ?? this.composing
    );
  }
}

/// A basic single-line input control.
///
/// This control is not intended to be used directly. Instead, consider using
/// [Input], which provides focus management and material design.
//
// TODO(mpcomplete): rename RawInput since it can span multiple lines.
class RawInput extends Scrollable {
  /// Creates a basic single-line input control.
  ///
  /// The [value] argument must not be null.
  RawInput({
    Key key,
    @required this.value,
    this.focusKey,
    this.hideText: false,
    this.style,
    this.cursorColor,
    this.textScaleFactor,
    int maxLines: 1,
    this.selectionColor,
    this.selectionControls,
    @required this.platform,
    this.keyboardType,
    this.onChanged,
    this.onSubmitted
  }) : maxLines = maxLines, super(
    key: key,
    initialScrollOffset: 0.0,
    scrollDirection: maxLines > 1 ? Axis.vertical : Axis.horizontal
  ) {
    assert(value != null);
  }

  /// The string being displayed in this widget.
  final InputValue value;

  /// Key of the enclosing widget that holds the focus.
  final GlobalKey focusKey;

  /// Whether to hide the text being edited (e.g., for passwords).
  final bool hideText;

  /// The text style to use for the editable text.
  final TextStyle style;

  /// The number of font pixels for each logical pixel.
  ///
  /// For example, if the text scale factor is 1.5, text will be 50% larger than
  /// the specified font size.
  ///
  /// Defaults to [MediaQuery.textScaleFactor].
  final double textScaleFactor;

  /// The color to use when painting the cursor.
  final Color cursorColor;

  /// The maximum number of lines for the text to span, wrapping if necessary.
  /// If this is 1 (the default), the text will not wrap, but will scroll
  /// horizontally instead.
  final int maxLines;

  /// The color to use when painting the selection.
  final Color selectionColor;

  /// Optional delegate for building the text selection handles and toolbar.
  final TextSelectionControls selectionControls;

  /// The platform whose behavior should be approximated, in particular
  /// for scroll physics. (See [ScrollBehavior.platform].)
  ///
  /// Must not be null.
  final TargetPlatform platform;

  /// The type of keyboard to use for editing the text.
  final TextInputType keyboardType;

  /// Called when the text being edited changes.
  final ValueChanged<InputValue> onChanged;

  /// Called when the user indicates that they are done editing the text in the field.
  final ValueChanged<InputValue> onSubmitted;

  @override
  RawInputState createState() => new RawInputState();
}

/// State for a [RawInput].
class RawInputState extends ScrollableState<RawInput> implements TextInputClient {
  Timer _cursorTimer;
  bool _showCursor = false;

  InputValue _currentValue;
  TextInputConnection _textInputConnection;
  TextSelectionOverlay _selectionOverlay;

  @override
  ExtentScrollBehavior createScrollBehavior() => new BoundedBehavior(platform: config.platform);

  @override
  BoundedBehavior get scrollBehavior => super.scrollBehavior;

  @override
  void initState() {
    super.initState();
    _currentValue = config.value;
  }

  @override
  void didUpdateConfig(RawInput oldConfig) {
    if (_currentValue != config.value) {
      _currentValue = config.value;
      if (_isAttachedToKeyboard)
        _textInputConnection.setEditingState(_getTextEditingStateFromInputValue(_currentValue));
    }
  }

  bool get _isAttachedToKeyboard => _textInputConnection != null && _textInputConnection.attached;

  bool get _isMultiline => config.maxLines > 1;

  double _contentExtent = 0.0;
  double _containerExtent = 0.0;

  Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions, Rect caretRect) {
    // We make various state changes here but don't have to do so in a
    // setState() callback because we are called during layout and all
    // we're updating is the new offset, which we are providing to the
    // render object via our return value.
    _contentExtent = _isMultiline ?
      dimensions.contentSize.height :
      dimensions.contentSize.width;
    _containerExtent = _isMultiline ?
      dimensions.containerSize.height :
      dimensions.containerSize.width;
    didUpdateScrollBehavior(scrollBehavior.updateExtents(
      contentExtent: _contentExtent,
      containerExtent: _containerExtent,
      // TODO(ianh): We should really only do this when text is added,
      // not generally any time the size changes.
      scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent)
    ));
    updateGestureDetector();
    return scrollOffsetToPixelDelta(scrollOffset);
  }

  // Calculate the new scroll offset so the cursor remains visible.
  double _getScrollOffsetForCaret(Rect caretRect, double containerExtent) {
    double caretStart = _isMultiline ? caretRect.top : caretRect.left;
    double caretEnd = _isMultiline ? caretRect.bottom : caretRect.right;
    double newScrollOffset = scrollOffset;
    if (caretStart < 0.0)  // cursor before start of bounds
      newScrollOffset += pixelOffsetToScrollOffset(-caretStart);
    else if (caretEnd >= containerExtent)  // cursor after end of bounds
      newScrollOffset += pixelOffsetToScrollOffset(-(caretEnd - containerExtent));
    return newScrollOffset;
  }

  void _attachOrDetachKeyboard(bool focused) {
    if (focused && !_isAttachedToKeyboard) {
      _textInputConnection = TextInput.attach(
          this, new TextInputConfiguration(inputType: config.keyboardType))
        ..setEditingState(_getTextEditingStateFromInputValue(_currentValue))
        ..show();
    } else if (!focused) {
      if (_isAttachedToKeyboard) {
        _textInputConnection.close();
        _textInputConnection = null;
      }
      _clearComposing();
    }
  }

  void _clearComposing() {
    // TODO(abarth): We should call config.onChanged to notify our parent of
    // this change in our composing range.
    _currentValue = _currentValue.copyWith(composing: TextRange.empty);
  }

  /// Express interest in interacting with the keyboard.
  ///
  /// If this control is already attached to the keyboard, this function will
  /// request that the keyboard become visible. Otherwise, this function will
  /// ask the focus system that it become focused. If successful in acquiring
  /// focus, the control will then attach to the keyboard and request that the
  /// keyboard become visible.
  void requestKeyboard() {
    if (_isAttachedToKeyboard) {
      _textInputConnection.show();
    } else {
      Focus.moveTo(config.focusKey);
    }
  }

  @override
  void updateEditingState(TextEditingState state) {
    _currentValue = _getInputValueFromEditingState(state);
    if (config.onChanged != null)
      config.onChanged(_currentValue);
    if (_currentValue.text != config.value.text) {
      _selectionOverlay?.hide();
      _selectionOverlay = null;
    }
  }

  @override
  void performAction(TextInputAction action) {
    _clearComposing();
    Focus.clear(context);
    if (config.onSubmitted != null)
      config.onSubmitted(_currentValue);
  }

  void _handleSelectionChanged(TextSelection selection, RenderEditable renderObject, bool longPress) {
    // Note that this will show the keyboard for all selection changes on the
    // EditableWidget, not just changes triggered by user gestures.
    requestKeyboard();

    InputValue newInput = _currentValue.copyWith(selection: selection, composing: TextRange.empty);
    if (config.onChanged != null)
      config.onChanged(newInput);

    if (_selectionOverlay != null) {
      _selectionOverlay.hide();
      _selectionOverlay = null;
    }

    if (config.selectionControls != null) {
      _selectionOverlay = new TextSelectionOverlay(
        input: newInput,
        context: context,
        debugRequiredFor: config,
        renderObject: renderObject,
        onSelectionOverlayChanged: _handleSelectionOverlayChanged,
        selectionControls: config.selectionControls,
      );
      if (newInput.text.isNotEmpty || longPress)
        _selectionOverlay.showHandles();
      if (longPress)
        _selectionOverlay.showToolbar();
    }
  }

  void _handleSelectionOverlayChanged(InputValue newInput, Rect caretRect) {
    assert(!newInput.composing.isValid);  // composing range must be empty while selecting
    if (config.onChanged != null)
      config.onChanged(newInput);

    didUpdateScrollBehavior(scrollBehavior.updateExtents(
      // TODO(mpcomplete): should just be able to pass
      // scrollBehavior.containerExtent here (and remove the member var), but
      // scrollBehavior gets re-created too often, and is sometimes
      // uninitialized here. Investigate if this is a bug.
      scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent)
    ));
  }

  /// Whether the blinking cursor is actually visible at this precise moment
  /// (it's hidden half the time, since it blinks).
  bool get cursorCurrentlyVisible => _showCursor;

  /// The cursor blink interval (the amount of time the cursor is in the "on"
  /// state or the "off" state). A complete cursor blink period is twice this
  /// value (half on, half off).
  Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod;

  void _cursorTick(Timer timer) {
    setState(() {
      _showCursor = !_showCursor;
    });
  }

  void _startCursorTimer() {
    _showCursor = true;
    _cursorTimer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
  }

  @override
  void dispose() {
    if (_isAttachedToKeyboard) {
      _textInputConnection.close();
      _textInputConnection = null;
    }
    if (_cursorTimer != null)
      _stopCursorTimer();
    _selectionOverlay?.dispose();
    super.dispose();
  }

  void _stopCursorTimer() {
    _cursorTimer.cancel();
    _cursorTimer = null;
    _showCursor = false;
  }

  @override
  Widget buildContent(BuildContext context) {
    assert(config.style != null);
    assert(config.focusKey != null);
    assert(config.cursorColor != null);

    bool focused = Focus.at(config.focusKey.currentContext);
    _attachOrDetachKeyboard(focused);

    if (_cursorTimer == null && focused && config.value.selection.isCollapsed)
      _startCursorTimer();
    else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed))
      _stopCursorTimer();

    if (_selectionOverlay != null) {
      if (focused) {
        _selectionOverlay.update(config.value);
      } else {
        _selectionOverlay?.dispose();
        _selectionOverlay = null;
      }
    }

    return new ClipRect(
      child: new _Editable(
        value: _currentValue,
        style: config.style,
        cursorColor: config.cursorColor,
        showCursor: _showCursor,
        maxLines: config.maxLines,
        selectionColor: config.selectionColor,
        textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor,
        hideText: config.hideText,
        onSelectionChanged: _handleSelectionChanged,
        paintOffset: scrollOffsetToPixelDelta(scrollOffset),
        onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded
      )
    );
  }
}

class _Editable extends LeafRenderObjectWidget {
  _Editable({
    Key key,
    this.value,
    this.style,
    this.cursorColor,
    this.showCursor,
    this.maxLines,
    this.selectionColor,
    this.textScaleFactor,
    this.hideText,
    this.onSelectionChanged,
    this.paintOffset,
    this.onPaintOffsetUpdateNeeded
  }) : super(key: key);

  final InputValue value;
  final TextStyle style;
  final Color cursorColor;
  final bool showCursor;
  final int maxLines;
  final Color selectionColor;
  final double textScaleFactor;
  final bool hideText;
  final SelectionChangedHandler onSelectionChanged;
  final Offset paintOffset;
  final RenderEditablePaintOffsetNeededCallback onPaintOffsetUpdateNeeded;

  @override
  RenderEditable createRenderObject(BuildContext context) {
    return new RenderEditable(
      text: _styledTextSpan,
      cursorColor: cursorColor,
      showCursor: showCursor,
      maxLines: maxLines,
      selectionColor: selectionColor,
      textScaleFactor: textScaleFactor,
      selection: value.selection,
      onSelectionChanged: onSelectionChanged,
      paintOffset: paintOffset,
      onPaintOffsetUpdateNeeded: onPaintOffsetUpdateNeeded
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderEditable renderObject) {
    renderObject
      ..text = _styledTextSpan
      ..cursorColor = cursorColor
      ..showCursor = showCursor
      ..maxLines = maxLines
      ..selectionColor = selectionColor
      ..textScaleFactor = textScaleFactor
      ..selection = value.selection
      ..onSelectionChanged = onSelectionChanged
      ..paintOffset = paintOffset
      ..onPaintOffsetUpdateNeeded = onPaintOffsetUpdateNeeded;
  }

  TextSpan get _styledTextSpan {
    if (!hideText && value.composing.isValid) {
      TextStyle composingStyle = style.merge(
        const TextStyle(decoration: TextDecoration.underline)
      );

      return new TextSpan(
        style: style,
        children: <TextSpan>[
          new TextSpan(text: value.composing.textBefore(value.text)),
          new TextSpan(
            style: composingStyle,
            text: value.composing.textInside(value.text)
          ),
          new TextSpan(text: value.composing.textAfter(value.text))
      ]);
    }

    String text = value.text;
    if (hideText)
      text = new String.fromCharCodes(new List<int>.filled(text.length, 0x2022));
    return new TextSpan(style: style, text: text);
  }
}
