import React from 'react';
import ReactDOM from 'react-dom';
import { Value } from 'slate';
import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer';

// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import BracesPlugin from './slate-plugins/braces';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
import RunnerPlugin from './slate-plugins/runner';
import debounce from './utils/debounce';
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';

import Typeahead from './Typeahead';

const EMPTY_METRIC = '';
const METRIC_MARK = 'metric';
export const TYPEAHEAD_DEBOUNCE = 300;

function flattenSuggestions(s) {
  return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
}

export const getInitialValue = query =>
  Value.fromJSON({
    document: {
      nodes: [
        {
          object: 'block',
          type: 'paragraph',
          nodes: [
            {
              object: 'text',
              leaves: [
                {
                  text: query,
                },
              ],
            },
          ],
        },
      ],
    },
  });

class Portal extends React.Component<any, any> {
  node: any;

  constructor(props) {
    super(props);
    const { index = 0, prefix = 'query' } = props;
    this.node = document.createElement('div');
    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
    document.body.appendChild(this.node);
  }

  componentWillUnmount() {
    document.body.removeChild(this.node);
  }

  render() {
    return ReactDOM.createPortal(this.props.children, this.node);
  }
}

class QueryField extends React.Component<any, any> {
  menuEl: any;
  plugins: any;
  resetTimer: any;

  constructor(props, context) {
    super(props, context);

    const { prismDefinition = {}, prismLanguage = 'promql' } = props;

    this.plugins = [
      BracesPlugin(),
      ClearPlugin(),
      RunnerPlugin({ handler: props.onPressEnter }),
      NewlinePlugin(),
      PluginPrism({ definition: prismDefinition, language: prismLanguage }),
    ];

    this.state = {
      labelKeys: {},
      labelValues: {},
      metrics: props.metrics || [],
      suggestions: [],
      typeaheadIndex: 0,
      typeaheadPrefix: '',
      value: getInitialValue(props.initialQuery || ''),
    };
  }

  componentDidMount() {
    this.updateMenu();

    if (this.props.metrics === undefined) {
      this.fetchMetricNames();
    }
  }

  componentWillUnmount() {
    clearTimeout(this.resetTimer);
  }

  componentDidUpdate() {
    this.updateMenu();
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
      this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
    }
    // initialQuery is null in case the user typed
    if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
      this.setState({ value: getInitialValue(nextProps.initialQuery) });
    }
  }

  onChange = ({ value }) => {
    const changed = value.document !== this.state.value.document;
    this.setState({ value }, () => {
      if (changed) {
        this.handleChangeQuery();
      }
    });

    window.requestAnimationFrame(this.handleTypeahead);
  };

  onMetricsReceived = () => {
    if (!this.state.metrics) {
      return;
    }
    setPrismTokens(this.props.prismLanguage, METRIC_MARK, this.state.metrics);

    // Trigger re-render
    window.requestAnimationFrame(() => {
      // Bogus edit to trigger highlighting
      const change = this.state.value
        .change()
        .insertText(' ')
        .deleteBackward(1);
      this.onChange(change);
    });
  };

  request = url => {
    if (this.props.request) {
      return this.props.request(url);
    }
    return fetch(url);
  };

  handleChangeQuery = () => {
    // Send text change to parent
    const { onQueryChange } = this.props;
    if (onQueryChange) {
      onQueryChange(Plain.serialize(this.state.value));
    }
  };

  handleTypeahead = debounce(() => {
    const selection = window.getSelection();
    if (selection.anchorNode) {
      const wrapperNode = selection.anchorNode.parentElement;
      const editorNode = wrapperNode.closest('.slate-query-field');
      if (!editorNode || this.state.value.isBlurred) {
        // Not inside this editor
        return;
      }

      const range = selection.getRangeAt(0);
      const text = selection.anchorNode.textContent;
      const offset = range.startOffset;
      const prefix = cleanText(text.substr(0, offset));

      // Determine candidates by context
      const suggestionGroups = [];
      const wrapperClasses = wrapperNode.classList;
      let typeaheadContext = null;

      // Take first metric as lucky guess
      const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);

      if (wrapperClasses.contains('context-range')) {
        // Rate ranges
        typeaheadContext = 'context-range';
        suggestionGroups.push({
          label: 'Range vector',
          items: [...RATE_RANGES],
        });
      } else if (wrapperClasses.contains('context-labels') && metricNode) {
        const metric = metricNode.textContent;
        const labelKeys = this.state.labelKeys[metric];
        if (labelKeys) {
          if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
            // Label values
            const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
            if (labelKeyNode) {
              const labelKey = labelKeyNode.textContent;
              const labelValues = this.state.labelValues[metric][labelKey];
              typeaheadContext = 'context-label-values';
              suggestionGroups.push({
                label: 'Label values',
                items: labelValues,
              });
            }
          } else {
            // Label keys
            typeaheadContext = 'context-labels';
            suggestionGroups.push({ label: 'Labels', items: labelKeys });
          }
        } else {
          this.fetchMetricLabels(metric);
        }
      } else if (wrapperClasses.contains('context-labels') && !metricNode) {
        // Empty name queries
        const defaultKeys = ['job', 'instance'];
        // Munge all keys that we have seen together
        const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
          return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
        }, defaultKeys);
        if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
          // Label values
          const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
          if (labelKeyNode) {
            const labelKey = labelKeyNode.textContent;
            if (this.state.labelValues[EMPTY_METRIC]) {
              const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
              typeaheadContext = 'context-label-values';
              suggestionGroups.push({
                label: 'Label values',
                items: labelValues,
              });
            } else {
              // Can only query label values for now (API to query keys is under development)
              this.fetchLabelValues(labelKey);
            }
          }
        } else {
          // Label keys
          typeaheadContext = 'context-labels';
          suggestionGroups.push({ label: 'Labels', items: labelKeys });
        }
      } else if (metricNode && wrapperClasses.contains('context-aggregation')) {
        typeaheadContext = 'context-aggregation';
        const metric = metricNode.textContent;
        const labelKeys = this.state.labelKeys[metric];
        if (labelKeys) {
          suggestionGroups.push({ label: 'Labels', items: labelKeys });
        } else {
          this.fetchMetricLabels(metric);
        }
      } else if (
        (this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
        wrapperClasses.contains('context-function')
      ) {
        // Need prefix for metrics
        typeaheadContext = 'context-metrics';
        suggestionGroups.push({
          label: 'Metrics',
          items: this.state.metrics,
        });
      }

      let results = 0;
      const filteredSuggestions = suggestionGroups.map(group => {
        if (group.items) {
          group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
          results += group.items.length;
        }
        return group;
      });

      console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);

      this.setState({
        typeaheadPrefix: prefix,
        typeaheadContext,
        typeaheadText: text,
        suggestions: results > 0 ? filteredSuggestions : [],
      });
    }
  }, TYPEAHEAD_DEBOUNCE);

  applyTypeahead(change, suggestion) {
    const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;

    // Modify suggestion based on context
    switch (typeaheadContext) {
      case 'context-labels': {
        const nextChar = getNextCharacter();
        if (!nextChar || nextChar === '}' || nextChar === ',') {
          suggestion += '=';
        }
        break;
      }

      case 'context-label-values': {
        // Always add quotes and remove existing ones instead
        if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
          suggestion = `"${suggestion}`;
        }
        if (getNextCharacter() !== '"') {
          suggestion = `${suggestion}"`;
        }
        break;
      }

      default:
    }

    this.resetTypeahead();

    // Remove the current, incomplete text and replace it with the selected suggestion
    let backward = typeaheadPrefix.length;
    const text = cleanText(typeaheadText);
    const suffixLength = text.length - typeaheadPrefix.length;
    const offset = typeaheadText.indexOf(typeaheadPrefix);
    const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
    const forward = midWord ? suffixLength + offset : 0;

    return (
      change
        // TODO this line breaks if cursor was moved left and length is longer than whole prefix
        .deleteBackward(backward)
        .deleteForward(forward)
        .insertText(suggestion)
        .focus()
    );
  }

  onKeyDown = (event, change) => {
    const { typeaheadIndex, suggestions } = this.state;

    switch (event.key) {
      case 'Escape': {
        if (this.menuEl) {
          event.preventDefault();
          event.stopPropagation();
          this.resetTypeahead();
          return true;
        }
        break;
      }

      case ' ': {
        if (event.ctrlKey) {
          event.preventDefault();
          this.handleTypeahead();
          return true;
        }
        break;
      }

      case 'Tab': {
        if (this.menuEl) {
          // Dont blur input
          event.preventDefault();
          if (!suggestions || suggestions.length === 0) {
            return undefined;
          }

          // Get the currently selected suggestion
          const flattenedSuggestions = flattenSuggestions(suggestions);
          const selected = Math.abs(typeaheadIndex);
          const selectedIndex = selected % flattenedSuggestions.length || 0;
          const suggestion = flattenedSuggestions[selectedIndex];

          this.applyTypeahead(change, suggestion);
          return true;
        }
        break;
      }

      case 'ArrowDown': {
        if (this.menuEl) {
          // Select next suggestion
          event.preventDefault();
          this.setState({ typeaheadIndex: typeaheadIndex + 1 });
        }
        break;
      }

      case 'ArrowUp': {
        if (this.menuEl) {
          // Select previous suggestion
          event.preventDefault();
          this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
        }
        break;
      }

      default: {
        // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
        break;
      }
    }
    return undefined;
  };

  resetTypeahead = () => {
    this.setState({
      suggestions: [],
      typeaheadIndex: 0,
      typeaheadPrefix: '',
      typeaheadContext: null,
    });
  };

  async fetchLabelValues(key) {
    const url = `/api/v1/label/${key}/values`;
    try {
      const res = await this.request(url);
      const body = await (res.data || res.json());
      const pairs = this.state.labelValues[EMPTY_METRIC];
      const values = {
        ...pairs,
        [key]: body.data,
      };
      // const labelKeys = {
      //   ...this.state.labelKeys,
      //   [EMPTY_METRIC]: keys,
      // };
      const labelValues = {
        ...this.state.labelValues,
        [EMPTY_METRIC]: values,
      };
      this.setState({ labelValues }, this.handleTypeahead);
    } catch (e) {
      if (this.props.onRequestError) {
        this.props.onRequestError(e);
      } else {
        console.error(e);
      }
    }
  }

  async fetchMetricLabels(name) {
    const url = `/api/v1/series?match[]=${name}`;
    try {
      const res = await this.request(url);
      const body = await (res.data || res.json());
      const { keys, values } = processLabels(body.data);
      const labelKeys = {
        ...this.state.labelKeys,
        [name]: keys,
      };
      const labelValues = {
        ...this.state.labelValues,
        [name]: values,
      };
      this.setState({ labelKeys, labelValues }, this.handleTypeahead);
    } catch (e) {
      if (this.props.onRequestError) {
        this.props.onRequestError(e);
      } else {
        console.error(e);
      }
    }
  }

  async fetchMetricNames() {
    const url = '/api/v1/label/__name__/values';
    try {
      const res = await this.request(url);
      const body = await (res.data || res.json());
      this.setState({ metrics: body.data }, this.onMetricsReceived);
    } catch (error) {
      if (this.props.onRequestError) {
        this.props.onRequestError(error);
      } else {
        console.error(error);
      }
    }
  }

  handleBlur = () => {
    const { onBlur } = this.props;
    // If we dont wait here, menu clicks wont work because the menu
    // will be gone.
    this.resetTimer = setTimeout(this.resetTypeahead, 100);
    if (onBlur) {
      onBlur();
    }
  };

  handleFocus = () => {
    const { onFocus } = this.props;
    if (onFocus) {
      onFocus();
    }
  };

  handleClickMenu = item => {
    // Manually triggering change
    const change = this.applyTypeahead(this.state.value.change(), item);
    this.onChange(change);
  };

  updateMenu = () => {
    const { suggestions } = this.state;
    const menu = this.menuEl;
    const selection = window.getSelection();
    const node = selection.anchorNode;

    // No menu, nothing to do
    if (!menu) {
      return;
    }

    // No suggestions or blur, remove menu
    const hasSuggesstions = suggestions && suggestions.length > 0;
    if (!hasSuggesstions) {
      menu.removeAttribute('style');
      return;
    }

    // Align menu overlay to editor node
    if (node) {
      // Read from DOM
      const rect = node.parentElement.getBoundingClientRect();
      const scrollX = window.scrollX;
      const scrollY = window.scrollY;

      // Write DOM
      requestAnimationFrame(() => {
        menu.style.opacity = 1;
        menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
        menu.style.left = `${rect.left + scrollX - 2}px`;
      });
    }
  };

  menuRef = el => {
    this.menuEl = el;
  };

  renderMenu = () => {
    const { portalPrefix } = this.props;
    const { suggestions } = this.state;
    const hasSuggesstions = suggestions && suggestions.length > 0;
    if (!hasSuggesstions) {
      return null;
    }

    // Guard selectedIndex to be within the length of the suggestions
    let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
    const flattenedSuggestions = flattenSuggestions(suggestions);
    selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
    const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
      i => (typeof i === 'object' ? i.text : i)
    );

    // Create typeahead in DOM root so we can later position it absolutely
    return (
      <Portal prefix={portalPrefix}>
        <Typeahead
          menuRef={this.menuRef}
          selectedItems={selectedKeys}
          onClickItem={this.handleClickMenu}
          groupedItems={suggestions}
        />
      </Portal>
    );
  };

  render() {
    return (
      <div className="slate-query-field">
        {this.renderMenu()}
        <Editor
          autoCorrect={false}
          onBlur={this.handleBlur}
          onKeyDown={this.onKeyDown}
          onChange={this.onChange}
          onFocus={this.handleFocus}
          placeholder={this.props.placeholder}
          plugins={this.plugins}
          spellCheck={false}
          value={this.state.value}
        />
      </div>
    );
  }
}

export default QueryField;
