import * as path from "path";
import {
  arraySplice,
  Directory,
  memoize,
  EMPTY_ARRAY,
  Point,
  Translate,
  Bounds,
  pointIntersectsBounds,
  getSmallestBounds,
  mergeBounds,
  getNestedTreeNodeById,
  stripProtocol,
  getParentTreeNode,
  TreeNode,
  getBoundsSize,
  centerTransformZoom,
  createZeroBounds,
  FSItem,
  getTreeNodePath,
  KeyValue,
  updateNestedNodeTrail,
  getTreeNodeFromPath,
  EMPTY_OBJECT,
  TreeNodeUpdater,
  findNestedNode,
  findTreeNodeParent,
  containsNestedTreeNodeById,
  updateProperties,
  addProtocol,
  FILE_PROTOCOL
} from "tandem-common";
import {
  SyntheticVisibleNode,
  PCEditorState,
  getSyntheticSourceNode,
  getPCNodeDependency,
  getSyntheticNodeById,
  getSyntheticVisibleNodeDocument,
  Frame,
  getSyntheticDocumentDependencyUri,
  getSyntheticVisibleNodeRelativeBounds,
  updateDependencyGraph,
  updateSyntheticVisibleNodeMetadata,
  isSyntheticVisibleNodeMovable,
  isSyntheticVisibleNodeResizable,
  diffTreeNode,
  TreeNodeOperationalTransformType,
  PCSourceTagNames,
  patchTreeNode,
  SyntheticDocument,
  updateSyntheticDocument,
  getFramesByDependencyUri,
  PCVisibleNode,
  PCVariant,
  TreeNodeOperationalTransform,
  getPCNode,
  findInstanceOfPCNode,
  isPCComponentInstance,
  PCComponent,
  PCModule,
  SyntheticNode,
  isSyntheticContentNode,
  PCNode,
  getPCNodeModule,
  getSyntheticInstancePath,
  syntheticNodeIsInShadow,
  PCComponentInstanceElement,
  isSlot,
  Dependency,
  DependencyGraph,
  getModifiedDependencies,
  PCConfig,
  inspectorNodeInShadow,
  getInspectorContentNodeContainingChild,
  getInspectorNodeParentShadow,
  getInspectorSourceNode,
  InspectorTreeNodeName,
  expandInspectorNodeById,
  getInspectorContentNode,
  getInspectorSyntheticNode,
  getInspectorNodeBySourceNodeId,
  getSyntheticDocumentByDependencyUri
} from "paperclip";
import {
  CanvasToolOverlayMouseMoved,
  CanvasToolOverlayClicked,
  CanvasDroppedItem
} from "../actions";
import { uniq, values, clamp } from "lodash";
import { FSSandboxRootState, queueOpenFile, hasFileCacheItem } from "fsbox";
import {
  refreshInspectorTree,
  InspectorTreeBaseNode,
  expandSyntheticInspectorNode,
  getSyntheticInspectorNode,
  evaluateModuleInspector,
  InspectorNode,
  getInsertableInspectorNode
} from "paperclip";
import {
  ContextMenuItem,
  ContextMenu
} from "../components/context-menu/view.pc";
import { Action } from "redux";

export enum ToolType {
  TEXT,
  POINTER,
  COMPONENT,
  ELEMENT
}

export type ProjectOptions = {
  allowCascadeFonts?: boolean;
};

export type ProjectScripts = {
  previewServer?: string;
  build?: string;
  openApp?: string;
};

export type ProjectConfig = {
  scripts?: ProjectScripts;

  // relative path to main file
  mainFilePath?: string;

  // path to PC file where all global data is stored
  globalFilePath?: string;

  options?: ProjectOptions;
} & PCConfig;

export type ProjectInfo = {
  config: ProjectConfig;
  path: string;
};

export enum FrameMode {
  PREVIEW = "preview",
  DESIGN = "design"
}

export const REGISTERED_COMPONENT = "REGISTERED_COMPONENT";
export const SNAPSHOT_GAP = 50;

export enum SyntheticVisibleNodeMetadataKeys {
  EDITING_LABEL = "editingLabel"
}

export type ScriptProcessLog = {
  error: boolean;
  text: string;
};

export type ScriptProcess = {
  id: string;
  script: string;
  logs: ScriptProcessLog[];
  label: string;
};

export type RegisteredComponent = {
  uri?: string;
  tagName: string;
  template: TreeNode<any>;
};

export type Canvas = {
  backgroundColor: string;
  panning?: boolean;
  translate: Translate;
};

export type GraphHistoryItem = {
  snapshot?: DependencyGraph;
  transforms?: KeyValue<TreeNodeOperationalTransform[]>;
};

export type GraphHistory = {
  index: number;
  items: GraphHistoryItem[];
};

export type Editor = {
  canvas: Canvas;
};

export type EditorWindow = {
  tabUris: string[];
  activeFilePath?: string;
  mousePosition?: Point;
  movingOrResizing?: boolean;
  smooth?: boolean;
  secondarySelection?: boolean;
  fullScreen?: boolean;
  container?: HTMLElement;
};

export enum ConfirmType {
  ERROR,
  WARNING,
  SUCCESS
}

export type Confirm = {
  type: ConfirmType;
  message: string;
};

export type Prompt = {
  label: string;
  defaultValue?: string;
  okActionType: string;
};

export type FontFamily = {
  name: string;
};

export enum QuickSearchResultType {
  URI = "uri",
  COMPONENT = "component"
}

export type BaseQuickSearchResult<TType extends QuickSearchResultType> = {
  label: string;
  description: string;
  type: TType;
};

export enum ContextMenuOptionType {
  GROUP = "group",
  ITEM = "item"
}

export type ContextMenuItem = {
  type: ContextMenuOptionType.ITEM;
  label: string;
  action: Action;
  keyCombo?: string;
};

export type ContextMenuGroup = {
  type: ContextMenuOptionType.GROUP;
  options: ContextMenuItem[];
};

export type ContextMenuOption = ContextMenuGroup | ContextMenuItem;

export type QuickSearchUriResult = {
  uri: string;
} & BaseQuickSearchResult<QuickSearchResultType.URI>;

export type QuickSearchComponentResult = {
  componentId: string;
} & BaseQuickSearchResult<QuickSearchResultType.COMPONENT>;

export type QuickSearchResult =
  | QuickSearchUriResult
  | QuickSearchComponentResult;

export type QuickSearch = {
  filter: string;

  // TODO - will eventually need to interface things like components
  matches: QuickSearchResult[];
};

export enum EditMode {
  PRIMARY,
  SECONDARY
}

export type ProjectTemplate = {
  id: string;
  icon: string;
  label: string;
  description: string;
};

export type ProjectFileCreator = (options: Object) => KeyValue<string>;

export enum RootReadyType {
  LOADING,
  LOADED,
  UNLOADING
}

export type Unloader = {
  id: string;
  completed: boolean;
};

export type RootState = {
  editorWindows: EditorWindow[];
  mount: Element;
  openFiles: OpenFile[];
  toolType?: ToolType;
  activeEditorFilePath?: string;
  quickSearch?: QuickSearch;
  editMode: EditMode;
  showConfigureBuildModal?: boolean;
  scriptProcesses: ScriptProcess[];
  unloaders: Unloader[];

  buildScriptProcessId?: string;

  // defined by context menu
  editingBasenameUri?: string;
  confirm?: Confirm;
  prompt?: Prompt;
  selectedDirectoryPath?: string;
  prevGraph?: DependencyGraph;
  showSidebar?: boolean;
  showBottomGutter?: boolean;
  customChrome: boolean;
  renameInspectorNodeId?: string;

  // TODO - may need to be moved to EditorWindow
  selectedVariant?: PCVariant;

  recenterUriAfterEvaluation?: string;
  openedMain?: boolean;

  // seaprate from synthetic & AST since it represents both. May also have separate
  // tooling
  selectedInspectorNodes: InspectorNode[];
  hoveringInspectorNodes: InspectorNode[];
  fontFamilies?: FontFamily[];
  sourceNodeInspector: InspectorTreeBaseNode<any>;
  sourceNodeInspectorMap: KeyValue<string[]>;

  // used for syncing
  sourceNodeInspectorGraph?: DependencyGraph;

  // TODO - should be ref
  selectedFileNodeIds: string[];
  selectedComponentVariantName?: string;
  readyType?: RootReadyType;
  projectDirectory?: Directory;
  projectInfo?: ProjectInfo;
  history: GraphHistory;
  showQuickSearch?: boolean;
  selectedComponentId?: string;
  queuedDndInfo?: CanvasDroppedItem;
} & PCEditorState &
  FSSandboxRootState;

// TODO - change this to Editor
export type OpenFile = {
  temporary: boolean;
  newContent?: Buffer;
  uri: string;
  canvas: Canvas;
};

export const updateRootState = <TState extends RootState>(
  properties: Partial<TState>,
  root: TState
): TState => updateProperties(properties, root);

export const deselectRootProjectFiles = (state: RootState) =>
  updateRootState(
    {
      selectedFileNodeIds: []
    },
    state
  );

export const persistRootState = (
  persistPaperclipState: (state: RootState) => RootState,
  state: RootState
) => {
  const oldGraph = state.prevGraph || state.graph;
  state = updateRootState(persistPaperclipState(state), state);

  state = keepActiveFileOpen(state);

  const modifiedDeps = getModifiedDependencies(state.graph, oldGraph);
  state = addHistory(oldGraph, state.graph, state);

  state = modifiedDeps.reduce(
    (state, dep: Dependency<any>) => setOpenFileContent(dep, state),
    state
  );

  state = refreshModuleInspectorNodes(state);
  return state;
};

const getUpdatedInspectorNodes = (
  newState: RootState,
  oldState: RootState,
  scope: InspectorNode
) => {
  const MAX_DEPTH = 1;
  const oldScope: InspectorNode = getNestedTreeNodeById(
    scope.id,
    oldState.sourceNodeInspector
  );
  const newScope: InspectorNode = getNestedTreeNodeById(
    scope.id,
    newState.sourceNodeInspector
  );

  let newInspectorNodes: InspectorNode[] = [];
  let model = oldScope;
  diffTreeNode(oldScope, newScope).forEach(ot => {
    const target = getTreeNodeFromPath(ot.nodePath, model);
    model = patchTreeNode([ot], model);

    if (ot.nodePath.length > MAX_DEPTH) {
      return;
    }

    // TODO - will need to check if new parent is not in an instance of a component.
    // Will also need to consider child overrides though.
    if (ot.type === TreeNodeOperationalTransformType.INSERT_CHILD) {
      newInspectorNodes.push(ot.child as InspectorNode);
    } else if (
      ot.type === TreeNodeOperationalTransformType.SET_PROPERTY &&
      ot.name === "source"
    ) {
      newInspectorNodes.push(target);
    }
  });

  // ensure that content nodes are not selected.
  newInspectorNodes = newInspectorNodes.map(node => {
    return node.name === InspectorTreeNodeName.CONTENT
      ? node.children[0]
      : node;
  });

  return uniq(newInspectorNodes);
};

export const selectInsertedSyntheticVisibleNodes = (
  oldState: RootState,
  newState: RootState,
  scope: InspectorNode
) => {
  return setSelectedInspectorNodes(
    newState,
    ...getUpdatedInspectorNodes(newState, oldState, scope)
  );
};

export const getInsertableSourceNodeFromSyntheticNode = memoize(
  (
    node: SyntheticVisibleNode,
    document: SyntheticDocument,
    graph: DependencyGraph
  ) => {
    const sourceNode = getSyntheticSourceNode(node, graph);

    if (syntheticNodeIsInShadow(node, document, graph)) {
      const module = getPCNodeModule(sourceNode.id, graph);
      const instancePath = getSyntheticInstancePath(node, document, graph);
      const instancePCComponent = getPCNode(
        (getPCNode(instancePath[0], graph) as PCComponentInstanceElement).is,
        graph
      );
      const slot = findTreeNodeParent(sourceNode.id, module, (parent: PCNode) =>
        isSlot(parent)
      );

      return slot && containsNestedTreeNodeById(slot.id, instancePCComponent)
        ? slot
        : null;
    } else if (
      sourceNode.name !== PCSourceTagNames.COMPONENT_INSTANCE &&
      sourceNode.name !== PCSourceTagNames.TEXT
    ) {
      return sourceNode;
    }

    return null;
  }
);

export const getInsertableSourceNodeScope = memoize(
  (
    insertableSourceNode: PCNode,
    relative: SyntheticVisibleNode,
    rootInspectorNode: InspectorNode,
    document: SyntheticDocument,
    graph: DependencyGraph
  ): InspectorNode => {
    const containsSource = (current: SyntheticVisibleNode) => {
      const sourceNode = getSyntheticSourceNode(current, graph);
      return containsNestedTreeNodeById(insertableSourceNode.id, sourceNode);
    };

    if (containsSource(relative)) {
      return getSyntheticInspectorNode(
        relative,
        document,
        rootInspectorNode,
        graph
      );
    }

    return getSyntheticInspectorNode(
      findTreeNodeParent(relative.id, document, containsSource),
      document,
      rootInspectorNode,
      graph
    );
  }
);

export const teeHistory = (state: RootState) => {
  if (state.prevGraph) {
    return state;
  }
  return {
    ...state,
    prevGraph: state.graph
  };
};

export const getBuildScriptProcess = (state: RootState) =>
  state.scriptProcesses.find(
    process => process.id === state.buildScriptProcessId
  );

export const getSyntheticRelativesOfParentSource = memoize(
  (
    node: SyntheticVisibleNode,
    parentSourceNode: PCNode,
    documents: SyntheticDocument[],
    graph: DependencyGraph
  ) => {
    const document = getSyntheticVisibleNodeDocument(node.id, documents);
    const module = getPCNodeModule(parentSourceNode.id, graph);

    const relatedParent = findTreeNodeParent(
      node.id,
      document,
      (parent: SyntheticNode) => {
        const sourceNode = getSyntheticSourceNode(parent, graph);
        return (
          getParentTreeNode(sourceNode.id, module).id === parentSourceNode.id
        );
      }
    );
    const relatedParentParent = getParentTreeNode(relatedParent.id, document);
    return relatedParentParent.children.filter((child: SyntheticNode) => {
      const sourceNode = getSyntheticSourceNode(child, graph);
      return (
        getParentTreeNode(sourceNode.id, module).id === parentSourceNode.id
      );
    }) as SyntheticVisibleNode[];
  }
);

const setOpenFileContent = (dep: Dependency<any>, state: RootState) =>
  updateOpenFile(
    {
      temporary: false,
      newContent: new Buffer(JSON.stringify(dep.content, null, 2), "utf8")
    },
    dep.uri,
    state
  );

const addHistory = (
  oldGraph: DependencyGraph,
  newGraph: DependencyGraph,
  state: RootState
) => {
  const items = state.history.items.slice(0, state.history.index);

  const prevSnapshotItem: GraphHistoryItem = getNextHistorySnapshot(items);

  if (
    !items.length ||
    (prevSnapshotItem &&
      items.length - items.indexOf(prevSnapshotItem) > SNAPSHOT_GAP)
  ) {
    items.push({
      snapshot: oldGraph
    });
  }

  const currentGraph = getGraphAtHistoricPoint(items);

  const modifiedDeps = getModifiedDependencies(newGraph, currentGraph);
  const transforms = {};
  for (const dep of modifiedDeps) {
    transforms[dep.uri] = diffTreeNode(
      currentGraph[dep.uri].content,
      dep.content,
      EMPTY_OBJECT
    );
  }

  return updateRootState(
    {
      prevGraph: null,
      history: {
        index: items.length + 1,
        items: [
          ...items,
          {
            transforms
          }
        ]
      }
    },
    state
  );
};

export const getGlobalFileUri = (info: ProjectInfo) => {
  const globalRelativeFilePath =
    (info && info.config.globalFilePath) || info.config.mainFilePath;
  return (
    globalRelativeFilePath &&
    addProtocol(
      FILE_PROTOCOL,
      path.join(path.dirname(info.path), globalRelativeFilePath)
    )
  );
};

const getNextHistorySnapshot = (items: GraphHistoryItem[]) => {
  for (let i = items.length; i--; ) {
    const prevHistoryItem = items[i];
    if (prevHistoryItem.snapshot) {
      return items[i];
    }
  }
};

const getGraphAtHistoricPoint = (
  allItems: GraphHistoryItem[],
  index: number = allItems.length
) => {
  const items = allItems.slice(0, index);
  const snapshotItem = getNextHistorySnapshot(items);
  const snapshotIndex = items.indexOf(snapshotItem);
  const transformItems = items.slice(snapshotIndex + 1);

  const graphSnapshot = transformItems.reduce((graph, { transforms }) => {
    const newGraph = { ...graph };
    for (const uri in transforms) {
      newGraph[uri] = {
        ...newGraph[uri],
        content: patchTreeNode(transforms[uri], graph[uri].content)
      };
    }
    return newGraph;
  }, snapshotItem.snapshot) as DependencyGraph;

  return graphSnapshot;
};

const moveDependencyRecordHistory = (
  pos: number,
  state: RootState
): RootState => {
  if (!state.history.items.length) {
    return state;
  }
  const newIndex = clamp(
    state.history.index + pos,
    1,
    state.history.items.length
  );

  const graphSnapshot = getGraphAtHistoricPoint(state.history.items, newIndex);

  state = updateDependencyGraph(graphSnapshot, state);
  state = refreshModuleInspectorNodes(state);

  state = updateRootState(
    {
      history: {
        ...state.history,
        index: newIndex
      }
    },
    state
  );

  return state;
};

export const isUnsaved = (state: RootState) =>
  state.openFiles.some(openFile => Boolean(openFile.newContent));

export const removeBuildScriptProcess = (state: RootState) => {
  state = {
    ...state,
    scriptProcesses: state.scriptProcesses.filter(
      process => process.id !== state.buildScriptProcessId
    ),
    buildScriptProcessId: null
  };

  return state;
};
const DEFAULT_CANVAS: Canvas = {
  backgroundColor: "#EEE",
  translate: {
    left: 0,
    top: 0,
    zoom: 1
  }
};

export const confirm = (message: string, type: ConfirmType, state: RootState) =>
  updateRootState({ confirm: { message, type } }, state);

export const undo = (root: RootState) => moveDependencyRecordHistory(-1, root);
export const redo = (root: RootState) => moveDependencyRecordHistory(1, root);

export const getOpenFile = (uri: string, openFiles: OpenFile[]) =>
  openFiles.find(openFile => openFile.uri === uri);

export const getOpenFilesWithContent = (state: RootState) =>
  state.openFiles.filter(openFile => openFile.newContent);

export const updateOpenFileContent = (
  uri: string,
  newContent: Buffer,
  state: RootState
) => {
  return updateOpenFile(
    {
      temporary: false,
      newContent
    },
    uri,
    state
  );
};

export const updateProjectScripts = (
  scripts: Partial<ProjectScripts>,
  state: RootState
) => {
  // todo - queue file to save
  state = {
    ...state,
    projectInfo: {
      ...state.projectInfo,
      config: {
        ...state.projectInfo.config,
        scripts: {
          ...(state.projectInfo.config.scripts || EMPTY_OBJECT),
          ...scripts
        }
      }
    }
  };

  state = queueSaveProjectFile(state);

  return state;
};

const queueSaveProjectFile = (state: RootState) => {
  state = updateOpenFile(
    {
      temporary: false,
      newContent: new Buffer(JSON.stringify(state.projectInfo.config, null, 2))
    },
    state.projectInfo.path,
    state
  );
  return state;
};

export const getActiveEditorWindow = (state: RootState) =>
  getEditorWithActiveFileUri(state.activeEditorFilePath, state);

export const updateOpenFile = (
  properties: Partial<OpenFile>,
  uri: string,
  state: RootState
) => {
  const file = getOpenFile(uri, state.openFiles);

  if (!file) {
    state = addOpenFile(uri, false, state);
    return updateOpenFile(properties, uri, state);
  }

  const index = state.openFiles.indexOf(file);
  return updateRootState(
    {
      openFiles: arraySplice(state.openFiles, index, 1, {
        ...file,
        ...properties
      })
    },
    state
  );
};

export const openFile = (
  uri: string,
  temporary: boolean,
  secondaryTab: boolean,
  state: RootState
): RootState => {
  let file = getOpenFile(uri, state.openFiles);
  state = openEditorFileUri(uri, secondaryTab, state);
  if (!file) {
    state = addOpenFile(uri, temporary, state);
    file = getOpenFile(uri, state.openFiles);
    state = centerEditorCanvasOrLater(state, uri);
  }

  if (!hasFileCacheItem(uri, state)) {
    state = queueOpenFile(uri, state);
  }
  return state;
};

export const refreshModuleInspectorNodes = (state: RootState) => {
  const [sourceNodeInspector, sourceNodeInspectorMap] = refreshInspectorTree(
    state.sourceNodeInspector,
    state.graph,
    state.openFiles.map(({ uri }) => uri).filter(Boolean),
    state.sourceNodeInspectorMap,
    state.sourceNodeInspectorGraph
  );

  state = updateRootState(
    {
      sourceNodeInspector,
      sourceNodeInspectorMap,
      sourceNodeInspectorGraph: state.graph,
      selectedInspectorNodes: state.selectedInspectorNodes.filter(node =>
        Boolean(getNestedTreeNodeById(node.id, sourceNodeInspector))
      ),
      hoveringInspectorNodes: state.hoveringInspectorNodes.filter(node =>
        Boolean(getNestedTreeNodeById(node.id, sourceNodeInspector))
      )
    },
    state
  );

  return state;
};

export const updateSourceInspectorNode = (
  state: RootState,
  updater: TreeNodeUpdater<any>
) => {
  return updateRootState(
    {
      sourceNodeInspector: updater(state.sourceNodeInspector)
    },
    state
  );
};

export const getEditorWindowWithFileUri = (
  uri: string,
  state: RootState
): EditorWindow => {
  return state.editorWindows.find(window => window.tabUris.indexOf(uri) !== -1);
};

export const getEditorWithActiveFileUri = (
  uri: string,
  state: RootState
): EditorWindow => {
  return state.editorWindows.find(editor => editor.activeFilePath === uri);
};

const createEditorWindow = (
  tabUris: string[],
  activeFilePath: string
): EditorWindow => ({
  tabUris,
  activeFilePath
});

let scriptProcessCount = 0;

export const createScriptProcess = (
  label: string,
  script: string
): ScriptProcess => ({
  label,
  script,
  id: `script${scriptProcessCount++}`,
  logs: []
});

let unloaderCount = 0;

export const createUnloader = (): Unloader => ({
  id: `script${unloaderCount++}`,
  completed: false
});

export const isUnloaded = (state: RootState) =>
  state.readyType === RootReadyType.UNLOADING &&
  !state.unloaders.some(({ completed }) => !completed);

export const getProjectCWD = (state: RootState) =>
  state.projectInfo && path.dirname(stripProtocol(state.projectInfo.path));

export const getSyntheticWindowBounds = memoize(
  (uri: string, state: RootState) => {
    const frames = getFramesByDependencyUri(
      uri,
      state.frames,
      state.documents,
      state.graph
    );
    if (!window) return createZeroBounds();
    return mergeBounds(...(frames || EMPTY_ARRAY).map(frame => frame.bounds));
  }
);

export const isImageMimetype = (mimeType: string) => /^image\//.test(mimeType);
export const pruneOpenFiles = (state: RootState) => {
  const openFiles = state.openFiles.filter(openFile => {
    return !!state.fileCache[openFile.uri];
  });

  const editorWindows = state.editorWindows
    .map(window => {
      const tabUris = window.tabUris.filter(uri => {
        return !!state.fileCache[uri];
      });

      if (!tabUris.length) {
        return null;
      }
      return {
        ...window,
        tabUris
      };
    })
    .filter(Boolean);

  state = updateRootState(
    {
      openFiles,
      editorWindows,
      activeEditorFilePath: null
    },
    state
  );

  state = setNextOpenFile(state);

  return state;
};

export const openEditorFileUri = (
  uri: string,
  secondaryTab: boolean,
  state: RootState
): RootState => {
  const editor =
    getEditorWindowWithFileUri(uri, state) ||
    (secondaryTab
      ? state.editorWindows.length > 1
        ? state.editorWindows[1]
        : null
      : state.editorWindows[0]);

  if (
    secondaryTab &&
    editor === state.editorWindows[0] &&
    (editor.tabUris.length > 1 || state.editorWindows.length > 1)
  ) {
    state = closeEditorWindowUri(uri, state);
    state = openEditorFileUri(uri, true, state);
    return state;
  }

  return {
    ...state,
    selectedFileNodeIds:
      state.selectedFileNodeIds.length === 1 &&
      (getNestedTreeNodeById(
        state.selectedFileNodeIds[0],
        state.projectDirectory
      ) as FSItem).uri === uri
        ? state.selectedFileNodeIds
        : EMPTY_ARRAY,
    selectedInspectorNodes: EMPTY_ARRAY,
    hoveringInspectorNodes: EMPTY_ARRAY,
    activeEditorFilePath: uri,
    editorWindows: editor
      ? arraySplice(
          state.editorWindows,
          state.editorWindows.indexOf(editor),
          1,
          {
            ...editor,
            tabUris:
              editor.tabUris.indexOf(uri) === -1
                ? [...editor.tabUris, uri]
                : editor.tabUris,
            activeFilePath: uri
          }
        )
      : [...state.editorWindows, createEditorWindow([uri], uri)]
  };
};

export const shiftActiveEditorTab = (
  delta: number,
  state: RootState
): RootState => {
  const editor = getActiveEditorWindow(state);

  // nothing open
  if (!editor) {
    return state;
  }
  const index = editor.tabUris.indexOf(editor.activeFilePath);
  let newIndex = index + delta;
  if (newIndex < 0) {
    newIndex = editor.tabUris.length + delta;
  } else if (newIndex >= editor.tabUris.length) {
    newIndex = -1 + delta;
  }
  newIndex = clamp(newIndex, 0, editor.tabUris.length - 1);

  return openEditorFileUri(editor.tabUris[newIndex], false, state);
};

const removeEditorWindow = (
  { activeFilePath }: EditorWindow,
  state: RootState
): RootState => {
  const editor = getEditorWithActiveFileUri(activeFilePath, state);
  return {
    ...state,
    editorWindows: arraySplice(
      state.editorWindows,
      state.editorWindows.indexOf(editor),
      1
    )
  };
};

const closeEditorWindowUri = (uri: string, state: RootState): RootState => {
  const editorWindow = getEditorWindowWithFileUri(uri, state);
  if (editorWindow.tabUris.length === 1) {
    state = removeEditorWindow(editorWindow, state);
  } else {
    const index = editorWindow.tabUris.indexOf(uri);
    const tabUris = arraySplice(editorWindow.tabUris, index, 1);
    const nextActiveUri = tabUris[Math.max(0, index - 1)];
    state = updateEditorWindow(
      {
        tabUris,
        activeFilePath: nextActiveUri
      },
      uri,
      state
    );

    state = updateRootState({ activeEditorFilePath: nextActiveUri }, state);
  }
  return state;
};

export const closeFile = (uri: string, state: RootState): RootState => {
  state = closeEditorWindowUri(uri, state);

  state = updateRootState(
    {
      openFiles: state.openFiles.filter(openFile => openFile.uri !== uri)
    },
    state
  );

  state = setNextOpenFile(state);
  state = refreshModuleInspectorNodes(state);

  return state;
};

export const setNextOpenFile = (state: RootState): RootState => {
  const hasOpenFile = state.openFiles.find(openFile =>
    Boolean(getEditorWithActiveFileUri(openFile.uri, state))
  );

  if (hasOpenFile) {
    return state;
  }
  state = {
    ...state,
    hoveringInspectorNodes: EMPTY_ARRAY,
    selectedInspectorNodes: EMPTY_ARRAY
  };

  if (state.openFiles.length) {
    state = openEditorFileUri(state.openFiles[0].uri, false, state);
  }

  return state;
};

export const removeTemporaryOpenFiles = (state: RootState) => {
  return {
    ...state,
    openFiles: state.openFiles.filter(openFile => !openFile.temporary)
  };
};

export const openSyntheticVisibleNodeOriginFile = (
  node: SyntheticVisibleNode,
  state: RootState
) => {
  let sourceNode = getSyntheticSourceNode(
    node as SyntheticVisibleNode,
    state.graph
  ) as PCVisibleNode | PCComponent;

  if (isPCComponentInstance(sourceNode)) {
    sourceNode = getPCNode(sourceNode.is, state.graph) as PCComponent;
  }

  const uri = getPCNodeDependency(sourceNode.id, state.graph).uri;
  const editors = state.editorWindows;
  const activeEditor = getActiveEditorWindow(state);
  const existingEditor = getEditorWindowWithFileUri(uri, state);

  // if existing editor, then don't open in second tab
  state = openFile(
    uri,
    false,
    activeEditor === editors[1] && !existingEditor,
    state
  );
  const instance = findNestedNode(state.sourceNodeInspector, child => {
    return !child.instancePath && child.sourceNodeId === sourceNode.id;
  });
  state = setSelectedInspectorNodes(state, instance);
  // state = centerCanvasToSelectedNodes(state);
  return state;
};

export const centerCanvasToSelectedNodes = (state: RootState) => {
  state = centerEditorCanvasOrLater(state, state.activeEditorFilePath);
  return state;
};

export const addOpenFile = (
  uri: string,
  temporary: boolean,
  state: RootState
): RootState => {
  const file = getOpenFile(uri, state.openFiles);
  if (file) {
    return state;
  }

  state = removeTemporaryOpenFiles(state);

  state = {
    ...state,
    openFiles: [
      ...state.openFiles,
      {
        uri,
        temporary,
        canvas: DEFAULT_CANVAS
      }
    ]
  };

  // need to sync inspector nodes so that they show up in the inspector pane
  state = refreshModuleInspectorNodes(state);

  return state;
};

export const upsertPCModuleInspectorNode = (
  module: PCModule,
  state: RootState
) => {};

export const keepActiveFileOpen = (state: RootState): RootState => {
  return {
    ...state,
    openFiles: state.openFiles.map(openFile => ({
      ...openFile,
      temporary: false
    }))
  };
};

export const setRootStateSyntheticVisibleNodeLabelEditing = (
  nodeId: string,
  value: boolean,
  state: RootState
) => {
  const node = getSyntheticNodeById(nodeId, state.documents);
  const document = getSyntheticVisibleNodeDocument(node.id, state.documents);
  state = updateSyntheticDocument(
    updateSyntheticVisibleNodeMetadata(
      {
        [SyntheticVisibleNodeMetadataKeys.EDITING_LABEL]: value
      },
      node,
      document
    ),
    document,
    state
  );
  return state;
};

export const setRootStateFileNodeExpanded = (
  nodeId: string,
  value: boolean,
  state: RootState
) => {
  return updateRootState(
    {
      projectDirectory: updateNestedNodeTrail(
        getTreeNodePath(nodeId, state.projectDirectory),
        state.projectDirectory,
        (child: FSItem) => ({
          ...child,
          expanded: value
        })
      )
    },
    state
  );
};

export const updateEditorWindow = (
  properties: Partial<EditorWindow>,
  uri: string,
  root: RootState
) => {
  const window = getEditorWindowWithFileUri(uri, root);
  const i = root.editorWindows.indexOf(window);
  if (i === -1) {
    return root;
  }
  return updateRootState(
    {
      editorWindows: arraySplice(root.editorWindows, i, 1, {
        ...window,
        ...properties
      })
    },
    root
  );
};

const INITIAL_ZOOM_PADDING = 50;

export const centerEditorCanvasOrLater = (
  state: RootState,
  editorFileUri: string
): RootState => {
  const document = getSyntheticDocumentByDependencyUri(
    editorFileUri,
    state.documents,
    state.graph
  );
  return document
    ? centerEditorCanvas(state, editorFileUri)
    : {
        ...state,
        recenterUriAfterEvaluation: editorFileUri
      };
};

export const centerEditorCanvas = (
  state: RootState,
  editorFileUri: string,
  innerBounds?: Bounds,
  smooth: boolean = false,
  zoomOrZoomToFit: boolean | number = true
) => {
  if (!innerBounds) {
    const frames = getFramesByDependencyUri(
      editorFileUri,
      state.frames,
      state.documents,
      state.graph
    );

    if (!frames.length) {
      return state;
    }

    innerBounds = state.selectedInspectorNodes.length
      ? getSelectionBounds(
          state.selectedInspectorNodes,
          state.documents,
          state.frames,
          state.graph
        )
      : getSyntheticWindowBounds(editorFileUri, state);
  }

  // no windows loaded
  if (
    innerBounds.left +
      innerBounds.right +
      innerBounds.top +
      innerBounds.bottom ===
    0
  ) {
    console.warn(` Cannot center when bounds has no size`);
    return updateOpenFileCanvas(
      {
        translate: { left: 0, top: 0, zoom: 1 }
      },
      editorFileUri,
      state
    );
  }

  const editorWindow = getEditorWindowWithFileUri(editorFileUri, state);
  const openFile = getOpenFile(editorFileUri, state.openFiles);
  const { container } = editorWindow;

  if (!container) {
    console.warn("cannot center canvas without a container");
    return state;
  }

  const {
    canvas: { translate }
  } = openFile;

  const { width, height } = container.getBoundingClientRect();

  const innerSize = getBoundsSize(innerBounds);

  const centered = {
    left: -innerBounds.left + width / 2 - innerSize.width / 2,
    top: -innerBounds.top + height / 2 - innerSize.height / 2
  };

  const scale =
    typeof zoomOrZoomToFit === "boolean"
      ? Math.min(
          (width - INITIAL_ZOOM_PADDING) / innerSize.width,
          (height - INITIAL_ZOOM_PADDING) / innerSize.height
        )
      : typeof zoomOrZoomToFit === "number"
        ? zoomOrZoomToFit
        : translate.zoom;

  state = updateEditorWindow(
    {
      smooth
    },
    editorFileUri,
    state
  );

  state = updateOpenFileCanvas(
    {
      translate: centerTransformZoom(
        {
          ...centered,
          zoom: 1
        },
        { left: 0, top: 0, right: width, bottom: height },
        Math.min(scale, 1)
      )
    },
    editorFileUri,
    state
  );

  return state;
};

export const updateOpenFileCanvas = (
  properties: Partial<Canvas>,
  uri: string,
  root: RootState
) => {
  const openFile = getOpenFile(uri, root.openFiles);
  return updateOpenFile(
    {
      canvas: {
        ...openFile.canvas,
        ...properties
      }
    },
    uri,
    root
  );
};

// export const setInsertFile = (type: InsertFileType, state: RootState) => {
//   const file = getNestedTreeNodeById(
//     state.selectedFileNodeIds[0] || state.projectDirectory.id,
//     state.projectDirectory
//   );
//   return updateRootState(
//     {
//       insertFileInfo: {
//         type,
//         directoryId: isDirectory(file)
//           ? file.id
//           : getParentTreeNode(file.id, state.projectDirectory).id
//       }
//     },
//     state
//   );
// };

export const setTool = (toolType: ToolType, root: RootState) => {
  if (!root.editorWindows.length) {
    return root;
  }
  root = { ...root, selectedComponentId: null };
  root = updateRootState({ toolType }, root);
  return root;
};

export const getActiveFrames = (root: RootState): Frame[] =>
  values(root.frames).filter(
    frame =>
      getActiveEditorWindow(root).activeFilePath ===
      getSyntheticDocumentDependencyUri(
        getSyntheticVisibleNodeDocument(
          frame.syntheticContentNodeId,
          root.documents
        ),
        root.graph
      )
  );

export const getCanvasTranslate = (canvas: Canvas) => canvas.translate;

export const getScaledMouseCanvasPosition = (
  state: RootState,
  point: Point
) => {
  const canvas = getOpenFile(state.activeEditorFilePath, state.openFiles)
    .canvas;
  const translate = getCanvasTranslate(canvas);

  const scaledPageX = (point.left - translate.left) / translate.zoom;
  const scaledPageY = (point.top - translate.top) / translate.zoom;
  return { left: scaledPageX, top: scaledPageY };
};

export const getCanvasMouseTargetNodeId = (
  state: RootState,
  event: React.MouseEvent<any>,
  filter?: (node: TreeNode<any>) => boolean
): string => {
  return getCanvasMouseTargetNodeIdFromPoint(
    state,
    {
      left: event.pageX,
      top: event.pageY
    },
    filter
  );
};

export const getCanvasMouseTargetInspectorNode = (
  state: RootState,
  event: CanvasToolOverlayMouseMoved | CanvasToolOverlayClicked,
  filter?: (node: TreeNode<any>) => boolean
): InspectorNode => {
  const syntheticNodeId = getCanvasMouseTargetNodeId(
    state,
    event.sourceEvent,
    filter
  );
  if (!syntheticNodeId) {
    return null;
  }
  const syntheticNode = getSyntheticNodeById(
    syntheticNodeId,
    state.documents
  ) as SyntheticVisibleNode;

  const assocInspectorNode = getSyntheticInspectorNode(
    syntheticNode,
    getSyntheticVisibleNodeDocument(syntheticNode.id, state.documents),
    state.sourceNodeInspector,
    state.graph
  );

  const insertableSourceNode = getInsertableInspectorNode(
    assocInspectorNode,
    state.sourceNodeInspector,
    state.graph
  );

  return insertableSourceNode;
};

const getSelectedInspectorNodeParentShadowId = (state: RootState) => {
  const node = state.selectedInspectorNodes[0];
  if (!node) {
    return null;
  }
  const inspectorNode = getNestedTreeNodeById(
    node.id,
    state.sourceNodeInspector
  );
  const shadow =
    inspectorNode.name === InspectorTreeNodeName.SHADOW
      ? inspectorNode
      : getInspectorNodeParentShadow(inspectorNode, state.sourceNodeInspector);
  return shadow && shadow.id;
};

const defaultCanvasNodeFilter = ({ id }: SyntheticNode, state: RootState) => {
  const syntheticNode = getSyntheticNodeById(id, state.documents);
  const document = getSyntheticVisibleNodeDocument(id, state.documents);
  const inspectorNode = getSyntheticInspectorNode(
    syntheticNode,
    document,
    state.sourceNodeInspector,
    state.graph
  );

  if (!inspectorNode) {
    return false;
  }

  const contentNode =
    getInspectorContentNodeContainingChild(
      inspectorNode,
      state.sourceNodeInspector
    ) || inspectorNode;

  if (inspectorNodeInShadow(inspectorNode, contentNode)) {
    const selectedParentShadowId = getSelectedInspectorNodeParentShadowId(
      state
    );

    if (selectedParentShadowId) {
      const selectedShadowInspectorNode = getNestedTreeNodeById(
        selectedParentShadowId,
        state.sourceNodeInspector
      );
      const inspectorParentShadow = getInspectorNodeParentShadow(
        inspectorNode,
        state.sourceNodeInspector
      );

      const inspectorNodeWithinSelectedShadow =
        containsNestedTreeNodeById(
          inspectorNode.id,
          selectedShadowInspectorNode
        ) && selectedShadowInspectorNode.id === inspectorParentShadow.id;
      const selectedShadowWithinInspectorParentShadow = containsNestedTreeNodeById(
        selectedShadowInspectorNode.id,
        inspectorParentShadow
      );
      if (
        !inspectorNodeWithinSelectedShadow &&
        !selectedShadowWithinInspectorParentShadow
      ) {
        return false;
      }
    } else {
      return false;
    }
  }
  return true;
};

export const getCanvasMouseTargetNodeIdFromPoint = (
  state: RootState,
  point: Point,
  filter: (
    node: TreeNode<any>,
    state: RootState
  ) => boolean = defaultCanvasNodeFilter
): string => {
  const scaledMousePos = getScaledMouseCanvasPosition(state, point);

  const frame = getFrameFromPoint(scaledMousePos, state);

  if (!frame) return null;
  const contentNode = getSyntheticNodeById(
    frame.syntheticContentNodeId,
    state.documents
  );

  const { left: scaledPageX, top: scaledPageY } = scaledMousePos;

  const mouseX = scaledPageX - frame.bounds.left;
  const mouseY = scaledPageY - frame.bounds.top;

  const computedInfo = frame.computed || {};
  const intersectingBounds: Bounds[] = [];
  const intersectingBoundsMap = new Map<Bounds, string>();
  const mouseFramePoint = { left: mouseX, top: mouseY };
  for (const id in computedInfo) {
    const { bounds } = computedInfo[id];
    const node = getNestedTreeNodeById(id, contentNode);

    // synth nodes may be lagging behind graph
    if (!node) {
      continue;
    }
    if (pointIntersectsBounds(mouseFramePoint, bounds) && filter(node, state)) {
      intersectingBounds.unshift(bounds);
      intersectingBoundsMap.set(bounds, id);
    }
  }

  if (!intersectingBounds.length) return null;
  const smallestBounds = getSmallestBounds(...intersectingBounds);
  return intersectingBoundsMap.get(smallestBounds);
};

export const getCanvasMouseFrame = (
  state: RootState,
  event: CanvasToolOverlayMouseMoved | CanvasToolOverlayClicked
) => {
  return getFrameFromPoint(
    getScaledMouseCanvasPosition(state, {
      left: event.sourceEvent.pageX,
      top: event.sourceEvent.pageY
    }),
    state
  );
};

export const getFrameFromPoint = (point: Point, state: RootState) => {
  const activeFrames = getActiveFrames(state);
  if (!activeFrames.length) return null;
  for (let j = activeFrames.length; j--; ) {
    const frame = activeFrames[j];
    if (pointIntersectsBounds(point, frame.bounds)) {
      return frame;
    }
  }
};

export const setSelectedInspectorNodes = (
  root: RootState,
  ...selection: InspectorNode[]
) => {
  root = updateRootState(
    {
      selectedInspectorNodes: selection
    },
    root
  );
  root = expandedSelectedInspectorNode(root);

  return root;
};

const expandedSelectedInspectorNode = (state: RootState) => {
  return state.selectedInspectorNodes.reduce((state, node) => {
    state = updateSourceInspectorNode(state, sourceNodeInspector =>
      expandInspectorNodeById(node.id, sourceNodeInspector)
    );
    return state;
  }, state);
};

export const setSelectedFileNodeIds = (
  root: RootState,
  ...selectionIds: string[]
) => {
  const nodeIds = uniq([...selectionIds]);
  root = nodeIds.reduce(
    (state, nodeId) => setRootStateFileNodeExpanded(nodeId, true, root),
    root
  );

  root = updateRootState(
    {
      selectedFileNodeIds: nodeIds
    },
    root
  );
  return root;
};

export const setHoveringSyntheticVisibleNodeIds = (
  root: RootState,
  selectionIds: string[]
) => {
  const hoveringSyntheticNodeIds = uniq(
    [...selectionIds].filter(nodeId => {
      const node = getSyntheticNodeById(nodeId, root.documents);

      if (!node) {
        console.warn(`node ${nodeId} does not exist`);
      }

      return Boolean(node);
    })
  );

  return updateRootState(
    {
      hoveringInspectorNodes: hoveringSyntheticNodeIds
        .map(nodeId => {
          const inspectorNode = getSyntheticInspectorNode(
            getSyntheticNodeById(nodeId, root.documents),
            getSyntheticVisibleNodeDocument(nodeId, root.documents),
            root.sourceNodeInspector,
            root.graph
          );

          return inspectorNode;
        })
        .filter(Boolean)
    },
    root
  );
};

export const setHoveringInspectorNodes = (
  root: RootState,
  hoveringInspectorNodes: InspectorNode[]
) => {
  return updateRootState(
    {
      hoveringInspectorNodes
    },
    root
  );
};

export const getBoundedSelection = memoize(
  (
    selectedInspectorNodes: InspectorNode[],
    documents: SyntheticDocument[],
    frames: Frame[],
    graph: DependencyGraph
  ): InspectorNode[] => {
    return selectedInspectorNodes.filter(node => {
      const syntheticNode = getInspectorSyntheticNode(node, documents);
      return (
        syntheticNode &&
        getSyntheticVisibleNodeRelativeBounds(syntheticNode, frames, graph)
      );
    });
  }
);

export const getSelectionBounds = memoize(
  (
    selectedInspectorNodes: InspectorNode[],
    documents: SyntheticDocument[],
    frames: Frame[],
    graph: DependencyGraph
  ) =>
    mergeBounds(
      ...getBoundedSelection(
        selectedInspectorNodes,
        documents,
        frames,
        graph
      ).map(node =>
        getSyntheticVisibleNodeRelativeBounds(
          getInspectorSyntheticNode(node, documents),
          frames,
          graph
        )
      )
    )
);

export const isSelectionMovable = memoize(
  (
    selectedInspectorNodes: InspectorNode[],
    rootInspectorNode: InspectorNode,
    graph: DependencyGraph
  ) => {
    return selectedInspectorNodes.every(node => {
      return getInspectorContentNode(node, rootInspectorNode).id === node.id;
    });
  }
);

export const isSelectionResizable = memoize(
  (
    selectedSyntheticNodes: InspectorNode[],
    rootInspectorNode: InspectorNode,
    graph: DependencyGraph
  ) => {
    return selectedSyntheticNodes.every(node => {
      return getInspectorContentNode(node, rootInspectorNode).id === node.id;
    });
  }
);
