/**
 * GristDoc manages an open Grist document on the client side.
 */
// tslint:disable:no-console

import {AccessRules} from 'app/client/aclui/AccessRules';
import {ActionLog} from 'app/client/components/ActionLog';
import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel';
import * as commands from 'app/client/components/commands';
import {CursorPos} from 'app/client/components/Cursor';
import {DocComm, DocUserAction} from 'app/client/components/DocComm';
import * as DocConfigTab from 'app/client/components/DocConfigTab';
import * as GridView from 'app/client/components/GridView';
import {Importer} from 'app/client/components/Importer';
import * as REPLTab from 'app/client/components/REPLTab';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {DocPluginManager} from 'app/client/lib/DocPluginManager';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {setTestState} from 'app/client/lib/testState';
import {selectFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel';
import * as DataTableModel from 'app/client/models/DataTableModel';
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
import {DocData} from 'app/client/models/DocData';
import {DocInfoRec, DocModel, ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {QuerySetManager} from 'app/client/models/QuerySet';
import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory';
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
import {mediaSmall, testId} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {ActionGroup} from 'app/common/ActionGroup';
import {delay} from 'app/common/delay';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {isSchemaAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {HashLink, IDocPage} from 'app/common/gristUrls';
import {encodeQueryParams, waitObs} from 'app/common/gutil';
import {StringUnion} from 'app/common/StringUnion';
import {TableData} from 'app/common/TableData';
import {DocStateComparison} from 'app/common/UserAPI';
import {Computed, dom, Emitter, Holder, IDomComponent, subscribe, toKo} from 'grainjs';
import {IDisposable, Observable, styled} from 'grainjs';
import * as ko from 'knockout';
import cloneDeepWith = require('lodash/cloneDeepWith');
import isEqual = require('lodash/isEqual');

const G = getBrowserGlobals('document', 'window');

// Re-export DocComm to move it from main webpack bundle to the one with GristDoc.
export {DocComm};

export interface TabContent {
  showObs?: any;
  header?: boolean;
  label?: any;
  items?: any;
  buildDom?: any;
  keywords?: any;
}

export interface TabOptions {
  shortLabel?: string;
  hideSearchContent?: boolean;
  showObs?: any;
  category?: any;
}

const RightPanelTool = StringUnion("none", "docHistory", "validations", "repl");

export interface IExtraTool {
  icon: IconName;
  label: string;
  content: TabContent[]|IDomComponent;
}

export class GristDoc extends DisposableWithEvents {
  public docModel: DocModel;
  public viewModel: ViewRec;
  public activeViewId: Computed<IDocPage>;
  public currentPageName: Observable<string>;
  public docData: DocData;
  public docInfo: DocInfoRec;
  public docPluginManager: DocPluginManager;
  public querySetManager: QuerySetManager;
  public rightPanelTool: Observable<IExtraTool|null>;
  public isReadonly = this.docPageModel.isReadonly;
  public isReadonlyKo = toKo(ko, this.isReadonly);
  public comparison: DocStateComparison|null;

  // Emitter triggered when the main doc area is resized.
  public readonly resizeEmitter = this.autoDispose(new Emitter());

  // This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
  // previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at
  // most one instance of FieldEditor at any time.
  public readonly fieldEditorHolder = Holder.create(this);

  private _actionLog: ActionLog;
  private _undoStack: UndoStack;
  private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
  private _rightPanelTabs = new Map<string, TabContent[]>();
  private _docHistory: DocHistory;
  private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
  private _viewLayout: ViewLayout|null = null;

  constructor(
    public readonly app: App,
    public readonly docComm: DocComm,
    public readonly docPageModel: DocPageModel,
    openDocResponse: OpenLocalDocResult,
    options: {
      comparison?: DocStateComparison  // initial comparison with another document
    } = {}
  ) {
    super();
    console.log("RECEIVED DOC RESPONSE", openDocResponse.doc);
    this.docData = new DocData(this.docComm, openDocResponse.doc);
    this.docModel = new DocModel(this.docData);
    this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
    this.docPluginManager = new DocPluginManager(openDocResponse.plugins, app.getUntrustedContentOrigin(),
      this.docComm, app.clientScope);

    // Maintain the MetaRowModel for the global document info, including docId and peers.
    this.docInfo = this.docModel.docInfo.getRowModel(1);

    const defaultViewId = this.docInfo.newDefaultViewId;

    // Grainjs observable for current view id, which may be a string such as 'code'.
    this.activeViewId = Computed.create(this, urlState().state, (use, s) => s.docPage || defaultViewId.peek());

    // This viewModel reflects the currently active view, relying on the fact that
    // createFloatingRowModel() supports an observable rowId for its argument.
    // Although typings don't reflect it, createFloatingRowModel() accepts non-numeric values,
    // which yield an empty row, which is why we can cast activeViewId.
    this.viewModel = this.autoDispose(
      this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed<number>));

    // Grainjs observable reflecting the name of the current document page.
    this.currentPageName = Computed.create(this, this.activeViewId,
      (use, docPage) => typeof docPage === 'number' ? use(this.viewModel.name) : docPage);

    // Whenever the active viewModel is deleted, switch to the default view.
    this.autoDispose(this.viewModel._isDeleted.subscribe((isDeleted) => {
      if (isDeleted) {
        // This should not be done synchronously, as that affects the same viewModel that triggered
        // this callback, and causes some obscure effects on knockout subscriptions.
        Promise.resolve().then(() => urlState().pushUrl({docPage: undefined})).catch(() => null);
      }
    }));

    // Navigate to an anchor if one is present in the url hash.
    this.autoDispose(subscribe(urlState().state, async (use, state) => {
      if (state.hash) {
        try {
          const cursorPos = getCursorPosFromHash(state.hash);
          await this._recursiveMoveToCursorPos(cursorPos, true, state.hash && state.hash.colRef);
        } catch (e) {
          reportError(e);
        } finally {
          setTimeout(finalizeAnchor, 0);
        }
      }
    }));

    // Importer takes a function for creating previews.
    const createPreview = (vs: ViewSectionRec) => GridView.create(this, vs, true);

    const importSourceElems = ImportSourceElement.fromArray(this.docPluginManager.pluginsList);
    const importMenuItems = [
      {
        label: 'Import from file',
        action: () => Importer.selectAndImport(this, null, createPreview),
      },
      ...importSourceElems.map(importSourceElem => ({
        label: importSourceElem.importSource.label,
        action: () => Importer.selectAndImport(this, importSourceElem, createPreview)
      }))
    ];

    // Set the available import sources in the DocPageModel.
    this.docPageModel.importSources = importMenuItems;

    this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this }));
    this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this }));
    this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog);

    // Tap into docData's sendActions method to save the cursor position with every action, so that
    // undo/redo can jump to the right place.
    this.autoDispose(this.docData.sendActionsEmitter.addListener(this._onSendActionsStart, this));
    this.autoDispose(this.docData.sendActionsDoneEmitter.addListener(this._onSendActionsEnd, this));

    /* Command binding */
    this.autoDispose(commands.createGroup({
      undo() { this._undoStack.sendUndoAction().catch(reportError); },
      redo() { this._undoStack.sendRedoAction().catch(reportError); },
      reloadPlugins() { this.docComm.reloadPlugins().then(() => G.window.location.reload(false)); },
    }, this, true));

    this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);

    this.autoDispose(DocConfigTab.create({gristDoc: this}));

    const replTab = this.autoDispose(REPLTab.create(this));
    this.autoDispose(this.addOptionsTab(
      'REPL', dom('span.glyphicon.glyphicon-console'),
      replTab.buildConfigDomObj(),
      { hideSearchContent: true }
    ));

    this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool)));

    this.comparison = options.comparison || null;

    // We need prevent default here to allow drop events to fire.
    this.autoDispose(dom.onElem(window, 'dragover', (ev) => ev.preventDefault()));
    // The default action is to open dragged files as a link, navigating out of the app.
    this.autoDispose(dom.onElem(window, 'drop', (ev) => ev.preventDefault()));

    // On window resize, trigger the resizeEmitter to update ViewLayout and individual BaseViews.
    this.autoDispose(dom.onElem(window, 'resize', () => this.resizeEmitter.emit()));
  }

  public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable {
    this._rightPanelTabs.set(label, contentObj);
    // Return a do-nothing disposable, to satisfy the previous interface.
    return {dispose: () => null};
  }

  /**
   * Builds the DOM for this GristDoc.
   */
  public buildDom() {
    return cssViewContentPane(testId('gristdoc'),
      dom.domComputed<IDocPage>(this.activeViewId, (viewId) => (
        viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) :
        viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, this))) :
        viewId === 'new' ? null :
        dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId)))
      )),
    );
  }

  // Open the given page. Note that links to pages should use <a> elements together with setLinkUrl().
  public openDocPage(viewId: IDocPage) {
    return urlState().pushUrl({docPage: viewId});
  }

  public showTool(tool: typeof RightPanelTool.type): void {
    this._rightPanelTool.set(tool);
  }

  /**
   * Returns an object representing the position of the cursor, including the section. It will have
   * fields { sectionId, rowId, fieldIndex }. Fields may be missing if no section is active.
   */
  public getCursorPos(): CursorPos {
    const pos = { sectionId: this.viewModel.activeSectionId() };
    const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();
    return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
  }

  /**
   * Switch to the view/section and scroll to the record indicated by cursorPos. If cursorPos is
   * null, then moves to a position best suited for optActionGroup (not yet implemented).
   */
  public moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: ActionGroup): void {
    if (!cursorPos || cursorPos.sectionId == null) {
      // TODO We could come up with a suitable cursorPos here based on the action itself.
      // This should only come up if trying to undo/redo after reloading a page (since the cursorPos
      // associated with the action is only stored in memory of the current JS process).
      // A function like `getCursorPosForActionGroup(ag)` would also be useful to jump to the best
      // place from any action in the action log.
      return;
    }

    this._switchToSectionId(cursorPos.sectionId)
    .then(viewInstance => (viewInstance && viewInstance.setCursorPos(cursorPos)))
    .catch(reportError);
  }

  /**
   * Process actions received from the server by forwarding them to `docData.receiveAction()` and
   * pushing them to actionLog.
   */
  public onDocUserAction(message: DocUserAction) {
    console.log("GristDoc.onDocUserAction", message);
    let schemaUpdated = false;
    if (this.docComm.isActionFromThisDoc(message)) {
      const docActions = message.data.docActions;
      for (let i = 0, len = docActions.length; i < len; i++) {
        console.log("GristDoc applying #%d", i, docActions[i]);
        this.docData.receiveAction(docActions[i]);
        this.docPluginManager.receiveAction(docActions[i]);

        if (!schemaUpdated && isSchemaAction(docActions[i])) {
          schemaUpdated = true;
        }
      }
      // Add fromSelf property to actionGroup indicating if it's from the current session.
      const actionGroup = message.data.actionGroup;
      actionGroup.fromSelf = message.fromSelf || false;
      // Push to the actionLog and the undoStack.
      if (!actionGroup.internal) {
        this._actionLog.pushAction(actionGroup);
        this._undoStack.pushAction(actionGroup);
        if (actionGroup.fromSelf) {
          this._lastOwnActionGroup = actionGroup;
        }
      }
      if (schemaUpdated) {
        this.trigger('schemaUpdateAction', docActions);
      }
    }
  }

  public getTableModel(tableId: string): DataTableModel {
    return this.docModel.dataTables[tableId];
  }

  // Get a DataTableModel, possibly wrapped to include diff data if a comparison is
  // in effect.
  public getTableModelMaybeWithDiff(tableId: string): DataTableModel {
    const tableModel = this.getTableModel(tableId);
    if (!this.comparison?.details) { return tableModel; }
    // TODO: cache wrapped models and share between views.
    return new DataTableModelWithDiff(tableModel, this.comparison.details);
  }

  /**
   * Sends an action to create a new empty table and switches to that table's primary view.
   */
  public async addEmptyTable(): Promise<void> {
    const tableInfo = await this.docData.sendAction(['AddEmptyTable']);
    await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId());
  }

  /**
   * Adds a view section described by val to the current page.
   */
  public async addWidgetToPage(val: IPageWidget) {
    const docData = this.docModel.docData;
    const viewName = this.viewModel.name.peek();

    const res = await docData.bundleActions(
      `Added new linked section to view ${viewName}`,
      () => this.addWidgetToPageImpl(val)
    );

    // The newly-added section should be given focus.
    this.viewModel.activeSectionId(res.sectionRef);
  }

  /**
   * The actual implementation of addWidgetToPage
   */
  public async addWidgetToPageImpl(val: IPageWidget) {
    const viewRef = this.activeViewId.get();
    const tableRef = val.table === 'New Table' ? 0 : val.table;
    const link = linkFromId(val.link);

    const result = await this.docData.sendAction(
      ['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null]
    );
    await this.docData.sendAction(
      ['UpdateRecord', '_grist_Views_section', result.sectionRef, {
        linkSrcSectionRef: link.srcSectionRef,
        linkSrcColRef: link.srcColRef,
        linkTargetColRef: link.targetColRef
      }]
    );
    return result;
  }

  /**
   * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
   */
  public async addNewPage(val: IPageWidget) {
    if (val.table === 'New Table') {
      const result = await this.docData.sendAction(['AddEmptyTable']);
      await this.openDocPage(result.views[0].id);
    } else {
      const result = await this.docData.sendAction(
        ['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null]
      );
      await this.openDocPage(result.viewRef);
      // The newly-added section should be given focus.
      this.viewModel.activeSectionId(result.sectionRef);
    }
  }

  /**
   * Opens a dialog to upload one or multiple files as tables and then switches to the first table's
   * primary view.
   */
  public async uploadNewTable(): Promise<void> {
    const uploadResult = await selectFiles({docWorkerUrl: this.docComm.docWorkerUrl,
                                            multiple: true});
    if (uploadResult) {
      const dataSource = {uploadId: uploadResult.uploadId, transforms: []};
      const importResult = await this.docComm.finishImportFiles(dataSource, {}, []);
      const tableId = importResult.tables[0].hiddenTableId;
      const tableRowModel = this.docModel.dataTables[tableId].tableMetaRow;
      await this.openDocPage(tableRowModel.primaryViewId());
    }
  }

  public async saveViewSection(section: ViewSectionRec, newVal: IPageWidget) {
    const docData = this.docModel.docData;
    const oldVal: IPageWidget = toPageWidget(section);
    const viewModel = section.view();

    if (isEqual(oldVal, newVal)) {
      // nothing to be done
      return;
    }

    await this._viewLayout!.freezeUntil(docData.bundleActions(
      `Saved linked section ${section.title()} in view ${viewModel.name()}`,
      async () => {

        // if table changes or a table is made a summary table, let's replace the view section by a
        // new one, and return.
        if (oldVal.table !== newVal.table || oldVal.summarize !== newVal.summarize) {
          await this._replaceViewSection(section, newVal);
          return;
        }

        // if type changes, let's save it.
        if (oldVal.type !== newVal.type) {
          await section.parentKey.saveOnly(newVal.type);
        }

        // if grouped by column changes, let's use the specific user action.
        if (!isEqual(oldVal.columns, newVal.columns)) {
          await docData.sendAction(
            ['UpdateSummaryViewSection', section.getRowId(), newVal.columns]
          );
        }

        // update link
        if (oldVal.link !== newVal.link) {
          await this.saveLink(linkFromId(newVal.link));
        }
      }
    ));
  }

  // Save link for the active section.
  public async saveLink(link: IPageWidgetLink) {
    const viewModel = this.viewModel;
    return this.docData.sendAction(
      ['UpdateRecord', '_grist_Views_section', viewModel.activeSection.peek().getRowId(), {
        linkSrcSectionRef: link.srcSectionRef,
        linkSrcColRef: link.srcColRef,
        linkTargetColRef: link.targetColRef
      }]
    );
  }


  // Returns the list of all the valid links to link from one of the sections in the active view to
  // the page widget 'widget'.
  public selectBy(widget: IPageWidget) {
    const viewSections = this.viewModel.viewSections.peek().peek();
    return selectBy(this.docModel, viewSections, widget);
  }

  // Fork the document if it is in prefork mode.
  public async forkIfNeeded() {
    if (this.docPageModel.isPrefork.get()) {
      await this.docComm.forkAndUpdateUrl();
    }
  }

  public getCsvLink() {
    return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({
      ...this.docComm.getUrlParams(),
      title: this.docPageModel.currentDocTitle.get(),
      viewSection: this.viewModel.activeSectionId(),
      tableId: this.viewModel.activeSection().table().tableId(),
      activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec())
    });
  }

  private _getToolContent(tool: typeof RightPanelTool.type): IExtraTool|null {
    switch (tool) {
      case 'docHistory': {
        return {icon: 'Log', label: 'Document History', content: this._docHistory};
      }
      case 'validations': {
        const content = this._rightPanelTabs.get("Validate Data");
        return content ? {icon: 'Validation', label: 'Validation Rules', content} : null;
      }
      case 'repl': {
        const content = this._rightPanelTabs.get("REPL");
        return content ? {icon: 'Repl', label: 'REPL', content} : null;
      }
      case 'none':
      default: {
        return null;
      }
    }
  }

  private async _replaceViewSection(section: ViewSectionRec, newVal: IPageWidget) {

    const docModel = this.docModel;
    const viewModel = section.view();
    const docData = this.docModel.docData;

    // we must read the current layout from the view layout because it can override the one in
    // `section.layoutSpec` (in particular it provides a default layout when missing from the
    // latter).
    const layoutSpec = this._viewLayout!.layoutSpec();

    const sectionTitle = section.title();
    const sectionId = section.id();

    // create a new section
    const sectionCreationResult = await this.addWidgetToPageImpl(newVal);

    // update section name
    const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
    await newSection.title.saveOnly(sectionTitle);

    // replace old section id with new section id in the layout spec and save
    const newLayoutSpec = cloneDeepWith(layoutSpec, (val) => {
      if (typeof val === 'object' && val.leaf === sectionId) {
        return {...val, leaf: newSection.id()};
      }
    });
    await viewModel.layoutSpec.saveOnly(JSON.stringify(newLayoutSpec));

    // The newly-added section should be given focus.
    this.viewModel.activeSectionId(newSection.getRowId());

    // remove old section
    await docData.sendAction(['RemoveViewSection', sectionId]);
  }

  /**
   * Helper called before an action is sent to the server. It saves cursor position to come back to
   * in case of Undo.
   */
  private _onSendActionsStart(ev: {cursorPos: CursorPos}) {
    this._lastOwnActionGroup = null;
    ev.cursorPos = this.getCursorPos();
  }

  /**
   * Helper called when server responds to an action. It attaches the saved cursor position to the
   * received action (if any), and stores also the resulting position.
   */
  private _onSendActionsEnd(ev: {cursorPos: CursorPos}) {
    const a = this._lastOwnActionGroup;
    if (a) {
      a.cursorPos = ev.cursorPos;
      if (a.rowIdHint) {
        a.cursorPos.rowId = a.rowIdHint;
      }
    }
  }

  /**
   * Switch to a given sectionId, wait for it to load, and return a Promise for the instantiated
   * viewInstance (such as an instance of GridView or DetailView).
   */
  private async _switchToSectionId(sectionId: number) {
    const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);
    const view: ViewRec = section.view.peek();
    await this.openDocPage(view.getRowId());
    view.activeSectionId(sectionId);  // this.viewModel will reflect this with a delay.

    // Returns the value of section.viewInstance() as soon as it is truthy.
    return waitObs(section.viewInstance);
  }

  /**
   * Move to the desired cursor position.  If colRef is supplied, the cursor will be
   * moved to a field with that colRef.  Any linked sections that need their cursors
   * moved in order to achieve the desired outcome are handled recursively.
   * If setAsActiveSection is true, the section in cursorPos is set as the current
   * active section.
   */
  private async _recursiveMoveToCursorPos(cursorPos: CursorPos, setAsActiveSection: boolean,
                                          colRef?: number): Promise<void> {
    try {
      if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
      if (!cursorPos.rowId) { throw new Error('rowId required'); }
      const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
      const srcSection = section.linkSrcSection.peek();
      if (srcSection.id.peek()) {
        // We're in a linked section, so we need to recurse to make sure the row we want
        // will be visible.
        const linkTargetCol = section.linkTargetCol.peek();
        let controller: any;
        if (linkTargetCol.colId.peek()) {
          const destTable = await this._getTableData(section);
          controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek());
        } else {
          controller = cursorPos.rowId;
        }
        const colId = section.linkSrcCol.peek().colId.peek();
        let srcRowId: any;
        const isSrcSummary = srcSection.table.peek().summarySource.peek().id.peek();
        if (!colId && !isSrcSummary) {
          // Simple case - source linked by rowId, not a summary.
          srcRowId = controller;
        } else {
          const srcTable = await this._getTableData(srcSection);
          if (!colId) {
            // must be a summary -- otherwise dealt with earlier.
            const destTable = await this._getTableData(section);
            const filter: {[key: string]: any} = {};
            for (const c of srcSection.table.peek().columns.peek().peek()) {
              if (c.summarySourceCol.peek()) {
                const filterColId = c.summarySource.peek().colId.peek();
                const destValue = destTable.getValue(cursorPos.rowId, filterColId);
                filter[filterColId] = destValue;
              }
            }
            const result = srcTable.filterRecords(filter); // Should just have one record, or 0.
            srcRowId = result[0] && result[0].id;
          } else {
            srcRowId = srcTable.findRow(colId, controller);
          }
        }
        if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); }
        await this._recursiveMoveToCursorPos({
          rowId: srcRowId,
          sectionId: srcSection.id.peek()
        }, false);
      }
      const view: ViewRec = section.view.peek();
      await this.openDocPage(view.getRowId());
      if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); }
      const fieldIndex = colRef ? section.viewFields().peek().findIndex(f => f.colRef.peek() === colRef) : undefined;
      const viewInstance = await waitObs(section.viewInstance);
      if (!viewInstance) { throw new Error('view not found'); }
      // Give any synchronous initial cursor setting a chance to happen.
      await delay(0);
      viewInstance.setCursorPos({...cursorPos, fieldIndex});
      // TODO: column selection not working on card/detail view, or getting overridden -
      // look into it (not a high priority for now since feature not easily discoverable
      // in this view).
    } catch (e) {
      console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
      throw new UserError('There was a problem finding the desired cell.');
    }
  }

  private async _getTableData(section: ViewSectionRec): Promise<TableData> {
    const viewInstance = await waitObs(section.viewInstance);
    if (!viewInstance) { throw new Error('view not found'); }
    await viewInstance.getLoadingDonePromise();
    const table = this.docData.getTable(section.table.peek().tableId.peek());
    if (!table) { throw new Error('no section table'); }
    return table;
  }
}

/**
 * Convert a url hash to a cursor position.
 */
function getCursorPosFromHash(hash: HashLink): CursorPos {
  return { rowId: hash.rowId, sectionId: hash.sectionId };
}

async function finalizeAnchor() {
  await urlState().pushUrl({ hash: {} }, { replace: true });
  setTestState({anchorApplied: true});
}

const cssViewContentPane = styled('div', `
  flex: auto;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  position: relative;
  min-width: 240px;
  margin: 12px;
  @media ${mediaSmall} {
    & {
      margin: 4px;
    }
  }
  @media print {
    & {
      margin: 0px;
    }
  }
`);