import { BimChangeLayer, BimChangeType, LayerCompatibleChange } from '../loader/bim-api-client';

import { HttpResponseType, HttpStatusCode } from '../http';
import { Fail, Ok, isFailure, Failure } from '../fail';
import {
    MergableObject,
    MergableSet,
    MergableObjectWithState,
    MergableObjectState,
    ImmutableMergableObjectWithState
} from '../MergableSet';

import { LayerDefinition } from '../loader/LayerDefinitions';
import { Permission, Permissions } from '../loader/Permission';
import { enhanceChangeDtoWithCalculatedProperties } from '../loader/client/BimTwinfinityApiClient';
import { BimApi } from '../BimApi';
import { LayerAttachmentsApi } from './LayerAttachmentsApi';

/**
 * Methods to serialize/deserialize from one representation to another.
 */
export interface Serialize<
    DataTransferObject,
    DeserializedType extends MergableObject<DeserializedType>,
    LAYER extends LayerInterface<DeserializedType>
> {
    from(dto: DataTransferObject, layer: LAYER): DeserializedType[];
    to(o: DeserializedType[], layer: LAYER): DataTransferObject;
}

/**
 * Represents a layer failure.
 */
export type LayerFailure = Failure<{
    /**
     * If specified then the failure was due to a http request failure.
     */
    httpStatus?: number;
    /**
     * Gives reason for failure
     */
    reason: string;
}>;

/**
 * A layer can contain any type of object data structure.
 * When objects are added, deleted or modified events on the corresponding objects are raised. Since layer objects are divided
 * into local and remote objects. Remote objects are those that are retrieved from the backend.
 */
export interface LayerInterface<P extends MergableObject<P>> {
    /**
     * Id of the layer
     */

    readonly id: string;

    /**
     * `true` if a layer exists and if {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge}
     * has been called successfully at least once.
     */

    readonly isLoaded: boolean;

    /**
     * Get the version of the currently loaded layer. [-1] if no layer has yet been loaded.
     */
    readonly version: number;

    /**
     * Get the name of the layer. Returns undefined if no layer has been loaded.
     */

    readonly name: string | undefined;

    /**
     * Number of objects with conflicts in the layer. A conflict occurs if there is a mismatch between the local
     * and the remote representation of the layer. Conflicts can only occur when {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge}
     * is called.
     */

    readonly conflictCount: number;

    /**
     * Number of objects in layer not marked with state {@link MergableObjectState.Unchanged}.
     */
    readonly changeCount: number;

    /**
     * The layer definition.
     */
    readonly definition: LayerDefinition;

    /**
     * Permissions user has on layer.
     */
    readonly permission: Permissions;

    /**
     * Get the objects currently in the layer
     */

    readonly objects: IterableIterator<ImmutableMergableObjectWithState<P>>;

    /**
     * Conflicts in the layer. A conflict occurs if there is a mismatch between the local
     * and the remote representation of a object in the layer. Conflicts can only occur when {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge}
     * is called.
     */

    readonly conflicts: ImmutableMergableObjectWithState<P>[];

    /**
     * Get a specific object in the layer. Both the local and the remote representation
     * of the object is returned
     * @param id id of the object to get. Returns undefined if no such object exists.
     */

    get(id: string): MergableObjectWithState<P> | undefined;

    /**
     * Deletes a object
     * @param p Id of object to delete
     * @returns `true` if object was deleted. Otherwise `false`
     */
    delete(p: P | string): boolean;

    /**
     * Add an object. If object already exists it will not be added. That is if an item where
     * {@link MergableObjectProps.id} already exists.
     * @param o Object to add
     * @returns `true` if object was added, otherwise `false`.
     */
    add(o: P): boolean;

    /**
     * Updates an object that already exists.
     * @param id Id of object to update
     * @param updateAction function called with {@link MergableObject} instance of specified id.
     * Caller should make any required modfifications to the object in this function.
     * @returns `true` if object did exist, otherwise `false`.
     */
    update(id: string, updateAction: (existingItem: P) => void): boolean;

    /**
     * Saves all objects in {@link objects} and retrieves any added, modified layer objects
     * made in backend (Remote) at the same time. An attempt is then made to merge the object from
     * the backend (Remote) into the local (browser) objects.
     * @param overwriteLatestVersion If `true` then latest version is overwritten (if save was successful).
     * @return Number of conflicts. If > 0 then the operation was not successful. The conflicts must be resolved before
     * a new attempt is made. If a {@link LayerFailure} is returned then it signifies that some unexpected error
     * occured. Inspect the contents to determine the cause.
     */
    saveAndMerge(overwriteLatestVersion?: boolean): Promise<number | LayerFailure>;

    /**
     * Clear the layer.
     */
    clear(): void;

    /**
     * Loads all existing (remote) objects from the backend and attempts to merge them into {@link objects}.
     * This may result in conflicts between the local and the remote objects. A typical example is if the same
     * object has been modified locally but also remotely (in the backend.)
     */

    loadAndMerge(): Promise<number | LayerFailure>;

    /**
     * Provides access to the layer attachments API ({@link LayerAttachmentsApi})
     * Use methods on it to get, add, update and delete attachments for this layer.
     */
    attachments: LayerAttachmentsApi;
}

/**
 * A layer is attached to a {@link BimChangeBase} instance. A layer can contain any type of object data structure.
 * When objects are added, deleted or modified events on the corresponding objects are raised. Since layer objects are divided
 * into local and remote objects. Remote objects are those that are retrieved from the backend.
 */

export class Layer<Change extends LayerCompatibleChange, P extends MergableObject<P>, LayerDataTransferObject>
    implements LayerInterface<P>
{
    private _layerOnClient?: BimChangeLayer;
    private readonly _expectedLayerId: string;
    private readonly _layerObjects = new MergableSet<P>();

    public attachments: LayerAttachmentsApi;

    /**
     *
     * @param _api BimCoreApi instance
     * @param attachedTo Change this layer is attached to
     * @param layerFormat Format of layer. Example: "sensors"
     * @param serialize Methods to transform layer objects into their DTO counterparts (data transfer object).
     * DTO's are what is actually is stored in the layer.
     */

    public constructor(
        private readonly _api: BimApi,
        public readonly attachedTo: Change,
        public readonly layerFormat: string,
        private readonly serialize: Serialize<LayerDataTransferObject, P, LayerInterface<P>>
    ) {
        // Layer id is always on this format in the database. Hence we know beforehand
        // what it will look like if the layer actually exists
        this._expectedLayerId = `${attachedTo.id}.${layerFormat}`;
        this.attachments = new LayerAttachmentsApi(this._api.backend, this);
    }

    /**
     * Access to the layer instance that is currently loaded. If no layer is loaded then an exception is thrown.
     * This is the representation of the layer that is stored in the backend.
     * {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge} will change the instance of the layer so
     * do not store a reference to it and expect it to be valid after a call to these methods.
     */
    public get layer(): BimChangeLayer {
        if (!this._layerOnClient) throw new Error('Layer is not loaded. Call saveAndMerge or loadAndMerge first.');
        return this._layerOnClient;
    }

    /**
     * Id of the layer
     */

    public get id(): string {
        return this._expectedLayerId;
    }

    /**
     * `true` if a layer exists and if {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge}
     * has been called successfully at least once.
     */

    public get isLoaded(): boolean {
        return !!this._layerOnClient;
    }

    /**
     * Get the version of the currently loaded layer. [-1] if no layer has yet been loaded.
     */

    public get version(): number {
        return this._layerOnClient?.version ?? -1;
    }

    /**
     * Get the name of the layer. Returns undefined if no layer has been loaded.
     */

    public get name(): string | undefined {
        return this._layerOnClient?.name;
    }

    /**
     * Number of objects with conflicts in the layer. A conflict occurs if there is a mismatch between the local
     * and the remote representation of the layer. Conflicts can only occur when {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge}
     * is called.
     */

    public get conflictCount(): number {
        return this._layerObjects.conflictCount;
    }

    /**
     * Number of objects in layer not marked with state {@link MergableObjectState.Unchanged}.
     */
    public get changeCount(): number {
        return this._layerObjects.changeCount;
    }

    /**
     * The layer definition.
     */
    public get definition(): LayerDefinition {
        return this.attachedTo.availableLayerDefinitions.get(this.layerFormat);
    }

    /**
     * Permissions user has on layer.
     */
    public get permission(): Permissions {
        return this._layerOnClient?.permissions ?? this.definition;
    }

    /**
     * Get the objects currently in the layer
     */

    public get objects(): IterableIterator<ImmutableMergableObjectWithState<P>> {
        return this._layerObjects.objects;
    }

    /**
     * Conflicts in the layer. A conflict occurs if there is a mismatch between the local
     * and the remote representation of an object in the layer. Conflicts can only occur when {@link Layer.saveAndMerge} or {@link Layer.loadAndMerge}
     * is called.
     */

    public get conflicts(): ImmutableMergableObjectWithState<P>[] {
        return this._layerObjects.conflicts;
    }

    /**
     * Get a specific object in the layer. Both the local and the remote representation
     * of the object are returned
     * @param id id of the object to get. Returns undefined if no such object exists.
     */

    public get(id: string): MergableObjectWithState<P> | undefined {
        return this._layerObjects.get(id);
    }

    /**
     * Deletes an object. Requires user to have {@link Access.delete} on layer. {@link permission} should therefore be validated before method call to ensure that no exception is generated.
     * @param p Id of object to delete
     * @returns `true` if object was deleted. Otherwise `false`
     */
    public delete(p: P | string): boolean {
        this.throwIfNoPermissionsAreSatisfied('delete');
        return this._layerObjects.delete(p);
    }

    /**
     * Adds an object. If an object with the same {@link MergableObjectProps.id} already exists, no further object will be added.
     * Requires user to have {@link Access.add} on layer. {@link permission} should therefore be validated before method
     *  call to ensure that no exception is generated.
     * @param o Object to add
     * @returns `true` if object was added, otherwise `false`.
     */
    public add(o: P): boolean {
        this.throwIfNoPermissionsAreSatisfied('add');
        return this._layerObjects.add(o);
    }

    /**
     * Updates an existing object.
     * Requires user to have {@link Access.edit} on layer. {@link permission} should therefore
     * be validated before method call to ensure that no exception is generated.
     * @param id Id of object to update
     * @param updateAction function called with {@link MergableObject} instance of specified id.
     * Caller should make any required modfifications to the object in this function.
     * @returns `true` if object did exist, otherwise `false`.
     */
    public update(id: string, updateAction: (existingItem: P) => void): boolean {
        this.throwIfNoPermissionsAreSatisfied('edit');
        return this._layerObjects.update(id, updateAction);
    }

    /**
     * Saves all objects in {@link objects} and retrieves any added, modified layer objects
     * made in backend (Remote) at the same time. An attempt is then made to merge the object from
     * the backend (Remote) into the local (browser) objects.
     * Requires that user has {@link Access.view} (to add new layer) or {@link Access.edit} (to edit existing layer). {@link permission} should therefore
     * be validated before method call to ensure that no exception is generated.
     * @param overwriteLatestVersion If `true` then latest version is overwritten (if save was successful).
     * @return Number of conflicts. If > 0 then the operation was not successful. The conflicts must be resolved before
     * a new attempt is made. If a {@link LayerFailure} is returned then it signifies that some unexpected error
     * occured. Inspect the contents to determine the cause.
     */
    public async saveAndMerge(overwriteLatestVersion = false): Promise<number | LayerFailure> {
        this.throwIfNoPermissionsAreSatisfied('add', 'edit');

        if (this.conflictCount || this.changeCount === 0) {
            return Ok(this.conflictCount);
        }

        const objects = [...this._layerObjects.objects];

        // We only save the collection of objects that are not deleted.
        const notDeletedObjects = objects.filter((o) => o.state !== MergableObjectState.Deleted).map((o) => o.local);
        const dataTransferObject = this.serialize.to(notDeletedObjects, this);
        if (!this._layerOnClient) {
            // attempt to add layer. This may not work since layer may actually exist
            // in which case we will get a 409 back. If we get a 200 we know that the layer
            // did not exist and was created.

            const addLayerResponse = await this._api.backend.layers.add(this.attachedTo, {
                format: this.layerFormat,
                metadata: {},
                data: dataTransferObject
            });

            if (addLayerResponse.status === HttpStatusCode.Ok) {
                // All went well. backend objects are now equal to client objects since layer
                // was just created.
                this._layerOnClient = enhanceChangeDtoWithCalculatedProperties(await addLayerResponse.value);
                this._layerObjects.acceptChanges();
                return Ok(0);
            } else if (addLayerResponse.status === HttpStatusCode.Conflict) {
                // Layer already existed. The existing layer info is returned in the response
                // so read it and attempt to merge its contents
                const latestBackendLayer = enhanceChangeDtoWithCalculatedProperties(await addLayerResponse.value);
                let conflictCountOrFailure = await this.attemptMergeLayerContents(latestBackendLayer);
                if (!isFailure(conflictCountOrFailure) && conflictCountOrFailure === 0) {
                    // No conflicts so try to save again
                    conflictCountOrFailure = await this.saveAndMerge(overwriteLatestVersion);
                }
                return conflictCountOrFailure;
            }
            return Fail({
                httpStatus: addLayerResponse.status,
                reason: addLayerResponse.statusText
            });
        }
        // We have layer information and possibly objects on the client. Attempt to update the layer in backend
        // if we get a 409 back we have a conflict that we need to resolve
        const updateLayerResponse = await this._api.backend.layers.update(this._layerOnClient, {
            data: dataTransferObject,
            overwrite: overwriteLatestVersion
        });

        if (updateLayerResponse.status === HttpStatusCode.Ok) {
            // 200 back means we have updated the layer and no conflicts existed. Hence layer objects on client
            // now mirror layer objects on server
            this._layerOnClient = enhanceChangeDtoWithCalculatedProperties(await updateLayerResponse.value);
            this._layerObjects.acceptChanges();
            return Ok(0);
        } else if (updateLayerResponse.status === HttpStatusCode.Conflict) {
            const latestBackendLayer = await updateLayerResponse.value;
            let conflictCountOrFailure = await this.attemptMergeLayerContents(latestBackendLayer);
            if (!isFailure(conflictCountOrFailure) && conflictCountOrFailure === 0) {
                // No conflicts so try to save again
                conflictCountOrFailure = await this.saveAndMerge(overwriteLatestVersion);
            }
            return conflictCountOrFailure;
        }
        return Fail({
            httpStatus: updateLayerResponse.status,
            reason: updateLayerResponse.statusText
        });
    }

    /**
     * Clear the layer.
     */
    public clear(): void {
        this._layerOnClient = undefined;
        this._layerObjects.clear();
    }

    /**
     * Loads all existing (remote) objects from the backend and attempts to merge them into {@link objects}.
     * This may result in conflicts between the local and the remote objects. A typical example is if the same
     * object has been modified locally but also remotely (in the backend.)
     */

    public async loadAndMerge(): Promise<number | LayerFailure> {
        const layersResponse = await this._api.backend.getChanges(new URL(this.attachedTo.apiUrl), {
            id: this._expectedLayerId
        });

        if (layersResponse.status === HttpStatusCode.NotFound) {
            // Not finding a layer is expected (it simply may not exist or user does not have permission to read it.)
            return Ok(0);
        }

        if (layersResponse.status !== HttpStatusCode.Ok) {
            return Fail({ httpStatus: layersResponse.status, reason: layersResponse.statusText });
        }

        const layers = await layersResponse.value;
        const layerFromBackend = layers[0]; // We actually only get one layer back since we query back id above
        const isValidLayer =
            layerFromBackend.format === this.layerFormat &&
            layerFromBackend.type === BimChangeType.Layer &&
            layerFromBackend.id === this._expectedLayerId;
        if (!isValidLayer) {
            return Fail({
                reason: `Layer mismatch. Server responded with ${layerFromBackend.format} expected ${this.layerFormat}`
            });
        }
        if (this._layerOnClient && this._layerOnClient.etag === layerFromBackend.etag) {
            // No need to reload data since etag has not changed since last time around
            // this counts as data actually have been reloaded
            return Ok(this._layerObjects.conflictCount);
        }
        return this.attemptMergeLayerContents(layerFromBackend as BimChangeLayer);
    }

    public throwIfNoPermissionsAreSatisfied(...requiredPermissions: Permission[]): void {
        if (!this.definition.hasAny(...requiredPermissions)) {
            throw new Error(
                `Cannot add, delete or otherwise modify the ${
                    this.layerFormat
                } layer. Required permissions: ${requiredPermissions.join(', ')}.`
            );
        }
    }

    private async getLayerContents(layerFromBackend: BimChangeLayer): Promise<P[] | LayerFailure> {
        const contentResponse = await this._api.backend.get<any>(
            this.getBlobIndependentLayerContentUrl(layerFromBackend.url),
            HttpResponseType.json // TODO Hmm this should probably also be able to be binary etc. We can probably look directly att LayerDataTransferObject to determine what to use?
        );

        if (contentResponse.status !== HttpStatusCode.Ok) {
            return Fail({ httpStatus: contentResponse.status, reason: contentResponse.statusText });
        }

        return this.serialize.from(await contentResponse.value, this);
    }

    private getBlobIndependentLayerContentUrl(layerContentUrl: string): string {
        const urlWithoutBlobId = layerContentUrl.replace(/\/+[^\/]+$/, '');
        return `${urlWithoutBlobId}?timestamp=${Date.now()}`;
    }

    private async attemptMergeLayerContents(latestBackendLayer: BimChangeLayer): Promise<number | LayerFailure> {
        const layerContents = await this.getLayerContents(latestBackendLayer);

        if (!isFailure(layerContents)) {
            this._layerOnClient = latestBackendLayer;
            this._layerObjects.mergeRemotes(layerContents);
            return Ok(this.conflictCount);
        }
        return layerContents;
    }
}
