/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.

For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#include "chat_helpers/emoji_suggestions_widget.h"

#include "chat_helpers/emoji_suggestions_helper.h"
#include "ui/effects/ripple_animation.h"
#include "ui/widgets/shadow.h"
#include "ui/widgets/inner_dropdown.h"
#include "ui/widgets/input_fields.h"
#include "ui/emoji_config.h"
#include "platform/platform_specific.h"
#include "core/event_filter.h"
#include "styles/style_chat_helpers.h"

namespace Ui {
namespace Emoji {
namespace {

constexpr auto kRowLimit = 5;

} // namespace

class SuggestionsWidget::Row {
public:
	Row(not_null<EmojiPtr> emoji, const QString &label, const QString &replacement);
	Row(const Row &other) = delete;
	Row &operator=(const Row &other) = delete;
	Row(Row &&other) = default;
	Row &operator=(Row &&other) = default;
	~Row();

	not_null<EmojiPtr> emoji() const {
		return _emoji;
	}
	const QString &label() const {
		return _label;
	}
	const QString &replacement() const {
		return _replacement;
	}
	RippleAnimation *ripple() const {
		return _ripple.get();
	}
	void setRipple(std::unique_ptr<RippleAnimation> ripple) {
		_ripple = std::move(ripple);
	}
	void resetRipple() {
		_ripple.reset();
	}

private:
	not_null<EmojiPtr> _emoji;
	QString _label;
	QString _replacement;
	std::unique_ptr<RippleAnimation> _ripple;

};

SuggestionsWidget::Row::Row(not_null<EmojiPtr> emoji, const QString &label, const QString &replacement)
: _emoji(emoji)
, _label(label)
, _replacement(replacement) {
}

SuggestionsWidget::Row::~Row() = default;

SuggestionsWidget::SuggestionsWidget(QWidget *parent, const style::Menu &st) : TWidget(parent)
, _st(&st)
, _rowHeight(_st->itemPadding.top() + _st->itemFont->height + _st->itemPadding.bottom()) {
	setMouseTracking(true);
}

rpl::producer<bool> SuggestionsWidget::toggleAnimated() const {
	return _toggleAnimated.events();
}

rpl::producer<QString> SuggestionsWidget::triggered() const {
	return _triggered.events();
}

void SuggestionsWidget::showWithQuery(const QString &query) {
	if (_query == query) {
		return;
	}
	_query = query;
	auto rows = getRowsByQuery();
	if (rows.empty()) {
		_toggleAnimated.fire(false);
	}
	clearSelection();
	_rows = std::move(rows);
	resizeToRows();
	update();
	if (!_rows.empty()) {
		setSelected(0);
	}
	if (!_rows.empty()) {
		_toggleAnimated.fire(true);
	}
}

std::vector<SuggestionsWidget::Row> SuggestionsWidget::getRowsByQuery() const {
	auto result = std::vector<Row>();
	if (_query.isEmpty()) {
		return result;
	}
	auto suggestions = GetSuggestions(QStringToUTF16(_query));
	if (suggestions.empty()) {
		return result;
	}
	auto count = suggestions.size();
	auto suggestionsEmoji = std::vector<EmojiPtr>(count, nullptr);
	for (auto i = 0; i != count; ++i) {
		suggestionsEmoji[i] = Find(QStringFromUTF16(suggestions[i].emoji()));
	}
	auto recents = 0;
	auto &recent = GetRecent();
	for (auto &item : recent) {
		auto emoji = item.first->original();
		if (!emoji) emoji = item.first;
		auto it = std::find(suggestionsEmoji.begin(), suggestionsEmoji.end(), emoji);
		if (it != suggestionsEmoji.end()) {
			auto index = (it - suggestionsEmoji.begin());
			if (index >= recents) {
				if (index > recents) {
					auto recentEmoji = suggestionsEmoji[index];
					auto recentSuggestion = suggestions[index];
					for (auto i = index; i != recents; --i) {
						suggestionsEmoji[i] = suggestionsEmoji[i - 1];
						suggestions[i] = suggestions[i - 1];
					}
					suggestionsEmoji[recents] = recentEmoji;
					suggestions[recents] = recentSuggestion;
				}
				++recents;
			}
		}
	}

	result.reserve(kRowLimit);
	auto index = 0;
	for (auto &item : suggestions) {
		if (auto emoji = suggestionsEmoji[index++]) {
			if (emoji->hasVariants()) {
				auto it = cEmojiVariants().constFind(emoji->nonColoredId());
				if (it != cEmojiVariants().cend()) {
					emoji = emoji->variant(it.value());
				}
			}
			result.emplace_back(emoji, QStringFromUTF16(item.label()), QStringFromUTF16(item.replacement()));
			if (result.size() == kRowLimit) {
				break;
			}
		}
	}
	return result;
}

void SuggestionsWidget::resizeToRows() {
	auto newWidth = 0;
	for (auto &row : _rows) {
		accumulate_max(newWidth, countWidth(row));
	}
	newWidth = snap(newWidth, _st->widthMin, _st->widthMax);
	auto newHeight = _st->skip + (_rows.size() * _rowHeight) + _st->skip;
	resize(newWidth, newHeight);
}

int SuggestionsWidget::countWidth(const Row &row) {
	auto textw = _st->itemFont->width(row.label());
	return _st->itemPadding.left() + textw + _st->itemPadding.right();
}

void SuggestionsWidget::paintEvent(QPaintEvent *e) {
	Painter p(this);

	auto ms = getms();
	auto clip = e->rect();

	auto topskip = QRect(0, 0, width(), _st->skip);
	auto bottomskip = QRect(0, height() - _st->skip, width(), _st->skip);
	if (clip.intersects(topskip)) p.fillRect(clip.intersected(topskip), _st->itemBg);
	if (clip.intersects(bottomskip)) p.fillRect(clip.intersected(bottomskip), _st->itemBg);

	const auto top = _st->skip;
	p.setFont(_st->itemFont);
	const auto from = floorclamp(clip.top() - top, _rowHeight, 0, _rows.size());
	const auto to = ceilclamp(clip.top() + clip.height() - top, _rowHeight, 0, _rows.size());
	p.translate(0, top + from * _rowHeight);
	for (auto i = from; i != to; ++i) {
		auto &row = _rows[i];
		const auto selected = (i == _selected || i == _pressed);
		p.fillRect(0, 0, width(), _rowHeight, selected ? _st->itemBgOver : _st->itemBg);
		if (const auto ripple = row.ripple()) {
			ripple->paint(p, 0, 0, width(), ms);
			if (ripple->empty()) {
				row.resetRipple();
			}
		}
		const auto emoji = row.emoji();
		const auto esize = Ui::Emoji::GetSizeLarge();
		Ui::Emoji::Draw(
			p,
			emoji,
			esize,
			(_st->itemPadding.left() - (esize / cIntRetinaFactor())) / 2,
			(_rowHeight - (esize / cIntRetinaFactor())) / 2);
		p.setPen(selected ? _st->itemFgOver : _st->itemFg);
		p.drawTextLeft(_st->itemPadding.left(), _st->itemPadding.top(), width(), row.label());
		p.translate(0, _rowHeight);
	}
}

void SuggestionsWidget::keyPressEvent(QKeyEvent *e) {
	handleKeyEvent(e->key());
}

void SuggestionsWidget::handleKeyEvent(int key) {
	if (key == Qt::Key_Enter || key == Qt::Key_Return || key == Qt::Key_Tab) {
		triggerSelectedRow();
		return;
	}
	if ((key != Qt::Key_Up && key != Qt::Key_Down) || _rows.size() < 1) {
		return;
	}

	auto delta = (key == Qt::Key_Down ? 1 : -1), start = _selected;
	if (start < 0 || start >= _rows.size()) {
		start = (delta > 0) ? (_rows.size() - 1) : 0;
	}
	auto newSelected = start + delta;
	if (newSelected < 0) {
		newSelected += _rows.size();
	} else if (newSelected >= _rows.size()) {
		newSelected -= _rows.size();
	}

	_mouseSelection = false;
	setSelected(newSelected);
}

void SuggestionsWidget::setSelected(int selected) {
	if (selected >= _rows.size()) {
		selected = -1;
	}
	if (_selected != selected) {
		updateSelectedItem();
		_selected = selected;
		updateSelectedItem();
	}
}

void SuggestionsWidget::setPressed(int pressed) {
	if (pressed >= _rows.size()) {
		pressed = -1;
	}
	if (_pressed != pressed) {
		_pressed = pressed;
	}
}

void SuggestionsWidget::clearMouseSelection() {
	if (_mouseSelection) {
		clearSelection();
	}
}

void SuggestionsWidget::clearSelection() {
	_mouseSelection = false;
	setSelected(-1);
}

int SuggestionsWidget::itemTop(int index) {
	if (index > _rows.size()) {
		index = _rows.size();
	}
	return _st->skip + (_rowHeight * index);
}

void SuggestionsWidget::updateItem(int index) {
	if (index >= 0 && index < _rows.size()) {
		update(0, itemTop(index), width(), _rowHeight);
	}
}

void SuggestionsWidget::updateSelectedItem() {
	updateItem(_selected);
}

void SuggestionsWidget::mouseMoveEvent(QMouseEvent *e) {
	auto inner = rect().marginsRemoved(QMargins(0, _st->skip, 0, _st->skip));
	auto localPosition = e->pos();
	if (inner.contains(localPosition)) {
		_mouseSelection = true;
		updateSelection(e->globalPos());
	} else {
		clearMouseSelection();
	}
}

void SuggestionsWidget::updateSelection(QPoint globalPosition) {
	if (!_mouseSelection) return;

	auto p = mapFromGlobal(globalPosition) - QPoint(0, _st->skip);
	auto selected = (p.y() >= 0) ? (p.y() / _rowHeight) : -1;
	setSelected((selected >= 0 && selected < _rows.size()) ? selected : -1);
}
void SuggestionsWidget::mousePressEvent(QMouseEvent *e) {
	if (!_mouseSelection) {
		return;
	}
	if (_selected >= 0 && _selected < _rows.size()) {
		setPressed(_selected);
		if (!_rows[_pressed].ripple()) {
			auto mask = RippleAnimation::rectMask(QSize(width(), _rowHeight));
			_rows[_pressed].setRipple(std::make_unique<RippleAnimation>(_st->ripple, std::move(mask), [this, selected = _pressed] {
				updateItem(selected);
			}));
		}
		_rows[_pressed].ripple()->add(mapFromGlobal(QCursor::pos()) - QPoint(0, itemTop(_pressed)));
	}
}

void SuggestionsWidget::mouseReleaseEvent(QMouseEvent *e) {
	if (_pressed >= 0 && _pressed < _rows.size()) {
		auto pressed = _pressed;
		setPressed(-1);
		if (_rows[pressed].ripple()) {
			_rows[pressed].ripple()->lastStop();
		}
		if (pressed == _selected) {
			triggerRow(_rows[_selected]);
		}
	}
}

void SuggestionsWidget::triggerSelectedRow() {
	if (_selected >= 0 && _selected < _rows.size()) {
		triggerRow(_rows[_selected]);
	}
}

void SuggestionsWidget::triggerRow(const Row &row) {
	_triggered.fire(row.emoji()->text());
}

void SuggestionsWidget::enterEventHook(QEvent *e) {
	auto mouse = QCursor::pos();
	if (!rect().marginsRemoved(QMargins(0, _st->skip, 0, _st->skip)).contains(mapFromGlobal(mouse))) {
		clearMouseSelection();
	}
	return TWidget::enterEventHook(e);
}

void SuggestionsWidget::leaveEventHook(QEvent *e) {
	clearMouseSelection();
	return TWidget::leaveEventHook(e);
}

SuggestionsController::SuggestionsController(
	not_null<QWidget*> outer,
	not_null<QTextEdit*> field)
: _field(field) {
	_container = base::make_unique_q<InnerDropdown>(
		outer,
		st::emojiSuggestionsDropdown);
	_container->setAutoHiding(false);
	_suggestions = _container->setOwnedWidget(
		object_ptr<Ui::Emoji::SuggestionsWidget>(
			_container,
			st::emojiSuggestionsMenu));

	setReplaceCallback(nullptr);

	_fieldFilter.reset(Core::InstallEventFilter(
		_field,
		[=](not_null<QEvent*> event) { return fieldFilter(event); }));
	_outerFilter.reset(Core::InstallEventFilter(
		outer,
		[=](not_null<QEvent*> event) { return outerFilter(event); }));
	QObject::connect(
		_field,
		&QTextEdit::textChanged,
		_container,
		[=] { handleTextChange(); });
	QObject::connect(
		_field,
		&QTextEdit::cursorPositionChanged,
		_container,
		[=] { handleCursorPositionChange(); });

	_suggestions->toggleAnimated(
	) | rpl::start_with_next([=](bool visible) {
		suggestionsUpdated(visible);
	}, _lifetime);
	_suggestions->triggered(
	) | rpl::start_with_next([=](QString replacement) {
		replaceCurrent(replacement);
	}, _lifetime);

	updateForceHidden();

	handleTextChange();
}

SuggestionsController *SuggestionsController::Init(
		not_null<QWidget*> outer,
		not_null<Ui::InputField*> field) {
	const auto result = Ui::CreateChild<SuggestionsController>(
		field.get(),
		outer,
		field->rawTextEdit());
	result->setReplaceCallback([=](
			int from,
			int till,
			const QString &replacement) {
		field->commitInstantReplacement(from, till, replacement);
	});
	return result;
}

void SuggestionsController::setReplaceCallback(
	Fn<void(
		int from,
		int till,
		const QString &replacement)> callback) {
	if (callback) {
		_replaceCallback = std::move(callback);
	} else {
		_replaceCallback = [=](int from, int till, const QString &replacement) {
			auto cursor = _field->textCursor();
			cursor.setPosition(from);
			cursor.setPosition(till, QTextCursor::KeepAnchor);
			cursor.insertText(replacement);
		};
	}
}

void SuggestionsController::handleTextChange() {
	_ignoreCursorPositionChange = true;
	InvokeQueued(_container, [=] { _ignoreCursorPositionChange = false; });

	const auto query = getEmojiQuery();
	if (query.isEmpty() || _textChangeAfterKeyPress) {
		_suggestions->showWithQuery(query);
	}
}

QString SuggestionsController::getEmojiQuery() {
	if (!Global::SuggestEmoji()) {
		return QString();
	}

	auto cursor = _field->textCursor();
	if (cursor.hasSelection()) {
		return QString();
	}

	auto position = cursor.position();
	auto findTextPart = [this, &position] {
		auto document = _field->document();
		auto block = document->findBlock(position);
		for (auto i = block.begin(); !i.atEnd(); ++i) {
			auto fragment = i.fragment();
			if (!fragment.isValid()) continue;

			auto from = fragment.position();
			auto till = from + fragment.length();
			if (from >= position || till < position) {
				continue;
			}
			if (fragment.charFormat().isImageFormat()) {
				continue;
			}
			position -= from;
			_queryStartPosition = from;
			return fragment.text();
		}
		return QString();
	};

	auto text = findTextPart();
	if (text.isEmpty()) {
		return QString();
	}

	const auto isUpperCaseLetter = [](QChar ch) {
		return (ch >= 'A' && ch <= 'Z');
	};
	const auto isLetter = [](QChar ch) {
		return (ch >= 'a' && ch <= 'z')
			|| (ch >= 'A' && ch <= 'Z')
			|| (ch >= '0' && ch <= '9');
	};
	const auto isSuggestionChar = [](QChar ch) {
		return (ch >= 'a' && ch <= 'z')
			|| (ch >= 'A' && ch <= 'Z')
			|| (ch >= '0' && ch <= '9')
			|| (ch == '_')
			|| (ch == '-')
			|| (ch == '+');
	};
	const auto isGoodCharBeforeSuggestion = [&](QChar ch) {
		return !isSuggestionChar(ch) || (ch == 0);
	};
	Assert(position > 0 && position <= text.size());
	for (auto i = position; i != 0;) {
		auto ch = text[--i];
		if (ch == ':') {
			auto beforeColon = (i < 1) ? QChar(0) : text[i - 1];
			if (isGoodCharBeforeSuggestion(beforeColon)) {
				// At least one letter after colon.
				if (position > i + 1) {
					// Skip colon and the first letter.
					_queryStartPosition += i + 2;
					const auto length = position - i;
					auto result = text.mid(i, length);
					const auto upperCaseLetters = std::count_if(
						result.begin(),
						result.end(),
						isUpperCaseLetter);
					const auto letters = std::count_if(
						result.begin(),
						result.end(),
						isLetter);
					if (letters == upperCaseLetters && letters == 1) {
						// No upper case single letter suggestions.
						// We don't want to suggest emoji on :D and :-P
						return QString();
					}
					return result.toLower();
				}
			}
			return QString();
		}
		if (position - i > kSuggestionMaxLength) {
			return QString();
		}
		if (!isSuggestionChar(ch)) {
			return QString();
		}
	}
	return QString();
}

void SuggestionsController::replaceCurrent(const QString &replacement) {
	auto suggestion = getEmojiQuery();
	if (suggestion.isEmpty()) {
		_suggestions->showWithQuery(QString());
	} else {
		const auto cursor = _field->textCursor();
		const auto position = cursor.position();
		const auto from = position - suggestion.size();
		_replaceCallback(from, position, replacement);
	}
}

void SuggestionsController::handleCursorPositionChange() {
	InvokeQueued(_container, [=] {
		if (_ignoreCursorPositionChange) {
			return;
		}
		_suggestions->showWithQuery(QString());
	});
}

void SuggestionsController::suggestionsUpdated(bool visible) {
	_shown = visible;
	if (_shown) {
		_container->resizeToContent();
		updateGeometry();
		if (!_forceHidden) {
			if (_container->isHidden() || _container->isHiding()) {
				raise();
			}
			_container->showAnimated(
				Ui::PanelAnimation::Origin::BottomLeft);
		}
	} else if (!_forceHidden) {
		_container->hideAnimated();
	}
}

void SuggestionsController::updateGeometry() {
	auto cursor = _field->textCursor();
	cursor.setPosition(_queryStartPosition);
	auto aroundRect = _field->cursorRect(cursor);
	aroundRect.setTopLeft(_field->viewport()->mapToGlobal(aroundRect.topLeft()));
	aroundRect.setTopLeft(_container->parentWidget()->mapFromGlobal(aroundRect.topLeft()));
	auto boundingRect = _container->parentWidget()->rect();
	auto origin = rtl() ? PanelAnimation::Origin::BottomRight : PanelAnimation::Origin::BottomLeft;
	auto point = rtl() ? (aroundRect.topLeft() + QPoint(aroundRect.width(), 0)) : aroundRect.topLeft();
	point -= rtl() ? QPoint(_container->width() - st::emojiSuggestionsDropdown.padding.right(), _container->height()) : QPoint(st::emojiSuggestionsDropdown.padding.left(), _container->height());
	if (rtl()) {
		if (point.x() < boundingRect.x()) {
			point.setX(boundingRect.x());
		}
		if (point.x() + _container->width() > boundingRect.x() + boundingRect.width()) {
			point.setX(boundingRect.x() + boundingRect.width() - _container->width());
		}
	} else {
		if (point.x() + _container->width() > boundingRect.x() + boundingRect.width()) {
			point.setX(boundingRect.x() + boundingRect.width() - _container->width());
		}
		if (point.x() < boundingRect.x()) {
			point.setX(boundingRect.x());
		}
	}
	if (point.y() < boundingRect.y()) {
		point.setY(aroundRect.y() + aroundRect.height());
		origin = (origin == PanelAnimation::Origin::BottomRight) ? PanelAnimation::Origin::TopRight : PanelAnimation::Origin::TopLeft;
	}
	_container->move(point);
}

void SuggestionsController::updateForceHidden() {
	_forceHidden = !_field->isVisible() || !_field->hasFocus();
	if (_forceHidden) {
		_container->hideFast();
	} else if (_shown) {
		_container->showFast();
	}
}

bool SuggestionsController::fieldFilter(not_null<QEvent*> event) {
	auto type = event->type();
	switch (type) {
	case QEvent::Move:
	case QEvent::Resize: {
		if (_shown) {
			updateGeometry();
		}
	} break;

	case QEvent::Show:
	case QEvent::ShowToParent:
	case QEvent::Hide:
	case QEvent::HideToParent:
	case QEvent::FocusIn:
	case QEvent::FocusOut: {
		updateForceHidden();
	} break;

	case QEvent::KeyPress: {
		const auto key = static_cast<QKeyEvent*>(event.get())->key();
		switch (key) {
		case Qt::Key_Enter:
		case Qt::Key_Return:
		case Qt::Key_Tab:
		case Qt::Key_Up:
		case Qt::Key_Down:
			if (_shown && !_forceHidden) {
				_suggestions->handleKeyEvent(key);
				return true;
			}
			break;

		case Qt::Key_Escape:
			if (_shown && !_forceHidden) {
				_suggestions->showWithQuery(QString());
				return true;
			}
			break;
		}
		_textChangeAfterKeyPress = true;
		InvokeQueued(_container, [=] { _textChangeAfterKeyPress = false; });
	} break;
	}
	return false;
}

bool SuggestionsController::outerFilter(not_null<QEvent*> event) {
	auto type = event->type();
	switch (type) {
	case QEvent::Move:
	case QEvent::Resize: {
		// updateGeometry uses not only container geometry, but also
		// container children geometries that will be updated later.
		InvokeQueued(_container, [=] {
			if (_shown) {
				updateGeometry();
			}
		});
	} break;
	}
	return false;
}

void SuggestionsController::raise() {
	_container->raise();
}

} // namespace Emoji
} // namespace Ui