// Copyright 2020 the V8 project 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 {App} from '../index.mjs'

import {SelectionEvent, SelectRelatedEvent, ToolTipEvent} from './events.mjs';
import {arrayEquals, CollapsableElement, CSSColor, defer, delay, DOM, formatBytes, gradientStopsFromGroups, groupBy, LazyTable} from './helper.mjs';

// A source mapping proxy for source maps that don't have CORS headers.
// TODO(leszeks): Make this configurable.
const sourceMapFetchPrefix = 'http://localhost:8080/';

DOM.defineCustomElement('view/script-panel',
                        (templateText) =>
                            class SourcePanel extends CollapsableElement {
  _selectedSourcePositions = [];
  _sourcePositionsToMarkNodesPromise = defer();
  _scripts = [];
  _script;

  showToolTipEntriesHandler = this.handleShowToolTipEntries.bind(this);

  constructor() {
    super(templateText);
    this.scriptDropdown.addEventListener(
        'change', e => this._handleSelectScript(e));
    this.$('#selectedRelatedButton').onclick =
        this._handleSelectRelated.bind(this);
  }

  get script() {
    return this.$('#script');
  }

  get scriptNode() {
    return this.$('.scriptNode');
  }

  set script(script) {
    if (this._script === script) return;
    this._script = script;
    script.ensureSourceMapCalculated(sourceMapFetchPrefix);
    this._sourcePositionsToMarkNodesPromise = defer();
    this._selectedSourcePositions =
        this._selectedSourcePositions.filter(each => each.script === script);
    this.requestUpdate();
  }

  set focusedSourcePositions(sourcePositions) {
    this.selectedSourcePositions = sourcePositions;
  }

  set selectedSourcePositions(sourcePositions) {
    if (arrayEquals(this._selectedSourcePositions, sourcePositions)) {
      this._focusSelectedMarkers(0);
    } else {
      this._selectedSourcePositions = sourcePositions;
      // TODO: highlight multiple scripts
      this.script = sourcePositions[0]?.script;
      this._focusSelectedMarkers(100);
    }
  }

  set scripts(scripts) {
    this._scripts = scripts;
    this._initializeScriptDropdown();
  }

  get scriptDropdown() {
    return this.$('#script-dropdown');
  }

  _update() {
    this._renderSourcePanel();
    this._updateScriptDropdownSelection();
  }

  _initializeScriptDropdown() {
    this._scripts.sort((a, b) => a.name?.localeCompare(b.name) ?? 0);
    let select = this.scriptDropdown;
    select.options.length = 0;
    for (const script of this._scripts) {
      const option = document.createElement('option');
      const size = formatBytes(script.source.length);
      option.text = `${script.name} (id=${script.id} size=${size})`;
      option.script = script;
      select.add(option);
    }
  }

  _updateScriptDropdownSelection() {
    this.scriptDropdown.selectedIndex =
        this._script ? this._scripts.indexOf(this._script) : -1;
  }

  async _renderSourcePanel() {
    let scriptNode;
    const script = this._script;
    if (script) {
      await delay(1);
      if (script != this._script) return;
      const builder = new LineBuilder(this, this._script);
      scriptNode = await builder.createScriptNode(this._script.startLine);
      if (script != this._script) return;
      this._sourcePositionsToMarkNodesPromise.resolve(
          builder.sourcePositionToMarkers);
    } else {
      scriptNode = DOM.div();
      this._selectedMarkNodes = undefined;
      this._sourcePositionsToMarkNodesPromise.resolve(new Map());
    }
    const oldScriptNode = this.script.childNodes[1];
    this.script.replaceChild(scriptNode, oldScriptNode);
  }

  async _focusSelectedMarkers(delay_ms) {
    if (delay_ms) await delay(delay_ms);
    const sourcePositionsToMarkNodes =
        await this._sourcePositionsToMarkNodesPromise;
    // Remove all marked nodes.
    for (let markNode of sourcePositionsToMarkNodes.values()) {
      markNode.className = '';
    }
    for (let sourcePosition of this._selectedSourcePositions) {
      if (sourcePosition.script !== this._script) continue;
      sourcePositionsToMarkNodes.get(sourcePosition).className = 'marked';
    }
    this._scrollToFirstSourcePosition(sourcePositionsToMarkNodes)
  }

  _scrollToFirstSourcePosition(sourcePositionsToMarkNodes) {
    const sourcePosition = this._selectedSourcePositions.find(
        each => each.script === this._script);
    if (!sourcePosition) return;
    const markNode = sourcePositionsToMarkNodes.get(sourcePosition);
    markNode.scrollIntoView(
        {behavior: 'smooth', block: 'center', inline: 'center'});
  }

  _handleSelectScript(e) {
    const option =
        this.scriptDropdown.options[this.scriptDropdown.selectedIndex];
    this.script = option.script;
  }

  _handleSelectRelated(e) {
    if (!this._script) return;
    this.dispatchEvent(new SelectRelatedEvent(this._script));
  }

  setSelectedSourcePositionInternal(sourcePosition) {
    this._selectedSourcePositions = [sourcePosition];
    console.assert(sourcePosition.script === this._script);
  }

  handleSourcePositionClick(e) {
    const sourcePosition = e.target.sourcePosition;
    this.setSelectedSourcePositionInternal(sourcePosition);
    this.dispatchEvent(new SelectRelatedEvent(sourcePosition));
  }

  handleSourcePositionMouseOver(e) {
    const sourcePosition = e.target.sourcePosition;
    const entries = sourcePosition.entries;
    const toolTipContent = DOM.div();
    toolTipContent.appendChild(
        new ToolTipTableBuilder(this, entries).tableNode);

    let sourceMapContent;
    switch (this._script.sourceMapState) {
      case 'loaded': {
        const originalPosition = sourcePosition.originalPosition;
        if (originalPosition.source === null) {
          sourceMapContent =
              DOM.element('i', {textContent: 'no source mapping for location'});
        } else {
          sourceMapContent = DOM.element('a', {
            href: `${originalPosition.source}`,
            target: '_blank',
            textContent: `${originalPosition.source}:${originalPosition.line}:${
                originalPosition.column}`
          });
        }
        break;
      }
      case 'loading':
        sourceMapContent =
            DOM.element('i', {textContent: 'source map still loading...'});
        break;
      case 'failed':
        sourceMapContent =
            DOM.element('i', {textContent: 'source map failed to load'});
        break;
      case 'none':
        sourceMapContent = DOM.element('i', {textContent: 'no source map'});
        break;
      default:
        break;
    }
    toolTipContent.appendChild(sourceMapContent);
    this.dispatchEvent(new ToolTipEvent(toolTipContent, e.target, e.ctrlKey));
  }

  handleShowToolTipEntries(event) {
    let entries = event.currentTarget.data;
    const sourcePosition = entries[0].sourcePosition;
    // Add a source position entry so the current position stays focused.
    this.setSelectedSourcePositionInternal(sourcePosition);
    entries = entries.concat(this._selectedSourcePositions);
    this.dispatchEvent(new SelectionEvent(entries));
  }
});

class ToolTipTableBuilder {
  constructor(scriptPanel, entries) {
    this._scriptPanel = scriptPanel;
    this.tableNode = DOM.table();
    const tr = DOM.tr();
    tr.appendChild(DOM.td('Type'));
    tr.appendChild(DOM.td('Subtype'));
    tr.appendChild(DOM.td('Count'));
    this.tableNode.appendChild(document.createElement('thead')).appendChild(tr);
    groupBy(entries, each => each.constructor, true).forEach(group => {
      this.addRow(group.key.name, 'all', entries, false)
      groupBy(group.entries, each => each.type, true).forEach(group => {
        this.addRow('', group.key, group.entries, false)
      })
    })
  }

  addRow(name, subtypeName, entries) {
      const tr = DOM.tr();
      tr.appendChild(DOM.td(name));
      tr.appendChild(DOM.td(subtypeName));
      tr.appendChild(DOM.td(entries.length));
      const button =
          DOM.button('🔎', this._scriptPanel.showToolTipEntriesHandler);
      button.title =
          `Show all ${entries.length} ${name || subtypeName} entries.`
      button.data = entries;
      tr.appendChild(DOM.td(button));
      this.tableNode.appendChild(tr);
  }
}

class SourcePositionIterator {
  _entries;
  _index = 0;
  constructor(sourcePositions) {
      this._entries = sourcePositions;
  }

  * forLine(lineIndex) {
      this._findStart(lineIndex);
      while (!this._done() && this._current().line === lineIndex) {
        yield this._current();
        this._next();
      }
  }

  _findStart(lineIndex) {
      while (!this._done() && this._current().line < lineIndex) {
        this._next();
      }
  }

  _current() {
      return this._entries[this._index];
  }

  _done() {
      return this._index >= this._entries.length;
  }

  _next() {
      this._index++;
  }
}

function* lineIterator(source, startLine) {
  let current = 0;
  let line = startLine;
  while (current < source.length) {
      const next = source.indexOf('\n', current);
      if (next === -1) break;
      yield [line, source.substring(current, next)];
      line++;
      current = next + 1;
  }
  if (current < source.length) yield [line, source.substring(current)];
}

class LineBuilder {
  static _colorMap = (function() {
    const map = new Map();
    let i = 0;
    for (let type of App.allEventTypes) {
      map.set(type, CSSColor.at(i++));
    }
    return map;
  })();
  static get colorMap() {
      return this._colorMap;
  }

  _script;
  _clickHandler;
  _mouseoverHandler;
  _sourcePositionToMarkers = new Map();

  constructor(panel, script) {
      this._script = script;
      this._clickHandler = panel.handleSourcePositionClick.bind(panel);
      this._mouseoverHandler = panel.handleSourcePositionMouseOver.bind(panel);
  }

  get sourcePositionToMarkers() {
      return this._sourcePositionToMarkers;
  }

  async createScriptNode(startLine) {
      const scriptNode = DOM.div('scriptNode');

      // TODO: sort on script finalization.
      this._script.sourcePositions.sort((a, b) => {
        if (a.line === b.line) return a.column - b.column;
        return a.line - b.line;
      });

      const sourcePositionsIterator =
          new SourcePositionIterator(this._script.sourcePositions);
      scriptNode.style.counterReset = `sourceLineCounter ${startLine - 1}`;
      for (let [lineIndex, line] of lineIterator(
               this._script.source, startLine)) {
        scriptNode.appendChild(
            this._createLineNode(sourcePositionsIterator, lineIndex, line));
      }
      if (this._script.sourcePositions.length !=
          this._sourcePositionToMarkers.size) {
        console.error('Not all SourcePositions were processed.');
      }
      return scriptNode;
  }

  _createLineNode(sourcePositionsIterator, lineIndex, line) {
      const lineNode = DOM.span();
      let columnIndex = 0;
      for (const sourcePosition of sourcePositionsIterator.forLine(lineIndex)) {
        const nextColumnIndex = sourcePosition.column - 1;
        lineNode.appendChild(document.createTextNode(
            line.substring(columnIndex, nextColumnIndex)));
        columnIndex = nextColumnIndex;

        lineNode.appendChild(
            this._createMarkerNode(line[columnIndex], sourcePosition));
        columnIndex++;
      }
      lineNode.appendChild(
          document.createTextNode(line.substring(columnIndex) + '\n'));
      return lineNode;
  }

  _createMarkerNode(text, sourcePosition) {
      const marker = document.createElement('mark');
      this._sourcePositionToMarkers.set(sourcePosition, marker);
      marker.textContent = text;
      marker.sourcePosition = sourcePosition;
      marker.onclick = this._clickHandler;
      marker.onmouseover = this._mouseoverHandler;

      const entries = sourcePosition.entries;
      const groups = groupBy(entries, entry => entry.constructor);
      if (groups.length > 1) {
        const stops = gradientStopsFromGroups(
            entries.length, '%', groups,
            type => LineBuilder.colorMap.get(type));
        marker.style.backgroundImage =
            `linear-gradient(0deg,${stops.join(',')})`
      } else {
        marker.style.backgroundColor = LineBuilder.colorMap.get(groups[0].key)
      }

      return marker;
  }
}
