// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
// for details. 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:convert';
import 'dart:js_interop';

import 'package:dartdoc/src/search.dart';
import 'package:web/web.dart';

final String _htmlBase = () {
  final body = document.body;
  if (body == null) return '';

  // If dartdoc did not add a base-href tag, we will need to add the relative
  // path ourselves.
  if (body.getAttribute('data-using-base-href') == 'false') {
    // Dartdoc stores the htmlBase in 'body[data-base-href]'.
    return body.getAttribute('data-base-href') ?? '';
  } else {
    return '';
  }
}();

void init() {
  var searchBox = document.getElementById('search-box') as HTMLInputElement?;
  var searchBody = document.getElementById('search-body') as HTMLInputElement?;
  var searchSidebar =
      document.getElementById('search-sidebar') as HTMLInputElement?;

  void disableSearch() {
    print('Could not activate search functionality.');

    searchBox?.placeholder = 'Failed to initialize search';
    searchBody?.placeholder = 'Failed to initialize search';
    searchSidebar?.placeholder = 'Failed to initialize search';
  }

  window
      .fetch('${_htmlBase}index.json'.toJS)
      .toDart
      .then((fetchResponse) async {
    if (fetchResponse.status != HttpStatus.ok) {
      disableSearch();
      return;
    }

    final text = (await fetchResponse.text().toDart).toDart;
    final index = Index.fromJson(text);

    // Navigate to the first result from the 'search' query parameter
    // if specified and found.
    final url = Uri.parse(window.location.toString());
    final searchQuery = url.queryParameters['search'];
    if (searchQuery != null) {
      final href = index.find(searchQuery).firstOrNull?.href;
      if (href != null) {
        window.location.assign('$_htmlBase$href');
        return;
      }
    }

    // Initialize all three search fields.
    if (searchBox != null) {
      _Search(index).initialize(searchBox);
    }
    if (searchBody != null) {
      _Search(index).initialize(searchBody);
    }
    if (searchSidebar != null) {
      _Search(index).initialize(searchSidebar);
    }
  });
}

int _suggestionLimit = 10;
int _suggestionLength = 0;
const _htmlEscape = HtmlEscape();

/// A limited tree of element containers.
///
/// Each key is the inner HTML of a container suggestion element. Each value is
/// an element for the container, and which contains one or more child
/// suggestions, all of whom have the container as their parent. This is only
/// useful for the search results page.
final _containerMap = <String, Element>{};

class _Search {
  final Index index;
  final Uri uri;

  late final listBox = HTMLDivElement()
    ..setAttribute('role', 'listbox')
    ..setAttribute('aria-expanded', 'false')
    ..style.display = 'none'
    ..classList.add('tt-menu')
    ..appendChild(moreResults)
    ..appendChild(searchResults);

  /// Element used in [listBox] to inform the functionality of hitting enter in
  /// search box.
  late final moreResults = HTMLDivElement()
    ..classList.add('enter-search-message');

  /// Element that contains the search suggestions in a new format.
  late final searchResults = HTMLDivElement()
    ..classList.add('tt-search-results');

  String? storedValue;
  String actualValue = '';
  final List<HTMLElement> suggestionElements = <HTMLElement>[];
  List<IndexItem> suggestionsInfo = <IndexItem>[];
  int selectedElement = -1;

  _Search(this.index) : uri = Uri.parse(window.location.href);

  void initialize(HTMLInputElement inputElement) {
    inputElement.disabled = false;
    inputElement.setAttribute('placeholder', 'Search API Docs');
    // Handle grabbing focus when the user types '/' outside of the input.
    document.addEventListener(
      'keydown',
      (KeyboardEvent event) {
        if (event.key != '/') {
          return;
        }

        var activeElement = document.activeElement;
        if (activeElement == null || !activeElement.acceptsInput) {
          event.preventDefault();
          inputElement.focus();
        }
      }.toJS,
    );

    // Prepare elements.
    var wrapper = HTMLDivElement()..classList.add('tt-wrapper');
    inputElement
      ..replaceWith(wrapper)
      ..setAttribute('autocomplete', 'off')
      ..setAttribute('spellcheck', 'false')
      ..classList.add('tt-input');

    wrapper
      ..appendChild(inputElement)
      ..appendChild(listBox);

    setEventListeners(inputElement);

    // Display the search results in the main body, if we're rendering the
    // search page.
    if (window.location.href.contains('search.html')) {
      var query = uri.queryParameters['q'];
      if (query == null) {
        return;
      }
      query = _htmlEscape.convert(query);
      _suggestionLimit = _suggestionLength;
      handleSearch(query, isSearchPage: true);
      showSearchResultPage(query);
      hideSuggestions();
      _suggestionLimit = 10;
    }
  }

  /// Displays the suggestions [searchResults] list box.
  void showSuggestions() {
    if (searchResults.hasChildNodes()) {
      listBox
        ..style.display = 'block'
        ..setAttribute('aria-expanded', 'true');
    }
  }

  /// Creates the content displayed in the main-content element, for the search
  /// results page.
  void showSearchResultPage(String searchText) {
    final mainContent = document.getElementById('dartdoc-main-content');

    if (mainContent == null) {
      return;
    }

    mainContent
      ..textContent = ''
      ..appendChild(
        HTMLElement.section()..classList.add('search-summary'),
      )
      ..appendChild(
        HTMLHeadingElement.h2()..innerHtml = 'Search Results',
      )
      ..appendChild(
        HTMLDivElement()
          ..classList.add('search-summary')
          ..innerHtml = '$_suggestionLength results for "$searchText"',
      );

    if (_containerMap.isNotEmpty) {
      for (final element in _containerMap.values) {
        mainContent.appendChild(element);
      }
    } else {
      var noResults = HTMLDivElement()
        ..classList.add('search-summary')
        ..innerHtml =
            'There was not a match for "$searchText". Want to try searching '
                'from additional Dart-related sites? ';

      var buildLink = Uri.parse(
              'https://dart.dev/search?cx=011220921317074318178%3A_yy-tmb5t_i&ie=UTF-8&hl=en&q=')
          .replace(queryParameters: {'q': searchText});
      var link = HTMLAnchorElement()
        ..setAttribute('href', buildLink.toString())
        ..textContent = 'Search on dart.dev.';
      noResults.appendChild(link);
      mainContent.appendChild(noResults);
    }
  }

  void hideSuggestions() => listBox
    ..style.display = 'none'
    ..setAttribute('aria-expanded', 'false');

  void showEnterMessage() => moreResults.textContent = _suggestionLength > 10
      ? 'Press "Enter" key to see all $_suggestionLength results'
      : '';

  /// Updates the suggestions displayed below the search bar to [suggestions].
  ///
  /// [query] is only required here so that it can be displayed with emphasis
  /// (as a prefix, for example).
  void updateSuggestions(String query, List<IndexItem> suggestions,
      {bool isSearchPage = false}) {
    suggestionsInfo = [];
    suggestionElements.clear();
    _containerMap.clear();
    searchResults.textContent = '';

    if (suggestions.isEmpty) {
      hideSuggestions();
      return;
    }

    for (final suggestion in suggestions) {
      suggestionElements.add(_createSuggestion(query, suggestion));
    }

    var suggestionSource =
        isSearchPage ? _containerMap.values : suggestionElements;
    for (final element in suggestionSource) {
      searchResults.appendChild(element);
    }
    suggestionsInfo = suggestions;

    removeSelectedElement();

    showSuggestions();
    showEnterMessage();
  }

  /// Handles [searchText] by generating suggestions.
  void handleSearch(String? searchText,
      {bool forceUpdate = false, bool isSearchPage = false}) {
    if (actualValue == searchText && !forceUpdate) {
      return;
    }

    if (searchText == null || searchText.isEmpty) {
      updateSuggestions('', []);
      return;
    }

    var suggestions = index.find(searchText);
    _suggestionLength = suggestions.length;
    if (suggestions.length > _suggestionLimit) {
      suggestions = suggestions.sublist(0, _suggestionLimit);
    }

    actualValue = searchText;
    updateSuggestions(searchText, suggestions, isSearchPage: isSearchPage);
  }

  /// Clears the search box and suggestions.
  void clearSearch(HTMLInputElement inputElement) {
    removeSelectedElement();
    if (storedValue case var value?) {
      inputElement.value = value;
      storedValue = null;
    }
    hideSuggestions();
  }

  void setEventListeners(HTMLInputElement inputElement) {
    inputElement.addEventListener(
      'focus',
      (Event event) {
        handleSearch(inputElement.value, forceUpdate: true);
      }.toJS,
    );

    inputElement.addEventListener(
      'blur',
      (Event event) {
        clearSearch(inputElement);
      }.toJS,
    );

    inputElement.addEventListener(
      'input',
      (Event event) {
        handleSearch(inputElement.value);
      }.toJS,
    );

    inputElement.addEventListener(
      'keydown',
      (Event event) {
        if (event.type != 'keydown') {
          return;
        }

        event = event as KeyboardEvent;

        if (event.code == 'Enter') {
          event.preventDefault();
          if (!selectedElement.isBlurred) {
            if (suggestionElements[selectedElement].getAttribute('data-href')
                case var href?) {
              window.location.assign('$_htmlBase$href');
            }
            return;
          } else {
            // If there is no search suggestion selected, then change the
            // window location to `search.html`.
            var query = _htmlEscape.convert(actualValue);
            var searchPath = Uri.parse('${_htmlBase}search.html')
                .replace(queryParameters: {'q': query});
            window.location.assign(searchPath.toString());
            return;
          }
        }

        var lastIndex = suggestionElements.length - 1;
        var previousSelectedElement = selectedElement;

        if (event.code == 'ArrowUp') {
          if (selectedElement.isBlurred) {
            selectedElement = lastIndex;
          } else {
            selectedElement = selectedElement - 1;
          }
        } else if (event.code == 'ArrowDown') {
          if (selectedElement == lastIndex) {
            removeSelectedElement();
          } else {
            selectedElement = selectedElement + 1;
          }
        } else if (event.code == 'Escape') {
          clearSearch(inputElement);
        } else {
          if (storedValue != null) {
            storedValue = null;
            handleSearch(inputElement.value);
          }
          return;
        }

        if (!previousSelectedElement.isBlurred) {
          suggestionElements[previousSelectedElement]
              .classList
              .remove('tt-cursor');
        }

        if (!selectedElement.isBlurred) {
          var selected = suggestionElements[selectedElement];
          selected.classList.add('tt-cursor');

          // Guarantee the selected element is visible.
          if (selectedElement == 0) {
            listBox.scrollTop = 0;
          } else if (selectedElement == lastIndex) {
            listBox.scrollTop = listBox.scrollHeight;
          } else {
            var offsetTop = selected.offsetTop;
            var parentOffsetHeight = listBox.offsetHeight;
            if (offsetTop < parentOffsetHeight ||
                parentOffsetHeight < (offsetTop + selected.offsetHeight)) {
              selected.scrollIntoView();
            }
          }

          // Store the actual input value to
          // display their currently selected item.
          storedValue ??= inputElement.value;
          inputElement.value = suggestionsInfo[selectedElement].name;
        } else if (storedValue case var value?
            when !previousSelectedElement.isBlurred) {
          // They are moving back to the input field,
          // so return the stored value.
          inputElement.value = value;
          storedValue = null;
        }

        event.preventDefault();
      }.toJS,
    );
  }

  /// Sets the selection index to `-1`.
  void removeSelectedElement() => selectedElement = -1;
}

HTMLElement _createSuggestion(String query, IndexItem match) {
  final suggestion = HTMLDivElement()
    ..setAttribute('data-href', match.href ?? '')
    ..classList.add('tt-suggestion');

  final suggestionTitle = HTMLSpanElement()
    ..classList.add('tt-suggestion-title')
    ..innerHtml = _highlight(
        '${match.name} ${match.kind.toString().toLowerCase()}', query);
  suggestion.appendChild(suggestionTitle);

  final enclosingElement = match.enclosedBy;
  if (enclosingElement != null) {
    suggestion.appendChild(HTMLSpanElement()
      ..classList.add('tt-suggestion-container')
      ..innerHtml = '(in ${_highlight(enclosingElement.name, query)})');
  }

  // The one line description to use in the search suggestions.
  final matchDescription = match.desc;
  if (matchDescription != null && matchDescription.isNotEmpty) {
    final inputDescription = HTMLQuoteElement.blockquote()
      ..classList.add('one-line-description')
      ..setAttribute('title', _decodeHtml(matchDescription))
      ..innerHtml = _highlight(matchDescription, query);
    suggestion.appendChild(inputDescription);
  }

  suggestion.addEventListener(
    'mousedown',
    (Event event) {
      event.preventDefault();
    }.toJS,
  );

  suggestion.addEventListener(
    'click',
    (Event event) {
      if (match.href case var matchHref?) {
        window.location.assign('$_htmlBase$matchHref');
        event.preventDefault();
      }
    }.toJS,
  );

  if (enclosingElement != null) {
    _mapToContainer(
      _createContainer(
        '${enclosingElement.name} ${enclosingElement.kind}',
        enclosingElement.href,
      ),
      suggestion,
    );
  }
  return suggestion;
}

/// Maps a suggestion library/class [Element] to the other suggestions, if any.
void _mapToContainer(HTMLElement containerElement, HTMLElement suggestion) {
  final containerInnerHtml = containerElement.innerHtml;

  if (containerInnerHtml.isEmpty) {
    return;
  }

  final element = _containerMap[containerInnerHtml];
  if (element != null) {
    element.appendChild(suggestion);
  } else {
    containerElement.appendChild(suggestion);
    _containerMap[containerInnerHtml] = containerElement;
  }
}

/// Creates an `<a>` [Element] for the enclosing library/class.
HTMLElement _createContainer(String encloser, String href) => HTMLDivElement()
  ..classList.add('tt-container')
  ..appendChild(HTMLParagraphElement()
    ..textContent = 'Results from '
    ..classList.add('tt-container-text')
    ..appendChild(HTMLAnchorElement()
      ..setAttribute('href', href)
      ..innerHtml = encloser));

/// Wraps each instance of [query] in [text] with a `<strong>` tag, as HTML
/// text.
String _highlight(String text, String query) => text.replaceAllMapped(
      RegExp(query, caseSensitive: false),
      (match) => "<strong class='tt-highlight'>${match[0]}</strong>",
    );

/// Decodes HTML entities (like `&lt;`) into their HTML elements (like `<`).
///
/// This is safe for use in an HTML attribute like `title`.
String _decodeHtml(String html) {
  return (HTMLTextAreaElement()..innerHtml = html).value;
}

extension on int {
  // TODO(srawlins): Re-implement in inline class someday.
  bool get isBlurred => this == -1;
}

extension ElementExtension on Element {
  bool get acceptsInput =>
      const {'input', 'textarea'}.contains(nodeName.toLowerCase());

  // The default implementation allows `JSAny` to support trusted types. We only
  // use `String`s, so prefer this to avoid manual conversions.
  @JS('innerHTML')
  external String innerHtml;
}
