import React from 'react';
import {createElement} from 'glamor/react'; // eslint-disable-line
/* @jsx createElement */

import {MultiGrid, AutoSizer} from 'react-virtualized';
import 'react-virtualized/styles.css';
import FileSaver from 'file-saver';

import {
  inject as injectErrorOverlay,
  uninject as uninjectErrorOverlay,
} from 'react-error-overlay/lib/overlay';

import attributes from './attributes';

const types = [
  {
    name: 'string',
    testValue: 'a string',
    testDisplayValue: "'a string'",
  },
  {
    name: 'empty string',
    testValue: '',
    testDisplayValue: "''",
  },
  {
    name: 'array with string',
    testValue: ['string'],
    testDisplayValue: "['string']",
  },
  {
    name: 'empty array',
    testValue: [],
    testDisplayValue: '[]',
  },
  {
    name: 'object',
    testValue: {
      toString() {
        return 'result of toString()';
      },
    },
    testDisplayValue: "{ toString() { return 'result of toString()'; } }",
  },
  {
    name: 'numeric string',
    testValue: '42',
    displayValue: "'42'",
  },
  {
    name: '-1',
    testValue: -1,
  },
  {
    name: '0',
    testValue: 0,
  },
  {
    name: 'integer',
    testValue: 1,
  },
  {
    name: 'NaN',
    testValue: NaN,
  },
  {
    name: 'float',
    testValue: 99.99,
  },
  {
    name: 'true',
    testValue: true,
  },
  {
    name: 'false',
    testValue: false,
  },
  {
    name: "string 'true'",
    testValue: 'true',
    displayValue: "'true'",
  },
  {
    name: "string 'false'",
    testValue: 'false',
    displayValue: "'false'",
  },
  {
    name: "string 'on'",
    testValue: 'on',
    displayValue: "'on'",
  },
  {
    name: "string 'off'",
    testValue: 'off',
    displayValue: "'off'",
  },
  {
    name: 'symbol',
    testValue: Symbol('foo'),
    testDisplayValue: "Symbol('foo')",
  },
  {
    name: 'function',
    testValue: function f() {},
  },
  {
    name: 'null',
    testValue: null,
  },
  {
    name: 'undefined',
    testValue: undefined,
  },
];

const ALPHABETICAL = 'alphabetical';
const REV_ALPHABETICAL = 'reverse_alphabetical';
const GROUPED_BY_ROW_PATTERN = 'grouped_by_row_pattern';

const ALL = 'all';
const COMPLETE = 'complete';
const INCOMPLETE = 'incomplete';

function getCanonicalizedValue(value) {
  switch (typeof value) {
    case 'undefined':
      return '<undefined>';
    case 'object':
      if (value === null) {
        return '<null>';
      }
      if ('baseVal' in value) {
        return getCanonicalizedValue(value.baseVal);
      }
      if (value instanceof SVGLength) {
        return '<SVGLength: ' + value.valueAsString + '>';
      }
      if (value instanceof SVGRect) {
        return (
          '<SVGRect: ' +
          [value.x, value.y, value.width, value.height].join(',') +
          '>'
        );
      }
      if (value instanceof SVGPreserveAspectRatio) {
        return (
          '<SVGPreserveAspectRatio: ' +
          value.align +
          '/' +
          value.meetOrSlice +
          '>'
        );
      }
      if (value instanceof SVGNumber) {
        return value.value;
      }
      if (value instanceof SVGMatrix) {
        return (
          '<SVGMatrix ' +
          value.a +
          ' ' +
          value.b +
          ' ' +
          value.c +
          ' ' +
          value.d +
          ' ' +
          value.e +
          ' ' +
          value.f +
          '>'
        );
      }
      if (value instanceof SVGTransform) {
        return (
          getCanonicalizedValue(value.matrix) +
          '/' +
          value.type +
          '/' +
          value.angle
        );
      }
      if (typeof value.length === 'number') {
        return (
          '[' +
          Array.from(value)
            .map(v => getCanonicalizedValue(v))
            .join(', ') +
          ']'
        );
      }
      let name = (value.constructor && value.constructor.name) || 'object';
      return '<' + name + '>';
    case 'function':
      return '<function>';
    case 'symbol':
      return '<symbol>';
    case 'number':
      return `<number: ${value}>`;
    case 'string':
      if (value === '') {
        return '<empty string>';
      }
      return '"' + value + '"';
    case 'boolean':
      return `<boolean: ${value}>`;
    default:
      throw new Error('Switch statement should be exhaustive.');
  }
}

let _didWarn = false;
function warn(str) {
  _didWarn = true;
}
const UNKNOWN_HTML_TAGS = new Set(['keygen', 'time', 'command']);
function getRenderedAttributeValue(
  react,
  renderer,
  serverRenderer,
  attribute,
  type
) {
  const originalConsoleError = console.error;
  console.error = warn;

  const containerTagName = attribute.containerTagName || 'div';
  const tagName = attribute.tagName || 'div';

  function createContainer() {
    if (containerTagName === 'svg') {
      return document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    } else if (containerTagName === 'document') {
      return document.implementation.createHTMLDocument('');
    } else {
      return document.createElement(containerTagName);
    }
  }

  const read = attribute.read;
  let testValue = type.testValue;
  if (attribute.overrideStringValue !== undefined) {
    switch (type.name) {
      case 'string':
        testValue = attribute.overrideStringValue;
        break;
      case 'array with string':
        testValue = [attribute.overrideStringValue];
        break;
      default:
        break;
    }
  }
  let baseProps = {
    ...attribute.extraProps,
  };
  if (attribute.type) {
    baseProps.type = attribute.type;
  }
  const props = {
    ...baseProps,
    [attribute.name]: testValue,
  };

  let defaultValue;
  let canonicalDefaultValue;
  let result;
  let canonicalResult;
  let ssrResult;
  let canonicalSsrResult;
  let didWarn;
  let didError;
  let ssrDidWarn;
  let ssrDidError;

  _didWarn = false;
  try {
    let container = createContainer();
    renderer.render(react.createElement(tagName, baseProps), container);
    defaultValue = read(container.firstChild);
    canonicalDefaultValue = getCanonicalizedValue(defaultValue);

    container = createContainer();
    renderer.render(react.createElement(tagName, props), container);
    result = read(container.firstChild);
    canonicalResult = getCanonicalizedValue(result);
    didWarn = _didWarn;
    didError = false;
  } catch (error) {
    result = null;
    didWarn = _didWarn;
    didError = true;
  }

  _didWarn = false;
  let hasTagMismatch = false;
  let hasUnknownElement = false;
  try {
    let container;
    if (containerTagName === 'document') {
      const html = serverRenderer.renderToString(
        react.createElement(tagName, props)
      );
      container = createContainer();
      container.innerHTML = html;
    } else {
      const html = serverRenderer.renderToString(
        react.createElement(
          containerTagName,
          null,
          react.createElement(tagName, props)
        )
      );
      const outerContainer = document.createElement('div');
      outerContainer.innerHTML = html;
      container = outerContainer.firstChild;
    }

    if (
      !container.lastChild ||
      container.lastChild.tagName.toLowerCase() !== tagName.toLowerCase()
    ) {
      hasTagMismatch = true;
    }

    if (
      container.lastChild instanceof HTMLUnknownElement &&
      !UNKNOWN_HTML_TAGS.has(container.lastChild.tagName.toLowerCase())
    ) {
      hasUnknownElement = true;
    }

    ssrResult = read(container.lastChild);
    canonicalSsrResult = getCanonicalizedValue(ssrResult);
    ssrDidWarn = _didWarn;
    ssrDidError = false;
  } catch (error) {
    ssrResult = null;
    ssrDidWarn = _didWarn;
    ssrDidError = true;
  }

  console.error = originalConsoleError;

  if (hasTagMismatch) {
    throw new Error('Tag mismatch. Expected: ' + tagName);
  }
  if (hasUnknownElement) {
    throw new Error('Unexpected unknown element: ' + tagName);
  }

  let ssrHasSameBehavior;
  let ssrHasSameBehaviorExceptWarnings;
  if (didError && ssrDidError) {
    ssrHasSameBehavior = true;
  } else if (!didError && !ssrDidError) {
    if (canonicalResult === canonicalSsrResult) {
      ssrHasSameBehaviorExceptWarnings = true;
      ssrHasSameBehavior = didWarn === ssrDidWarn;
    }
    ssrHasSameBehavior =
      didWarn === ssrDidWarn && canonicalResult === canonicalSsrResult;
  } else {
    ssrHasSameBehavior = false;
  }

  return {
    tagName,
    containerTagName,
    testValue,
    defaultValue,
    result,
    canonicalResult,
    canonicalDefaultValue,
    didWarn,
    didError,
    ssrResult,
    canonicalSsrResult,
    ssrDidWarn,
    ssrDidError,
    ssrHasSameBehavior,
    ssrHasSameBehaviorExceptWarnings,
  };
}

function prepareState(initGlobals) {
  function getRenderedAttributeValues(attribute, type) {
    const {
      ReactStable,
      ReactDOMStable,
      ReactDOMServerStable,
      ReactNext,
      ReactDOMNext,
      ReactDOMServerNext,
    } = initGlobals(attribute, type);
    const reactStableValue = getRenderedAttributeValue(
      ReactStable,
      ReactDOMStable,
      ReactDOMServerStable,
      attribute,
      type
    );
    const reactNextValue = getRenderedAttributeValue(
      ReactNext,
      ReactDOMNext,
      ReactDOMServerNext,
      attribute,
      type
    );

    let hasSameBehavior;
    if (reactStableValue.didError && reactNextValue.didError) {
      hasSameBehavior = true;
    } else if (!reactStableValue.didError && !reactNextValue.didError) {
      hasSameBehavior =
        reactStableValue.didWarn === reactNextValue.didWarn &&
        reactStableValue.canonicalResult === reactNextValue.canonicalResult &&
        reactStableValue.ssrHasSameBehavior ===
          reactNextValue.ssrHasSameBehavior;
    } else {
      hasSameBehavior = false;
    }

    return {
      reactStable: reactStableValue,
      reactNext: reactNextValue,
      hasSameBehavior,
    };
  }

  const table = new Map();
  const rowPatternHashes = new Map();

  // Disable error overlay while testing each attribute
  uninjectErrorOverlay();
  for (let attribute of attributes) {
    const results = new Map();
    let hasSameBehaviorForAll = true;
    let rowPatternHash = '';
    for (let type of types) {
      const result = getRenderedAttributeValues(attribute, type);
      results.set(type.name, result);
      if (!result.hasSameBehavior) {
        hasSameBehaviorForAll = false;
      }
      rowPatternHash += [result.reactStable, result.reactNext]
        .map(res =>
          [
            res.canonicalResult,
            res.canonicalDefaultValue,
            res.didWarn,
            res.didError,
          ].join('||')
        )
        .join('||');
    }
    const row = {
      results,
      hasSameBehaviorForAll,
      rowPatternHash,
      // "Good enough" id that we can store in localStorage
      rowIdHash: `${attribute.name} ${attribute.tagName} ${
        attribute.overrideStringValue
      }`,
    };
    const rowGroup = rowPatternHashes.get(rowPatternHash) || new Set();
    rowGroup.add(row);
    rowPatternHashes.set(rowPatternHash, rowGroup);
    table.set(attribute, row);
  }

  // Renable error overlay
  injectErrorOverlay();

  return {
    table,
    rowPatternHashes,
  };
}

const successColor = 'white';
const warnColor = 'yellow';
const errorColor = 'red';

function RendererResult({
  result,
  canonicalResult,
  defaultValue,
  canonicalDefaultValue,
  didWarn,
  didError,
  ssrHasSameBehavior,
  ssrHasSameBehaviorExceptWarnings,
}) {
  let backgroundColor;
  if (didError) {
    backgroundColor = errorColor;
  } else if (didWarn) {
    backgroundColor = warnColor;
  } else if (canonicalResult !== canonicalDefaultValue) {
    backgroundColor = 'cyan';
  } else {
    backgroundColor = successColor;
  }

  let style = {
    display: 'flex',
    alignItems: 'center',
    position: 'absolute',
    height: '100%',
    width: '100%',
    backgroundColor,
  };

  if (!ssrHasSameBehavior) {
    const color = ssrHasSameBehaviorExceptWarnings ? 'gray' : 'magenta';
    style.border = `3px dotted ${color}`;
  }

  return <div css={style}>{canonicalResult}</div>;
}

function ResultPopover(props) {
  return (
    <pre
      css={{
        padding: '1em',
        width: '25em',
      }}>
      {JSON.stringify(
        {
          reactStable: props.reactStable,
          reactNext: props.reactNext,
          hasSameBehavior: props.hasSameBehavior,
        },
        null,
        2
      )}
    </pre>
  );
}

class Result extends React.Component {
  state = {showInfo: false};
  onMouseEnter = () => {
    if (this.timeout) {
      clearTimeout(this.timeout);
    }
    this.timeout = setTimeout(() => {
      this.setState({showInfo: true});
    }, 250);
  };
  onMouseLeave = () => {
    if (this.timeout) {
      clearTimeout(this.timeout);
    }
    this.setState({showInfo: false});
  };

  componentWillUnmount() {
    if (this.timeout) {
      clearTimeout(this.interval);
    }
  }

  render() {
    const {reactStable, reactNext, hasSameBehavior} = this.props;
    const style = {
      position: 'absolute',
      width: '100%',
      height: '100%',
    };

    let highlight = null;
    let popover = null;
    if (this.state.showInfo) {
      highlight = (
        <div
          css={{
            position: 'absolute',
            height: '100%',
            width: '100%',
            border: '2px solid blue',
          }}
        />
      );

      popover = (
        <div
          css={{
            backgroundColor: 'white',
            border: '1px solid black',
            position: 'absolute',
            top: '100%',
            zIndex: 999,
          }}>
          <ResultPopover {...this.props} />
        </div>
      );
    }

    if (!hasSameBehavior) {
      style.border = '4px solid purple';
    }
    return (
      <div
        css={style}
        onMouseEnter={this.onMouseEnter}
        onMouseLeave={this.onMouseLeave}>
        <div css={{position: 'absolute', width: '50%', height: '100%'}}>
          <RendererResult {...reactStable} />
        </div>
        <div
          css={{
            position: 'absolute',
            width: '50%',
            left: '50%',
            height: '100%',
          }}>
          <RendererResult {...reactNext} />
        </div>
        {highlight}
        {popover}
      </div>
    );
  }
}

function ColumnHeader({children}) {
  return (
    <div
      css={{
        position: 'absolute',
        width: '100%',
        height: '100%',
        display: 'flex',
        alignItems: 'center',
      }}>
      {children}
    </div>
  );
}

function RowHeader({children, checked, onChange}) {
  return (
    <div
      css={{
        position: 'absolute',
        width: '100%',
        height: '100%',
        display: 'flex',
        alignItems: 'center',
      }}>
      <input type="checkbox" checked={checked} onChange={onChange} />
      {children}
    </div>
  );
}

function CellContent(props) {
  const {
    columnIndex,
    rowIndex,
    attributesInSortedOrder,
    completedHashes,
    toggleAttribute,
    table,
  } = props;
  const attribute = attributesInSortedOrder[rowIndex - 1];
  const type = types[columnIndex - 1];

  if (columnIndex === 0) {
    if (rowIndex === 0) {
      return null;
    }
    const row = table.get(attribute);
    const rowPatternHash = row.rowPatternHash;
    return (
      <RowHeader
        checked={completedHashes.has(rowPatternHash)}
        onChange={() => toggleAttribute(rowPatternHash)}>
        {row.hasSameBehaviorForAll ? (
          attribute.name
        ) : (
          <b css={{color: 'purple'}}>{attribute.name}</b>
        )}
      </RowHeader>
    );
  }

  if (rowIndex === 0) {
    return <ColumnHeader>{type.name}</ColumnHeader>;
  }

  const row = table.get(attribute);
  const result = row.results.get(type.name);

  return <Result {...result} />;
}

function saveToLocalStorage(completedHashes) {
  const str = JSON.stringify([...completedHashes]);
  localStorage.setItem('completedHashes', str);
}

function restoreFromLocalStorage() {
  const str = localStorage.getItem('completedHashes');
  if (str) {
    const completedHashes = new Set(JSON.parse(str));
    return completedHashes;
  }
  return new Set();
}

const useFastMode = /[?&]fast\b/.test(window.location.href);

class App extends React.Component {
  state = {
    sortOrder: ALPHABETICAL,
    filter: ALL,
    completedHashes: restoreFromLocalStorage(),
    table: null,
    rowPatternHashes: null,
  };

  renderCell = props => {
    return (
      <div style={props.style}>
        <CellContent
          toggleAttribute={this.toggleAttribute}
          completedHashes={this.state.completedHashes}
          table={this.state.table}
          attributesInSortedOrder={this.attributes}
          {...props}
        />
      </div>
    );
  };

  onUpdateSort = e => {
    this.setState({sortOrder: e.target.value});
  };

  onUpdateFilter = e => {
    this.setState({filter: e.target.value});
  };

  toggleAttribute = rowPatternHash => {
    const completedHashes = new Set(this.state.completedHashes);
    if (completedHashes.has(rowPatternHash)) {
      completedHashes.delete(rowPatternHash);
    } else {
      completedHashes.add(rowPatternHash);
    }
    this.setState({completedHashes}, () => saveToLocalStorage(completedHashes));
  };

  async componentDidMount() {
    const sources = {
      ReactStable: 'https://unpkg.com/react@latest/umd/react.development.js',
      ReactDOMStable:
        'https://unpkg.com/react-dom@latest/umd/react-dom.development.js',
      ReactDOMServerStable:
        'https://unpkg.com/react-dom@latest/umd/react-dom-server.browser.development.js',
      ReactNext: '/react.development.js',
      ReactDOMNext: '/react-dom.development.js',
      ReactDOMServerNext: '/react-dom-server.browser.development.js',
    };
    const codePromises = Object.values(sources).map(src =>
      fetch(src).then(res => res.text())
    );
    const codesByIndex = await Promise.all(codePromises);

    const pool = [];
    function initGlobals(attribute, type) {
      if (useFastMode) {
        // Note: this is not giving correct results for warnings.
        // But it's much faster.
        if (pool[0]) {
          return pool[0].globals;
        }
      } else {
        document.title = `${attribute.name} (${type.name})`;
      }

      // Creating globals for every single test is too slow.
      // However caching them between runs won't work for the same attribute names
      // because warnings will be deduplicated. As a result, we only share globals
      // between different attribute names.
      for (let i = 0; i < pool.length; i++) {
        if (!pool[i].testedAttributes.has(attribute.name)) {
          pool[i].testedAttributes.add(attribute.name);
          return pool[i].globals;
        }
      }

      let globals = {};
      Object.keys(sources).forEach((name, i) => {
        eval.call(window, codesByIndex[i]); // eslint-disable-line
        globals[name] = window[name.replace(/Stable|Next/g, '')];
      });

      // Cache for future use (for different attributes).
      pool.push({
        globals,
        testedAttributes: new Set([attribute.name]),
      });

      return globals;
    }

    const {table, rowPatternHashes} = prepareState(initGlobals);
    document.title = 'Ready';

    this.setState({
      table,
      rowPatternHashes,
    });
  }

  componentWillUpdate(nextProps, nextState) {
    if (
      nextState.sortOrder !== this.state.sortOrder ||
      nextState.filter !== this.state.filter ||
      nextState.completedHashes !== this.state.completedHashes ||
      nextState.table !== this.state.table
    ) {
      this.attributes = this.getAttributes(
        nextState.table,
        nextState.rowPatternHashes,
        nextState.sortOrder,
        nextState.filter,
        nextState.completedHashes
      );
      if (this.grid) {
        this.grid.forceUpdateGrids();
      }
    }
  }

  getAttributes(table, rowPatternHashes, sortOrder, filter, completedHashes) {
    // Filter
    let filteredAttributes;
    switch (filter) {
      case ALL:
        filteredAttributes = attributes.filter(() => true);
        break;
      case COMPLETE:
        filteredAttributes = attributes.filter(attribute => {
          const row = table.get(attribute);
          return completedHashes.has(row.rowPatternHash);
        });
        break;
      case INCOMPLETE:
        filteredAttributes = attributes.filter(attribute => {
          const row = table.get(attribute);
          return !completedHashes.has(row.rowPatternHash);
        });
        break;
      default:
        throw new Error('Switch statement should be exhaustive');
    }

    // Sort
    switch (sortOrder) {
      case ALPHABETICAL:
        return filteredAttributes.sort(
          (attr1, attr2) =>
            attr1.name.toLowerCase() < attr2.name.toLowerCase() ? -1 : 1
        );
      case REV_ALPHABETICAL:
        return filteredAttributes.sort(
          (attr1, attr2) =>
            attr1.name.toLowerCase() < attr2.name.toLowerCase() ? 1 : -1
        );
      case GROUPED_BY_ROW_PATTERN: {
        return filteredAttributes.sort((attr1, attr2) => {
          const row1 = table.get(attr1);
          const row2 = table.get(attr2);
          const patternGroup1 = rowPatternHashes.get(row1.rowPatternHash);
          const patternGroupSize1 = (patternGroup1 && patternGroup1.size) || 0;
          const patternGroup2 = rowPatternHashes.get(row2.rowPatternHash);
          const patternGroupSize2 = (patternGroup2 && patternGroup2.size) || 0;
          return patternGroupSize2 - patternGroupSize1;
        });
      }
      default:
        throw new Error('Switch statement should be exhaustive');
    }
  }

  handleSaveClick = e => {
    e.preventDefault();

    if (useFastMode) {
      alert(
        'Fast mode is not accurate. Please remove ?fast from the query string, and reload.'
      );
      return;
    }

    let log = '';
    for (let attribute of attributes) {
      log += `## \`${attribute.name}\` (on \`<${attribute.tagName ||
        'div'}>\` inside \`<${attribute.containerTagName || 'div'}>\`)\n`;
      log += '| Test Case | Flags | Result |\n';
      log += '| --- | --- | --- |\n';

      const attributeResults = this.state.table.get(attribute).results;
      for (let type of types) {
        const {
          didError,
          didWarn,
          canonicalResult,
          canonicalDefaultValue,
          ssrDidError,
          ssrHasSameBehavior,
          ssrHasSameBehaviorExceptWarnings,
        } = attributeResults.get(type.name).reactNext;

        let descriptions = [];
        if (canonicalResult === canonicalDefaultValue) {
          descriptions.push('initial');
        } else {
          descriptions.push('changed');
        }
        if (didError) {
          descriptions.push('error');
        }
        if (didWarn) {
          descriptions.push('warning');
        }
        if (ssrDidError) {
          descriptions.push('ssr error');
        }
        if (!ssrHasSameBehavior) {
          if (ssrHasSameBehaviorExceptWarnings) {
            descriptions.push('ssr warning');
          } else {
            descriptions.push('ssr mismatch');
          }
        }
        log +=
          `| \`${attribute.name}=(${type.name})\`` +
          `| (${descriptions.join(', ')})` +
          `| \`${canonicalResult || ''}\` |\n`;
      }
      log += '\n';
    }

    const blob = new Blob([log], {type: 'text/plain;charset=utf-8'});
    FileSaver.saveAs(blob, 'AttributeTableSnapshot.md');
  };

  render() {
    if (!this.state.table) {
      return (
        <div>
          <h1>Loading...</h1>
          {!useFastMode && (
            <h3>The progress is reported in the window title.</h3>
          )}
        </div>
      );
    }
    return (
      <div>
        <div>
          <select value={this.state.sortOrder} onChange={this.onUpdateSort}>
            <option value={ALPHABETICAL}>alphabetical</option>
            <option value={REV_ALPHABETICAL}>reverse alphabetical</option>
            <option value={GROUPED_BY_ROW_PATTERN}>
              grouped by row pattern :)
            </option>
          </select>
          <select value={this.state.filter} onChange={this.onUpdateFilter}>
            <option value={ALL}>all</option>
            <option value={INCOMPLETE}>incomplete</option>
            <option value={COMPLETE}>complete</option>
          </select>
          <button style={{marginLeft: '10px'}} onClick={this.handleSaveClick}>
            Save latest results to a file{' '}
            <span role="img" aria-label="Save">
              💾
            </span>
          </button>
        </div>
        <AutoSizer disableHeight={true}>
          {({width}) => (
            <MultiGrid
              ref={input => {
                this.grid = input;
              }}
              cellRenderer={this.renderCell}
              columnWidth={200}
              columnCount={1 + types.length}
              fixedColumnCount={1}
              enableFixedColumnScroll={true}
              enableFixedRowScroll={true}
              height={1200}
              rowHeight={40}
              rowCount={this.attributes.length + 1}
              fixedRowCount={1}
              width={width}
            />
          )}
        </AutoSizer>
      </div>
    );
  }
}

export default App;
