import { Observable } from 'rxjs';
import {
    distinctUntilChanged,
    filter,
    map,
    shareReplay,
    startWith
} from 'rxjs';
import { overwritable } from '../../overwritable.ts';
import { getDocumentDataOfRxChangeEvent } from '../../rx-change-event.ts';
import {
    basePrototype,
    createRxDocumentConstructor
} from '../../rx-document.ts';
import {
    newRxError,
    newRxTypeError
} from '../../rx-error.ts';
import {
    getWrappedStorageInstance,
    getWrittenDocumentsFromBulkWriteResponse,
    writeSingle
} from '../../rx-storage-helper.ts';
import type {
    EventBulk,
    LocalDocumentModifyFunction,
    LocalDocumentParent,
    RxChangeEvent,
    RxChangeEventBulk,
    RxCollection,
    RxDatabase,
    RxDocument,
    RxDocumentData,
    RxLocalDocument,
    RxLocalDocumentData,
    RxStorageChangeEvent
} from '../../types/';
import {
    ensureNotFalsy,
    flatClone,
    getFromMapOrThrow,
    getProperty,
    RXJS_SHARE_REPLAY_DEFAULTS
} from '../../plugins/utils/index.ts';
import {
    createLocalDocumentStorageInstance,
    getLocalDocStateByParent,
    LOCAL_DOC_STATE_BY_PARENT,
    LOCAL_DOC_STATE_BY_PARENT_RESOLVED,
    RX_LOCAL_DOCUMENT_SCHEMA
} from './local-documents-helper.ts';
import { isRxDatabase } from '../../rx-database.ts';
import { DocumentCache } from '../../doc-cache.ts';
import { IncrementalWriteQueue } from '../../incremental-write.ts';

const RxDocumentParent = createRxDocumentConstructor() as any;

class RxLocalDocumentClass<DocData = any> extends RxDocumentParent {
    constructor(
        public readonly id: string,
        jsonData: DocData,
        public readonly parent: RxCollection | RxDatabase
    ) {
        super(null, jsonData);
    }
}



const RxLocalDocumentPrototype: any = {
    get isLocal() {
        return true;
    },

    //
    // overwrites
    //
    get allAttachments$() {
        // this is overwritten here because we cannot re-set getters on the prototype
        throw newRxError('LD1', {
            document: this
        });
    },
    get primaryPath() {
        return 'id';
    },
    get primary() {
        return this.id;
    },
    get $(): Observable<RxLocalDocument<any, any>> {
        const _this: RxLocalDocumentClass = this as any;
        const state = getFromMapOrThrow(LOCAL_DOC_STATE_BY_PARENT_RESOLVED, this.parent);

        const id = this.primary;
        return _this.parent.eventBulks$.pipe(
            filter((bulk: RxChangeEventBulk<any>) => !!bulk.isLocal),
            map((bulk: RxChangeEventBulk<any>) => bulk.events.find((ev: RxStorageChangeEvent<any>) => ev.documentId === id)),
            filter((event: RxStorageChangeEvent<any> | undefined) => !!event),
            map((changeEvent: any) => getDocumentDataOfRxChangeEvent(ensureNotFalsy(changeEvent))),
            startWith(state.docCache.getLatestDocumentData(this.primary)),
            distinctUntilChanged((prev: any, curr: any) => prev._rev === curr._rev),
            map((docData: any) => state.docCache.getCachedRxDocument(docData)),
            shareReplay(RXJS_SHARE_REPLAY_DEFAULTS)
        ) as Observable<any>;;
    },
    get $$(): any {
        const _this: RxLocalDocumentClass = this as any;
        const db = getRxDatabaseFromLocalDocument(_this);
        const reactivity = db.getReactivityFactory();
        return reactivity.fromObservable(
            _this.$,
            _this.getLatest()._data,
            db
        );
    },
    get deleted$$() {
        const _this: RxLocalDocumentClass = this as any;
        const db = getRxDatabaseFromLocalDocument(_this);
        const reactivity = db.getReactivityFactory();
        return reactivity.fromObservable(
            _this.deleted$,
            _this.getLatest().deleted,
            db
        );
    },
    getLatest(this: RxLocalDocument<any>): RxLocalDocument<any> {
        const state = getFromMapOrThrow(LOCAL_DOC_STATE_BY_PARENT_RESOLVED, this.parent);
        const latestDocData = state.docCache.getLatestDocumentData(this.primary);
        return state.docCache.getCachedRxDocument(latestDocData) as any;
    },
    get(this: RxDocument, objPath: string) {
        objPath = 'data.' + objPath;

        if (!this._data) {
            return undefined;
        }
        if (typeof objPath !== 'string') {
            throw newRxTypeError('LD2', {
                objPath
            });
        }

        let valueObj = getProperty(this._data, objPath);
        valueObj = overwritable.deepFreezeWhenDevMode(valueObj);
        return valueObj;
    },
    get$(this: RxDocument, objPath: string) {
        objPath = 'data.' + objPath;

        if (overwritable.isDevMode()) {
            if (objPath.includes('.item.')) {
                throw newRxError('LD3', {
                    objPath
                });
            }
            if (objPath === this.primaryPath) {
                throw newRxError('LD4');
            }
        }
        return this.$
            .pipe(
                map((localDocument: any) => localDocument._data),
                map((data: any) => getProperty(data, objPath)),
                distinctUntilChanged()
            );
    },
    get$$(this: RxDocument, objPath: string) {
        const db = getRxDatabaseFromLocalDocument(this as any);
        const reactivity = db.getReactivityFactory();
        return reactivity.fromObservable(
            this.get$(objPath),
            this.getLatest().get(objPath),
            db
        );
    },
    async incrementalModify<DocData>(
        this: RxLocalDocument<any, DocData>,
        mutationFunction: LocalDocumentModifyFunction<any>
    ) {
        const state = await getLocalDocStateByParent(this.parent);

        return state.incrementalWriteQueue.addWrite(
            this._data as any,
            async (docData) => {
                docData.data = await mutationFunction(docData.data, this);
                return docData;
            }
        ).then(result => state.docCache.getCachedRxDocument(result as any));
    },
    incrementalPatch(patch: Partial<any>) {
        return this.incrementalModify((docData: any) => {
            Object
                .entries(patch)
                .forEach(([k, v]) => {
                    docData[k] = v;
                });
            return docData;
        });
    },
    async _saveData(this: RxLocalDocument<any>, newData: RxDocumentData<RxLocalDocumentData>) {
        const state = await getLocalDocStateByParent(this.parent);
        const oldData: RxDocumentData<RxLocalDocumentData> = this._data;
        newData.id = (this as any).id;
        const writeRows = [{
            previous: oldData,
            document: newData
        }];
        return state.storageInstance.bulkWrite(writeRows, 'local-document-save-data')
            .then((res) => {
                if (res.error[0]) {
                    throw res.error[0];
                }
                const success = getWrittenDocumentsFromBulkWriteResponse(this.collection.schema.primaryPath, writeRows, res)[0];
                newData = flatClone(newData);
                newData._rev = success._rev;
            });
    },

    async remove(this: RxLocalDocument<any>): Promise<RxLocalDocument<any>> {
        const state = await getLocalDocStateByParent(this.parent);
        const writeData = flatClone(this._data);
        writeData._deleted = true;
        return writeSingle(state.storageInstance, {
            previous: this._data,
            document: writeData
        }, 'local-document-remove')
            .then((writeResult) => state.docCache.getCachedRxDocument(writeResult) as any);
    }
};



let INIT_DONE = false;
const _init = () => {
    if (INIT_DONE) return;
    else INIT_DONE = true;

    // add functions of RxDocument
    const docBaseProto = basePrototype;
    const props = Object.getOwnPropertyNames(docBaseProto);
    props.forEach(key => {
        const exists = Object.getOwnPropertyDescriptor(RxLocalDocumentPrototype, key);
        if (exists) return;
        const desc: any = Object.getOwnPropertyDescriptor(docBaseProto, key);
        Object.defineProperty(RxLocalDocumentPrototype, key, desc);
    });


    /**
     * Overwrite things that do not work on local documents
     * with a throwing function.
     */
    const getThrowingFun = (k: string) => () => {
        throw newRxError('LD6', {
            functionName: k
        });
    };
    [
        'populate',
        'update',
        'putAttachment',
        'putAttachmentBase64',
        'getAttachment',
        'allAttachments'
    ].forEach((k: string) => RxLocalDocumentPrototype[k] = getThrowingFun(k));
};



export function createRxLocalDocument<DocData>(
    data: RxDocumentData<RxLocalDocumentData<DocData>>,
    parent: any
): RxLocalDocument<DocData> {
    _init();
    const newDoc = new RxLocalDocumentClass(data.id, data, parent);
    Object.setPrototypeOf(newDoc, RxLocalDocumentPrototype);
    newDoc.prototype = RxLocalDocumentPrototype;
    return newDoc as any;
}


export function getRxDatabaseFromLocalDocument(doc: RxLocalDocument<any> | RxLocalDocumentClass) {
    const parent = doc.parent;
    if (isRxDatabase(parent)) {
        return parent;
    } else {
        return (parent as RxCollection).database;
    }
}

export function createLocalDocStateByParent(parent: LocalDocumentParent): void {
    const database: RxDatabase = parent.database ? parent.database : parent as any;
    const collectionName = parent.database ? parent.name : '';
    const statePromise = (async () => {
        let storageInstance = await createLocalDocumentStorageInstance(
            database.token,
            database.storage,
            database.name,
            collectionName,
            database.instanceCreationOptions,
            database.multiInstance
        );
        storageInstance = getWrappedStorageInstance(
            database,
            storageInstance,
            RX_LOCAL_DOCUMENT_SCHEMA
        );

        const docCache = new DocumentCache<RxLocalDocumentData, {}>(
            'id',
            database.eventBulks$.pipe(
                filter((changeEventBulk: RxChangeEventBulk<any>) => {
                    let ret = false;
                    if (
                        // parent is database
                        (
                            collectionName === '' &&
                            !changeEventBulk.collectionName
                        ) ||
                        // parent is collection
                        (
                            collectionName !== '' &&
                            changeEventBulk.collectionName === collectionName
                        )
                    ) {
                        ret = true;
                    }
                    return ret && changeEventBulk.isLocal;
                }),
                map((b: RxChangeEventBulk<any>) => b.events)
            ),
            docData => createRxLocalDocument(docData, parent) as any
        );

        const incrementalWriteQueue = new IncrementalWriteQueue(
            storageInstance,
            'id',
            () => { },
            () => { }
        );

        /**
         * Emit the changestream into the collections change stream
         */
        const databaseStorageToken = await database.storageToken;
        const subLocalDocs = storageInstance.changeStream().subscribe((eventBulk: EventBulk<RxStorageChangeEvent<RxLocalDocumentData>, any>) => {
            const events = new Array(eventBulk.events.length);
            const rawEvents = eventBulk.events;
            const collectionName = parent.database ? parent.name : undefined;
            for (let index = 0; index < rawEvents.length; index++) {
                const event = rawEvents[index];
                events[index] = {
                    documentId: event.documentId,
                    collectionName,
                    isLocal: true,
                    operation: event.operation,
                    documentData: overwritable.deepFreezeWhenDevMode(event.documentData) as any,
                    previousDocumentData: overwritable.deepFreezeWhenDevMode(event.previousDocumentData) as any
                };
            }
            const changeEventBulk: RxChangeEventBulk<RxLocalDocumentData> = {
                id: eventBulk.id,
                isLocal: true,
                internal: false,
                collectionName: parent.database ? parent.name : undefined,
                storageToken: databaseStorageToken,
                events,
                databaseToken: database.token,
                checkpoint: eventBulk.checkpoint,
                context: eventBulk.context
            };
            database.$emit(changeEventBulk);
        });
        parent._subs.push(subLocalDocs);

        const state = {
            database,
            parent,
            storageInstance,
            docCache,
            incrementalWriteQueue
        };
        LOCAL_DOC_STATE_BY_PARENT_RESOLVED.set(parent, state);
        return state;
    })();
    LOCAL_DOC_STATE_BY_PARENT.set(parent, statePromise);
}

