/**
 * 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 React, {useCallback, useContext} from 'react';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
import {BridgeContext, StoreContext} from '../context';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import HooksTree from './HooksTree';
import {ModalDialogContext} from '../ModalDialog';
import HocBadges from './HocBadges';
import InspectedElementTree from './InspectedElementTree';
import {InspectedElementContext} from './InspectedElementContext';
import ViewElementSourceContext from './ViewElementSourceContext';
import NativeStyleEditor from './NativeStyleEditor';
import Toggle from '../Toggle';
import Badge from './Badge';
import {
  ComponentFilterElementType,
  ElementTypeClass,
  ElementTypeForwardRef,
  ElementTypeFunction,
  ElementTypeMemo,
  ElementTypeSuspense,
} from 'react-devtools-shared/src/types';

import styles from './SelectedElement.css';

import type {GetInspectedElementPath} from './InspectedElementContext';
import type {Element, InspectedElement} from './types';
import type {ElementType} from 'react-devtools-shared/src/types';

export type Props = {||};

export default function SelectedElement(_: Props) {
  const {inspectedElementID} = useContext(TreeStateContext);
  const dispatch = useContext(TreeDispatcherContext);
  const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
    ViewElementSourceContext,
  );
  const bridge = useContext(BridgeContext);
  const store = useContext(StoreContext);
  const {dispatch: modalDialogDispatch} = useContext(ModalDialogContext);

  const {getInspectedElementPath, getInspectedElement} = useContext(
    InspectedElementContext,
  );

  const element =
    inspectedElementID !== null
      ? store.getElementByID(inspectedElementID)
      : null;

  const inspectedElement =
    inspectedElementID != null ? getInspectedElement(inspectedElementID) : null;

  const highlightElement = useCallback(
    () => {
      if (element !== null && inspectedElementID !== null) {
        const rendererID = store.getRendererIDForElement(inspectedElementID);
        if (rendererID !== null) {
          bridge.send('highlightNativeElement', {
            displayName: element.displayName,
            hideAfterTimeout: true,
            id: inspectedElementID,
            openNativeElementsPanel: true,
            rendererID,
            scrollIntoView: true,
          });
        }
      }
    },
    [bridge, element, inspectedElementID, store],
  );

  const logElement = useCallback(
    () => {
      if (inspectedElementID !== null) {
        const rendererID = store.getRendererIDForElement(inspectedElementID);
        if (rendererID !== null) {
          bridge.send('logElementToConsole', {
            id: inspectedElementID,
            rendererID,
          });
        }
      }
    },
    [bridge, inspectedElementID, store],
  );

  const viewSource = useCallback(
    () => {
      if (viewElementSourceFunction != null && inspectedElement !== null) {
        viewElementSourceFunction(
          inspectedElement.id,
          ((inspectedElement: any): InspectedElement),
        );
      }
    },
    [inspectedElement, viewElementSourceFunction],
  );

  // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
  // To detect this case, we defer to an injected helper function (if present).
  const canViewSource =
    inspectedElement !== null &&
    inspectedElement.canViewSource &&
    viewElementSourceFunction !== null &&
    (canViewElementSourceFunction === null ||
      canViewElementSourceFunction(inspectedElement));

  const isSuspended =
    element !== null &&
    element.type === ElementTypeSuspense &&
    inspectedElement != null &&
    inspectedElement.state != null;

  const canToggleSuspense =
    inspectedElement != null && inspectedElement.canToggleSuspense;

  // TODO (suspense toggle) Would be nice to eventually use a two setState pattern here as well.
  const toggleSuspended = useCallback(
    () => {
      let nearestSuspenseElement = null;
      let currentElement = element;
      while (currentElement !== null) {
        if (currentElement.type === ElementTypeSuspense) {
          nearestSuspenseElement = currentElement;
          break;
        } else if (currentElement.parentID > 0) {
          currentElement = store.getElementByID(currentElement.parentID);
        } else {
          currentElement = null;
        }
      }

      // If we didn't find a Suspense ancestor, we can't suspend.
      // Instead we can show a warning to the user.
      if (nearestSuspenseElement === null) {
        modalDialogDispatch({
          type: 'SHOW',
          content: <CannotSuspendWarningMessage />,
        });
      } else {
        const nearestSuspenseElementID = nearestSuspenseElement.id;

        // If we're suspending from an arbitary (non-Suspense) component, select the nearest Suspense element in the Tree.
        // This way when the fallback UI is shown and the current element is hidden, something meaningful is selected.
        if (nearestSuspenseElement !== element) {
          dispatch({
            type: 'SELECT_ELEMENT_BY_ID',
            payload: nearestSuspenseElementID,
          });
        }

        const rendererID = store.getRendererIDForElement(
          nearestSuspenseElementID,
        );

        // Toggle suspended
        if (rendererID !== null) {
          bridge.send('overrideSuspense', {
            id: nearestSuspenseElementID,
            rendererID,
            forceFallback: !isSuspended,
          });
        }
      }
    },
    [bridge, dispatch, element, isSuspended, modalDialogDispatch, store],
  );

  if (element === null) {
    return (
      <div className={styles.SelectedElement}>
        <div className={styles.TitleRow} />
      </div>
    );
  }

  return (
    <div className={styles.SelectedElement}>
      <div className={styles.TitleRow}>
        <div className={styles.SelectedComponentName}>
          <div className={styles.Component} title={element.displayName}>
            {element.displayName}
          </div>
        </div>

        {canToggleSuspense && (
          <Toggle
            className={styles.IconButton}
            isChecked={isSuspended}
            onChange={toggleSuspended}
            title={
              isSuspended
                ? 'Unsuspend the selected component'
                : 'Suspend the selected component'
            }>
            <ButtonIcon type="suspend" />
          </Toggle>
        )}
        {store.supportsNativeInspection && (
          <Button
            className={styles.IconButton}
            onClick={highlightElement}
            title="Inspect the matching DOM element">
            <ButtonIcon type="view-dom" />
          </Button>
        )}
        <Button
          className={styles.IconButton}
          onClick={logElement}
          title="Log this component data to the console">
          <ButtonIcon type="log-data" />
        </Button>
        <Button
          className={styles.IconButton}
          disabled={!canViewSource}
          onClick={viewSource}
          title="View source for this element">
          <ButtonIcon type="view-source" />
        </Button>
      </div>

      {inspectedElement === null && (
        <div className={styles.Loading}>Loading...</div>
      )}

      {inspectedElement !== null && (
        <InspectedElementView
          key={
            inspectedElementID /* Force reset when seleted Element changes */
          }
          element={element}
          getInspectedElementPath={getInspectedElementPath}
          inspectedElement={inspectedElement}
        />
      )}
    </div>
  );
}

export type InspectPath = (path: Array<string | number>) => void;

type InspectedElementViewProps = {|
  element: Element,
  getInspectedElementPath: GetInspectedElementPath,
  inspectedElement: InspectedElement,
|};

const IS_SUSPENDED = 'Suspended';

function InspectedElementView({
  element,
  getInspectedElementPath,
  inspectedElement,
}: InspectedElementViewProps) {
  const {id, type} = element;
  const {
    canEditFunctionProps,
    canEditHooks,
    canToggleSuspense,
    hasLegacyContext,
    context,
    hooks,
    owners,
    props,
    state,
  } = inspectedElement;

  const {ownerID} = useContext(TreeStateContext);
  const bridge = useContext(BridgeContext);
  const store = useContext(StoreContext);

  const inspectContextPath = useCallback(
    (path: Array<string | number>) => {
      getInspectedElementPath(id, ['context', ...path]);
    },
    [getInspectedElementPath, id],
  );
  const inspectPropsPath = useCallback(
    (path: Array<string | number>) => {
      getInspectedElementPath(id, ['props', ...path]);
    },
    [getInspectedElementPath, id],
  );
  const inspectStatePath = useCallback(
    (path: Array<string | number>) => {
      getInspectedElementPath(id, ['state', ...path]);
    },
    [getInspectedElementPath, id],
  );

  let overrideContextFn = null;
  let overridePropsFn = null;
  let overrideStateFn = null;
  let overrideSuspenseFn = null;
  if (type === ElementTypeClass) {
    overrideContextFn = (path: Array<string | number>, value: any) => {
      const rendererID = store.getRendererIDForElement(id);
      if (rendererID !== null) {
        bridge.send('overrideContext', {id, path, rendererID, value});
      }
    };
    overridePropsFn = (path: Array<string | number>, value: any) => {
      const rendererID = store.getRendererIDForElement(id);
      if (rendererID !== null) {
        bridge.send('overrideProps', {id, path, rendererID, value});
      }
    };
    overrideStateFn = (path: Array<string | number>, value: any) => {
      const rendererID = store.getRendererIDForElement(id);
      if (rendererID !== null) {
        bridge.send('overrideState', {id, path, rendererID, value});
      }
    };
  } else if (
    (type === ElementTypeFunction ||
      type === ElementTypeMemo ||
      type === ElementTypeForwardRef) &&
    canEditFunctionProps
  ) {
    overridePropsFn = (path: Array<string | number>, value: any) => {
      const rendererID = store.getRendererIDForElement(id);
      if (rendererID !== null) {
        bridge.send('overrideProps', {id, path, rendererID, value});
      }
    };
  } else if (type === ElementTypeSuspense && canToggleSuspense) {
    overrideSuspenseFn = (path: Array<string | number>, value: boolean) => {
      if (path.length !== 1 && path !== IS_SUSPENDED) {
        throw new Error('Unexpected path.');
      }
      const rendererID = store.getRendererIDForElement(id);
      if (rendererID !== null) {
        bridge.send('overrideSuspense', {
          id,
          rendererID,
          forceFallback: value,
        });
      }
    };
  }

  return (
    <div className={styles.InspectedElement}>
      <HocBadges element={element} />
      <InspectedElementTree
        label="props"
        data={props}
        inspectPath={inspectPropsPath}
        overrideValueFn={overridePropsFn}
        showWhenEmpty={true}
        canAddEntries={typeof overridePropsFn === 'function'}
      />
      {type === ElementTypeSuspense ? (
        <InspectedElementTree
          label="suspense"
          data={{
            [IS_SUSPENDED]: state !== null,
          }}
          overrideValueFn={overrideSuspenseFn}
        />
      ) : (
        <InspectedElementTree
          label="state"
          data={state}
          inspectPath={inspectStatePath}
          overrideValueFn={overrideStateFn}
        />
      )}
      <HooksTree canEditHooks={canEditHooks} hooks={hooks} id={id} />
      <InspectedElementTree
        label={hasLegacyContext ? 'legacy context' : 'context'}
        data={context}
        inspectPath={inspectContextPath}
        overrideValueFn={overrideContextFn}
      />

      <NativeStyleEditor />

      {ownerID === null &&
        owners !== null &&
        owners.length > 0 && (
          <div className={styles.Owners}>
            <div className={styles.OwnersHeader}>rendered by</div>
            {owners.map(owner => (
              <OwnerView
                key={owner.id}
                displayName={owner.displayName || 'Anonymous'}
                hocDisplayNames={owner.hocDisplayNames}
                id={owner.id}
                isInStore={store.containsElement(owner.id)}
                type={owner.type}
              />
            ))}
          </div>
        )}
    </div>
  );
}

type OwnerViewProps = {|
  displayName: string,
  hocDisplayNames: Array<string> | null,
  id: number,
  isInStore: boolean,
  type: ElementType,
|};

function OwnerView({
  displayName,
  hocDisplayNames,
  id,
  isInStore,
  type,
}: OwnerViewProps) {
  const dispatch = useContext(TreeDispatcherContext);

  const handleClick = useCallback(
    () =>
      dispatch({
        type: 'SELECT_ELEMENT_BY_ID',
        payload: id,
      }),
    [dispatch, id],
  );

  return (
    <Button
      key={id}
      className={styles.OwnerButton}
      disabled={!isInStore}
      onClick={handleClick}>
      <span
        className={`${styles.Owner} ${isInStore ? '' : styles.NotInStore}`}
        title={displayName}>
        {displayName}
      </span>
      <Badge hocDisplayNames={hocDisplayNames} type={type} />
    </Button>
  );
}

function CannotSuspendWarningMessage() {
  const store = useContext(StoreContext);
  const areSuspenseElementsHidden = !!store.componentFilters.find(
    filter =>
      filter.type === ComponentFilterElementType &&
      filter.value === ElementTypeSuspense &&
      filter.isEnabled,
  );

  // Has the user filted out Suspense nodes from the tree?
  // If so, the selected element might actually be in a Suspense tree after all.
  if (areSuspenseElementsHidden) {
    return (
      <div className={styles.CannotSuspendWarningMessage}>
        Suspended state cannot be toggled while Suspense components are hidden.
        Disable the filter and try agan.
      </div>
    );
  } else {
    return (
      <div className={styles.CannotSuspendWarningMessage}>
        The selected element is not within a Suspense container. Suspending it
        would cause an error.
      </div>
    );
  }
}
