import {Injectable} from '@angular/core';
import {ApiService, ResourceNotAvailableOfflineError} from './api.service';
import PageRecord, {PageVersionRecord} from '../structures/PageRecord';
import HostRecord from '../structures/HostRecord';
import {EditorNodeContract} from '../components/nodes/EditorNode.contract';
import {BehaviorSubject, Subscription} from 'rxjs';
import {NavigationService} from './navigation.service';
import {DatabaseService} from './db/database.service';
import {Page} from './db/Page';
import {PageNode} from './db/PageNode';
import {MenuItem} from './db/MenuItem';
import {SessionService} from './session.service';
import {debounce, uuid_v4} from '../utility';

export class NoPageLoadedError extends Error {
    constructor(msg = 'There is no page open for editing.') {
        super(msg);
    }
}

@Injectable({
    providedIn: 'root'
})
export class EditorService {
    private static instances: {[key: string]: EditorService} = {};

    protected currentPage?: PageRecord;
    protected currentNodes: HostRecord[] = [];
    protected nodeIdToEditorContract: { [key: string]: EditorNodeContract } = {};
    protected dirtyOverride = false;
    protected ready$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
    protected subs: Subscription[] = [];
    protected saving = false;
    protected currentPageVersion?: number;
    public forceReadonly = false;
    protected saveTriggered = false;
    public notAvailable = false;
    public readonly instanceUUID: string;

    protected privTriggerSave = debounce(() => {
        if ( this.saving ) {
            this.triggerSave();
        } else {
            this.save();
        }

        this.saveTriggered = false;
    }, 3000);

    public static registerInstance(inst: EditorService) {
        this.instances[inst.instanceUUID] = inst;
    }

    public triggerSave() {
        this.saveTriggered = true;
        this.privTriggerSave();
    }

    public get currentPageId() {
        return this.currentPage?.UUID;
    }

    public get isSaving() {
        return this.saving;
    }

    public get willSave() {
        return this.saveTriggered;
    }

    public get isEditing() {
        return !!this.currentPage;
    }

    public get metadata() {
        if ( this.currentPage.AdditionalData ) {
            return this.currentPage.AdditionalData;
        }

        return {};
    }

    public set metadata(data: any) {
        this.currentPage.AdditionalData = data;
        this.dirtyOverride = true;
    }

    public get immutableNodes(): HostRecord[] {
        return [...this.currentNodes];
    }

    public get mutablePageName(): string {
        if ( this.currentPage ) {
            return this.currentPage.Name;
        }

        return '';
    }

    public set mutablePageName(name: string) {
        if ( this.currentPage && this.canEdit() ) {
            if ( this.currentPage.Name !== name ) {
                this.dirtyOverride = true;
                this.triggerSave();
            }

            this.currentPage.Name = name;
        }
    }

    public get currentPageType() {
        return this.currentPage?.PageType;
    }

    constructor(
        protected api: ApiService,
        protected nav: NavigationService,
        protected db: DatabaseService,
        protected session: SessionService,
    ) {
        this.instanceUUID = uuid_v4();
        console.log('editor service', this);
    }

    getEditor(uuid?: string) {
        if ( uuid ) {
            return EditorService.instances[uuid];
        }

        const inst = new EditorService(this.api, this.nav, this.db, this.session);
        EditorService.registerInstance(inst);
        return inst;
    }

    async reload() {
        await this.startEditing(this.currentPageId);
    }

    async startEditing(pageId: string, version?: number) {
        if ( this.currentPage ) {
            await this.stopEditing();
        }

        try {
            this.currentPageVersion = version;
            this.currentPage = await this.loadPage(pageId, version);
            this.currentNodes = await this.loadNodes(pageId, version);
            this.notAvailable = false;
            await this.ready$.next(true);
        } catch (e) {
            if ( e instanceof ResourceNotAvailableOfflineError ) {
                this.notAvailable = true;
                await this.ready$.next(true);
            } else {
                throw e;
            }
        }
    }

    async stopEditing() {
        delete this.currentPage;
        this.currentNodes = [];
        this.nodeIdToEditorContract = {};
        this.subs.forEach(sub => sub.unsubscribe());
        this.subs = [];
        this.ready$.next(false);
    }

    async save() {
        if ( !(await this.needsSave()) || this.saving || this.forceReadonly ) {
            return;
        }

        this.saving = true;
        const editors = Object.values(this.nodeIdToEditorContract);

        // Save all editors that handle their data independently first
        await Promise.all(editors.map(async editor => {
            if ( await editor.needsSave() ) {
                await editor.performSave();
            }
        }));

        // Tell the editors to write their state changes to the HostRecords
        await Promise.all(editors.map(async editor => {
            await editor.writeChangesToNode();
        }));

        await this.savePage(this.currentPage);
        await this.saveNodesAsPage(this.currentPage, this.currentNodes);
        this.dirtyOverride = false;
        this.saving = false;
        this.nav.requestSidebarRefresh({ quiet: true });
    }

    async moveNode(node: HostRecord, direction: 'up' | 'down') {
        if ( !this.currentPage ) {
            throw new NoPageLoadedError();
        }

        if ( this.forceReadonly ) {
            return;
        }

        const nodeIndex = this.currentNodes.findIndex(maybeNode => maybeNode.UUID === node.UUID);
        if ( nodeIndex < 0 ) {
            return;
        }

        if ( direction === 'up' && nodeIndex > 0 ) {
            const otherIdx = nodeIndex - 1;
            const otherNode = this.currentNodes[otherIdx];
            this.currentNodes[otherIdx] = this.currentNodes[nodeIndex];
            this.currentNodes[nodeIndex] = otherNode;
        } else if ( direction === 'down' && nodeIndex !== (this.currentNodes.length - 1) ) {
            const otherIdx = nodeIndex + 1;
            const otherNode = this.currentNodes[otherIdx];
            this.currentNodes[otherIdx] = this.currentNodes[nodeIndex];
            this.currentNodes[nodeIndex] = otherNode;
        }

        this.dirtyOverride = true;
        this.triggerSave();
    }

    async savePage(page: PageRecord): Promise<void> {
        await new Promise(async (res, rej) => {
            const existingLocalPage = await this.db.pages.where({ UUID: page.UUID }).first() as Page;
            const saveData = page.toSave();

            if ( this.api.isOffline ) {
                if ( existingLocalPage ) {
                    existingLocalPage.fillFromRecord(page);
                    existingLocalPage.UpdatedAt = String(new Date()); // FIXME update UpdateUserId
                    existingLocalPage.needsServerUpdate = 1;
                    await existingLocalPage.save();
                } else {
                    const newLocalPage = new Page(
                        page.UUID,
                        page.Name,
                        page.OrgUserId,
                        page.IsPublic,
                        page.IsVisibleInMenu,
                        page.ParentId,
                        page.NodeIds,
                        String(page.CreatedAt || new Date()),
                        String(new Date()),
                        true,
                        page.CreatedUserId,
                        page.UpdateUserId, // FIXME fill in the current user's ID
                        page.ChildPageIds,
                        false,
                        false,
                        1,
                    );

                    await newLocalPage.save();
                }

                return res();
            }

            this.api.post(`/page/${page.UUID}/save`, saveData).subscribe({
                next: async result => {
                    if ( existingLocalPage ) {
                        existingLocalPage.fillFromRecord(page);
                        await existingLocalPage.save();
                    } else {
                        const newLocalPage = new Page(
                            result.data.UUID,
                            result.data.Name,
                            result.data.OrgUserId,
                            result.data.IsPublic,
                            result.data.IsVisibleInMenu,
                            result.data.ParentId,
                            result.data.NodeIds,
                            String(result.data.CreatedAt),
                            String(result.data.UpdatedAt),
                            true,
                            result.data.CreatedUserId,
                            result.data.UpdateUserId,
                            result.data.ChildPageIds,
                            result.data.noDelete,
                            result.data.virtual,
                            0
                        );

                        await newLocalPage.save();
                    }

                    res();
                },
                error: rej,
            });
        });
    }

    async saveNodesAsPage(page: PageRecord, nodes: HostRecord[]): Promise<HostRecord[]> {
        return new Promise(async (res, rej) => {
            const saveNodes = nodes.map(x => {
                x.PageId = page.UUID;
                return x.toSave();
            });

            const existingLocalPage = await this.db.pages.where({ UUID: page.UUID }).first() as Page;

            // If we're offline save the nodes locally
            if ( this.api.isOffline ) {
                if ( !existingLocalPage ) {
                    return rej(new ResourceNotAvailableOfflineError());
                }

                const nodeRecs: PageNode[] = [];
                for ( const nodeRec of saveNodes ) {
                    const existingLocalNode = await this.db.pageNodes.where({ UUID: nodeRec.UUID }).first() as PageNode;
                    if ( existingLocalNode ) {
                        existingLocalNode.fillFromRecord(nodeRec);
                        existingLocalNode.needsServerUpdate = true;
                        await existingLocalNode.save();
                        nodeRecs.push(existingLocalNode);
                    } else {
                        const newLocalNode = new PageNode(
                            nodeRec.UUID || PageNode.getUUID(),
                            nodeRec.Type,
                            JSON.stringify(nodeRec.Value),
                            nodeRec.PageId,
                            nodeRec.CreatedAt,
                            nodeRec.UpdatedAt,
                            nodeRec.CreatedUserId,
                            nodeRec.UpdateUserId,
                            true
                        );

                        await newLocalNode.save();
                        nodeRecs.push(newLocalNode);
                    }
                }

                page.NodeIds = nodeRecs.map(x => x.UUID);
                existingLocalPage.NodeIds = nodeRecs.map(x => x.UUID);
                existingLocalPage.needsServerUpdate = 1;
                await existingLocalPage.save();

                return res(nodeRecs.map(x => {
                    const rec = x.inflateToRecord();
                    const host = new HostRecord(rec.Value.Value);
                    host.load(rec);
                    return host;
                }));
            }

            // Otherwise, use the server to save them and update the local records
            this.api.post(`/page/${page.UUID}/nodes/save`, saveNodes).subscribe({
                next: async result => {
                    await this.db.pageNodes.where({ PageId: page.UUID }).delete();

                    const returns = [];
                    for ( const rec of result.data ) {
                        const newLocalNode = new PageNode(
                            rec.UUID,
                            rec.Type,
                            JSON.stringify(rec.Value),
                            rec.PageId,
                            rec.CreatedAt,
                            rec.UpdatedAt,
                            rec.CreatedUserId,
                            rec.UpdateUsetId,
                        );

                        await newLocalNode.save();

                        const host = new HostRecord(rec.Value.Value);
                        host.load(rec);
                        returns.push(host);
                    }

                    return res(returns);
                },
                error: rej,
            });
        });
    }

    async saveNodeToPage(page: PageRecord, node: HostRecord): Promise<HostRecord> {
        return new Promise(async (res, rej) => {
            node.PageId = page.UUID;
            const nodeData = node.toSave();
            const localPage = await this.db.pages.where({ UUID: page.UUID }).first() as Page;

            if ( this.api.isOffline ) {
                if ( !localPage ) {
                    return rej(new ResourceNotAvailableOfflineError());
                }

                if ( !nodeData.UUID ) {
                    nodeData.UUID = PageNode.getUUID();
                }

                const newLocalNode = new PageNode(
                    nodeData.UUID,
                    nodeData.Type,
                    JSON.stringify(nodeData.Value),
                    nodeData.PageId,
                    nodeData.CreatedAt,
                    nodeData.UpdatedAt,
                    nodeData.CreatedUserId,
                    nodeData.UpdateUserId,
                    true
                );

                await newLocalNode.save();

                localPage.NodeIds.push(newLocalNode.UUID);
                localPage.needsServerUpdate = 1;
                await localPage.save();

                const host = new HostRecord(nodeData.Value.Value);
                host.load(nodeData);
                return res(host);
            }

            this.api.post(`/page/${page.UUID}/nodes/save_one`, { nodeData }).subscribe({
                next: async result => {
                    const newLocalNode = new PageNode(
                        result.data.UUID,
                        result.data.Type,
                        JSON.stringify(result.data.Value),
                        result.data.PageId,
                        result.data.CreatedAt,
                        result.data.UpdatedAt,
                        result.data.CreatedUserId,
                        result.data.UpdateUserId
                    );

                    await newLocalNode.save();

                    if ( localPage ) {
                        localPage.NodeIds.push(result.data.UUID);
                        await localPage.save();
                    }

                    const host = new HostRecord(result.data.Value.Value);
                    host.load(result.data);
                    res(host);
                },
                error: rej,
            });
        });
    }

    async needsSave() {
        if ( this.forceReadonly ) {
            return false;
        }

        if ( this.dirtyOverride ) {
            return true;
        }

        const dirties = await Promise.all(Object.values(this.nodeIdToEditorContract).map(editor => editor.isDirty()));
        const needSaves = await Promise.all(Object.values(this.nodeIdToEditorContract).map(editor => editor.needsSave()));

        return dirties.some(Boolean) || needSaves.some(Boolean);
    }

    async deleteNode(nodeId: string) {
        if ( !this.currentPage ) {
            throw new NoPageLoadedError();
        }

        if ( this.forceReadonly ) {
            return;
        }

        const node = this.currentNodes.find(maybeNode => maybeNode.UUID === nodeId);
        if ( !node ) {
            throw new Error('Invalid node ID.');
        }

        const editor = this.nodeIdToEditorContract[nodeId];
        if ( editor ) {
            await editor.performDelete();
            delete this.nodeIdToEditorContract[nodeId];
        }

        // If we're offline, we need to flag the local node record for deletion
        if ( this.api.isOffline ) {
            const existingLocalNode = await this.db.pageNodes.where({ UUID: nodeId }).first() as PageNode;
            if ( existingLocalNode ) {
                existingLocalNode.deleted = true;
                existingLocalNode.needsServerUpdate = true;
                await existingLocalNode.save();
            }
        }

        this.currentNodes = this.currentNodes.filter(x => x.UUID !== nodeId);
        this.dirtyOverride = true;
        this.triggerSave();
    }

    async addNode(type: 'paragraph' | 'code_ref' | 'database_ref' | 'file_ref', position?: 'before' | 'after', positionNodeId?: string) {
        if ( !this.currentPage ) {
            throw new NoPageLoadedError();
        }

        if ( this.forceReadonly ) {
            return;
        }

        const baseHost = new HostRecord();
        baseHost.type = type;
        baseHost.PageId = this.currentPage.UUID;

        const host = await this.saveNodeToPage(this.currentPage, baseHost);

        let placed = false;
        if ( position === 'before' && positionNodeId ) {
            const index = this.currentNodes.findIndex(node => node.UUID === positionNodeId);
            if ( index > -1 ) {
                this.currentNodes.splice(index, 0, host);
                placed = true;
            }
        } else if ( position === 'after' && positionNodeId ) {
            const index = this.currentNodes.findIndex(node => node.UUID === positionNodeId);
            if ( index > -1 ) {
                this.currentNodes.splice(index + 1, 0, host);
                placed = true;
            }
        }

        if ( !placed ) {
            this.currentNodes.push(host);
        }

        this.currentPage.NodeIds.push(host.UUID);
        this.dirtyOverride = true;
        this.triggerSave();
        return host;
    }

    canEdit() {
        return this.currentPage && !this.currentPage.isViewOnly() && !this.forceReadonly;
    }

    async registerNodeEditor(nodeId: string, editor: EditorNodeContract) {
        return new Promise((res, rej) => {
            const sub = this.ready$.subscribe(async val => {
                if ( val ) {
                    try {
                        if ( !this.currentPage ) {
                            return rej(new NoPageLoadedError());
                        }

                        const node = this.currentNodes.find(maybeNode => maybeNode.UUID === nodeId);
                        if ( !node ) {
                            return rej(new Error('Invalid node ID.'));
                        }

                        editor.page = this.currentPage;
                        editor.node = node;
                        this.nodeIdToEditorContract[nodeId] = editor;

                        if ( editor.needsLoad() ) {
                            await editor.performLoad();
                        }

                        res();
                    } catch (e) {
                        rej(e);
                    }
                }
            });

            this.subs.push(sub);
        });
    }

    async unregisterNodeEditor(nodeId: string) {
        if ( !this.currentPage ) {
            throw new NoPageLoadedError();
        }

        delete this.nodeIdToEditorContract[nodeId];
    }

    async loadPage(pageId: string, version?: number): Promise<PageRecord> {
        return new Promise(async (res, rej) => {
            const existingLocalPage = await this.db.pages.where({ UUID: pageId }).first() as Page;

            // If we're offline, return the local record, or throw an error.
            if ( this.api.isOffline ) {
                if ( existingLocalPage ) {
                    return res(new PageRecord(existingLocalPage.getSaveRecord()));
                } else {
                    return rej(new ResourceNotAvailableOfflineError());
                }
            }

            // If we're online, fetch the page record and store it locally
            this.api.get(`/page/${pageId}${version ? '?version=' + version : ''}`).subscribe({
                next: async result => {
                    const page = new PageRecord(result.data);

                    if ( existingLocalPage ) {
                        existingLocalPage.fillFromRecord(result.data);
                        existingLocalPage.needsServerUpdate = 0;
                        await existingLocalPage.save();
                    } else {
                        const newLocalPage = new Page(
                            page.UUID,
                            page.Name,
                            page.OrgUserId,
                            page.IsPublic,
                            page.IsVisibleInMenu,
                            page.ParentId,
                            page.NodeIds,
                            String(page.CreatedAt),
                            String(page.UpdatedAt),
                            true,
                            page.CreatedUserId,
                            page.UpdateUserId,
                            page.ChildPageIds,
                            result.data.noDelete,
                            result.data.virtual,
                        );

                        await newLocalPage.save();
                    }

                    res(page);
                },
                error: rej,
            });
        });
    }

    async loadPageVersions(pageId: string): Promise<PageVersionRecord[]> {
        return new Promise(async (res, rej) => {
            if ( this.api.isOffline ) {
                return rej(new ResourceNotAvailableOfflineError());
            }

            this.api.get(`/page/${pageId}/versions`).subscribe({
                next: results => {
                    return res(results.data.map(data => {
                        return {
                            currentVersion: Boolean(data.current_version),
                            versionNum: Number(data.version_num),
                            versionUserId: data.version_user_id,
                            versionMessage: data.version_message,
                            versionUUID: data.version_UUID,
                            versionCreateDate: new Date(data.version_create_date),
                            userDisplay: data.user_display,
                        };
                    }));
                },
                error: rej,
            });
        });
    }

    async revertPageToVersion(pageId: string, versionNum: number): Promise<PageRecord> {
        return new Promise(async (res, rej) => {
            const existingLocalPage = await this.db.pages.where({ UUID: pageId }).first() as Page;

            if ( this.api.isOffline ) {
                return rej(new ResourceNotAvailableOfflineError());
            }

            this.api.post(`/page/${pageId}/versions/revert`, { version_num: versionNum }).subscribe({
                next: async result => {
                    const page = new PageRecord(result.data);

                    if ( existingLocalPage ) {
                        existingLocalPage.fillFromRecord(result.data);
                        existingLocalPage.needsServerUpdate = 0;
                        await existingLocalPage.save();
                    } else {
                        const newLocalPage = new Page(
                            page.UUID,
                            page.Name,
                            page.OrgUserId,
                            page.IsPublic,
                            page.IsVisibleInMenu,
                            page.ParentId,
                            page.NodeIds,
                            String(page.CreatedAt),
                            String(page.UpdatedAt),
                            true,
                            page.CreatedUserId,
                            page.UpdateUserId,
                            page.ChildPageIds,
                            result.data.noDelete,
                            result.data.virtual,
                        );

                        await newLocalPage.save();
                    }

                    res(page);
                },
                error: rej,
            });
        });
    }

    async loadNodes(pageId: string, version?: number): Promise<HostRecord[]> {
        return new Promise(async (res, rej) => {
            const existingNodes = await this.db.pageNodes.where({ PageId: pageId }).toArray() as PageNode[];

            const inflateRecords = (records) => {
                return records.map(rec => {
                    const host = new HostRecord(rec.Value.Value);
                    host.load(rec);
                    return host;
                });
            };

            // If we're offline, just resolve the offline nodes
            if ( this.api.isOffline ) {
                const parsedRecords = existingNodes.map(x => x.inflateToRecord());
                return res(inflateRecords(parsedRecords));
            }

            this.api.get(`/page/${pageId}/nodes${version ? '?version=' + version : ''}`).subscribe({
                next: async result => {
                    // If we got resolved records, delete the local ones to replace them
                    await this.db.pageNodes.where({ PageId: pageId }).delete();

                    for ( const rawRec of result.data ) {
                        const newLocalNode = new PageNode(
                            rawRec.UUID,
                            rawRec.Type,
                            JSON.stringify(rawRec.Value),
                            rawRec.PageId,
                            rawRec.CreatedAt,
                            rawRec.UpdatedAt,
                            rawRec.CreatedUserId,
                            rawRec.UpdateUserId,
                        );

                        await newLocalNode.save();
                    }

                    res(inflateRecords(result.data));
                },
                error: rej,
            });
        });
    }

    public createPage(name: string): Promise<any> {
        return new Promise(async (res, rej) => {
            const userId = this.session.get('user.id');
            if ( !userId ) {
                throw new ResourceNotAvailableOfflineError();
            }

            // If we're offline, create a stub page to be saved later
            if ( this.api.isOffline ) {
                const page = new Page(
                    Page.getUUID(),
                    name,
                    userId,
                    true,
                    true,
                    '0',
                    [],
                    String(new Date()),
                    String(new Date()),
                    true,
                    userId,
                    userId,
                    [],
                    false,
                    false,
                    1
                );

                const firstNode = new PageNode(
                    PageNode.getUUID(),
                    'paragraph',
                    JSON.stringify({ Value: 'Double-click to edit...' }),
                    page.UUID,
                    String(new Date()),
                    String(new Date()),
                    userId,
                    userId,
                    true
                );

                await firstNode.save();

                page.NodeIds.push(firstNode.UUID);
                await page.save();

                // Because we're offline, we need to manually create the menu item node
                const topLevelItem = await this.db.menuItems.where({
                    serverId: 0,
                    name: 'My Info Tree',
                }).first() as MenuItem;

                if ( topLevelItem ) {
                    const newItem = new MenuItem(
                        page.Name,
                        page.UUID,
                        [],
                        false,
                        false,
                        false,
                        'page',
                        false,
                        true
                    );

                    await newItem.save();

                    topLevelItem.childIds.push(newItem.serverId);
                    await topLevelItem.save();
                }

                return res(page.getSaveRecord());
            }

            // If we're online, the server will handle all of that mess...
            this.api.post('/page/create', { name }).subscribe({
                next: async result => {
                    const page = new Page(
                        result.data.UUID,
                        result.data.Name,
                        result.data.OrgUserId,
                        result.data.IsPublic,
                        result.data.IsVisibleInMenu,
                        result.data.ParentId,
                        result.data.NodeIds,
                        result.data.CreatedAt,
                        result.data.UpdatedAt,
                        true,
                        result.data.CreatedUserId,
                        result.data.UpdateUserId,
                        result.data.ChildPageIds,
                        result.data.noDelete,
                        result.data.virtual
                    );

                    await page.save();
                    return res(result.data);
                },
                error: rej,
            });
        });
    }
}
