import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {allCommands} from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import * as components from 'app/client/components/Forms/elements';
import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {GristDoc} from 'app/client/components/GristDoc';
import {Disposable} from 'app/client/lib/dispose';
import {makeTestId} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {not} from 'app/common/gutil';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';

const testId = makeTestId('test-forms-');

export class FormView extends Disposable {
  public viewPane: HTMLElement;
  public gristDoc: GristDoc;
  public viewSection: ViewSectionRec;
  public isEdit: Observable<boolean>;
  public selectedBox: Observable<BoxModel | null>;

  protected sortedRows: SortedRowSet;
  protected tableModel: DataTableModel;
  protected cursor: Cursor;
  protected menuHolder: Holder<any>;
  protected bundle: (clb: () => Promise<void>) => Promise<void>;

  private _autoLayout: Computed<Box>;
  private _root: BoxModel;
  private _savedLayout: any;
  private _saving: boolean = false;
  private _url: Computed<string>;

  public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
    BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
    this.isEdit = Observable.create(this, true);
    this.menuHolder = Holder.create(this);

    this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true});

    this.selectedBox = Observable.create(this, null);

    this.selectedBox.addListener((v) => {
      if (!v) { return; }
      const colRef = Number(v.prop('leaf').get());
      if (!colRef || typeof colRef !== 'number') { return; }
      const fieldIndex = this.viewSection.viewFields().all().findIndex(f => f.getRowId() === colRef);
      if (fieldIndex === -1) { return; }
      this.cursor.setCursorPos({fieldIndex});
    });

    this._autoLayout = Computed.create(this, use => {
      // If the layout is already there, don't do anything.
      const existing = use(this.viewSection.layoutSpecObj);
      if (!existing || !existing.id) {
        // Else create a temporary one.
        const fields = use(use(this.viewSection.viewFields).getObservable());
        const children: Box[] = fields.map(f => {
          return {
            type: 'Field',
            leaf: use(f.id),
          };
        });
        children.push({type: 'Submit'});
        return {
          type: 'Layout',
          children,
        };
      }
      return existing;
    });

    this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async () => {
      await this._saveNow();
    }, this));

    this._autoLayout.addListener((v) => {
      if (this._saving) {
        console.error('Layout changed while saving');
        return;
      }
      // When the layout has changed, we will update the root, but only when it is not the same
      // as the one we just saved.
      if (isEqual(v, this._savedLayout)) { return; }
      if (this._savedLayout) {
        this._savedLayout = v;
      }
      this._root.update(v);
    });

    const keyboardActions = {
      copy: () => {
        const selected = this.selectedBox.get();
        if (!selected) { return; }
        // Add this box as a json to clipboard.
        const json = selected.toJSON();
        navigator.clipboard.writeText(JSON.stringify({
          ...json,
          id: uuidv4(),
        })).catch(reportError);
      },
      cut: () => {
        const selected = this.selectedBox.get();
        if (!selected) { return; }
        selected.cutSelf().catch(reportError);
      },
      paste: () => {
        const doPast = async () => {
          const boxInClipboard = parseBox(await navigator.clipboard.readText());
          if (!boxInClipboard) { return; }
          if (!this.selectedBox.get()) {
            this.selectedBox.set(this._root.insert(boxInClipboard, 0));
          } else {
            this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
          }

          // Remove the orginal box from the clipboard.
          const cutted = this._root.find(boxInClipboard.id);
          cutted?.removeSelf();

          await this._root.save();

          await navigator.clipboard.writeText('');
        };
        doPast().catch(reportError);
      },
      nextField: () => {
        const current = this.selectedBox.get();
        const all = [...this._root.list()];
        if (!all.length) { return; }
        if (!current) {
          this.selectedBox.set(all[0]);
        } else {
          const next = all[all.indexOf(current) + 1];
          if (next) {
            this.selectedBox.set(next);
          } else {
            this.selectedBox.set(all[0]);
          }
        }
      },
      prevField: () => {
        const current = this.selectedBox.get();
        const all = [...this._root.list()];
        if (!all.length) { return; }
        if (!current) {
          this.selectedBox.set(all[all.length - 1]);
        } else {
          const next = all[all.indexOf(current) - 1];
          if (next) {
            this.selectedBox.set(next);
          } else {
            this.selectedBox.set(all[all.length - 1]);
          }
        }
      },
      lastField: () => {
        const all = [...this._root.list()];
        if (!all.length) { return; }
        this.selectedBox.set(all[all.length - 1]);
      },
      firstField: () => {
        const all = [...this._root.list()];
        if (!all.length) { return; }
        this.selectedBox.set(all[0]);
      },
      edit: () => {
        const selected = this.selectedBox.get();
        if (!selected) { return; }
        (selected as any)?.edit?.set(true); // TODO: hacky way
      },
      clearValues: () => {
        const selected = this.selectedBox.get();
        if (!selected) { return; }
        keyboardActions.nextField();
        this.bundle(async () => {
          await selected.deleteSelf();
        }).catch(reportError);
      },
      insertFieldBefore: (type: {field: BoxType} | {structure: BoxType}) => {
        const selected = this.selectedBox.get();
        if (!selected) { return; }
        if ('field' in type) {
          this.addNewQuestion(selected.placeBeforeMe(), type.field).catch(reportError);
        } else {
          selected.insertBefore(components.defaultElement(type.structure));
        }
      },
      insertFieldAfter: (type: {field: BoxType} | {structure: BoxType}) => {
        const selected = this.selectedBox.get();
        if (!selected) { return; }
        if ('field' in type) {
          this.addNewQuestion(selected.placeAfterMe(), type.field).catch(reportError);
        } else {
          selected.insertAfter(components.defaultElement(type.structure));
        }
      },
      showColumns: (colIds: string[]) => {
        this.bundle(async () => {
          const boxes: Box[] = [];
          for (const colId of colIds) {
            const fieldRef = await this.viewSection.showColumn(colId);
            const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
            if (!field) { continue; }
            const box = {
              type: field.pureType.peek() as BoxType,
              leaf: fieldRef,
            };
            boxes.push(box);
          }
          boxes.forEach(b => this._root.append(b));
          await this._saveNow();
        }).catch(reportError);
      },
    };
    this.autoDispose(commands.createGroup({
      ...keyboardActions,
      cursorDown: keyboardActions.nextField,
      cursorUp: keyboardActions.prevField,
      cursorLeft: keyboardActions.prevField,
      cursorRight: keyboardActions.nextField,
      shiftDown: keyboardActions.lastField,
      shiftUp: keyboardActions.firstField,
      editField: keyboardActions.edit,
      deleteFields: keyboardActions.clearValues,
    }, this, this.viewSection.hasFocus));

    this._url = Computed.create(this, use => {
      const doc = use(this.gristDoc.docPageModel.currentDoc);
      if (!doc) { return ''; }
      const url = this.gristDoc.app.topAppModel.api.formUrl(doc.id, use(this.viewSection.id));
      return url;
    });

    // Last line, build the dom.
    this.viewPane = this.autoDispose(this.buildDom());
  }

  public insertColumn(colId?: string | null, options?: InsertColOptions) {
    return this.viewSection.insertColumn(colId, {...options, nestInActiveBundle: true});
  }

  public showColumn(colRef: number|string, index?: number) {
    return this.viewSection.showColumn(colRef, index);
  }

  public buildDom() {
    return dom('div.flexauto.flexvbox',
      this._buildSwitcher(),
      style.cssFormEdit.cls('-preview', not(this.isEdit)),
      style.cssFormEdit.cls('', this.isEdit),
      testId('preview', not(this.isEdit)),
      testId('editor', this.isEdit),

      dom.maybe(this.isEdit, () => [
        style.cssFormContainer(
          dom.forEach(this._root.children, (child) => {
            if (!child) {
              // This shouldn't happen, and it is bad design, as columns allow nulls, where other container
              // don't. But for now, just ignore it.
              return dom('div', 'Empty node');
            }
            const element = this.renderBox(this._root.children, child);
            if (Array.isArray(element)) {
              throw new Error('Element is an array');
            }
            if (!(element instanceof HTMLElement)) {
              throw new Error('Element is not an HTMLElement');
            }
            return element;
          }),
          this.buildDropzone(this, this._root.placeAfterListChild()),
        ),
      ]),
      dom.maybe(not(this.isEdit), () => [
        style.cssPreview(
          dom.prop('src', this._url),
        )
      ]),
      dom.on('click', () => this.selectedBox.set(null))
    );
  }

  public renderBox(owner: IDisposableOwner, box: BoxModel, ...args: IDomArgs<HTMLElement>): HTMLElement {
    const overlay = Observable.create(owner, true);

    return this.buildEditor(owner, {box, overlay},
      dom.domComputedOwned(box.type, (scope, type) => {
        const renderedElement = box.render({overlay});
        const element = renderedElement;
        return dom.update(
          element,
          testId('element'),
          testId(box.type),
          ...args,
        );
      })
    );
  }

  public buildDropzone(owner: IDisposableOwner, insert: Place, ...args: IDomArgs) {
    const dragHover = Observable.create(owner, false);
    const forceShow = Observable.create(owner, false);
    return style.cssAddElement(
      testId('dropzone'),
      style.cssDrag(),
      style.cssAddText(),
      this.buildAddMenu(insert, {
        onOpen: () => forceShow.set(true),
        onClose: () => forceShow.set(false),
      }),
      style.cssAddElement.cls('-hover', use => use(dragHover)),
      // And drop zone handlers
      dom.on('drop', async (ev) => {
        ev.stopPropagation();
        ev.preventDefault();
        dragHover.set(false);

        // Get the box that was dropped.
        const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));

        // We need to remove it from the parent, so find it first.
        const droppedId = dropped.id;

        const droppedRef = this._root.find(droppedId);

        await this.bundle(async () => {
          // Save the layout if it is not saved yet.
          await this._saveNow();
          // Remove the orginal box from the clipboard.
          droppedRef?.removeSelf();
          await insert(dropped).onDrop();

          // Save the change.
          await this._saveNow();
        });
      }),
      dom.on('dragover', (ev) => {
        ev.preventDefault();
        ev.dataTransfer!.dropEffect = "move";
        dragHover.set(true);
      }),
      dom.on('dragleave', (ev) => {
        ev.preventDefault();
        dragHover.set(false);
      }),
      style.cssAddElement.cls('-hover', dragHover),
      ...args,
    );
  }

  public buildFieldPanel() {
    return dom('div', 'Hello there');
  }

  public buildEditor(
    owner: IDisposableOwner | null,
    options: {
      box: BoxModel,
      overlay: Observable<boolean>
    }
    ,
    ...args: IDomArgs
  ) {
    const {box, overlay} = options;
    const myOwner = new MultiHolder();
    if (owner) {
      owner.autoDispose(myOwner);
    }

    let element: HTMLElement;
    const dragHover = Observable.create(myOwner, false);

    myOwner.autoDispose(this.selectedBox.addListener(v => {
      if (v !== box) { return; }
      if (!element) { return; }
      element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
    }));

    const isSelected = Computed.create(myOwner, use => {
      if (!this.viewSection || this.viewSection.isDisposed()) { return false; }
      if (use(this.selectedBox) === box) {
        // We are only selected when the section is also selected.
        return use(this.viewSection.hasFocus);
      }
      return false;
    });

    return style.cssFieldEditor(
      testId('editor'),
      style.cssDrag(),

      dom.maybe(overlay, () => this.buildOverlay(myOwner, box)),

      owner ? null : dom.autoDispose(myOwner),
      (el) => { element = el; },
      // Control panel
      style.cssControls(
        style.cssControlsLabel(dom.text(box.type)),
      ),

      // Turn on active like state when we clicked here.
      style.cssFieldEditor.cls('-selected', isSelected),
      style.cssFieldEditor.cls('-cut', use => use(box.cut)),
      testId('field-editor-selected', isSelected),

      // Select on click.
      (el) => {
        dom.onElem(el, 'click', (ev) => {
          // Only if the click was in this element.
          const target = ev.target as HTMLElement;
          if (!target.closest) { return; }
          // Make sure that the closest editor is this one.
          const closest = target.closest(`.${style.cssFieldEditor.className}`);
          if (closest !== el) { return; }

          // It looks like we clicked somewhere in this editor, and not inside any other inside.
          this.selectedBox.set(box);
          ev.stopPropagation();
          ev.preventDefault();
          ev.stopImmediatePropagation();
        });
      },

      // Attach menu
      menus.menu((ctl) => {
        this.menuHolder.autoDispose(ctl);
        this.selectedBox.set(box);
        const field = (type: string) => ({field: type});
        const struct = (structure: string) => ({structure});
        const above = (el: {field: string} | {structure: string}) => () => allCommands.insertFieldBefore.run(el);
        const below: typeof above = (el) => () => allCommands.insertFieldAfter.run(el);
        const quick = ['Text', 'Numeric', 'Choice', 'Date'];
        const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
        const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
        const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
        const insertMenu = (where: typeof above) => () => {
          return [
            menus.menuSubHeader('New question'),
            ...commonTypes()
              .filter(isQuick)
              .map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName))
            ,
            menus.menuItemSubmenu(
              () => commonTypes()
                .filter(notQuick)
                .map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)),
              {},
              menus.menuIcon('Dots'),
              dom('span', "More", dom.style('margin-right', '8px'))
            ),
            menus.menuDivider(),
            menus.menuSubHeader('Static element'),
            menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
            menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
            menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
            // menus.menuItem(where(struct('Button')),    menus.menuIcon('Tick'), "Button",  ),
          ];
        };


        return [
          menus.menuItemSubmenu(insertMenu(above), {action: above(field('Text'))}, "Insert question above"),
          menus.menuItemSubmenu(insertMenu(below), {action: below(field('Text'))}, "Insert question below"),
          menus.menuDivider(),
          menus.menuItemCmd(allCommands.contextMenuCopy, "Copy"),
          menus.menuItemCmd(allCommands.contextMenuCut, "Cut"),
          menus.menuItemCmd(allCommands.contextMenuPaste, "Paste"),
          menus.menuDivider(),
          menus.menuItemCmd(allCommands.deleteFields, "Hide"),
        ];
      }, {trigger: ['contextmenu']}),

      dom.on('contextmenu', (ev) => {
        ev.stopPropagation();
        ev.preventDefault();
      }),

      // And now drag and drop support.
      {draggable: "true"},

      // When started, we just put the box into the dataTransfer as a plain text.
      // TODO: this might be very sofisticated in the future.
      dom.on('dragstart', (ev) => {
        // Prevent propagation, as we might be in a nested editor.
        ev.stopPropagation();
        ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
        ev.dataTransfer!.dropEffect = "move";
      }),

      dom.on('dragover', (ev) => {
        // As usual, prevent propagation.
        ev.stopPropagation();
        ev.preventDefault();
        // Here we just change the style of the element.
        ev.dataTransfer!.dropEffect = "move";
        dragHover.set(true);
      }),

      dom.on('dragleave', (ev) => {
        ev.stopPropagation();
        ev.preventDefault();
        // Just remove the style and stop propagation.
        dragHover.set(false);
      }),

      dom.on('drop', async (ev) => {
        ev.stopPropagation();
        ev.preventDefault();
        dragHover.set(false);
        const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
        // We need to remove it from the parent, so find it first.
        const droppedId = dropped.id;
        if (droppedId === box.id) { return; }
        const droppedRef = this._root.find(droppedId);
        await this.bundle(async () => {
          await this._root.save();
          droppedRef?.removeSelf();
          await box.drop(dropped)?.onDrop();
          await this._saveNow();
        });
      }),

      style.cssFieldEditor.cls('-drag-hover', dragHover),

      ...args,
    );
  }

  public buildOverlay(owner: IDisposableOwner, box: BoxModel) {
    return style.cssSelectedOverlay(
    );
  }

  public async addNewQuestion(insert: Place, type: string) {
    await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => {
      // First save the layout, so that
      await this._saveNow();
      // Now that the layout is saved, we won't be bottered with autogenerated layout,
      // and we can safely insert to column.
      const {fieldRef} = await this.insertColumn(null, {
        colInfo: {
          type,
        }
      });

      // And add it into the layout.
      this.selectedBox.set(insert({
        leaf: fieldRef,
        type: 'Field'
      }));

      await this._root.save();
    }, {nestInActiveBundle: true});
  }

  public buildAddMenu(insert: Place, {
    onClose: onClose = () => {},
    onOpen: onOpen = () => {},
    customItems = [] as Element[],
  } = {}) {
    return menus.menu(
      (ctl) => {
        onOpen();
        ctl.onDispose(onClose);

        const field = (colType: BoxType) => ({field: colType});
        const struct = (structure: BoxType) => ({structure});
        const where = (el: {field: string} | {structure: BoxType}) => () => {
          if ('field' in el) {
            return this.addNewQuestion(insert, el.field);
          } else {
            insert(components.defaultElement(el.structure));
            return this._root.save();
          }
        };
        const quick = ['Text', 'Numeric', 'Choice', 'Date'];
        const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
        const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
        const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
        return [
          menus.menuSubHeader('New question'),
          ...commonTypes()
            .filter(isQuick)
            .map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName))
          ,
          menus.menuItemSubmenu(
            () => commonTypes()
              .filter(notQuick)
              .map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)),
            {},
            menus.menuIcon('Dots'),
            dom('span', "More", dom.style('margin-right', '8px'))
          ),
          menus.menuDivider(),
          menus.menuSubHeader('Static element'),
          menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
          menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
          menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
          // menus.menuItem(where(struct('Button')),    menus.menuIcon('Tick'), "Button",  ),
          elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
          customItems.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
          ...customItems,
        ];
      },
      {
        selectOnOpen: true,
        trigger: [
          'click',
        ],
      }
    );
  }

  private async _saveNow() {
    try {
      this._saving = true;
      const newVersion = {...this._root.toJSON()};
      // If nothing has changed, don't bother.
      if (isEqual(newVersion, this._savedLayout)) { return; }
      this._savedLayout = newVersion;
      await this.viewSection.layoutSpecObj.setAndSave(newVersion);
    } finally {
      this._saving = false;
    }
  }

  private _buildSwitcher() {

    const toggle = (val: boolean) => () => {
      this.isEdit.set(val);
      this._saveNow().catch(reportError);
    };

    return style.cssButtonGroup(
      style.cssIconButton(
        icon('Pencil'),
        testId('edit'),
        dom('div', 'Editor'),
        cssButton.cls('-primary', this.isEdit),
        style.cssIconButton.cls('-standard', not(this.isEdit)),
        dom.on('click', toggle(true))
      ),
      style.cssIconButton(
        icon('EyeShow'),
        dom('div', 'Preview'),
        testId('preview'),
        cssButton.cls('-primary', not(this.isEdit)),
        style.cssIconButton.cls('-standard', (this.isEdit)),
        dom.on('click', toggle(false))
      ),
      style.cssIconLink(
        icon('FieldAttachment'),
        testId('link'),
        dom('div', 'Link'),
        dom.prop('href', this._url),
        {target: '_blank'}
      ),
    );
  }
}

// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
defaults(FormView.prototype, BaseView.prototype);
Object.assign(FormView.prototype, BackboneEvents);
