import { buildInfo } from './BuildInfo';
import { Writeable } from './Types';

/** Represents return result for {@link TwinfinityApiClient.get}. */
export interface TypedResponse<T> extends Response {
    /**
     * Returns result for responses considered Ok. Otherwise an error is thrown.
     */
    readonly value: Promise<T>;
}

/**
 * HTTP Status codes (only a subset is used in the current model)
 */
export enum HttpStatusCode {
    Ok = 200,
    Created = 201,
    Accepted = 202,
    NoContent = 204,
    Aborted = 299, // Made up status code for aborted requests
    Unauthorized = 401,
    Forbidden = 403,
    Conflict = 409,
    NotFound = 404,
    InternalServerError = 500
}

/** Creates a mocked TypedResponse for testing */
export function createMockTypedResponse<T>(o: { value: T } & Partial<Response>): TypedResponse<T> {
    return {
        ok: o.ok ?? true,
        redirected: o.redirected ?? false,
        status: o.status ?? 200,
        statusText: o.statusText ?? 'OK',
        bytes: o.bytes ?? (() => Promise.resolve(new Uint8Array())),
        headers: o.headers ?? new Headers(),
        body: o.body ?? null,
        bodyUsed: o.bodyUsed ?? false,
        //trailer: o.trailer ?? Promise.resolve(new Headers()),
        arrayBuffer: o.arrayBuffer ?? notImplemented,
        blob: o.blob ?? notImplemented,
        text: o.text ?? notImplemented,
        formData: o.formData ?? notImplemented,
        json: o.json ?? notImplemented,
        clone: o.clone ?? notImplemented,
        type: o.type ?? 'default',
        url: o.url ?? 'http://foo.com',
        value: Promise.resolve(o.value)
    };

    function notImplemented<T>(): T {
        throw new Error('');
    }
}

/**
 * HTTP methods
 */
export enum HttpMethod {
    Post = 'POST',
    Put = 'PUT',
    Delete = 'DELETE',
    Get = 'GET',
    Patch = 'PATCH'
}

/**
 * Same as {@link RequestInit} but without the method. Used with methods on {@link Http}
 */
export type HttpRequestInit = Omit<RequestInit, 'method'>;

export interface AuthorizationHeaderProvider {
    getAuthorizationHeader(): Promise<string | undefined>;
}

/**
 * Utility class for making HTTP requests. If requests are made to the Twinfinity
 * backend then correct headers are automatically set.
 */
export class Http {
    private static _authorizationHeaderProvider?: AuthorizationHeaderProvider;

    /**
     * Issues a GET HTTP request
     * @param absoluteUrl Absolute url
     * @param responseconverter How response should be converted. Use functions on {@link HttpResponseType}
     * @param init Optional {@link HttpRequestInit} values.
     */
    public static async get<R>(
        absoluteUrl: string | URL,
        responseconverter: (r: Response) => TypedResponse<R>,
        init?: HttpRequestInit
    ): Promise<TypedResponse<R>> {
        return Http.fetch(HttpMethod.Get, absoluteUrl, responseconverter, init);
    }

    /**
     * Issues a POST HTTP request
     * @param absoluteUrl Absolute url
     * @param responseconverter How response should be converted. Use functions on {@link HttpResponseType}
     * @param init Optional {@link HttpRequestInit} values.
     */
    public static async post<R>(
        absoluteUrl: string | URL,
        responseconverter: (r: Response) => TypedResponse<R>,
        init?: HttpRequestInit
    ): Promise<TypedResponse<R>> {
        return Http.fetch(HttpMethod.Post, absoluteUrl, responseconverter, init);
    }

    /**
     * Issues a DELETE HTTP request
     * @param absoluteUrl Absolute url
     * @param responseconverter How response should be converted. Use functions on {@link HttpResponseType}
     * @param init Optional {@link HttpRequestInit} values.
     */
    public static async delete<R>(
        absoluteUrl: string | URL,
        responseconverter: (r: Response) => TypedResponse<R>,
        init?: HttpRequestInit
    ): Promise<TypedResponse<R>> {
        return Http.fetch(HttpMethod.Delete, absoluteUrl, responseconverter, init);
    }

    /**
     * Issues a PUT HTTP request
     * @param absoluteUrl Absolute url
     * @param responseconverter How response should be converted. Use functions on {@link HttpResponseType}
     * @param init Optional {@link HttpRequestInit} values.
     */
    public static async put<R>(
        absoluteUrl: string | URL,
        responseconverter: (r: Response) => TypedResponse<R>,
        init?: HttpRequestInit
    ): Promise<TypedResponse<R>> {
        return Http.fetch(HttpMethod.Put, absoluteUrl, responseconverter, init);
    }

    /**
     * Issues a PATCH HTTP request
     * @param absoluteUrl Absolute url
     * @param responseconverter How response should be converted. Use functions on {@link HttpResponseType}
     * @param init Optional {@link HttpRequestInit} values.
     */
    public static async patch<R>(
        absoluteUrl: string | URL,
        responseconverter: (r: Response) => TypedResponse<R>,
        init?: HttpRequestInit
    ): Promise<TypedResponse<R>> {
        return Http.fetch(HttpMethod.Patch, absoluteUrl, responseconverter, init);
    }

    /**
     * Issues a HTTP request
     * @param method Method of http request.
     * @param absoluteUrl Absolute url
     * @param responseconverter How response should be converted. Use functions on {@link HttpResponseType}
     * @param init Optional {@link HttpRequestInit} values.
     */
    public static async fetch<R>(
        method: HttpMethod,
        absoluteUrl: string | URL,
        responseconverter: (r: Response) => TypedResponse<R>,
        init?: HttpRequestInit
    ): Promise<TypedResponse<R>> {
        absoluteUrl = Http.toURL(absoluteUrl);
        const tmpInit: RequestInit = { method };
        Object.assign(tmpInit, init ?? {});

        if (Http.isTwinfinityUrl(absoluteUrl)) {
            tmpInit.credentials = 'include';
            // These headers prevent so called simple CORS requests (for example GET) to twinfinity backend. That is requests
            // that are not preflighted (with OPTIONS request).  See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
            // Server does set Access-Control-Max-Age so browser can cache result of preflight requests though
            // (so it does not always have to issue them if a cached result already exist). For now it looks like that is enough. However
            // if it turns out that making simple requests will increase performance/reduce backend load then it might
            // be a good idea to revisit this and disable the custom headers (for simple requests). They are nice to have though
            // since backend can look at them and do stuff like transform data to keep compability with old client versions
            // if neeeded etc.
            tmpInit.headers = new Headers(init?.headers ?? {});
            tmpInit.headers.set('twinfinity-client-version', buildInfo.version);
        }
        if (Http.requiresAuthorizationHeader(absoluteUrl)) {
            tmpInit.headers = new Headers(init?.headers ?? {});
            const authorizationHeader = await this._authorizationHeaderProvider?.getAuthorizationHeader();
            if (authorizationHeader != null) {
                tmpInit.headers.set('Authorization', authorizationHeader);
            }
        }

        const response = await fetch(absoluteUrl.toString(), tmpInit);
        return responseconverter(response);
    }

    public static setAuthorizationHeaderProvider(authorizationHeaderProvider: AuthorizationHeaderProvider): void {
        this._authorizationHeaderProvider = authorizationHeaderProvider;
    }

    private static toURL(u: string | URL): URL {
        return u instanceof URL ? u : new URL(u);
    }

    private static isTwinfinityUrl(u: URL): boolean {
        return u.host.toLowerCase().startsWith('bim.');
    }

    private static requiresAuthorizationHeader(u: URL): boolean {
        return this.isTwinfinityUrl(u) || u.pathname.includes('_ps/api');
    }
}

/** Used with  {@link TwinfinityApiClient.get} to convert responses to correct data. See {@link TwinfinityApiClient.get} for an example. */
export class HttpResponseType {
    /**
     * By default, {@link TypedResponse.value} conversion methods only assign {@link TypedResponse.value} when {@link Response.status} === `200`
     */
    public static readonly default = new HttpResponseType([HttpStatusCode.Ok]);

    /**
     * Converts an array buffer {@link Response} to {@link TypedResponse<ArrayBuffer>}.
     */
    public readonly arrayBuffer: (r: Response) => TypedResponse<ArrayBuffer>;
    /**
     * Converts a JSON {@link Response} to {@link TypedResponse}.
     */
    public readonly json: <T>(r: Response) => TypedResponse<T>;
    /**
     * Converts a text {@link Response} to {@link TypedResponse<string>}.
     */
    public readonly text: (r: Response) => TypedResponse<string>;
    /**
     * Converts a blob {@link Response} to {@link TypedResponse<Blob>}.
     */
    public readonly blob: (r: Response) => TypedResponse<Blob>;

    /**
     * Converts an empty {@link Response} to {@link TypedResponse<undefined>}.
     */
    public readonly empty: (r: Response) => TypedResponse<undefined>;

    private constructor(private readonly _statuses: number[]) {
        // Since these properties are almost always passed as function references
        // and we need to keep the 'this' pointer to this class we have to defined the methods
        // like this
        this.arrayBuffer = (r) => this.setValue(r as TypedResponse<ArrayBuffer>, (r) => r.arrayBuffer());
        this.json = <T>(r: Response) => this.setValue(r as TypedResponse<T>, (r) => r.json());
        this.text = (r) => this.setValue(r as TypedResponse<string>, (r) => r.text());
        this.blob = (r) => this.setValue(r as TypedResponse<Blob>, (r) => r.blob());
        this.empty = (r) => this.setValue(r as TypedResponse<undefined>, (r) => Promise.resolve(undefined));
    }

    /**
     * Default is that {@link TypedResponse.value} is only valid if {@link Response.status} === `200` for
     * responses sent into {@link arrayBuffer}, {@link json}, {@link text} and {@link blob}. Call this method
     * to create a custom {@link HttpResponseType} where {@link TypedResponse.value} can be assigned for other status values
     * as well.
     * Example: A conflict response may still contain data which should be assigned to {@link TypedResponse.value}.
     * @param statuses
     */
    public static status(statuses: number[]): HttpResponseType {
        return new HttpResponseType(statuses);
    }

    /**
     * Converts an array buffer {@link Response} to {@link TypedResponse<ArrayBuffer>}.
     */
    public static arrayBuffer(r: Response): TypedResponse<ArrayBuffer> {
        return HttpResponseType.default.arrayBuffer(r);
    }
    /**
     * Converts a JSON {@link Response} to {@link TypedResponse}.
     */
    public static json<T>(r: Response): TypedResponse<T> {
        return HttpResponseType.default.json(r);
    }

    /**
     * Converts a text {@link Response} to {@link TypedResponse<string>}.
     */
    public static text(r: Response): TypedResponse<string> {
        return HttpResponseType.default.text(r);
    }

    /**
     * Converts a blob {@link Response} to {@link TypedResponse<Blob>}.
     */
    public static blob(r: Response): TypedResponse<Blob> {
        return HttpResponseType.default.blob(r);
    }

    private setValue<T>(r: Writeable<TypedResponse<T>>, converter: (r: Response) => Promise<T>): TypedResponse<T> {
        const isValidHttpStatus = this._statuses.find((s) => s === r.status) !== undefined;
        if (isValidHttpStatus) {
            r.value = converter(r);
        } else {
            // value is not valid if status was not listed as a ok. In that case
            // we just throw a exception  because user is not supposed to access this
            // property.
            Object.defineProperty(r, 'value', {
                get() {
                    const error = new Error(`HTTP ${r.type} ${r.status} ${r.statusText}`);
                    (error as any).response = r;
                    throw error;
                }
            });
        }

        return r;
    }
}
