import React, {
  useState,
  useCallback,
  useRef,
  useEffect,
  useLayoutEffect,
  useImperativeHandle,
  useContext,
  useMemo
} from 'react';
import { useStore } from 'react-redux';
import { scope } from '@jlongster/lively';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from './FixedSizeList';
import { styles, colors } from '../style';
import DeleteIcon from '../svg/Delete';
import Checkmark from '../svg/v1/Checkmark';
import ExpandArrow from '../svg/ExpandArrow';
import AnimatedLoading from '../svg/v1/AnimatedLoading';
import {
  View,
  Text,
  Button,
  Input,
  Tooltip,
  IntersectionBoundary,
  Menu
} from './common';
import { KeyHandlers } from './KeyHandlers';
import SheetValue from './spreadsheet/SheetValue';
import DateSelect from './DateSelect';
import format from './spreadsheet/format';
import { keys } from '../util/keys';
import {
  AvoidRefocusScrollProvider,
  useProperFocus,
  focusElement
} from './useProperFocus';
import { useSelectedItems } from './useSelected';

export const ROW_HEIGHT = 32;
export const TABLE_BACKGROUND_COLOR = colors.n11;

function fireBlur(onBlur, e) {
  if (document.hasFocus()) {
    // We only fire the blur event if the app is still focused
    // because the blur event is fired when the app goes into
    // the background and we want to ignore that
    onBlur && onBlur(e);
  } else {
    // Otherwise, stop React from bubbling this event and swallow it
    e.stopPropagation();
  }
}

const CellContext = React.createContext({
  backgroundColor: 'white',
  borderColor: colors.n9
});

function CellProvider({ backgroundColor, borderColor, children }) {
  let value = useMemo(
    () => ({
      backgroundColor,
      borderColor
    }),
    [backgroundColor, borderColor]
  );

  return <CellContext.Provider value={value}>{children}</CellContext.Provider>;
}

export const Field = React.forwardRef(function Field(
  {
    width,
    name,
    borderColor: oldBorderColor,
    truncate = true,
    children,
    style,
    contentStyle,
    ...props
  },
  ref
) {
  let { backgroundColor, borderColor } = useContext(CellContext);

  // TODO: Get rid of this. Go through and remove all the places where
  // the border color is manually passed in.
  if (oldBorderColor) {
    borderColor = oldBorderColor;
  }

  return (
    <View
      innerRef={ref}
      {...props}
      style={[
        width === 'flex' ? { flex: 1, flexBasis: 0 } : { width },
        {
          position: 'relative',
          borderTopWidth: borderColor ? 1 : 0,
          borderBottomWidth: borderColor ? 1 : 0,
          borderColor,
          backgroundColor
        },
        styles.smallText,
        style
      ]}
      data-testid={name}
    >
      {/* This is wrapped so that the padding is not taken into
          account with the flex width (which aligns it with the Cell
          component) */}
      <View
        style={[
          {
            flex: 1,
            padding: '0 5px',
            justifyContent: 'center'
          },
          contentStyle
        ]}
      >
        {truncate ? (
          <Text
            style={{
              whiteSpace: 'nowrap',
              overflow: 'hidden',
              textOverflow: 'ellipsis'
            }}
          >
            {children}
          </Text>
        ) : (
          children
        )}
      </View>
    </View>
  );
});

export function Cell({
  width,
  name,
  exposed,
  focused,
  value,
  formatter,
  textAlign,
  onExpose,
  borderColor: oldBorderColor,
  children,
  plain,
  style,
  valueStyle,
  ...viewProps
}) {
  let mouseCoords = useRef(null);
  let viewRef = useRef(null);

  let { backgroundColor, borderColor } = useContext(CellContext);

  useProperFocus(viewRef, focused !== undefined ? focused : exposed);

  // TODO: Get rid of this. Go through and remove all the places where
  // the border color is manually passed in.
  if (oldBorderColor) {
    borderColor = oldBorderColor;
  }

  const widthStyle = width === 'flex' ? { flex: 1, flexBasis: 0 } : { width };
  const cellStyle = {
    position: 'relative',
    textAlign: textAlign || 'left',
    justifyContent: 'center',
    borderTopWidth: borderColor ? 1 : 0,
    borderBottomWidth: borderColor ? 1 : 0,
    borderColor,
    backgroundColor
  };

  return (
    <View
      innerRef={viewRef}
      style={[widthStyle, cellStyle, style]}
      className="animated-cell"
      {...viewProps}
      data-testid={name}
    >
      {plain ? (
        children
      ) : exposed ? (
        children()
      ) : (
        <View
          style={[
            {
              flex: 1,
              padding: '0 5px',
              justifyContent: 'center'
            },
            styles.smallText,
            valueStyle
          ]}
          // Can't use click because we only want to expose the cell if
          // the user does a direct click, not if they also drag the
          // mouse to select something
          onMouseDown={e => (mouseCoords.current = [e.clientX, e.clientY])}
          onMouseUp={e => {
            if (
              mouseCoords.current &&
              Math.abs(e.clientX - mouseCoords.current[0]) < 5 &&
              Math.abs(e.clientY - mouseCoords.current[1]) < 5
            ) {
              onExpose && onExpose(name);
            }
          }}
          // When testing, allow the click handler to be used instead
          onClick={global.IS_TESTING && (() => onExpose && onExpose(name))}
        >
          <Text
            style={{
              whiteSpace: 'nowrap',
              overflow: 'hidden',
              textOverflow: 'ellipsis'
            }}
          >
            {formatter ? formatter(value) : value}
          </Text>
        </View>
      )}
    </View>
  );
}

export function Row({
  backgroundColor = 'white',
  borderColor = colors.border,
  inset = 0,
  collapsed,
  focused,
  highlighted,
  children,
  height,
  style,
  ...nativeProps
}) {
  let [shouldHighlight, setShouldHighlight] = useState(false);
  let prevHighlighted = useRef(false);
  let rowRef = useRef(null);
  let timer = useRef(null);

  useEffect(
    () => {
      if (highlighted && !prevHighlighted.current && rowRef.current) {
        rowRef.current.classList.add('animated');
        setShouldHighlight(true);

        clearTimeout(timer.current);
        timer.current = setTimeout(() => {
          setShouldHighlight(false);

          timer.current = setTimeout(() => {
            if (rowRef.current) {
              rowRef.current.classList.remove('animated');
            }
          }, 500);
        }, 500);
      }
    },
    [highlighted]
  );

  useEffect(() => {
    prevHighlighted.current = highlighted;
  });

  return (
    <CellProvider
      backgroundColor={shouldHighlight ? colors.y9 : backgroundColor}
      borderColor={shouldHighlight ? colors.y8 : borderColor}
    >
      <View
        innerRef={rowRef}
        style={[
          {
            flexDirection: 'row',
            height: height || ROW_HEIGHT,
            flex: '0 0 ' + (height || ROW_HEIGHT) + 'px',
            userSelect: 'text',
            '&.animated .animated-cell': {
              transition: '.7s background-color'
            }
          },
          collapsed && { marginTop: -1 },
          style
        ]}
        data-testid="row"
        {...nativeProps}
      >
        {inset !== 0 && <Field width={inset} />}
        {children}
        {inset !== 0 && <Field width={inset} />}
      </View>
    </CellProvider>
  );
}

const inputCellStyle = {
  backgroundColor: 'white',
  padding: '5px 3px',
  margin: '0 1px'
};

const readonlyInputStyle = {
  backgroundColor: 'transparent',
  '::selection': { backgroundColor: '#d9d9d9' }
};

function InputValue({ value: defaultValue, onUpdate, onBlur, ...props }) {
  let [value, setValue] = useState(defaultValue);

  function onBlur_(e) {
    onUpdate && onUpdate(value);
    onBlur && fireBlur(onBlur, e);
  }

  function onKeyDown(e) {
    // Only enter and tab to escape (which allows the user to move
    // around)
    if (e.keyCode !== keys.ENTER && e.keyCode !== keys.TAB) {
      e.stopPropagation();
    }

    if (e.keyCode === keys.ESC) {
      if (value !== defaultValue) {
        setValue(defaultValue);
      }
    } else if (shouldSaveFromKey(e)) {
      onUpdate && onUpdate(value);
    }
  }

  return (
    <Input
      {...props}
      value={value}
      onUpdate={text => setValue(text)}
      onBlur={onBlur_}
      onKeyDown={onKeyDown}
      style={[
        inputCellStyle,
        props.readOnly ? readonlyInputStyle : null,
        props.style
      ]}
    />
  );
}

export function InputCell({
  inputProps,
  onUpdate,
  onBlur,
  textAlign,
  error,
  ...props
}) {
  return (
    <Cell textAlign={textAlign} {...props}>
      {() => (
        <React.Fragment>
          <InputValue
            value={props.value}
            onUpdate={onUpdate}
            onBlur={onBlur}
            style={[{ textAlign }, inputProps && inputProps.style]}
            {...inputProps}
          />
          {error && (
            <Tooltip
              key="error"
              targetHeight={ROW_HEIGHT}
              width={180}
              position="bottom-left"
            >
              {error}
            </Tooltip>
          )}
        </React.Fragment>
      )}
    </Cell>
  );
}

export function shouldSaveFromKey(e) {
  switch (e.keyCode) {
    case keys.TAB:
    case keys.ENTER:
      e.preventDefault();
      return true;
    default:
  }
}

export function CustomCell({
  value: defaultValue,
  children,
  onUpdate,
  onBlur,
  ...props
}) {
  let [value, setValue] = useState(defaultValue);
  let [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue);

  if (prevDefaultValue !== defaultValue) {
    setValue(defaultValue);
    setPrevDefaultValue(defaultValue);
  }

  function onBlur_(e) {
    // Only save on blur if the app is focused. Blur events fire when
    // the app unfocuses, and it's unintuitive to save the value since
    // the input will be focused again when the app regains focus
    if (document.hasFocus()) {
      onUpdate && onUpdate(value);
      fireBlur(onBlur, e);
    }
  }

  function onKeyDown(e) {
    if (shouldSaveFromKey(e)) {
      onUpdate && onUpdate(value);
    }
  }

  return (
    <Cell {...props} value={defaultValue}>
      {() =>
        children({
          onBlur: onBlur_,
          onKeyDown,
          onUpdate: val => setValue(val),
          onSave: val => {
            setValue(val);
            onUpdate && onUpdate(val);
          },
          shouldSaveFromKey,
          inputStyle: inputCellStyle
        })
      }
    </Cell>
  );
}

export const DateSelectCell = scope(lively => {
  function DateSelectCell({ props: { dateSelectProps, ...props }, updater }) {
    const { inputProps = {} } = dateSelectProps;
    return (
      <Cell
        {...props}
        style={{
          zIndex: props.exposed ? 1 : 0,
          ...props.style
        }}
      >
        {() => (
          <DateSelect
            {...dateSelectProps}
            tooltipStyle={{ minWidth: 225 }}
            inputProps={{
              ...inputProps,
              onBlur: e => fireBlur(inputProps && inputProps.onBlur, e),
              style: [inputCellStyle, { zIndex: 300 }]
            }}
          />
        )}
      </Cell>
    );
  }

  return lively(DateSelectCell);
});

export function DeleteCell({ onDelete, style, ...props }) {
  return (
    <Cell
      {...props}
      name="delete"
      width={20}
      style={[{ alignItems: 'center', userSelect: 'none' }, style]}
      onClick={e => {
        e.stopPropagation();
        onDelete && onDelete();
      }}
    >
      {() => <DeleteIcon width={7} height={7} />}
    </Cell>
  );
}

export const CellButton = React.forwardRef(
  ({ style, disabled, clickBehavior, onSelect, onEdit, children }, ref) => {
    // This represents a cell that acts like a button: it's clickable,
    // focusable, etc. The reason we don't use a button is because the
    // full behavior is undesirable: we really don't want stuff like
    // "click is fired when enter is pressed". We have very custom
    // controls and focus/active states.
    //
    // Important behavior:
    // * X/SPACE/etc keys select the button _on key down_ and not on key
    //   up. This means it instantly selects and if you hold it down it
    //   will repeatedly select.
    // * The cell begins editing on focus. That means if the user does a
    //   mouse down, but moves out of the element and then does mouse
    //   up, it will properly still receive focus & being editing
    return (
      <View
        innerRef={ref}
        className="cell-button"
        tabIndex="0"
        onKeyDown={e => {
          if (e.keyCode === keys.X || e.keyCode === keys.SPACE) {
            e.preventDefault();
            if (!disabled) {
              onSelect && onSelect();
            }
          }
        }}
        style={[
          {
            flexDirection: 'row',
            alignItems: 'center',
            cursor: 'default',
            transition: 'box-shadow .15s',
            ':focus': {
              outline: 0,
              boxShadow: `0 0 0 3px white, 0 0 0 5px ${colors.b5}`
            }
          },
          style
        ]}
        onFocus={() => onEdit && onEdit()}
        data-testid="cell-button"
        onClick={
          clickBehavior === 'none'
            ? null
            : () => {
                if (!disabled) {
                  onSelect && onSelect();
                  onEdit && onEdit();
                }
              }
        }
      >
        {children}
      </View>
    );
  }
);

export function SelectCell({
  focused,
  selected,
  partial,
  style,
  onSelect,
  onEdit,
  ...props
}) {
  return (
    <Cell
      {...props}
      focused={focused}
      name="select"
      width={20}
      style={[{ alignItems: 'center', userSelect: 'none' }, style]}
      onClick={e => {
        e.stopPropagation();
        onSelect && onSelect();
        onEdit && onEdit();
      }}
    >
      {() => (
        <CellButton
          style={[
            {
              width: 12,
              height: 12,
              border: '1px solid ' + colors.n8,
              borderRadius: 3,
              justifyContent: 'center',
              alignItems: 'center',

              ':focus': {
                border: '1px solid ' + colors.b5,
                boxShadow: '0 1px 2px ' + colors.b5
              }
            },
            selected && {
              backgroundColor: partial ? colors.b9 : colors.b5,
              borderColor: partial ? colors.b9 : colors.b5
            }
          ]}
          onEdit={onEdit}
          onSelect={onSelect}
          clickBehavior="none"
        >
          {selected && (
            <Checkmark width={6} height={6} style={{ color: 'white' }} />
          )}
        </CellButton>
      )}
    </Cell>
  );
}

export function SheetCell({
  valueProps,
  valueStyle,
  inputProps,
  sync,
  textAlign,
  onSave,
  ...props
}) {
  const { binding, type, getValueStyle, formatExpr, unformatExpr } = valueProps;

  return (
    <SheetValue
      binding={binding}
      onChange={() => {
        // "close" the cell if it's editing
        if (props.exposed && inputProps && inputProps.onBlur) {
          inputProps.onBlur();
        }
      }}
    >
      {node => {
        return (
          <Cell
            valueStyle={
              getValueStyle
                ? [valueStyle, getValueStyle(node.value)]
                : valueStyle
            }
            textAlign={textAlign}
            {...props}
            value={node.value}
            formatter={value =>
              props.formatter
                ? props.formatter(value, type)
                : format(value, type)
            }
            data-cellname={node.name}
          >
            {() => {
              return (
                <InputValue
                  value={formatExpr ? formatExpr(node.value) : node.value}
                  onUpdate={value => {
                    onSave(unformatExpr ? unformatExpr(value) : value);
                  }}
                  style={{ textAlign }}
                  {...inputProps}
                />
              );
            }}
          </Cell>
        );
      }}
    </SheetValue>
  );
}

export const Highlight = scope(lively => {
  function Highlight({ inst, state: { activated, highlightOff } }) {
    return (
      <View
        innerRef={el => (inst.el = el)}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          transition: 'background-color 1.8s',
          backgroundColor: 'white'
        }}
      />
    );
  }

  function activate({ inst }) {
    inst.el.style.transitionDuration = '0s';
    inst.el.style.backgroundColor = colors.y9;
    setTimeout(() => {
      if (inst.el) {
        inst.el.style.transitionDuration = '1.8s';
        inst.el.style.backgroundColor = 'white';
      }
    }, 0);
  }

  return lively(Highlight, {
    getInitialState({ props }) {
      return { activated: false, highlightOff: true };
    },

    componentWillReceiveProps(bag, nextProps) {
      if (!bag.props.active && nextProps.active) {
        return activate(bag);
      }
    },

    componentDidMount(bag) {
      if (bag.props.active) {
        return activate(bag);
      }
    }
  });
});

export function TableHeader({ headers, children, version, ...rowProps }) {
  return (
    <View
      style={
        version === 'v2' && { borderRadius: '6px 6px 0 0', overflow: 'hidden' }
      }
    >
      <Row
        backgroundColor="white"
        borderColor={colors.border}
        collapsed={true}
        {...rowProps}
        style={[
          { zIndex: 200 },
          version === 'v2'
            ? { color: colors.n4, fontWeight: 500 }
            : { color: colors.n4 },
          rowProps.style
        ]}
      >
        {headers
          ? headers.map(header => {
              return (
                <Cell
                  key={header.name}
                  value={header.name}
                  width={header.width}
                  style={header.style}
                  valueStyle={header.valueStyle}
                />
              );
            })
          : children}
      </Row>
    </View>
  );
}

export function SelectedItemsButton({ name, keyHandlers, items, onSelect }) {
  let selectedItems = useSelectedItems();
  let [menuOpen, setMenuOpen] = useState(null);

  if (selectedItems.size === 0) {
    return null;
  }

  return (
    <View>
      <KeyHandlers keys={keyHandlers || {}} />

      <Button
        bare
        style={{ color: colors.b3 }}
        onClick={() => setMenuOpen(true)}
      >
        <ExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
        {selectedItems.size} {name}
      </Button>

      {menuOpen && (
        <Tooltip
          position="bottom-right"
          width={200}
          style={{ padding: 0 }}
          onClose={() => setMenuOpen(false)}
        >
          <Menu
            onMenuSelect={name => {
              onSelect(name, [...selectedItems]);
              setMenuOpen(false);
            }}
            items={items}
          />
        </Tooltip>
      )}
    </View>
  );
}

let rowStyle = { position: 'absolute', willChange: 'transform', width: '100%' };

export const TableWithNavigator = React.forwardRef(
  ({ fields, ...props }, ref) => {
    let navigator = useTableNavigator(props.items, fields);
    return <Table {...props} navigator={navigator} />;
  }
);

export const Table = React.forwardRef(
  (
    {
      items,
      count,
      headers,
      contentHeader,
      loading,
      rowHeight = ROW_HEIGHT,
      backgroundColor = TABLE_BACKGROUND_COLOR,
      renderItem,
      renderEmpty,
      getItemKey,
      loadMore,
      style,
      navigator,
      listRef,
      onScroll,
      version = 'v1',
      animated,
      allowPopupsEscape,
      isSelected,
      ...props
    },
    ref
  ) => {
    if (!navigator) {
      navigator = {
        onEdit: () => {},
        editingId: null,
        focusedField: null,
        getNavigatorProps: props => props
      };
    }

    let { onEdit, editingId, focusedField, getNavigatorProps } = navigator;
    let list = useRef(null);
    let listContainer = useRef(null);
    let scrollContainer = useRef(null);
    let initialScrollTo = useRef(null);
    let listInitialized = useRef(false);

    useImperativeHandle(ref, () => ({
      scrollTo: (id, alignment = 'smart') => {
        let index = items.findIndex(item => item.id === id);
        if (index !== -1) {
          if (!list.current) {
            // If the table hasn't been laid out yet, we need to wait for
            // that to happen before we can scroll to something
            initialScrollTo.current = index;
          } else {
            list.current.scrollToItem(index, alignment);
          }
        }
      },

      scrollToTop: () => {
        list.current && list.current.scrollTo(0);
      },

      getScrolledItem: () => {
        if (scrollContainer.current) {
          let offset = scrollContainer.current.scrollTop;
          let index = list.current.getStartIndexForOffset(offset);
          return items[index].id;
        }
        return 0;
      },

      setRowAnimation: flag => {
        list.current && list.current.setRowAnimation(flag);
      },

      edit(id, field, shouldScroll) {
        onEdit(id, field);

        if (id && shouldScroll) {
          ref.scrollTo(id);
        }
      },

      anchor() {
        list.current && list.current.anchor();
      },

      unanchor() {
        list.current && list.current.unanchor();
      },

      isAnchored() {
        return list.current && list.current.isAnchored();
      }
    }));

    useLayoutEffect(() => {
      // We wait for the list to mount because AutoSizer needs to run
      // before it's mounted
      if (!listInitialized.current && listContainer.current) {
        // Animation is on by default
        list.current && list.current.setRowAnimation(true);
        listInitialized.current = true;
      }
    });

    function renderRow({ index, style, key }) {
      let item = items[index];
      let editing = editingId === item.id;
      let selected = isSelected && isSelected(item.id);

      let row = renderItem({
        item,
        editing,
        focusedField: editing && focusedField,
        onEdit,
        index,
        position: style.top
      });

      // TODO: Need to also apply zIndex if item is selected
      // * Port over ListAnimation to Table
      // * Move highlighted functionality into here
      return (
        <View
          key={key}
          className="animated-row"
          style={[
            rowStyle,
            {
              zIndex: editing || selected ? 101 : 'auto',
              transform: 'translateY(var(--pos))'
            }
          ]}
          nativeStyle={{ '--pos': `${style.top - 1}px` }}
          data-focus-key={item.id}
        >
          {row}
        </View>
      );
    }

    function getScrollOffset(height, index) {
      return (
        index * (rowHeight - 1) +
        (rowHeight - 1) / 2 -
        height / 2 +
        (rowHeight - 1) * 2
      );
    }

    function onItemsRendered({ overscanStartIndex, overscanStopIndex }) {
      if (loadMore && overscanStopIndex > items.length - 100) {
        loadMore();
      }
    }

    function getEmptyContent(empty) {
      if (empty == null) {
        return null;
      } else if (typeof empty === 'function') {
        return empty();
      }

      return (
        <View
          style={{
            justifyContent: 'center',
            alignItems: 'center',
            fontStyle: 'italic',
            color: colors.n6,
            flex: 1
          }}
        >
          {empty}
        </View>
      );
    }

    if (loading) {
      return (
        <View
          style={[{ flex: 1, justifyContent: 'center', alignItems: 'center' }]}
        >
          <AnimatedLoading width={25} color={colors.n1} />
        </View>
      );
    }

    let isEmpty = (count || items.length) === 0;

    return (
      <View
        style={[
          {
            flex: 1,
            outline: 'none',
            '& .animated .animated-row': { transition: '.25s transform' }
          },
          style
        ]}
        tabIndex="1"
        {...getNavigatorProps(props)}
        data-testid="table"
      >
        {headers && (
          <TableHeader
            version={version}
            height={rowHeight}
            {...(Array.isArray(headers) ? { headers } : { children: headers })}
          />
        )}
        <View style={{ flex: 1, backgroundColor }}>
          {isEmpty ? (
            getEmptyContent(renderEmpty)
          ) : (
            <AutoSizer>
              {({ width, height }) => {
                if (width === 0 || height === 0) {
                  return null;
                }

                return (
                  <IntersectionBoundary.Provider
                    value={!allowPopupsEscape && listContainer}
                  >
                    <AvoidRefocusScrollProvider>
                      <FixedSizeList
                        ref={list}
                        header={contentHeader}
                        innerRef={listContainer}
                        outerRef={scrollContainer}
                        width={width}
                        height={height}
                        renderRow={renderRow}
                        itemCount={count || items.length}
                        itemSize={rowHeight - 1}
                        itemKey={
                          getItemKey || ((index, data) => items[index].id)
                        }
                        indexForKey={key =>
                          items.findIndex(item => item.id === key)
                        }
                        initialScrollOffset={
                          initialScrollTo.current
                            ? getScrollOffset(height, initialScrollTo.current)
                            : 0
                        }
                        version={version}
                        animated={animated}
                        overscanCount={5}
                        onItemsRendered={onItemsRendered}
                        onScroll={onScroll}
                      />
                    </AvoidRefocusScrollProvider>
                  </IntersectionBoundary.Provider>
                );
              }}
            </AutoSizer>
          )}
        </View>
      </View>
    );
  }
);

export function useTableNavigator(data, fields, opts = {}) {
  let getFields = typeof fields !== 'function' ? () => fields : fields;
  let { initialEditingId, initialFocusedField, moveKeys } = opts;
  let [editingId, setEditingId] = useState(initialEditingId || null);
  let [focusedField, setFocusedField] = useState(initialFocusedField || null);
  let containerRef = useRef();

  // See `onBlur` for why we need this
  let store = useStore();
  let modalStackLength = useRef(0);

  // onEdit is passed to children, so make sure it maintains identity
  let onEdit = useCallback((id, field) => {
    setEditingId(id);
    setFocusedField(id ? field : null);
  }, []);

  useEffect(() => {
    modalStackLength.current = store.getState().modals.modalStack.length;
  }, []);

  function flashInput() {
    // Force the container to be focused which suppresses the "space
    // pages down" behavior. If we don't do this and the user presses
    // up + space down quickly while nothing is focused, it would page
    // down.
    containerRef.current.focus();

    // Not ideal, but works for now. Let the UI show the input
    // go away, and then bring it back on the same row/field
    onEdit(null);

    setTimeout(() => {
      onEdit(editingId, focusedField);
    }, 100);
  }

  function onFocusPrevious() {
    let idx = data.findIndex(item => item.id === editingId);
    if (idx > 0) {
      let item = data[idx - 1];
      let fields = getFields(item);
      onEdit(item.id, fields[fields.length - 1]);
    } else {
      flashInput();
    }
  }

  function onFocusNext() {
    let idx = data.findIndex(item => item.id === editingId);
    if (idx < data.length - 1) {
      let item = data[idx + 1];
      let fields = getFields(item);
      onEdit(item.id, fields[0]);
    } else {
      flashInput();
    }
  }

  function moveHorizontally(dir) {
    if (editingId) {
      let fields = getFields(data.find(item => item.id === editingId));
      let idx = fields.indexOf(focusedField) + dir;

      if (idx < 0) {
        onFocusPrevious();
      } else if (idx >= fields.length) {
        onFocusNext();
      } else {
        setFocusedField(fields[idx]);
      }
    }
  }

  function moveVertically(dir) {
    if (editingId) {
      let idx = data.findIndex(item => item.id === editingId);
      let nextIdx = idx;

      while (true) {
        nextIdx = nextIdx + dir;
        if (nextIdx >= 0 && nextIdx < data.length) {
          const next = data[nextIdx];
          if (getFields(next).includes(focusedField)) {
            onEdit(next.id, focusedField);
            break;
          }
        } else {
          flashInput();
          break;
        }
      }
    }
  }

  function onMove(dir) {
    switch (dir) {
      case 'left':
        moveHorizontally(-1);
        break;
      case 'right':
        moveHorizontally(1);
        break;
      case 'up':
        moveVertically(-1);
        break;
      case 'down':
        moveVertically(1);
        break;
      default:
        throw new Error('Unknown direction: ' + dir);
    }
  }

  function getNavigatorProps(userProps) {
    return {
      ...userProps,

      innerRef: containerRef,

      onKeyDown: e => {
        userProps && userProps.onKeyDown && userProps.onKeyDown(e);
        if (e.isPropagationStopped()) {
          return;
        }

        let fieldKeys =
          moveKeys && moveKeys[focusedField] && moveKeys[focusedField];

        if (fieldKeys && fieldKeys[e.keyCode]) {
          e.preventDefault();
          e.stopPropagation();

          onMove(fieldKeys[e.keyCode]);
        } else {
          switch (e.keyCode) {
            case keys.UP:
            case keys.K:
              if (e.target.tagName !== 'INPUT') {
                onMove('up');
              }
              break;

            case keys.DOWN:
            case keys.J:
              if (e.target.tagName !== 'INPUT') {
                onMove('down');
              }
              break;

            case keys.ENTER:
            case keys.TAB:
              e.preventDefault();
              e.stopPropagation();

              onMove(
                e.keyCode === keys.ENTER
                  ? e.shiftKey
                    ? 'up'
                    : 'down'
                  : e.shiftKey
                    ? 'left'
                    : 'right'
              );
              break;
            default:
          }
        }
      },

      onBlur: e => {
        // We want to hide the editing field if the user clicked away
        // from the table. We use `relatedTarget` to figure out where
        // the focus is going, and if it's nothing (the user clicked
        // somewhere that doesn't have an editable field) or if it's
        // anything outside of the table, clear editing.
        //
        // Also important: only do this if the app is focused. The
        // blur event is fired when the app loses focus and we don't
        // want to hide the input.

        // The last tricky edge case: we don't want to blur if a new
        // modal just opened. This way the field still shows an
        // input, and it will be refocused when the modal closes.
        let prevNumModals = modalStackLength.current;
        let numModals = store.getState().modals.modalStack.length;

        if (
          document.hasFocus() &&
          (e.relatedTarget == null ||
            !containerRef.current.contains(e.relatedTarget)) &&
          prevNumModals === numModals
        ) {
          onEdit(null);
        }
      }
    };
  }

  return { onEdit, editingId, focusedField, getNavigatorProps };
}