/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

import typeof ReactTestRenderer from 'react-test-renderer';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type Store from 'react-devtools-shared/src/devtools/store';
import type {
  DispatcherContext,
  StateContext,
} from 'react-devtools-shared/src/devtools/views/Components/TreeContext';

describe('TreeListContext', () => {
  let React;
  let ReactDOM;
  let TestRenderer: ReactTestRenderer;
  let bridge: FrontendBridge;
  let store: Store;
  let utils;

  let BridgeContext;
  let StoreContext;
  let TreeContext;

  let dispatch: DispatcherContext;
  let state: StateContext;

  beforeEach(() => {
    utils = require('./utils');
    utils.beforeEachProfiling();

    bridge = global.bridge;
    store = global.store;
    store.collapseNodesByDefault = false;

    React = require('react');
    ReactDOM = require('react-dom');
    TestRenderer = utils.requireTestRenderer();

    BridgeContext = require('react-devtools-shared/src/devtools/views/context')
      .BridgeContext;
    StoreContext = require('react-devtools-shared/src/devtools/views/context')
      .StoreContext;
    TreeContext = require('react-devtools-shared/src/devtools/views/Components/TreeContext');
  });

  afterEach(() => {
    // Reset between tests
    dispatch = ((null: any): DispatcherContext);
    state = ((null: any): StateContext);
  });

  const Capture = () => {
    dispatch = React.useContext(TreeContext.TreeDispatcherContext);
    state = React.useContext(TreeContext.TreeStateContext);
    return null;
  };

  const Contexts = () => {
    return (
      <BridgeContext.Provider value={bridge}>
        <StoreContext.Provider value={store}>
          <TreeContext.TreeContextController>
            <Capture />
          </TreeContext.TreeContextController>
        </StoreContext.Provider>
      </BridgeContext.Provider>
    );
  };

  describe('tree state', () => {
    it('should select the next and previous elements in the tree', () => {
      const Grandparent = () => <Parent />;
      const Parent = () => (
        <React.Fragment>
          <Child />
          <Child />
        </React.Fragment>
      );
      const Child = () => null;

      utils.act(() =>
        ReactDOM.render(<Grandparent />, document.createElement('div')),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: select first element');

      while (
        state.selectedElementIndex !== null &&
        state.selectedElementIndex < store.numElements - 1
      ) {
        const index = ((state.selectedElementIndex: any): number);
        utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'}));
        utils.act(() => renderer.update(<Contexts />));
        expect(state).toMatchSnapshot(`3: select element after (${index})`);
      }

      while (
        state.selectedElementIndex !== null &&
        state.selectedElementIndex > 0
      ) {
        const index = ((state.selectedElementIndex: any): number);
        utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'}));
        utils.act(() => renderer.update(<Contexts />));
        expect(state).toMatchSnapshot(`4: select element before (${index})`);
      }

      utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('5: select previous wraps around to last');

      utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('6: select next wraps around to first');
    });

    it('should select child elements', () => {
      const Grandparent = () => (
        <React.Fragment>
          <Parent />
          <Parent />
        </React.Fragment>
      );
      const Parent = () => (
        <React.Fragment>
          <Child />
          <Child />
        </React.Fragment>
      );
      const Child = () => null;

      utils.act(() =>
        ReactDOM.render(<Grandparent />, document.createElement('div')),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 0}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: select first element');

      utils.act(() => dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: select Parent');

      utils.act(() => dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: select Child');

      const previousState = state;

      // There are no more children to select, so this should be a no-op
      utils.act(() => dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toEqual(previousState);
    });

    it('should select parent elements and then collapse', () => {
      const Grandparent = () => (
        <React.Fragment>
          <Parent />
          <Parent />
        </React.Fragment>
      );
      const Parent = () => (
        <React.Fragment>
          <Child />
          <Child />
        </React.Fragment>
      );
      const Child = () => null;

      utils.act(() =>
        ReactDOM.render(<Grandparent />, document.createElement('div')),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      const lastChildID = store.getElementIDAtIndex(store.numElements - 1);

      utils.act(() =>
        dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: lastChildID}),
      );
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: select last child');

      utils.act(() => dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: select Parent');

      utils.act(() => dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: select Grandparent');

      const previousState = state;

      // There are no more ancestors to select, so this should be a no-op
      utils.act(() => dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toEqual(previousState);
    });

    it('should clear selection if the selected element is unmounted', async done => {
      const Grandparent = props => props.children || null;
      const Parent = props => props.children || null;
      const Child = () => null;

      const container = document.createElement('div');
      utils.act(() =>
        ReactDOM.render(
          <Grandparent>
            <Parent>
              <Child />
              <Child />
            </Parent>
          </Grandparent>,
          container,
        ),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 3}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: select second child');

      await utils.actAsync(() =>
        ReactDOM.render(
          <Grandparent>
            <Parent />
          </Grandparent>,
          container,
        ),
      );
      expect(state).toMatchSnapshot(
        '3: remove children (parent should now be selected)',
      );

      await utils.actAsync(() => ReactDOM.unmountComponentAtNode(container));
      expect(state).toMatchSnapshot(
        '4: unmount root (nothing should be selected)',
      );

      done();
    });
  });

  describe('search state', () => {
    it('should find elements matching search text', () => {
      const Foo = () => null;
      const Bar = () => null;
      const Baz = () => null;
      const Qux = () => null;

      Qux.displayName = `withHOC(${Qux.name})`;

      utils.act(() =>
        ReactDOM.render(
          <React.Fragment>
            <Foo />
            <Bar />
            <Baz />
            <Qux />
          </React.Fragment>,
          document.createElement('div'),
        ),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      // NOTE: multi-match
      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: search for "ba"');

      // NOTE: single match
      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'f'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: search for "f"');

      // NOTE: no match
      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'y'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: search for "y"');

      // NOTE: HOC match
      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'w'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('5: search for "w"');
    });

    it('should select the next and previous items within the search results', () => {
      const Foo = () => null;
      const Bar = () => null;
      const Baz = () => null;

      utils.act(() =>
        ReactDOM.render(
          <React.Fragment>
            <Foo />
            <Baz />
            <Bar />
            <Baz />
          </React.Fragment>,
          document.createElement('div'),
        ),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: search for "ba"');

      utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: go to second result');

      utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: go to third result');

      utils.act(() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('5: go to second result');

      utils.act(() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('6: go to first result');

      utils.act(() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('7: wrap to last result');

      utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('8: wrap to first result');
    });

    it('should add newly mounted elements to the search results set if they match the current text', async done => {
      const Foo = () => null;
      const Bar = () => null;
      const Baz = () => null;

      const container = document.createElement('div');

      utils.act(() =>
        ReactDOM.render(
          <React.Fragment>
            <Foo />
            <Bar />
          </React.Fragment>,
          container,
        ),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: search for "ba"');

      await utils.actAsync(() =>
        ReactDOM.render(
          <React.Fragment>
            <Foo />
            <Bar />
            <Baz />
          </React.Fragment>,
          container,
        ),
      );
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: mount Baz');

      done();
    });

    it('should remove unmounted elements from the search results set', async done => {
      const Foo = () => null;
      const Bar = () => null;
      const Baz = () => null;

      const container = document.createElement('div');

      utils.act(() =>
        ReactDOM.render(
          <React.Fragment>
            <Foo />
            <Bar />
            <Baz />
          </React.Fragment>,
          container,
        ),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: search for "ba"');

      utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: go to second result');

      await utils.actAsync(() =>
        ReactDOM.render(
          <React.Fragment>
            <Foo />
            <Bar />
          </React.Fragment>,
          container,
        ),
      );
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: unmount Baz');

      done();
    });
  });

  describe('owners state', () => {
    it('should support entering and existing the owners tree view', () => {
      const Grandparent = () => <Parent />;
      const Parent = () => (
        <React.Fragment>
          <Child />
          <Child />
        </React.Fragment>
      );
      const Child = () => null;

      utils.act(() =>
        ReactDOM.render(<Grandparent />, document.createElement('div')),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      let parentID = ((store.getElementIDAtIndex(1): any): number);
      utils.act(() => dispatch({type: 'SELECT_OWNER', payload: parentID}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: parent owners tree');

      utils.act(() => dispatch({type: 'RESET_OWNER_STACK'}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: final state');
    });

    it('should remove an element from the owners list if it is unmounted', async done => {
      const Grandparent = ({count}) => <Parent count={count} />;
      const Parent = ({count}) =>
        new Array(count).fill(true).map((_, index) => <Child key={index} />);
      const Child = () => null;

      const container = document.createElement('div');
      utils.act(() => ReactDOM.render(<Grandparent count={2} />, container));

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      let parentID = ((store.getElementIDAtIndex(1): any): number);
      utils.act(() => dispatch({type: 'SELECT_OWNER', payload: parentID}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: parent owners tree');

      await utils.actAsync(() =>
        ReactDOM.render(<Grandparent count={1} />, container),
      );
      expect(state).toMatchSnapshot('3: remove second child');

      await utils.actAsync(() =>
        ReactDOM.render(<Grandparent count={0} />, container),
      );
      expect(state).toMatchSnapshot('4: remove first child');

      done();
    });

    it('should exit the owners list if the current owner is unmounted', async done => {
      const Parent = props => props.children || null;
      const Child = () => null;

      const container = document.createElement('div');
      utils.act(() =>
        ReactDOM.render(
          <Parent>
            <Child />
          </Parent>,
          container,
        ),
      );

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      let childID = ((store.getElementIDAtIndex(1): any): number);
      utils.act(() => dispatch({type: 'SELECT_OWNER', payload: childID}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: child owners tree');

      await utils.actAsync(() => ReactDOM.render(<Parent />, container));
      expect(state).toMatchSnapshot('3: remove child');

      let parentID = ((store.getElementIDAtIndex(0): any): number);
      utils.act(() => dispatch({type: 'SELECT_OWNER', payload: parentID}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: parent owners tree');

      await utils.actAsync(() => ReactDOM.unmountComponentAtNode(container));
      expect(state).toMatchSnapshot('5: unmount root');

      done();
    });

    // This tests ensures support for toggling Suspense boundaries outside of the active owners list.
    it('should exit the owners list if an element outside the list is selected', () => {
      const Grandchild = () => null;
      const Child = () => (
        <React.Suspense fallback="Loading">
          <Grandchild />
        </React.Suspense>
      );
      const Parent = () => (
        <React.Suspense fallback="Loading">
          <Child />
        </React.Suspense>
      );

      const container = document.createElement('div');
      utils.act(() => ReactDOM.render(<Parent />, container));

      expect(store).toMatchSnapshot('0: mount');

      let renderer;
      utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
      expect(state).toMatchSnapshot('1: initial state');

      const outerSuspenseID = ((store.getElementIDAtIndex(1): any): number);
      const childID = ((store.getElementIDAtIndex(2): any): number);
      const innerSuspenseID = ((store.getElementIDAtIndex(3): any): number);

      utils.act(() => dispatch({type: 'SELECT_OWNER', payload: childID}));
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('2: child owners tree');

      // Toggling a Suspense boundary inside of the flat list should update selected index
      utils.act(() =>
        dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: innerSuspenseID}),
      );
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('3: child owners tree');

      // Toggling a Suspense boundary outside of the flat list should exit owners list and update index
      utils.act(() =>
        dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: outerSuspenseID}),
      );
      utils.act(() => renderer.update(<Contexts />));
      expect(state).toMatchSnapshot('4: main tree');
    });
  });
});
