import { Subject } from 'rxjs';
import { debounceTime, filter, map, share, takeUntil } from 'rxjs/operators';
import { commands } from '../../commands';
import { coord, defaultValue, MemoryCache, R, toSelectionValues, util, value as valueUtil, } from '../../common';
import { DEFAULT } from '../../common/constants';
import { keyboard } from '../../keyboard';
import { Cell } from '../Cell';
import { calc } from './Grid.calc';
export class Grid {
    constructor(args) {
        this._ = {
            id: '',
            totalColumns: -1,
            totalRows: -1,
            defaults: undefined,
            keyBindings: undefined,
            cache: MemoryCache.create(),
            table: undefined,
            dispose$: new Subject(),
            events$: new Subject(),
            redraw$: new Subject(),
            isReady: false,
            isEditing: false,
            ns: DEFAULT.NS,
            cells: {},
            columns: {},
            rows: {},
            lastSelection: undefined,
            calc: undefined,
            refs: undefined,
        };
        this.dispose$ = this._.dispose$.pipe(share());
        this.events$ = this._.events$.pipe(takeUntil(this.dispose$), share());
        this.keyboard$ = this._.events$.pipe(filter((e) => e.type === 'GRID/keydown'), map((e) => e.payload), share());
        this.getValueSync = (key) => {
            const cell = this.data.cells[key];
            return cell && typeof cell.value === 'string' ? cell.value : undefined;
        };
        this.refsTable = coord.refs.table({
            getKeys: async () => Object.keys(this.data.cells),
            getValue: async (key) => this.getValueSync(key),
        });
        this.command = (args) => {
            const payload = {
                command: args.command,
                grid: this,
                selection: this.selection,
                props: args.props || {},
                isCancelled: false,
                cancel: () => {
                    payload.isCancelled = true;
                    if (args.cancel) {
                        args.cancel();
                    }
                },
            };
            this.fire({ type: 'GRID/command', payload });
            return this;
        };
        this.fire = (e) => {
            this._.events$.next(e);
            return this;
        };
        this._init(args);
    }
    static create(args) {
        return new Grid(args);
    }
    static defaults(input) {
        const partial = input || {};
        return {
            ns: defaultValue(partial.ns, DEFAULT.NS),
            totalColumns: defaultValue(partial.totalColumns, DEFAULT.TOTAL_COLUMNS),
            totalRows: defaultValue(partial.totalRows, DEFAULT.TOTAL_ROWS),
            columnWidth: defaultValue(partial.columnWidth, DEFAULT.COLUMN.WIDTH),
            columnWidthMin: defaultValue(partial.columnWidthMin, DEFAULT.COLUMN.WIDTH_MIN),
            rowHeight: defaultValue(partial.rowHeight, DEFAULT.ROW.HEIGHT),
            rowHeightMin: defaultValue(partial.rowHeightMin, DEFAULT.ROW.HEIGHT_MIN),
        };
    }
    static toDataArray(args) {
        return Array.from({ length: args.totalRows }).map((v, row) => Array.from({ length: args.totalColumns }).map((v, column) => {
            return args.cells[Cell.toKey({ row, column })];
        }));
    }
    static toNs(input) {
        const ns = input === undefined ? DEFAULT.NS : typeof input === 'string' ? { id: input } : input;
        ns.id = ns.id.trim().replace(/^ns\:/, '');
        return ns;
    }
    static isDefaultValue(args) {
        const { kind, value } = args;
        const defaults = Grid.defaults(args.defaults);
        return util.isDefaultGridValue({ defaults, kind, value });
    }
    _init(args) {
        const defaults = (this._.defaults = Grid.defaults(args.defaults));
        this._.totalColumns = defaultValue(args.totalColumns, defaults.totalColumns);
        this._.totalRows = defaultValue(args.totalRows, defaults.totalRows);
        this._.ns = Grid.toNs(args.ns);
        this._.cells = args.cells || {};
        this._.columns = args.columns || {};
        this._.rows = args.rows || {};
        this._.calc = calc({ grid: this, getFunc: args.getFunc });
        this.events$
            .pipe(filter((e) => e.type === 'GRID/ready'))
            .subscribe(() => (this._.isReady = true));
        this._.keyBindings = R.uniqBy(R.prop('command'), [
            ...(args.keyBindings || []),
            ...DEFAULT.KEY_BINDINGS,
        ]);
        const { table } = args;
        if (table) {
            this.initialize({ table });
        }
    }
    initialize(args) {
        const { table } = args;
        this._.table = table;
        this._.id = `grid/${table.guid.replace(/^ht_/, '')}`;
        keyboard.init({ grid: this });
        commands.init({ grid: this, fire: this.fire });
        this._.redraw$
            .pipe(takeUntil(this.dispose$), debounceTime(0))
            .subscribe((e) => this.fire({ type: 'GRID/redraw', payload: { redraw: true } }));
        this.events$
            .pipe(filter((e) => e.type === 'GRID/EDITOR/begin'))
            .subscribe(() => (this._.isEditing = true));
        const editEnd$ = this.events$.pipe(filter((e) => e.type === 'GRID/EDITOR/end'), map((e) => e.payload));
        editEnd$.subscribe(() => (this._.isEditing = false));
        editEnd$.pipe(filter((e) => e.isChanged)).subscribe((e) => {
            const key = e.cell.key;
            const value = e.value.to;
            const props = Object.assign(Object.assign({}, this.cell(key).data.props), { value: undefined });
            const cell = { value, props };
            this.changeCells({ [key]: cell }, { source: 'EDIT' });
        });
        editEnd$
            .pipe(filter((e) => !e.isCancelled), filter((e) => e.cell.key === this.selection.cell))
            .subscribe((e) => {
            const below = e.cell.siblings.bottom;
            if (below) {
                this.select({ cell: below });
            }
        });
        const selection$ = this.events$.pipe(filter((e) => e.type === 'GRID/selection'), map((e) => e.payload));
        selection$
            .pipe(filter((e) => Boolean(e.to.cell)))
            .subscribe((e) => (this._.lastSelection = e.to));
        selection$
            .pipe(debounceTime(0), filter((e) => !Boolean(e.from.cell) && Boolean(e.to.cell)))
            .subscribe((e) => this.fire({ type: 'GRID/focus', payload: { grid: this } }));
        selection$
            .pipe(debounceTime(0), filter((e) => Boolean(e.from.cell) && !Boolean(e.to.cell)))
            .subscribe((e) => this.fire({ type: 'GRID/blur', payload: { grid: this } }));
        this.events$
            .pipe(filter((e) => (this._.isReady = true)), filter((e) => e.type === 'GRID/cells/change'), map((e) => e.payload))
            .subscribe(async (e) => {
            const cells = e.changes.map((change) => change.cell.key);
            await this.calc.update({ cells });
        });
        return this;
    }
    get id() {
        return this._.id;
    }
    get totalColumns() {
        return this._.totalColumns;
    }
    get totalRows() {
        return this._.totalRows;
    }
    get defaults() {
        return this._.defaults;
    }
    get keyBindings() {
        return this._.keyBindings;
    }
    get isInitialized() {
        return Boolean(this._.table);
    }
    get isDisposed() {
        return this._.table.isDestroyed || this._.dispose$.isStopped;
    }
    get isReady() {
        return this.isDisposed ? false : this._.isReady;
    }
    get isEditing() {
        return this._.isEditing;
    }
    get data() {
        const { ns, cells, columns, rows } = this._;
        const files = {};
        return { ns, cells, columns, rows, files };
    }
    setCells(cells) {
        cells = Object.assign({}, cells);
        const totalColumns = this.totalColumns;
        const totalRows = this.totalRows;
        const data = Grid.toDataArray({ cells, totalColumns, totalRows });
        this._.cells = cells;
        this._.table.loadData(data);
    }
    get calc() {
        return this._.calc;
    }
    get selection() {
        const toKey = (coord) => Cell.toKey({ row: coord.row, column: coord.col });
        const last = this._.table.getSelectedRangeLast();
        const cell = last ? toKey(last.highlight) : undefined;
        const selectedRanges = this._.table.getSelectedRange() || [];
        let ranges = selectedRanges.map((item) => `${toKey(item.from)}:${toKey(item.to)}`);
        ranges = ranges.length === 1 && ranges[0] === `${cell}:${cell}` ? [] : ranges;
        let all = false;
        if (ranges.length > 0) {
            const min = { row: -1, col: -1 };
            const max = { row: -1, col: -1 };
            selectedRanges.forEach((range) => {
                const { from, to } = range;
                min.row = min.row === -1 || from.row < min.row ? from.row : min.row;
                min.col = min.col === -1 || from.col < min.col ? from.col : min.col;
                max.row = max.row === -1 || to.row > max.row ? to.row : max.row;
                max.col = max.col === -1 || to.col > max.col ? to.col : max.col;
            });
            if (min.row === 0 &&
                min.col === 0 &&
                max.row === this.totalRows - 1 &&
                max.col === this.totalColumns - 1) {
                all = true;
            }
        }
        if (ranges.length > 0) {
            const totalColumns = this.totalColumns;
            const totalRows = this.totalRows;
            const union = coord.range.union(ranges).formated({ totalColumns, totalRows });
            ranges = union.ranges.map((range) => range.key);
            if (cell) {
                ranges = ranges.filter((range) => range !== `${cell}:${cell}`);
            }
            ranges = R.uniq(ranges);
        }
        let result = { cell, ranges };
        result = all ? Object.assign(Object.assign({}, result), { all }) : result;
        return result;
    }
    get selectionValues() {
        const cells = this.data.cells;
        const selection = this.selection;
        return toSelectionValues({ cells, selection });
    }
    dispose() {
        const { table, dispose$ } = this._;
        if (!table.isDestroyed) {
            table.destroy();
        }
        dispose$.next();
        dispose$.complete();
    }
    mergeCells(args) {
        const { cells } = args;
        const list = args.init
            ? []
            : this._.table.getSettings().mergeCells || [];
        const map = list.reduce((acc, next) => {
            const key = coord.cell.toKey(next.col, next.row);
            acc[key] = next;
            return acc;
        }, {});
        Object.keys(cells).map((key) => {
            const item = cells[key];
            if (item) {
                const props = item.props || {};
                const merge = props.merge;
                if (merge) {
                    const rowspan = Math.max(1, defaultValue(merge.rowspan, 1));
                    const colspan = Math.max(1, defaultValue(merge.colspan, 1));
                    const { row, column: col } = coord.cell.fromKey(key);
                    map[key] = { row, col, rowspan, colspan };
                }
            }
        });
        const mergeCells = Object.keys(map).map((key) => map[key]);
        this._.table.updateSettings({ mergeCells }, false);
        return this;
    }
    changeCells(cells, options = {}) {
        const done = () => this;
        const format = (key, to) => {
            if (Cell.isEmpty(to)) {
                return undefined;
            }
            if (to) {
                to = Object.assign(Object.assign({}, to), { hash: util.gridCellHash(this, key, to) });
                if (Cell.isEmptyProps(to.props)) {
                    delete to.props;
                }
            }
            return to;
        };
        if (options.init && cells && Object.keys(cells).length === 0) {
            this.setCells({});
            return done();
        }
        if (cells) {
            const current = Object.assign({}, (options.init ? {} : this.data.cells));
            cells = Object.assign({}, cells);
            Object.keys(cells)
                .filter((key) => !coord.cell.isCell(key))
                .forEach((key) => delete cells[key]);
            const formatted = Object.keys(cells).reduce((acc, key) => {
                acc[key] = {
                    from: format(key, current[key]),
                    to: format(key, cells[key]),
                };
                return acc;
            }, {});
            const changes = Object.keys(cells).map((key) => {
                const cell = this.cell(key);
                const { from, to } = formatted[key];
                return Cell.changeEvent({ cell, from, to });
            });
            const isChanged = changes.some((e) => e.isChanged);
            if (!isChanged) {
                return done();
            }
            if (!options.silent) {
                const payload = {
                    source: defaultValue(options.source, 'EDIT'),
                    changes,
                    get isCancelled() {
                        return changes.some((change) => change.isCancelled);
                    },
                    cancel() {
                        changes.forEach((change) => change.cancel());
                    },
                };
                this.fire({ type: 'GRID/cells/change', payload });
                changes
                    .filter((change) => change.isModified)
                    .forEach((change) => (cells[change.cell.key] = change.value.to));
                changes
                    .filter((change) => change.isCancelled)
                    .forEach((change) => (cells[change.cell.key] = change.value.from));
            }
            const mergeChanges = {};
            const updates = Object.assign(Object.assign({}, current), Object.keys(formatted).reduce((acc, key) => {
                acc[key] = formatted[key].to;
                return acc;
            }, {}));
            Object.keys(formatted).forEach((key) => {
                const { from, to } = formatted[key];
                if (Cell.isEmpty(to)) {
                    delete updates[key];
                    return;
                }
                const isMergeChanged = !R.equals(((from || {}).props || {}).merge, ((to || {}).props || {}).merge);
                if (isMergeChanged) {
                    mergeChanges[key] = to;
                }
            });
            if (Object.keys(mergeChanges).length > 0) {
                this.mergeCells({ cells: mergeChanges });
            }
            this.setCells(updates);
        }
        return done();
    }
    changeColumns(columns, options = {}) {
        const { source = 'UPDATE' } = options;
        const from = Object.assign({}, this._.columns);
        const to = Object.assign({}, from);
        let changes = [];
        Object.keys(columns).forEach((key) => {
            const prev = from[key] || { props: { grid: { width: -1 } } };
            const next = columns[key] || { props: { grid: { width: this.defaults.columnWidth } } };
            const nextProps = next.props || {};
            const isDefault = nextProps.grid && nextProps.grid.width === this.defaults.columnWidth;
            if (isDefault) {
                delete to[key];
            }
            else {
                to[key] = next;
            }
            if (!R.equals(prev, next)) {
                changes = [...changes, { column: key, source, from: prev, to: next }];
            }
        });
        this._.columns = to;
        if (!R.equals(from, to)) {
            this.fire({ type: 'GRID/columns/change', payload: { from, to, changes } });
        }
        return this;
    }
    changeRows(rows, options = {}) {
        const { source = 'UPDATE' } = options;
        const from = Object.assign({}, this._.rows);
        const to = Object.assign({}, from);
        let changes = [];
        Object.keys(rows).forEach((key) => {
            const prev = from[key] || { props: { grid: { height: -1 } } };
            const next = rows[key] || { props: { grid: { height: this.defaults.rowHeight } } };
            const nextProps = next.props || {};
            const isDefault = nextProps.grid && nextProps.grid.height === this.defaults.rowHeight;
            if (isDefault) {
                delete to[key];
            }
            else {
                to[key] = next;
            }
            if (!R.equals(prev, next)) {
                const row = coord.cell.fromKey(key).row;
                changes = [...changes, { row, source, from: prev, to: next }];
            }
        });
        this._.rows = to;
        if (!R.equals(from, to)) {
            this.fire({
                type: 'GRID/rows/change',
                payload: { from, to, changes },
            });
        }
        return this;
    }
    cell(key) {
        const args = typeof key === 'string' ? Cell.fromKey(key) : key;
        const { row, column } = args;
        if (row < 0 || column < 0) {
            let msg = `Cell does not exist at row:${row}, column:${column}.`;
            msg = typeof key === 'string' ? `${msg} key: "${key}"` : msg;
            throw new Error(msg);
        }
        const ns = this.data.ns.id;
        const cacheKey = `${ns}:cell/${column}:${row}`;
        return this._.cache.get(cacheKey, () => {
            const table = this._.table;
            return Cell.create({ ns, table, row, column });
        });
    }
    scrollTo(args) {
        const { row, column } = this.toPosition(args.cell);
        const { snapToBottom = false, snapToRight = false } = args;
        this._.table.scrollViewportTo(row, column, snapToBottom, snapToRight);
        return this;
    }
    select(args) {
        const totalColumns = this.totalColumns;
        const totalRows = this.totalRows;
        const table = this._.table;
        const scrollToCell = valueUtil.defaultValue(args.scrollToCell, true);
        const ranges = (args.ranges || [])
            .map((range) => Cell.toRangePositions({ range, totalColumns, totalRows }))
            .map(({ start, end }) => {
            return [start.row, start.column, end.row, end.column];
        });
        const pos = this.toPosition(args.cell);
        const current = [pos.row, pos.column, pos.row, pos.column];
        const selection = [...ranges, current];
        table.selectCells(selection, scrollToCell);
        return this;
    }
    deselect() {
        this._.table.deselectCell();
        return this;
    }
    focus() {
        const last = this._.lastSelection;
        const cell = (last && last.cell) || 'A1';
        const ranges = (last && last.ranges) || [];
        this.select({ cell, ranges });
        return this;
    }
    blur() {
        this.fire({ type: 'GRID/blur', payload: { grid: this } });
        this.deselect();
        return this;
    }
    redraw() {
        this._.redraw$.next();
        return this;
    }
    toPosition(ref) {
        const pos = Cell.toPosition(ref);
        const row = R.clamp(0, this.totalRows - 1, pos.row);
        const column = R.clamp(0, this.totalColumns - 1, pos.column);
        return { row, column };
    }
    updateHashes(options = {}) {
        const data = this.data;
        const cells = Object.assign({}, data.cells);
        let isChanged = false;
        Object.keys(cells).forEach((key) => {
            const cell = cells[key];
            if (cell) {
                let hash = cell.hash;
                if (!hash || options.force) {
                    hash = util.gridCellHash(this, key, cell);
                    cells[key] = Object.assign(Object.assign({}, cell), { hash });
                    isChanged = true;
                }
            }
        });
        if (isChanged) {
            this.setCells(cells);
        }
        return this;
    }
}
