import { Injectable } from "@angular/core";
import {
    extend,
    Jsonable,
    NgLifeCycleEvents,
    Payload,
    PayloadEnvelope,
    TupleOfflineStorageNameService,
    TupleOfflineStorageService,
    TupleSelector,
    TupleStorageBatchSaveArguments,
    TupleStorageFactoryService,
    VortexService,
    VortexStatusService,
} from "@synerty/vortexjs";

import {
    docDbCacheStorageName,
    docDbFilt,
    docDbTuplePrefix,
} from "../PluginNames";

import { Observable, Subject } from "rxjs";
import { EncodedDocumentChunkTuple } from "./EncodedDocumentChunkTuple";
import { DocumentUpdateDateTuple } from "./DocumentUpdateDateTuple";
import { DocumentTuple } from "../../DocumentTuple";
import { DocDbTupleService } from "../DocDbTupleService";
import { DocDbDocumentTypeTuple } from "../../DocDbDocumentTypeTuple";
import { PrivateDocumentLoaderStatusTuple } from "./PrivateDocumentLoaderStatusTuple";

import { OfflineConfigTuple } from "../tuples/OfflineConfigTuple";
import { DocDbModelSetTuple } from "../../DocDbModelSetTuple";

// ----------------------------------------------------------------------------

export interface DocumentResultI {
    [key: string]: DocumentTuple;
}

// ----------------------------------------------------------------------------

let clientDocumentWatchUpdateFromDeviceFilt = extend(
    { key: "clientDocumentWatchUpdateFromDevice" },
    docDbFilt
);

const cacheAll = "cacheAll";

// ----------------------------------------------------------------------------
/** DocumentChunkTupleSelector
 *
 * This is just a short cut for the tuple selector
 */

class DocumentChunkTupleSelector extends TupleSelector {
    constructor(private chunkKey: string) {
        super(docDbTuplePrefix + "DocumentChunkTuple", { key: chunkKey });
    }

    toOrderedJsonStr(): string {
        return this.chunkKey;
    }
}

// ----------------------------------------------------------------------------
/** UpdateDateTupleSelector
 *
 * This is just a short cut for the tuple selector
 */
class UpdateDateTupleSelector extends TupleSelector {
    constructor() {
        super(DocumentUpdateDateTuple.tupleName, {});
    }
}

// ----------------------------------------------------------------------------
/** hash method
 */
let BUCKET_COUNT = 8192;

function keyChunk(modelSetKey: string, key: string): string {
    /** Object ID Chunk
     
     This method creates an int from 0 to MAX, representing the hash bucket for this
     object Id.
     
     This is simple, and provides a reasonable distribution
     
     @param modelSetKey: The key of the model set that the documents are in
     @param key: The key of the document to get the chunk key for
     
     @return: The bucket / chunkKey where you'll find the object with this ID
     
     */
    if (key == null || key.length == 0)
        throw new Error("key is None or zero length");

    let bucket = 0;

    for (let i = 0; i < key.length; i++) {
        bucket = (bucket << 5) - bucket + key.charCodeAt(i);
        bucket |= 0; // Convert to 32bit integer
    }

    bucket = bucket & (BUCKET_COUNT - 1);

    return `${modelSetKey}.${bucket}`;
}

// ----------------------------------------------------------------------------
/** Document Cache
 *
 * This class has the following responsibilities:
 *
 * 1) Maintain a local storage of the index
 *
 * 2) Return DispKey docDbs based on the index.
 *
 */
@Injectable()
export class PrivateDocumentLoaderService extends NgLifeCycleEvents {
    private UPDATE_CHUNK_FETCH_SIZE = 5;
    private OFFLINE_CHECK_PERIOD_MS = 15 * 60 * 1000; // 15 minutes

    private index = new DocumentUpdateDateTuple();
    private askServerChunks: DocumentUpdateDateTuple[] = [];

    private _hasLoaded = false;

    private _hasLoadedSubject = new Subject<void>();
    private storage: TupleOfflineStorageService;

    private _statusSubject = new Subject<PrivateDocumentLoaderStatusTuple>();
    private _status = new PrivateDocumentLoaderStatusTuple();

    private objectTypesByIds: { [id: number]: DocDbDocumentTypeTuple } = {};
    private _hasDocTypeLoaded = false;

    private modelSetByIds: { [id: number]: DocDbModelSetTuple } = {};
    private _hasModelSetLoaded = false;

    private offlineConfig: OfflineConfigTuple = new OfflineConfigTuple();

    constructor(
        private vortexService: VortexService,
        private vortexStatusService: VortexStatusService,
        storageFactory: TupleStorageFactoryService,
        private tupleService: DocDbTupleService
    ) {
        super();

        this.tupleService.offlineObserver
            .subscribeToTupleSelector(
                new TupleSelector(OfflineConfigTuple.tupleName, {}),
                false,
                false,
                true
            )
            .takeUntil(this.onDestroyEvent)
            .filter((v) => v.length != 0)
            .subscribe((tuples: OfflineConfigTuple[]) => {
                this.offlineConfig = tuples[0];
                if (this.offlineConfig.cacheChunksForOffline)
                    this.initialLoad();
                this._notifyStatus();
            });

        let objTypeTs = new TupleSelector(DocDbDocumentTypeTuple.tupleName, {});
        this.tupleService.offlineObserver
            .subscribeToTupleSelector(objTypeTs)
            .takeUntil(this.onDestroyEvent)
            .subscribe((tuples: DocDbDocumentTypeTuple[]) => {
                this.objectTypesByIds = {};
                for (let item of tuples) {
                    this.objectTypesByIds[item.id] = item;
                }
                this._hasDocTypeLoaded = true;
                this._notifyReady();
            });

        let modelSetTs = new TupleSelector(DocDbModelSetTuple.tupleName, {});
        this.tupleService.offlineObserver
            .subscribeToTupleSelector(modelSetTs)
            .takeUntil(this.onDestroyEvent)
            .subscribe((tuples: DocDbModelSetTuple[]) => {
                this.modelSetByIds = {};
                for (let item of tuples) {
                    this.modelSetByIds[item.id] = item;
                }
                this._hasModelSetLoaded = true;
                this._notifyReady();
            });

        this.storage = new TupleOfflineStorageService(
            storageFactory,
            new TupleOfflineStorageNameService(docDbCacheStorageName)
        );

        this.setupVortexSubscriptions();
        this._notifyStatus();

        // Check for updates every so often
        Observable.interval(this.OFFLINE_CHECK_PERIOD_MS)
            .takeUntil(this.onDestroyEvent)
            .subscribe(() => this.askServerForUpdates());
    }

    isReady(): boolean {
        return this._hasLoaded;
    }

    isReadyObservable(): Observable<void> {
        return this._hasLoadedSubject;
    }

    statusObservable(): Observable<PrivateDocumentLoaderStatusTuple> {
        return this._statusSubject;
    }

    status(): PrivateDocumentLoaderStatusTuple {
        return this._status;
    }

    /** Get Documents
     *
     * Get the objects with matching keywords from the index..
     *
     */
    getDocuments(
        modelSetKey: string,
        keys: string[]
    ): Promise<DocumentResultI> {
        if (keys == null || keys.length == 0) {
            throw new Error("We've been passed a null/empty keys");
        }

        // If there is no offline support, or we're online
        if (
            !this.offlineConfig.cacheChunksForOffline ||
            this.vortexStatusService.snapshot.isOnline
        ) {
            let ts = new TupleSelector(DocumentTuple.tupleName, {
                modelSetKey: modelSetKey,
                keys: keys,
            });

            let isOnlinePromise: any = this.vortexStatusService.snapshot
                .isOnline
                ? Promise.resolve()
                : this.vortexStatusService.isOnline
                      .filter((online) => online)
                      .first()
                      .toPromise();

            return isOnlinePromise
                .then(() =>
                    this.tupleService.offlineObserver.pollForTuples(ts, false)
                )
                .then((docs: DocumentTuple[]) =>
                    this._populateAndIndexObjectTypes(docs)
                );
        }

        // If we do have offline support
        if (this.isReady())
            return this.getDocumentsWhenReady(modelSetKey, keys).then((docs) =>
                this._populateAndIndexObjectTypes(docs)
            );

        return this.isReadyObservable()
            .first()
            .toPromise()
            .then(() => this.getDocumentsWhenReady(modelSetKey, keys))
            .then((docs) => this._populateAndIndexObjectTypes(docs));
    }

    private _notifyReady(): void {
        if (
            this._hasDocTypeLoaded &&
            this._hasModelSetLoaded &&
            this._hasLoaded
        )
            this._hasLoadedSubject.next();
    }

    private _notifyStatus(): void {
        this._status.cacheForOfflineEnabled =
            this.offlineConfig.cacheChunksForOffline;
        this._status.initialLoadComplete = this.index.initialLoadComplete;

        this._status.loadProgress = Object.keys(
            this.index.updateDateByChunkKey
        ).length;
        for (let chunk of this.askServerChunks)
            this._status.loadProgress -= Object.keys(
                chunk.updateDateByChunkKey
            ).length;

        this._statusSubject.next(this._status);
    }

    /** Initial load
     *
     * Load the dates of the index buckets and ask the server if it has any updates.
     */
    private initialLoad(): void {
        this.storage
            .loadTuples(new UpdateDateTupleSelector())
            .then((tuplesAny: any[]) => {
                let tuples: DocumentUpdateDateTuple[] = tuplesAny;
                if (tuples.length != 0) {
                    this.index = tuples[0];

                    if (this.index.initialLoadComplete) {
                        this._hasLoaded = true;
                        this._notifyReady();
                    }
                }

                this.askServerForUpdates();
                this._notifyStatus();
            });

        this._notifyStatus();
    }

    private setupVortexSubscriptions(): void {
        // Services don't have destructors, I'm not sure how to unsubscribe.
        this.vortexService
            .createEndpointObservable(
                this,
                clientDocumentWatchUpdateFromDeviceFilt
            )
            .takeUntil(this.onDestroyEvent)
            .subscribe((payloadEnvelope: PayloadEnvelope) => {
                this.processDocumentsFromServer(payloadEnvelope);
            });

        // If the vortex service comes back online, update the watch grids.
        this.vortexStatusService.isOnline
            .filter((isOnline) => isOnline == true)
            .takeUntil(this.onDestroyEvent)
            .subscribe(() => this.askServerForUpdates());
    }

    private areWeTalkingToTheServer(): boolean {
        return (
            this.offlineConfig.cacheChunksForOffline &&
            this.vortexStatusService.snapshot.isOnline
        );
    }

    /** Ask Server For Updates
     *
     * Tell the server the state of the chunks in our index and ask if there
     * are updates.
     *
     */
    private askServerForUpdates() {
        if (!this.areWeTalkingToTheServer()) return;

        // If we're still caching, then exit
        if (this.askServerChunks.length != 0) {
            this.askServerForNextUpdateChunk();
            return;
        }

        this.tupleService.observer
            .pollForTuples(new UpdateDateTupleSelector())
            .then((tuplesAny: any) => {
                let serverIndex: DocumentUpdateDateTuple = tuplesAny[0];
                let keys = Object.keys(serverIndex.updateDateByChunkKey);
                let keysNeedingUpdate: string[] = [];

                this._status.loadTotal = keys.length;

                // Tuples is an array of strings
                for (let chunkKey of keys) {
                    if (
                        !this.index.updateDateByChunkKey.hasOwnProperty(
                            chunkKey
                        )
                    ) {
                        this.index.updateDateByChunkKey[chunkKey] = null;
                        keysNeedingUpdate.push(chunkKey);
                    } else if (
                        this.index.updateDateByChunkKey[chunkKey] !=
                        serverIndex.updateDateByChunkKey[chunkKey]
                    ) {
                        keysNeedingUpdate.push(chunkKey);
                    }
                }
                this.queueChunksToAskServer(keysNeedingUpdate);
            });
    }

    /** Queue Chunks To Ask Server
     *
     */
    private queueChunksToAskServer(keysNeedingUpdate: string[]) {
        if (!this.areWeTalkingToTheServer()) return;

        this.askServerChunks = [];

        let count = 0;
        let indexChunk = new DocumentUpdateDateTuple();

        for (let key of keysNeedingUpdate) {
            indexChunk.updateDateByChunkKey[key] =
                this.index.updateDateByChunkKey[key];
            count++;

            if (count == this.UPDATE_CHUNK_FETCH_SIZE) {
                this.askServerChunks.push(indexChunk);
                count = 0;
                indexChunk = new DocumentUpdateDateTuple();
            }
        }

        if (count) this.askServerChunks.push(indexChunk);

        this.askServerForNextUpdateChunk();

        this._status.lastCheck = new Date();
    }

    private askServerForNextUpdateChunk() {
        if (!this.areWeTalkingToTheServer()) return;

        if (this.askServerChunks.length == 0) return;

        let indexChunk: DocumentUpdateDateTuple = this.askServerChunks.pop();
        let filt = extend({}, clientDocumentWatchUpdateFromDeviceFilt);
        filt[cacheAll] = true;
        let pl = new Payload(filt, [indexChunk]);
        this.vortexService.sendPayload(pl);

        this._status.lastCheck = new Date();
        this._notifyStatus();
    }

    /** Process Documentes From Server
     *
     * Process the grids the server has sent us.
     */
    private processDocumentsFromServer(payloadEnvelope: PayloadEnvelope) {
        if (payloadEnvelope.result != null && payloadEnvelope.result != true) {
            console.log(`ERROR: ${payloadEnvelope.result}`);
            return;
        }

        payloadEnvelope
            .decodePayload()
            .then((payload: Payload) => this.storeDocumentPayload(payload))
            .then(() => {
                if (this.askServerChunks.length == 0) {
                    this.index.initialLoadComplete = true;
                    this._hasLoaded = true;
                    this._hasLoadedSubject.next();
                } else if (payloadEnvelope.filt[cacheAll] == true) {
                    this.askServerForNextUpdateChunk();
                }
            })
            .then(() => this._notifyStatus())
            .catch(
                (e) => `DocumentCache.processDocumentsFromServer failed: ${e}`
            );
    }

    private storeDocumentPayload(payload: Payload) {
        let tuplesToSave: EncodedDocumentChunkTuple[] = <
            EncodedDocumentChunkTuple[]
        >payload.tuples;
        if (tuplesToSave.length == 0) return;

        // 2) Store the index
        this.storeDocumentChunkTuples(tuplesToSave)
            .then(() => {
                // 3) Store the update date

                for (let docDbIndex of tuplesToSave) {
                    this.index.updateDateByChunkKey[docDbIndex.chunkKey] =
                        docDbIndex.lastUpdate;
                }

                return this.storage.saveTuples(new UpdateDateTupleSelector(), [
                    this.index,
                ]);
            })
            .catch((e) =>
                console.log(`DocumentCache.storeDocumentPayload: ${e}`)
            );
    }

    /** Store Index Bucket
     * Stores the index bucket in the local db.
     */
    private storeDocumentChunkTuples(
        tuplesToSave: EncodedDocumentChunkTuple[]
    ): Promise<void> {
        const Selector = DocumentChunkTupleSelector;
        const batchStore: TupleStorageBatchSaveArguments[] = [];

        for (const tuple of tuplesToSave) {
            batchStore.push({
                tupleSelector: new Selector(tuple.chunkKey),
                vortexMsg: tuple.encodedData,
            });
        }

        return this.storage.batchSaveTuplesEncoded(batchStore);
    }

    /** Get Documents When Ready
     *
     * Get the objects with matching keywords from the index..
     *
     */
    private getDocumentsWhenReady(
        modelSetKey: string,
        keys: string[]
    ): Promise<DocumentTuple[]> {
        let keysByChunkKey: { [key: string]: string[] } = {};
        let chunkKeys: string[] = [];

        for (let key of keys) {
            let chunkKey: string = keyChunk(modelSetKey, key);
            if (keysByChunkKey[chunkKey] == null) keysByChunkKey[chunkKey] = [];
            keysByChunkKey[chunkKey].push(key);
            chunkKeys.push(chunkKey);
        }

        let promises = [];
        for (let chunkKey of chunkKeys) {
            let keysForThisChunk = keysByChunkKey[chunkKey];
            promises.push(this.getDocumentsForKeys(keysForThisChunk, chunkKey));
        }

        return Promise.all(promises).then(
            (promiseResults: DocumentTuple[][]) => {
                let objects: DocumentTuple[] = [];
                for (let results of promiseResults) {
                    for (let result of results) {
                        objects.push(result);
                    }
                }
                return objects;
            }
        );
    }

    /** Get Documents for Object ID
     *
     * Get the objects with matching keywords from the index..
     *
     */
    private getDocumentsForKeys(
        keys: string[],
        chunkKey: string
    ): Promise<DocumentTuple[]> {
        if (!this.index.updateDateByChunkKey.hasOwnProperty(chunkKey)) {
            console.log(`ObjectIDs: ${keys} doesn't appear in the index`);
            return Promise.resolve([]);
        }

        let retPromise: any;
        retPromise = this.storage
            .loadTuplesEncoded(new DocumentChunkTupleSelector(chunkKey))
            .then((vortexMsg: string) => {
                if (vortexMsg == null) {
                    return [];
                }

                return Payload.fromEncodedPayload(vortexMsg)
                    .then((payload: Payload) => JSON.parse(<any>payload.tuples))
                    .then((chunkData: { [key: number]: string }) => {
                        let foundDocuments: DocumentTuple[] = [];

                        for (let key of keys) {
                            // Find the keyword, we're just iterating
                            if (!chunkData.hasOwnProperty(key)) {
                                console.log(
                                    `WARNING: Document ${key} is missing from index,` +
                                        ` chunkKey ${chunkKey}`
                                );
                                continue;
                            }

                            // Reconstruct the data
                            let objectProps: {} = JSON.parse(chunkData[key]);
                            objectProps = new Jsonable().fromJsonField(
                                objectProps
                            );

                            // Get out the object type
                            let thisDocumentTypeId = objectProps["_dtid"];
                            delete objectProps["_dtid"];

                            // Get out the object type
                            let thisModelSetId = objectProps["_msid"];
                            delete objectProps["_msid"];

                            // Create the new object
                            let newObject = new DocumentTuple();
                            foundDocuments.push(newObject);

                            newObject.key = key;
                            newObject.modelSet = new DocDbModelSetTuple();
                            newObject.modelSet.id = thisModelSetId;
                            newObject.documentType =
                                new DocDbDocumentTypeTuple();
                            newObject.documentType.id = thisDocumentTypeId;
                            newObject.document = objectProps;
                        }

                        return foundDocuments;
                    });
            });

        return retPromise;
    }

    private _populateAndIndexObjectTypes(
        docs: DocumentTuple[]
    ): DocumentResultI {
        let objects: { [key: string]: DocumentTuple } = {};
        for (let doc of docs) {
            objects[doc.key] = doc;
            doc.documentType = this.objectTypesByIds[doc.documentType.id];
            doc.modelSet = this.modelSetByIds[doc.modelSet.id];
        }
        return objects;
    }
}
