import { action, computed, Lambda, makeObservable, observable, reaction } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
import { returnFalse, returnZero, setupMoveUpEvents } from '../../../../ClientUtils';
import { Doc, Opt } from '../../../../fields/Doc';
import { Id } from '../../../../fields/FieldSymbols';
import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types';
import { emptyFunction } from '../../../../Utils';
import { Docs } from '../../../documents/Documents';
import { DragManager } from '../../../util/DragManager';
import { Transform } from '../../../util/Transform';
import { undoable } from '../../../util/UndoManager';
import { ContextMenu } from '../../ContextMenu';
import { ContextMenuProps } from '../../ContextMenuItem';
import { DocumentView } from '../../nodes/DocumentView';
import { CollectionSubView, SubCollectionViewProps } from '../CollectionSubView';
import './CollectionGridView.scss';
import Grid, { Layout } from './Grid';

@observer
export class CollectionGridView extends CollectionSubView() {
    private _containerRef: React.RefObject<HTMLDivElement> = React.createRef();
    private _changeListenerDisposer: Opt<Lambda>; // listens for changes in this.childLayoutPairs
    private _resetListenerDisposer: Opt<Lambda>; // listens for when the reset button is clicked
    private _dropLocation: object = {}; // sets the drop location for external drops
    @observable private _rowHeight: Opt<number> = undefined; // temporary store of row height to make change undoable
    @observable private _scroll: number = 0; // required to make sure the decorations box container updates on scroll

    constructor(props: SubCollectionViewProps) {
        super(props);
        makeObservable(this);
    }

    onChildClickHandler = () => ScriptCast(this.Document.onChildClick);

    @computed get numCols() {
        return NumCast(this.Document.gridNumCols, 10);
    }
    @computed get rowHeight() {
        return this._rowHeight === undefined ? NumCast(this.Document.gridRowHeight, 100) : this._rowHeight;
    }
    // sets the default width and height of the grid nodes
    @computed get defaultW() {
        return NumCast(this.Document.gridDefaultW, 2);
    }
    @computed get defaultH() {
        return NumCast(this.Document.gridDefaultH, 2);
    }

    @computed get colWidthPlusGap() {
        return (this._props.PanelWidth() - 2 * this.xMargin - this.gridGap) / this.numCols;
    }
    @computed get rowHeightPlusGap() {
        return this.rowHeight + this.gridGap;
    }

    @computed get xMargin() {
        return NumCast(this.layoutDoc._xMargin, Math.max(3, 0.05 * this._props.PanelWidth()));
    }
    @computed get yMargin() {
        return this._props.yPadding || NumCast(this.layoutDoc._yMargin, Math.min(5, 0.05 * this._props.PanelWidth()));
    }
    @computed get gridGap() {
        return NumCast(this.Document._gridGap, 10);
    } // sets the margin between grid nodes

    @computed get flexGrid() {
        return BoolCast(this.Document.gridFlex, true);
    } // is grid static/flexible i.e. whether nodes be moved around and resized
    @computed get compaction() {
        return StrCast(this.Document.gridStartCompaction, StrCast(this.Document.gridCompaction, 'vertical'));
    } // is grid static/flexible i.e. whether nodes be moved around and resized

    /**
     * Sets up the listeners for the list of documents and the reset button.
     */
    componentDidMount() {
        this._changeListenerDisposer = reaction(
            () => this.childLayoutPairs,
            pairs => {
                const newLayouts: Layout[] = [];
                const oldLayouts = this.savedLayoutList;
                pairs.forEach((pair, i) => {
                    const existing = oldLayouts.find(l => l.i === pair.layout[Id]);
                    if (existing) newLayouts.push(existing);
                    else if (Object.keys(this._dropLocation).length) {
                        // external drop
                        this.addLayoutItem(newLayouts, this.makeLayoutItem(pair.layout, this._dropLocation as { x: number; y: number }, !this.flexGrid));
                        this._dropLocation = {};
                    } else {
                        // internal drop
                        this.addLayoutItem(newLayouts, this.makeLayoutItem(pair.layout, this.unflexedPosition(i), !this.flexGrid));
                    }
                });
                pairs?.length && this.setLayoutList(newLayouts);
            },
            { fireImmediately: true }
        );

        // updates the layouts if the reset button has been clicked
        this._resetListenerDisposer = reaction(
            () => this.Document.gridResetLayout,
            reset => {
                if (reset && this.flexGrid) {
                    this.setLayout(this.childLayoutPairs.map((pair, index) => this.makeLayoutItem(pair.layout, this.unflexedPosition(index))));
                }
                this.Document.gridResetLayout = false;
            }
        );
    }

    /**
     * Disposes the listeners.
     */
    componentWillUnmount() {
        this._changeListenerDisposer?.();
        this._resetListenerDisposer?.();
    }

    /**
     * @returns the default location of the grid node (i.e. when the grid is static)
     * @param index
     */
    unflexedPosition = (index: number): Omit<Layout, 'i'> => ({
        x: (index % (Math.floor(this.numCols / this.defaultW) || 1)) * this.defaultW,
        y: Math.floor(index / (Math.floor(this.numCols / this.defaultH) || 1)) * this.defaultH,
        w: this.defaultW,
        h: this.defaultH,
        static: true,
    });

    /**
     * Maps the x- and y- coordinates of the event to a grid cell.
     */
    screenToCell = (sx: number, sy: number) => {
        const [ptx, pty] = this.ScreenToLocalBoxXf().transformPoint(sx, sy);
        const x = Math.floor((ptx + this.xMargin) / this.colWidthPlusGap);
        const y = Math.floor((pty + this.yMargin + this._scroll) / this.rowHeight);
        return { x, y };
    };

    /**
     * Creates a layout object for a grid item
     */
    makeLayoutItem = (doc: Doc, pos: { x: number; y: number }, Static: boolean = false, w: number = this.defaultW, h: number = this.defaultH) => 
        ({ i: doc[Id], w, h, x: pos.x, y: pos.y, static: Static }); // prettier-ignore

    /**
     * Adds a layout to the list of layouts.
     */
    addLayoutItem = (layouts: Layout[], layout: Layout) => {
        const f = layouts.findIndex(l => l.i === layout.i);
        f !== -1 && layouts.splice(f, 1);
        layouts.push(layout);
        return layouts;
    };
    /**
     * @returns the transform that will correctly place the document decorations box.
     */
    lookupIndividualTransform = (layout: Layout) => {
        const xypos = this.flexGrid ? layout : this.unflexedPosition(this.renderedLayoutList.findIndex(l => l.i === layout.i));
        const pos = { x: xypos.x * this.colWidthPlusGap + this.gridGap + this.xMargin, y: xypos.y * this.rowHeightPlusGap + this.gridGap - this._scroll + this.yMargin };

        return this.ScreenToLocalBoxXf().translate(-pos.x, -pos.y);
    };

    /**
     * @returns the layout list converted from JSON
     */
    get savedLayoutList() {
        return (this.Document.gridLayoutString ? JSON.parse(StrCast(this.Document.gridLayoutString)) : []) as Layout[];
    }

    /**
     * Stores the layout list on the Document as JSON
     */
    setLayoutList = (layouts: Layout[]) => {
        this.Document.gridLayoutString = JSON.stringify(layouts);
    };

    isContentActive = () => this._props.isSelected() || this._props.isContentActive();
    isChildContentActive = () => (this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive)) ? true : undefined);
    /**
     *
     * @param childLayout
     * @param dxf the x- and y-translations of the decorations box as a transform i.e. this.lookupIndividualTransform
     * @param width
     * @param height
     * @returns the `ContentFittingDocumentView` of the node
     */
    getDisplayDoc = (childLayout: Doc, dxf: () => Transform, width: () => number, height: () => number) => (
        <DocumentView
            {...this._props}
            Document={childLayout}
            TemplateDataDocument={childLayout.isTemplateDoc || childLayout.isTemplateForField ? this._props.TemplateDataDocument : undefined}
            NativeWidth={returnZero}
            NativeHeight={returnZero}
            fitWidth={this._props.childLayoutFitWidth}
            containerViewPath={this.childContainerViewPath}
            renderDepth={this._props.renderDepth + 1}
            isContentActive={this.isChildContentActive}
            PanelWidth={width}
            PanelHeight={height}
            rejectDrop={this._props.childRejectDrop}
            ScreenToLocalTransform={dxf}
            setContentViewBox={emptyFunction}
            whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged}
            onClickScript={this.onChildClickHandler}
            dontCenter={StrCast(this.layoutDoc.layout_dontCenter) as 'x' | 'y' | 'xy'}
            showTags={BoolCast(this.layoutDoc.showChildTags) || BoolCast(this.Document._layout_showTags)}
        />
    );

    /**
     * Saves the layouts received from the Grid to the Document.
     * @param layouts `Layout[]`
     */
    @action
    setLayout = (layoutArray: Layout[]) => {
        // for every child in the collection, check to see if there's a corresponding grid layout object and
        // updated layout object. If both exist, which they should, update the grid layout object from the updated object
        if (this.flexGrid) {
            const savedLayouts = this.savedLayoutList;
            this.childLayoutPairs.forEach(({ layout: doc }) => {
                const gridLayout = savedLayouts.find(layout => layout.i === doc[Id]);
                if (gridLayout) Object.assign(gridLayout, layoutArray.find(layout => layout.i === doc[Id]) || gridLayout);
            });

            if (this.Document.gridStartCompaction) {
                undoable(() => {
                    this.Document.gridCompaction = this.Document.gridStartCompaction;
                    this.setLayoutList(savedLayouts);
                }, 'start grid compaction')();
                this.Document.gridStartCompaction = undefined;
            } else {
                undoable(() => this.setLayoutList(savedLayouts), 'start grid compaction')();
            }
        }
    };

    /**
     * @returns a list of `ContentFittingDocumentView`s inside wrapper divs.
     * The key of the wrapper div must be the same as the `i` value of the corresponding layout.
     */
    @computed get contents(): JSX.Element[] {
        const collector: JSX.Element[] = [];
        if (this.renderedLayoutList.length === this.childLayoutPairs.length) {
            this.renderedLayoutList.forEach(l => {
                const child = this.childLayoutPairs.find(c => c.layout[Id] === l.i);
                const dxf = () => this.lookupIndividualTransform(l);
                const width = () => (this.flexGrid ? l.w : this.defaultW) * this.colWidthPlusGap - this.gridGap;
                const height = () => (this.flexGrid ? l.h : this.defaultH) * this.rowHeightPlusGap - this.gridGap;
                child &&
                    collector.push(
                        <div key={child.layout[Id]} className={'document-wrapper' + (this.flexGrid && this._props.isSelected() ? '' : ' static')}>
                            {this.getDisplayDoc(child.layout, dxf, width, height)}
                        </div>
                    );
            });
        }
        return collector;
    }

    /**
     * @returns a list of `Layout` objects with attributes depending on whether the grid is flexible or static
     */
    @computed get renderedLayoutList(): Layout[] {
        return this.flexGrid
            ? this.savedLayoutList.map(({ i, x, y, w, h }) => ({
                  i,
                  y,
                  h,
                  x: x + w > this.numCols ? 0 : x, // handles wrapping around of nodes when numCols decreases
                  w: Math.min(w, this.numCols), // reduces width if greater than numCols
                  static: BoolCast(this.childLayoutPairs.find(({ layout }) => layout[Id] === i)?.layout._lockedPosition, false), // checks if the lock position item has been selected in the context menu
              }))
            : this.savedLayoutList.map((layout, index) => {
                  Object.assign(layout, this.unflexedPosition(index));
                  return layout;
              });
    }

    /**
     * Handles internal drop of Dash documents.
     */
    onInternalDrop = (e: Event, de: DragManager.DropEvent) => {
        const savedLayouts = this.savedLayoutList;
        const dropped = de.complete.docDragData?.droppedDocuments;
        if (dropped && super.onInternalDrop(e, de) && savedLayouts.length !== this.childDocs.length) {
            dropped.forEach(doc => this.addLayoutItem(savedLayouts, this.makeLayoutItem(doc, this.screenToCell(de.x, de.y)))); // shouldn't place all docs in the same cell;
            this.setLayoutList(savedLayouts);
            return true;
        }
        return false;
    };

    /**
     * Handles external drop of images/PDFs etc from outside Dash.
     */
    onExternalDrop = async (e: React.DragEvent): Promise<void> => {
        this._dropLocation = this.screenToCell(e.clientX, e.clientY);
        super.onExternalDrop(e, {});
    };

    /**
     * Handles the change in the value of the rowHeight slider.
     */
    @action
    onSliderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        this._rowHeight = event.currentTarget.valueAsNumber;
    };
    /**
     * Handles the user clicking on the slider.
     */
    @action
    onSliderDown = (e: React.PointerEvent) => {
        this._rowHeight = this.rowHeight; // uses _rowHeight during dragging and sets doc's rowHeight when finished so that operation is undoable
        setupMoveUpEvents(
            this,
            e,
            returnFalse,
            undoable(
                action(() => {
                    this.Document.gridRowHeight = this._rowHeight;
                    this._rowHeight = undefined;
                }),
                'changing row height'
            ),
            emptyFunction,
            false,
            false
        );
        e.stopPropagation();
    };
    /**
     * Adds the display option to change the css display attribute of the `ContentFittingDocumentView`s
     */
    onContextMenu = () => {
        const displayOptionsMenu: ContextMenuProps[] = [];
        displayOptionsMenu.push({
            description: 'Toggle Content Display Style',
            event: () => {
                this.Document.display = this.Document.display ? undefined : 'contents';
            },
            icon: 'copy',
        });
        displayOptionsMenu.push({
            description: 'Toggle Vertical Centering',
            event: () => {
                this.Document.centerY = !this.Document.centerY;
            },
            icon: 'copy',
        });
        ContextMenu.Instance.addItem({ description: 'Display', subitems: displayOptionsMenu, icon: 'tv' });
    };

    /**
     * Handles text document creation on double click.
     */
    onPointerDown = (e: React.PointerEvent) => {
        if (this._props.isContentActive()) {
            setupMoveUpEvents(
                this,
                e,
                returnFalse,
                returnFalse,
                (clickEv: PointerEvent, doubleTap?: boolean) => {
                    if (doubleTap && !clickEv.button) {
                        undoable(
                            action(() => {
                                const text = Docs.Create.TextDocument('', { _width: 150, _height: 50 });
                                DocumentView.SetSelectOnLoad(text); // track the new text box so we can give it a prop that tells it to focus itself when it's displayed
                                Doc.AddDocToList(this.Document, this._props.fieldKey, text);
                                this.setLayoutList(this.addLayoutItem(this.savedLayoutList, this.makeLayoutItem(text, this.screenToCell(clickEv.clientX, clickEv.clientY))));
                            }),
                            'create grid text'
                        )();
                    }
                },
                false
            );
            if (this._props.isSelected()) e.stopPropagation();
        }
    };

    render() {
        return (
            <div
                className="collectionGridView-contents"
                ref={this.createDashEventsTarget}
                style={{ pointerEvents: !this._props.isContentActive() ? 'none' : undefined }}
                onContextMenu={this.onContextMenu}
                onPointerDown={this.onPointerDown}
                onDrop={this.onExternalDrop}>
                <div
                    className="collectionGridView-gridContainer"
                    ref={this._containerRef}
                    style={{ backgroundColor: StrCast(this.layoutDoc._backgroundColor, 'white'), padding: `${this.yMargin} ${this.xMargin}` }}
                    onWheel={e => e.stopPropagation()}
                    onScroll={action(e => {
                        if (!this._props.isSelected()) e.currentTarget.scrollTop = this._scroll;
                        else this._scroll = e.currentTarget.scrollTop;
                    })}>
                    <Grid
                        width={this._props.PanelWidth() - 2 * this.xMargin}
                        nodeList={this.contents.length ? this.contents : null}
                        layout={this.contents.length ? this.renderedLayoutList : undefined}
                        childrenDraggable={!!this._props.isSelected()}
                        numCols={this.numCols}
                        rowHeight={this.rowHeight}
                        setLayout={this.setLayout}
                        transformScale={this.ScreenToLocalBoxXf().Scale}
                        compactType={this.compaction} // determines whether nodes should remain in position, be bound to the top, or to the left
                        preventCollision={BoolCast(this.Document.gridPreventCollision)} // determines whether nodes should move out of the way (i.e. collide) when other nodes are dragged over them
                        margin={this.gridGap}
                    />
                    <input
                        className="rowHeightSlider"
                        type="range"
                        style={{ width: this._props.PanelHeight() - 2 * this.yMargin }}
                        min={1}
                        value={this.rowHeight}
                        max={this._props.PanelHeight() - 2 * this.yMargin}
                        onPointerDown={this.onSliderDown}
                        onChange={this.onSliderChange}
                    />
                </div>
            </div>
        );
    }
}
