/** @internal NOTE: Internal APIs. Subject to change. Use of these APIs in production applications is not supported. */
import { BimChangeLayer, BimFolder } from '../bim-api-client';
import { Http, HttpResponseType, HttpStatusCode } from '../../http';
import { toBlob } from '../../blob';
import { Scalar, DeepImmutable, Observable } from '../babylonjs-import';
import { Fail, isFailure, Ok } from '../../fail';
import { CopyWithPartialProperties } from '../../Types';
import {
    BimChangeUploadable,
    UploadApiClient,
    UploadApiClientContentOperation,
    UploadApiClientContentOptions,
    UploadApiClientFailure,
    UploadApiTask
} from './UploadApiClient';
import { BimCoreApiClient } from './BimCoreApiClient';
import { OwnerSystem } from '../OwnerSystem';
import { enhanceChangeDtoWithCalculatedProperties } from './BimTwinfinityApiClient';

interface CreateUploadSessionResponse {
    maxChunkSize: number;
    uploadUrl: string;
    expiresUTC: Date;
}

interface UploadAcceptedResponse {
    expiresUtc: Date;
    nextExpectedRanges: string[];
}

class UploadTask<Change extends BimChangeUploadable> implements UploadApiTask<Change> {
    private static readonly _defaultChunkSize = 1024 * 1024 * 25;
    private static readonly _minimumChunkSize = 1024;
    private static readonly _cancelResponse = HttpResponseType.status([HttpStatusCode.NoContent]);
    private static readonly _uploadChunkResponseType = HttpResponseType.status([
        HttpStatusCode.Accepted,
        HttpStatusCode.Created
    ]);

    public readonly chunkSize: number;
    private readonly _requestController = new AbortController();

    private _bytesUploaded = 0;

    public readonly onChunkuploaded = new Observable<UploadApiTask<Change>>();

    public constructor(
        private readonly createUploadSession: CreateUploadSessionResponse,
        private readonly blob: Blob,
        chunkSize?: number
    ) {
        this.chunkSize = Scalar.Clamp(
            chunkSize ?? UploadTask._defaultChunkSize,
            UploadTask._minimumChunkSize,
            createUploadSession.maxChunkSize
        );
    }

    public get sizeInBytes(): number {
        return this.blob.size;
    }

    public get bytesUploaded(): number {
        return this._bytesUploaded;
    }

    public get progress(): number {
        return (this.bytesUploaded / this.sizeInBytes) * 100.0;
    }

    public async cancel(): Promise<boolean> {
        if (this._requestController.signal.aborted) {
            return true;
        }
        // Aborts any ongoing upload. Results in AbortError DOMException being thrown
        this._requestController.abort();

        await Http.delete(this.createUploadSession.uploadUrl, UploadTask._cancelResponse.empty);
        return true;
    }

    public async upload(): Promise<Change | UploadApiClientFailure> {
        if (this._requestController.signal.aborted) {
            throw new Error('Upload has alrady been canceled.');
        }

        // TODO It would be pretty simple to add compression (for example brotli) to the
        //      chunks and then set a header indicating compression. That way some files
        //      (like ifc) would upload a whole lot faster since significantly less bandwidth
        //      would be used.
        try {
            const blobCursor = { offset: 0, end: 0 };
            while (1 === 1) {
                blobCursor.end = blobCursor.offset + this.chunkSize - 1; // Expected end offset in bytes
                const blobSlice = this.blob.slice(blobCursor.offset, blobCursor.end);
                blobCursor.end = blobCursor.offset + blobSlice.size - 1; // Actual end offset in bytes

                const chunkResponse = await Http.put<UploadAcceptedResponse | Change>(
                    this.createUploadSession.uploadUrl,
                    UploadTask._uploadChunkResponseType.json,
                    {
                        body: blobSlice,
                        headers: {
                            'Content-Range': `bytes ${blobCursor.offset}-${blobCursor.end}/${this.sizeInBytes}`,
                            'Content-Length': `${blobSlice.size}`,
                            'Content-Type': 'application/octet-stream'
                        },
                        // makes Http.put() throw if this._requestController is aborted();
                        signal: this._requestController.signal
                    }
                );

                if (chunkResponse.status === HttpStatusCode.Created) {
                    const createdChange = await chunkResponse.value;
                    // type guard to ensure response is of type Change.
                    if ('type' in createdChange) {
                        this._bytesUploaded += blobSlice.size; // We know that entire slice was uploaded here
                        this.onChunkuploaded.notifyObservers(this);
                        enhanceChangeDtoWithCalculatedProperties(createdChange);
                        return Ok(createdChange);
                    }
                } else if (chunkResponse.status === HttpStatusCode.Accepted) {
                    const val = await chunkResponse.value;
                    // type guard to ensure response is of type UploadAcceptedResponse.
                    if ('nextExpectedRanges' in val) {
                        const nextExpectedRanges = this.parseNextExpectedRange(val.nextExpectedRanges);
                        const serverSliceWriteWasSuccess =
                            nextExpectedRanges.length === 1 && nextExpectedRanges[0].end === undefined;
                        if (!serverSliceWriteWasSuccess) {
                            throw new Error(
                                `Invalid format on nextExpectedRanges in response with Accepted statuscode. Was ${JSON.stringify(
                                    nextExpectedRanges
                                )}`
                            );
                        }
                        // Use the offset for next slice that the server thinks we should use.
                        blobCursor.offset = nextExpectedRanges[0].offset;
                        this._bytesUploaded = blobCursor.offset;
                        this.onChunkuploaded.notifyObservers(this);
                        continue;
                    }
                } else {
                    const reason = (await chunkResponse.text()) ?? chunkResponse.statusText;
                    return Fail({ status: chunkResponse.status, reason });
                }
                throw Error('Bug! Unsupported response!!!'); // Should not happen
            }

            throw Error('Bug!. Should never get here!');
        } catch (err: any) {
            if (err.name === 'AbortError') {
                return Fail({ status: HttpStatusCode.Aborted, reason: 'Upload was canceled' });
            }
            throw err;
        }
    }

    private parseNextExpectedRange(nextExpectedRanges: string[]): { offset: number; end?: number }[] {
        // range texts are on format <number>-<number> (failure) or on <number>- (success)
        // So no ending number means success.
        return nextExpectedRanges.map((str) => {
            const parts = str.split('-');
            if (parts.length !== 2) {
                throw new Error(`${str} is not in correct format of <number>- or <number>-<number>.`);
            }
            const end = Number.parseInt(parts[1]);
            return {
                offset: Number.parseInt(parts[0]),
                end: Number.isNaN(end) ? undefined : end
            };
        });
    }
}

/** See {@link BimUploadApiClient} for documentation.  */
export class BimUploadApiClient implements UploadApiClient {
    private static readonly _createUploadSessionResponseType = HttpResponseType.status([HttpStatusCode.Ok]);

    public constructor(readonly _apiClient: BimCoreApiClient) {}

    public canCreateFileInFolder(folder: DeepImmutable<BimFolder>): boolean {
        return this.canCreateFileIn(folder);
    }

    public canCreateFileIn({ permissions }: DeepImmutable<BimFolder | BimChangeLayer>): boolean {
        return permissions.hasAny('add');
    }

    public canAppendFileVersion({ permissions, ownerSystem }: DeepImmutable<BimChangeUploadable>): boolean {
        // Only possible to append a file if user has write access on it and if it originates from
        // twinfinity. Files that have been uploaded into twinfinity from other systesm (for example projektstruktur)
        // are not possible to append version to. Because then those versions would be overwritten once the
        // source system uploads a new version to twinfinity.
        return permissions.hasAny('edit') && ownerSystem === OwnerSystem.twinfinity.id;
    }

    public async createFileSession<Change extends BimChangeUploadable>(
        operation: UploadApiClientContentOperation<Change>,
        o?: DeepImmutable<UploadApiClientContentOptions>
    ): Promise<UploadApiTask<Change> | UploadApiClientFailure> {
        let createUploadSession: CreateUploadSessionResponse | UploadApiClientFailure;
        if (operation.operation === 'create') {
            const parent = 'folder' in operation ? operation.folder : operation.parent;
            const { apiUrl, etag } = parent;
            if (!this.canCreateFileIn(parent)) {
                return Fail({
                    status: HttpStatusCode.Forbidden,
                    reason: `Cannot create file in folder. No write permission.`
                });
            }

            createUploadSession = await this.postCreateUploadSession<Change>(
                `${apiUrl}/up/createUploadSession/${operation.filename}`,
                etag,
                operation
            );
        } else if (operation.operation === 'append') {
            const { apiUrl, id, etag } = operation.previousVersion;
            if (!this.canAppendFileVersion(operation.previousVersion)) {
                return Fail({
                    status: HttpStatusCode.Forbidden,
                    reason: `Cannot append file version. No write permission on previous version.`
                });
            }

            createUploadSession = await this.postCreateUploadSession<Change>(
                `${apiUrl}/up/${id}/createUploadSession`,
                etag,
                operation
            );
        } else {
            throw new Error('Not supported');
        }

        if (isFailure(createUploadSession)) {
            return createUploadSession;
        }

        return new UploadTask<Change>(createUploadSession, toBlob(operation.content), o?.chunkSize);
    }

    private async postCreateUploadSession<Change extends BimChangeUploadable>(
        url: string,
        etag: string,
        { metadata }: CopyWithPartialProperties<UploadApiClientContentOperation<Change>, 'metadata'>
    ): Promise<CreateUploadSessionResponse | UploadApiClientFailure> {
        const createUploadSessionResponse = await Http.post<CreateUploadSessionResponse>(
            url,
            BimUploadApiClient._createUploadSessionResponseType.json,
            {
                body: JSON.stringify({ etag, metadata }),
                headers: {
                    Accept: 'application/json',
                    'Content-Type': 'application/json'
                }
            }
        );
        const { status, statusText } = createUploadSessionResponse;
        if (status !== HttpStatusCode.Ok) {
            const errorMessage = (await createUploadSessionResponse.text()) ?? statusText;
            return Fail({ status, reason: errorMessage });
        }

        return Ok(await createUploadSessionResponse.value);
    }
}
