/*
 * Copyright (c) 2023, 2025, Oracle and/or its affiliates.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License, version 2.0,
 * as published by the Free Software Foundation.
 *
 * This program is designed to work with certain software (including
 * but not limited to OpenSSL) that is licensed under separate terms, as
 * designated in a particular file or component or in included license
 * documentation.  The authors of MySQL hereby grant you an additional
 * permission to link the program and your derivative works with the
 * separately licensed software that they have either included with
 * the program or referenced in the documentation.
 *
 * This program is distributed in the hope that it will be useful,  but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
 * the GNU General Public License, version 2.0, for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
 */

/**
 * Plain JSON error responses sent by the MySQL Router.
 * Always include the status code, can include a message and additional information about the root cause.
 */
interface IMrsErrorResponse {
    status: number;
    message?: string;
    what?: string;
}

export class NotFoundError extends Error {
    public constructor(public msg: string) {
        super(msg);
    }
}

export interface IFetchInput<T> {
    errorMsg?: string;
    method?: string;
    body?: T;
    input: string;
    timeout?: number;
}

export interface IMrsAuthUser {
    name: string;
    email: string;
}

export interface IMrsAuthStatus {
    status: string;
    user?: IMrsAuthUser;
}

/**
 * If the authentication is successful, the MySQL Router sends back an HTTP response with an empty JSON object in the
 * body and the "Set-Cookie" header if the session type is "cookie".
 * If the session type is "bearer" the "Set-Cookie" header is not set and the JSON object contains an "accessToken"
 * field with the corresponding bearer token.
 */
interface IMrsAuthSuccessResponse {
    accessToken?: string;
}

interface IAuthChallenge {
    nonce: string;
    iterations: number;
    salt: Uint8Array<ArrayBuffer>;
    session?: string;
}

interface IMrsLoginState {
    userName?: string;
    password?: string;
    clientFirst?: string;
    clientFinal?: string;
    serverFirst?: string;
    challenge?: IAuthChallenge;
    loginError?: string;
}

export interface IMrsLoginResult {
    authApp?: string;
    jwt?: string;
    errorCode?: number;
    errorMessage?: string;
}

// --- MySQL Shell for VS Code Extension Remove --- Begin
declare const mrsLoginResult: IMrsLoginResult | undefined;
// --- MySQL Shell for VS Code Extension Remove --- End

/**
 * Implements a session that is used by a MrsService to perform fetch() calls.
 *
 * The session also supports authentication via MRS AuthApps.
 */
export class MrsBaseSession {
    public accessToken?: string;
    public authApp?: string;
    public gtid?: string;

    protected loginState: IMrsLoginState = {};
    protected deauthPath: string;

    public constructor(
        protected serviceUrl: string,
        protected authPath = "/authentication",
        protected defaultTimeout = 8000) {
        this.authPath = `${authPath}/login`;
        this.deauthPath = `${authPath}/logout`;
        // --- MySQL Shell for VS Code Extension Only --- Begin
        try {
            // Try to get global mrsLoginResult values when already authenticated in the DB NoteBook
            this.accessToken = mrsLoginResult?.jwt;
            this.authApp = mrsLoginResult?.authApp;
        } catch {
            // Ignore
        }
        // --- MySQL Shell for VS Code Extension Only --- End
    }

    /**
     * A small wrapper around fetch() that uses the active JWT accessToken to the MRS and throws
     * an exception if the response was not OK
     *
     * @param input The RequestInfo, either a URL string or a JSON object with named parameters
     * @param errorMsg The error message to include in the exception if the fetch is not successful
     * @param method The HTTP method to use with GET being the default
     * @param body The request body as object
     * @param autoResponseCheck Set to false if the error checking should not be performed
     * @param timeout The timeout for this fetch call. If not specified, the default timeout of the session is used.
     *
     * @returns The response object
     */
    public doFetch = async <T>(input: string | IFetchInput<T>, errorMsg?: string,
        method?: string, body?: T, autoResponseCheck = true,
        timeout?: number): Promise<Response> => {
        // Check if parameters are passed as named parameters and if so, assign them
        if (typeof input === "string") {
            errorMsg = errorMsg ?? "Failed to fetch data.";
            method = method ?? "GET";
        } else {
            errorMsg = input.errorMsg ?? "Failed to fetch data.";
            method = input.method ?? "GET";
            body = input.body;
            timeout = input.timeout;
            input = input.input;
        }

        let response;
        const signal = AbortSignal.timeout(timeout ?? this.defaultTimeout);

        try {
            response = await fetch(`${this.serviceUrl}${input}`, {
                method,
                headers: (this.accessToken !== undefined) ? { Authorization: "Bearer " + this.accessToken } : undefined,
                body: (body !== undefined) ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined,
                signal,
            });
        } catch (e) {
            if (e instanceof Error) {
                if (e.name === "TimeoutError" && signal.aborted) {
                    throw new Error(`${errorMsg}\n\nRequest to endpoint ` +
                        `${this.serviceUrl}${input} timed out.`);
                }
                if (e.name === "TypeError" && e.message.includes("Failed to fetch")) {
                    throw new Error(`${errorMsg}\n\nNetwork error during request to endpoint ` +
                        `${this.serviceUrl}${input}.\nPlease check if MySQL Router is running and its SSL ` +
                        `certificates are valid.\n\n${e.message}`);
                }
            }
            throw new Error(`${errorMsg}\n\nError during request to endpoint ` +
                `${this.serviceUrl}${input}.\n\n${(e instanceof Error) ? e.message : String(e)}`);
        }

        if (!response.ok && autoResponseCheck) {
            const requestPath = typeof input === "string" ? input : (input as unknown as IFetchInput<T>).input;

            // Check if the current session has expired
            if (response.status === 401 && !requestPath.endsWith(this.authPath)) {
                throw new Error(`Not authenticated. Please authenticate first before accessing the ` +
                    `path ${this.serviceUrl}${input}.`);
            }

            let errorInfo;
            try {
                errorInfo = await response.json() as IMrsErrorResponse;
            } catch {
                throw new Error(`${response.status}. ${errorMsg} (${response.statusText})`);
            }
            // If there is a message, throw with that message
            if (typeof errorInfo.message === "string") {
                throw new Error(String(errorInfo.message));
            } else {
                throw new Error(`${response.status}. ${errorMsg} (${response.statusText})` +
                    "\n\n" + JSON.stringify(errorInfo, null, 4) + "\n");
            }
        }

        return response;
    };

    /**
     * Gets the authentication status of the current session as defined by the accessToken
     *
     * @returns The response object with {"status":"authorized", "user": {...}} or {"status":"unauthorized"}
     */
    public readonly getAuthenticationStatus = async (): Promise<IMrsAuthStatus> => {
        try {
            return (await (await this.doFetch(
                { input: `/authentication/status`, errorMsg: "Failed to authenticate." })).json()) as IMrsAuthStatus;
        } catch {
            return { status: "unauthorized" };
        }
    };

    public readonly verifyCredentials = async ({ username, password = "", authApp }: {
        username: string, password: string, authApp: string }): Promise<IMrsLoginResult> => {
        try {
            const response = await this.doFetch({
                input: this.authPath,
                method: "POST",
                body: {
                    username,
                    password,
                    authApp,
                    sessionType: "bearer",
                },
            }, undefined, undefined, undefined, false);

            if (!response.ok) {
                this.accessToken = undefined;

                return {
                    authApp,
                    errorCode: response.status,
                    errorMessage: (response.status === 401)
                        ? "The sign in failed. Please check your username and password."
                        : `The sign in failed. Error code: ${String(response.status)}`,
                };
            } else {
                const result = await response.json() as IMrsAuthSuccessResponse;

                if (result.accessToken === undefined) {
                    return {
                        authApp,
                        errorCode: 401,
                        errorMessage:
                            "Authentication failed. The authentication app is of a different vendor.",
                    };
                }

                this.accessToken = result.accessToken;

                return {
                    authApp,
                    jwt: this.accessToken,
                };
            }
        } catch (e) {
            return {
                authApp,
                errorCode: 2,
                errorMessage: `The sign in failed. Server Error: ${String(e)}`,
            };
        }
    };

    public readonly sendClientFirst = async (authApp: string, userName: string): Promise<void> => {
        this.authApp = authApp;

        const nonce = this.hex(crypto.getRandomValues(new Uint8Array(10)));
        const response = await this.doFetch({
            input: this.authPath,
            method: "POST",
            body: {
                authApp,
                user: userName,
                nonce,
                sessionType: "bearer",
            },
        });
        const challenge = await response.json() as IAuthChallenge;

        // Convert the salt to and Uint8Array
        challenge.salt = new Uint8Array(challenge.salt);

        this.loginState = {
            clientFirst: `n=${userName},r=${nonce}`,
            clientFinal: `r=${challenge.nonce}`,
            serverFirst: this.buildServerFirst(challenge),
            challenge,
            loginError: undefined,
        };
    };

    public readonly sendClientFinal = async (password?: string): Promise<IMrsLoginResult> => {
        const { challenge, clientFirst, serverFirst, clientFinal } = this.loginState;

        if (password !== undefined && password !== "" && this.authApp !== undefined &&
            clientFirst !== undefined && serverFirst !== undefined && challenge !== undefined &&
            clientFinal !== undefined) {
            const te = new TextEncoder();
            const authMessage = `${clientFirst},${serverFirst},${clientFinal}`;
            const clientProof = Array.from(await this.calculateClientProof(
                password, challenge.salt, challenge.iterations, te.encode(authMessage)));

            try {
                const response = await this.doFetch({
                    input: this.authPath,
                    method: "POST",
                    body: {
                        clientProof,
                        nonce: challenge.nonce,
                        state: "response",
                    },
                }, undefined, undefined, undefined, false);

                if (!response.ok) {
                    this.accessToken = undefined;

                    return {
                        authApp: this.authApp,
                        errorCode: response.status,
                        errorMessage: (response.status === 401)
                            ? "The sign in failed. Please check your username and password."
                            : `The sign in failed. Error code: ${String(response.status)}`,
                    };
                } else {
                    const result = await response.json() as IMrsAuthSuccessResponse;

                    this.accessToken = String(result.accessToken);

                    return {
                        authApp: this.authApp,
                        jwt: this.accessToken,
                    };
                }
            } catch (e) {
                return {
                    authApp: this.authApp,
                    errorCode: 2,
                    errorMessage: `The sign in failed. Server Error: ${String(e)}`,
                };
            }
        } else {
            return {
                authApp: this.authApp,
                errorCode: 1,
                errorMessage: `No password given.`,
            };
        }
    };

    public async logout(): Promise<void> {
        if (this.accessToken === undefined) {
            throw new Error("No user is currently authenticated.");
        }

        await this.doFetch({
            input: this.deauthPath,
            method: "POST",
        });

        delete this.accessToken;
    }

    private readonly hex = (arrayBuffer: Uint8Array): string => {
        return Array.from(new Uint8Array(arrayBuffer))
            .map((n) => {
                return n.toString(16).padStart(2, "0");
            })
            .join("");
    };

    private readonly buildServerFirst = (challenge: IAuthChallenge): string => {
        const b64Salt = globalThis.btoa(String.fromCharCode.apply(null, Array.from(challenge.salt)));

        return `r=${challenge.nonce},s=${b64Salt},i=${String(challenge.iterations)}`;
    };

    private readonly calculatePbkdf2 = async (password: BufferSource, salt: Uint8Array<ArrayBuffer>,
        iterations: number): Promise<Uint8Array<ArrayBuffer>> => {
        const ck1 = await crypto.subtle.importKey(
            "raw", password, { name: "PBKDF2" }, false, ["deriveKey", "deriveBits"]);
        const result = new Uint8Array(await crypto.subtle.deriveBits(
            { name: "PBKDF2", hash: "SHA-256", salt, iterations }, ck1, 256));

        return result;
    };

    private readonly calculateSha256 = async (data: BufferSource): Promise<Uint8Array<ArrayBuffer>> => {
        return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
    };

    private readonly calculateHmac = async (secret: Uint8Array<ArrayBuffer>, data: Uint8Array<ArrayBuffer>):
    Promise<Uint8Array<ArrayBuffer>> => {
        const key = await globalThis.crypto.subtle.importKey(
            "raw", secret, { name: "HMAC", hash: { name: "SHA-256" } }, true, ["sign", "verify"]);
        const signature = await globalThis.crypto.subtle.sign("HMAC", key, data);

        return new Uint8Array(signature);
    };

    private readonly calculateXor = (a1: Uint8Array, a2: Uint8Array): Uint8Array<ArrayBuffer> => {
        const l1 = a1.length;
        const l2 = a2.length;
        // cSpell:ignore amax
        let amax;
        let amin;
        let loop;

        if (l1 > l2) {
            amax = new Uint8Array(a1);
            amin = a2;
            loop = l2;
        } else {
            amax = new Uint8Array(a2);
            amin = a1;
            loop = l1;
        }

        for (let i = 0; i < loop; ++i) {
            amax[i] ^= amin[i];
        }

        return amax;
    };

    private readonly calculateClientProof = async (password: string, salt: Uint8Array<ArrayBuffer>, iterations: number,
        authMessage: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> => {
        const te = new TextEncoder();
        const saltedPassword = await this.calculatePbkdf2(te.encode(password), salt, iterations);
        const clientKey = await this.calculateHmac(saltedPassword, te.encode("Client Key"));
        const storedKey = await this.calculateSha256(clientKey);
        const clientSignature = await this.calculateHmac(storedKey, authMessage);
        const clientProof = this.calculateXor(clientSignature, clientKey);

        return clientProof;
    };
}

export interface IMrsAuthApp {
    name: string;
    vendorId: string;
}

export interface IMrsOperator {
    "=": "$eq",
    "!=": "$ne",
    "<": "$lt",
    "<=": "$lte",
    ">": "$gt",
    ">=": "$gte",
    "like": "$like",
    "null": "$null",
    "notNull": "$notnull",
    "between": "$between",
}

/**
 * A collection of MRS resources is represented by a JSON object returned by the MySQL Router, which includes the list
 * of underlying resource objects and additional hypermedia-related properties with pagination state and the
 * relationships with additional resources.
 *
 * @see MrsDownstreamDocumentData
 * @see IMrsLink
 * @see JsonObject
 */
export type MrsDownstreamDocumentListData<C> = {
    items: Array<MrsDownstreamDocumentData<C>>,
    limit: number,
    offset: number,
    hasMore: boolean,
    count: number,
    links: IMrsLink[]
} & JsonObject;

/**
 * A single MRS resource object is represented by JSON object returned by the MySQL Router, which includes the
 * corresponding fields and values alongside additional hypermedia-related properties.
 *
 * @see IMrsResourceDetails
 */
export type MrsDownstreamDocumentData<T> = T & IMrsResourceDetails & IPojo;

/**
 * Actual MRS Document definition + "_metadata" (BUG#37716544).
 */
export type MrsUpstreamResourceData<T> = T & Omit<IMrsResourceDetails, "links">;

/**
 * A resource object is always represented as JSON and can include specific hypermedia properties such as a potential
 * list of links it has with other resources and metadata associated to the resource (e.g. its ETag).
 *
 * @see IMrsLink
 * @see IMrsResourceMetadata
 * @see JsonObject
 */
interface IMrsResourceDetails {
    links: IMrsLink[];
    _metadata: IMrsResourceMetadata;
}

interface IMrsTransactionalMetadata {
    gtid?: string;
}

/**
 * Resource metadata includes the resource ETag.
 */
interface IMrsResourceMetadata extends IMrsTransactionalMetadata {
    etag: string;
}

/**
 * A link definition includes a type of relationship between two resources and a URL for accessing the other resource
 * in that relationship.
 */
export interface IMrsLink {
    rel: string,
    href: string,
}

type IMrsCommonRoutineResponse = {
    _metadata?: IMrsTransactionalMetadata;
} & JsonObject;

export type IMrsProcedureResponse<OutParams, ResultSet> = {
    outParameters?: OutParams;
    resultSets: ResultSet[]
} & IMrsCommonRoutineResponse;

export interface IMrsProcedureResult<OutParams, ResultSet> {
    outParameters?: OutParams;
    resultSets: ResultSet[]
}

export type IMrsFunctionResponse<C> = {
    result: C;
} & IMrsCommonRoutineResponse;

export interface IMrsDeleteResult {
    itemsDeleted: number;
    _metadata?: Pick<IMrsResourceMetadata, "gtid">,
}

export interface IExhaustedList<T> extends Array<T> {
    hasMore: false,
}

export interface INotExhaustedList<T> extends Array<T> {
    hasMore: true,
    next(): Promise<PaginatedList<T>>,
}

export type PaginatedList<T> = IExhaustedList<T> | INotExhaustedList<T>;

export type DataFilter<Type> = DelegationFilter<Type> | HighOrderFilter<Type>;

export type DelegationFilter<Type> = {
    [Key in keyof Type]?: DataFilterField<Type, Type[Key]> | Type[Key] | Array<ComparisonOpExpr<Type[Key]>>
};

/**
 * An object containing checks that should apply for the value of one or more fields.
 *
 * @example
 * { name: "foo", age: 42 }
 * { name: { $eq: "foo" }, age: 42 }
 * { name: "foo", age: { $gte: 42 } }
 * { name: { $like: "%foo%" }, age: { $lte: 42 } }
 */
export type PureFilter<Type> = {
    [Key in keyof Type]: ComparisonOpExpr<Type[Key]> | Type[Key]
};

/**
 * An object that specifies multiple filters which must all be verified (AND) or alternatively, or in which only some
 * of them are verified (OR).
 *
 * @example
 * { $and: [{ name: "foo" }, { age: 42 }] }
 * { $or: [{ name: { $eq: "foo" } }, { age: { $gte: 42 }}] }
 * @see {PureFilter}
 */
export type HighOrderFilter<Type> = {
    // $and/$or can work for a subset of fields, so we need Partial
    [Key in BinaryOperator]?: Array<PureFilter<Partial<Type>> | HighOrderFilter<Type>>
};

/**
 * An object that specifies an explicit operation to check against a given value.
 *
 * @example
 * { $eq: "foo" }
 * { $gte: 42 }
 * { not: null }
 * { $between: [1, 2] }
 * @see {ISimpleOperatorProperty}
 */
export type ComparisonOpExpr<Type> = {
    [Operator in keyof ISimpleOperatorProperty]?: Type & ISimpleOperatorProperty[Operator]
} & Partial<Record<"$notnull" | "$null", boolean | null>> & {
    not?: null;
} & {
    $between?: NullStartingRange<Type & BetweenRegular> | NullEndingRange<Type & BetweenRegular>;
};

type NullStartingRange<Type> = readonly [Type | null, Type];
type NullEndingRange<Type> = readonly [Type, Type | null];

type BetweenRegular = string | number | Date;

interface ISimpleOperatorProperty {
    "$eq": string | number | Date;
    "$gt": number | Date;
    "$instr": string;
    "$gte": number | Date;
    "$lt": number | Date;
    "$lte": number | Date;
    "$like": string;
    "$ne": string | number | Date;
    "$ninstr": string;
}

export type BinaryOpExpr<ParentType, Type> = {
    [Operator in BinaryOperator]?: Array<BinaryOperatorParam<ParentType, Type>>
};

export type DataFilterField<ParentType, Type> = ComparisonOpExpr<Type> | BinaryOpExpr<ParentType, Type>;

export type BinaryOperatorParam<ParentType, Type> = ComparisonOpExpr<Type> | DelegationFilter<ParentType>;

export type BinaryOperator = "$and" | "$or";

export type ColumnOrder<Field extends string[]> = Partial<Record<Field[number], "ASC" | "DESC" | 1 | -1>>;

// Prisma-like API type definitions.

// We need to distinguish between primitive types and objects (arrays and pojos) in order to
// determine if there are nesting levels to inspect.
type Primitive =
    | null
    | undefined
    | string
    | number
    | boolean
    | symbol
    | bigint;

/**
 * A JSON object contains keys which are strings and values in a specific range of primitives.
 */
export type JsonObject = { [Key in string]: JsonValue } & { [Key in string]?: JsonValue | undefined };

/**
 * A JSON array is just a list of valid JSON values.
 * Since ReadonlyArray is a first-class type in TypeScript, it needs to be accounted for.
 */
type JsonArray = JsonValue[] | readonly JsonValue[];

/**
 * JSON supports a set of primitives that includes strings, numbers, booleans and null.
 */
type JsonPrimitive = string | number | boolean | null;

/**
 * JSON supports a set of values that includes specific primitive types, other JSON object definitions
 * and arrays of these.
 */
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;

/** Type representing a plain old JavaScript object. */
export type IPojo = Record<symbol | string, JsonValue | undefined>;

/**
 * Columns can be assigned "NOT NULL" constraints, which can be enforced by the client type.
 * However, columns without that constraint should allow "null" assignments.
 */
export type MaybeNull<T> = T | null;

export type BigInteger = bigint | number;
export type Decimal = string | number;

/**
 * A GEOMETRY column can store geometry values of any spatial type.
 */
export type Geometry = Point | LineString | Polygon | MultiPoint | MultiLineString | MultiPolygon | GeometryCollection;

/**
 * A GEOMETRYCOLLECTION column can store a collection of objects of any spatial type.
 */
export interface GeometryCollection {
    type: "GeometryCollection";
    geometries: Geometry[];
}

/**
 * A position represents a coordinate in the grid.
 */
type Position = [number, number];

/**
 * A Point consists of a single position in the grid.
 */
export interface Point {
    type: "Point";
    coordinates: Position;
};

/**
 * A MultiPoint consists of a list of positions in the grid.
 */
export interface MultiPoint {
    type: "MultiPoint";
    coordinates: Position[];
};

/**
 * A LineString consists of two or more positions in the grid.
 */
export interface LineString {
    type: "LineString";
    coordinates: [Position, Position, ...Position[]];
};

/**
 * A MultiLineString consists of a list where each element consists of two or more positions in the grid.
 */
export interface MultiLineString {
    type: "MultiLineString";
    coordinates: Array<[Position, Position, ...Position[]]>;
};

/**
 * A linear ring is a closed LineString with four or more positions. The first and last positions are equivalent, and
 * they MUST contain identical values; their representation SHOULD also be identical. A linear ring is the boundary of
 * a surface or the boundary of a hole in a surface. A linear ring MUST follow the right-hand rule with respect to the
 * area it bounds, i.e., exterior rings are counterclockwise, and holes are clockwise.
 * Apart from the minimum number of positions, the remaining constraints are not feasible to enforce via a TypeScript
 * type definition.
 */
type LinearRing = [Position, Position, Position, Position, ...Position[]];

/**
 * A Polygon consists of a list of linear rings. For Polygons with more than one of these rings, the first MUST be the
 * exterior ring, and any others MUST be interior rings. The exterior ring bounds the surface, and the interior rings
 * (if present) bound holes within the surface. This constraint is not feasible to enforce via a TypeScript type
 * definition.
 */
export interface Polygon {
    type: "Polygon";
    coordinates: LinearRing[];
};

/**
 * A MultiPolygon consists of a list where each element is itself a list of linear rings.
 */
export interface MultiPolygon {
    type: "MultiPolygon";
    coordinates: LinearRing[][];
};

/**
 * A cursor is a field which is unique and sequential. Examples are auto generated columns (such as ones created with
 * "AUTO INCREMENT") or TIMESTAMP columns.
 */
export type Cursor<EligibleFields> = {
    [Key in keyof EligibleFields]: EligibleFields[Key]
};

interface IMrsTaskOptions<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames extends string[],
    FixedPointParameterNames extends string[]> extends IMrsObjectMetadata<BigIntParameterNames,
        FixedPointParameterNames>, IMrsTaskRunOptions<MrsTaskStatusUpdate, MrsTaskResult> {
    routineType?: "FUNCTION" | "PROCEDURE";
}

interface IMrsObjectMetadata<BigIntFieldNames extends string[] = never, FixedPointFieldNames extends string[] = never> {
    bigIntKeys?: BigIntFieldNames;
    fixedPointKeys?: FixedPointFieldNames;
}

interface IMrsViewMetadata<PrimaryKeyFieldNames extends string[] = never, BigIntFieldNames extends string[] = never,
    FixedPointFieldNames extends string[] = never> extends IMrsObjectMetadata<BigIntFieldNames, FixedPointFieldNames> {
    identifierKeys?: PrimaryKeyFieldNames;
};

interface IMrsRoutineMetadata<BigIntParameterNames extends string[] = never,
    FixedPointParameterNames extends string[] = never> extends IMrsObjectMetadata<BigIntParameterNames,
        FixedPointParameterNames> {}

// authenticate() API

/**
 * Options available to authenticate in a REST service.
 */
export interface IAuthenticateOptions {
    app: string;
    password?: string;
    username: string;
    vendor?: string;
}

// create*() API

/**
 * Options available to create a new REST document
 */
export interface ICreateOptions<Type> {
    /**
     * Mapping of values for each field of the document.
     */
    data: Type;
}

interface IFindCommonOptions<Item> {
    // "select" determines which fields are included in the result set.
    // It supports both an object with boolean toggles to select or ignore specific fields or an array of field names
    // to include. Nested REST data mapping view fields can be specified using nested objects or with boolean toggles
    // or an array with field names containing the full field path using dot "." notation
    /** Fields to include in the result set. */
    select?: BooleanFieldMapSelect<Item> | FieldNameSelect<Item>;
    readOwnWrites?: boolean;
}

/** Options available to find records based on a given filter. */
interface IFindAnyOptions<Item, Filterable> extends IFindCommonOptions<Item> {
    // A filter that matches multiple documents should be optional and allow both logical operators and valid field
    // names.
    where?: DataFilter<Filterable>;
}

/** Options available to find a record based on a unique identifier or primary key. */
export interface IFindUniqueOptions<Item, Filterable> extends IFindCommonOptions<Item> {
    // A filter that matches a single document via a unique field must be mandatory and should not allow logical
    // operators because a unique field by nature should be enough to identify a given item.
    where: DelegationFilter<Filterable>;
}

/**
 * Options available to find documents (one or many) after a given cursor.
 */
type CursorEnabledOptions<Item, Filterable, Iterable> = [Iterable] extends [never]
    ? IFindAnyOptions<Item, Filterable> : (IFindAnyOptions<Item, Filterable> & {
        cursor?: Cursor<Iterable>
    });

/** Options available to find the first document that optionally matches a given filter. */
export type IFindFirstOptions<Item, Filterable, Sortable extends string[] = never, Iterable = never> =
CursorEnabledOptions<Item, Filterable, Iterable> & {
    /* Return the first or last document depending on the specified order clause. */
    orderBy?: ColumnOrder<Sortable>;
    /** Skip a given number of document that match the same filter. */
    skip?: number;
};

/** Options available to find multiple documents that optionally match a given filter. */
export type IFindManyOptions<Item, Filterable, Sortable extends string[] = never, Iterable = never> =
IFindFirstOptions<Item, Filterable, Sortable, Iterable> & {
    /** Set the maximum number of documents in the result set. */
    take?: number;
};

/**
 * When retrieving a range of documents, once can specify the total number of documents to retrieve.
 * When retrieving a single document, that is not the case.
 */
type IFindRangeOptions<Item, Filterable, Sortable extends string[], Iterable> =
    IFindFirstOptions<Item, Filterable, Sortable, Iterable>
    | IFindManyOptions<Item, Filterable, Sortable, Iterable>;

/**
 * The options for available for finding a unique REST document are not the same as the ones available for finding a
 * range of documents.
 */
export type IFindOptions<Item, Filterable, Sortable extends string[], Iterable> =
    IFindRangeOptions<Item, Filterable, Sortable, Iterable>
    | IFindUniqueOptions<Item, Filterable>;

/**
 * Object with boolean fields that determine if the field should be included (or not) in the result set.
 *
 * @example
 * { select: { foo: { bar: true, baz: true } }
 * { select: { foo: { qux: false } } }
 */
export type BooleanFieldMapSelect<TableMetadata> = {
    /**
     * If the column contains a primitive value the type should be a boolean, otherwise, it contains an object value
     * and the type is inferred from the corresponding children.
     */
    [Key in keyof TableMetadata]?: TableMetadata[Key] extends (
        Primitive | JsonValue) ? boolean : NestingFieldMap<TableMetadata[Key]>;
};

/**
 * Non-empty list of fields identified by their full path using dot "." notation.
 *
 * @example
 * { foo: { bar: { baz: string } } } => ['foo.bar.baz']
 */
export type FieldNameSelect<Type> = { 0: FieldPath<Type> } & Array<FieldPath<Type>>;

/** Full path of a field. */
export type FieldPath<Type> = keyof {
    /**
     * In the presence of nested fields, the field path is composed by the names of each parent field in the branch.
     * The entire path up to the root is carried as a list of field names,
     */
    [ColumnName in keyof Type & string as NestingPath<ColumnName, Type[ColumnName]>]?: unknown;
};

/**
 * When a field contains a primitive value (i.e. not an object), it is a valid stop condition, otherwise it is a nested
 * field and the entire path needs to be composed.
 */
export type NestingPath<ParentPath extends string, Child> = Child extends (Primitive | JsonValue) ? ParentPath :
    `${ParentPath}.${NestingFieldName<Child> & string}`;

/**
 * With an array of field names, if a field contains an array, the path of each sub-field in the array needs to be
 * composed by iterating over the array.
 */
export type NestingFieldName<Type> = Type extends unknown[] ? FieldPath<Type[number]> : FieldPath<Type>;

/**
 * With a boolean field map, if a field contains an array, each boolean sub-field map needs to be checked by
 * iterating over the array.
 */
export type NestingFieldMap<Type> = Type extends unknown[] ? BooleanFieldMapSelect<Type[number]>
    : BooleanFieldMapSelect<Type> | boolean;

// delete*() API

/**
 * Options available when deleting a REST document.
 * To avoid unwarranted data loss, deleting REST documents always requires a filter.
 * Deleting a single item requires a filter that only matches unique fields.
 */
export type IDeleteOptions<Type, Options extends { many: boolean } = { many: true }> =
    Options["many"] extends true ? {
        where: DataFilter<Type>,
        readOwnWrites?: boolean,
    } : {
        where: DelegationFilter<Type>,
        readOwnWrites?: boolean,
    };

// update*() API

/**
 * Options available when updating a REST document.
 * For now, the options are exactly the same as when creating a REST document.
 */
export type IUpdateOptions<Type> = ICreateOptions<Type>;

/**
 * Top-level operators available for a query filter in MRS.
 */
type MrsQueryFilter<Sortable extends string[]> = {
    $orderby?: ColumnOrder<Sortable>, $asof?: string } & Record<string, unknown>;

/**
 * JSON utilities with MRS-specific glue code.
 */
class MrsJSON {
    public static stringify = <InputType>(obj: InputType): string => {
        return JSON.stringify(obj, (key: string, value: MaybeNull<{ not?: null; } & InputType>) => {
            // expand $notnull operator (lookup at the child level)
            // if we are operating at the root of the object, "not" is a field name, in which case, there is nothing
            // left to do
            // { where: { not: { foo: null } } } => ?q={"foo":{"$notnull":null}}
            if (key !== "" && typeof value === "object" && value !== null && value.not === null) {
                return { $notnull: null };
            }

            // expand $null operator
            // ignore correct variations of "$null" and "$notnull" to avoid infinite recursion
            // { where: { foo: null } } ?q={"foo":{"$null":null}}
            // { where: { not: null } } => ?q={"not":{"$null":null}}
            if (key !== "$notnull" && key !== "$null" && value === null) {
                return { $null: null };
            }

            if (typeof value === "bigint") {
                // value must be sent as string
                return `${value}`;
            }

            return value;
        });
    };

    public static parse = <ReturnType, BigIntFieldNames extends string[], FixedPointFieldNames extends string[]>(
        json: string, options: IMrsObjectMetadata<BigIntFieldNames, FixedPointFieldNames> = {}): ReturnType => {
        return JSON.parse(json, (key, value: MaybeNull<ReturnType>, context?: { source: string }) => {
            const rawValue = context?.source ?? String(value);

            if (value !== null && options.bigIntKeys?.includes(key)) {
                const int = BigInt(rawValue);

                if (int <= Number.MAX_SAFE_INTEGER && int >= Number.MIN_SAFE_INTEGER) {
                    return Number.parseInt(rawValue, 10);
                }

                return int;
            }

            if (value !== null && options.fixedPointKeys?.includes(key)) {
                // remove trailing zeros
                const normalized = rawValue.replace(/0+$/, "");
                const [_, int, decimal] = normalized.match(/(\d+).(\d+)/) ?? [];
                const isUnsafe = `${int}.${decimal}` !== Number.parseFloat(`${int}.${decimal}`)
                    .toFixed(decimal.length);

                return isUnsafe ? normalized : Number.parseFloat(normalized);
            }

            return value;
        }) as ReturnType;
    };
}

class MrsRequestBody<Doc> {
    public constructor(
        private readonly json: MrsDownstreamDocumentData<Doc>) {
    }

    public createProxy() {
        return new Proxy(this.json, {
            get: (target: MrsDownstreamDocumentData<Doc>, key) => {
                if (key === "toJSON") {
                    return this.serialize.bind(this);
                }

                return target[key];
            },
        });
    }

    private serialize() {
        return { ...this.json, _metadata: this.json._metadata };
    }
}

/**
 * @template Doc The type representing an MRS Document produced by a REST View.
 * @template KeyFieldNames The set of fields that constitute the identifying key of a REST View.
 */
class MrsDocument<Doc extends IPojo, PrimaryKeyFieldNames extends string[], BigIntFieldNames extends string[] = never,
    FixedPointFieldNames extends string[] = never> {
    #hypermediaProperties = ["_metadata", "links"];

    public constructor (
        private readonly json: Doc,
        private readonly schema: MrsBaseSchema,
        private readonly requestPath: string,
        private readonly options?: IMrsViewMetadata<PrimaryKeyFieldNames, BigIntFieldNames, FixedPointFieldNames>) {
    }

    /**
     * Create an application-level MRS Document object that hides hypermedia-related properties and prevents the
     * application from changing or deleting them.
     *
     * @see {MrsDownstreamDocumentData}
     * @returns An MRS Document without hypermedia-related properties.
     */
    public createProxy () {
        return new Proxy(this.json, {
            deleteProperty: (target, p) => {
                const property = String(p); // convert symbols to strings
                const isPrimaryKey = this.options?.identifierKeys?.includes(property);

                if (this.#hypermediaProperties.includes(property) || isPrimaryKey) {
                    throw new Error(`The "${property}" property cannot be deleted.`);
                }

                delete target[property];

                return true;
            },

            get: (target: MrsDownstreamDocumentData<Doc>, key) => {
                const property = String(key); // convert symbols to strings

                if (property === "toJSON") {
                    return this.deserialize.bind(this);
                }

                // primaryKeys only contains items if the "UPDATE" CRUD operation is enabled
                // and there are, in fact, primary keys
                if (property === "update" && this.options?.identifierKeys?.length) {
                    return this.update.bind(this);
                }

                // same as above
                if (property === "delete" && this.options?.identifierKeys?.length) {
                    return this.delete.bind(this);
                }

                return target[property];
            },

            has: (target, p) => {
                const property = String(p); // convert symbols to strings

                if (this.#hypermediaProperties.includes(property)) {
                    return false;
                }

                return p in target;
            },

            ownKeys: (target) => {
                return Object.keys(target).filter((key) => {
                    return !this.#hypermediaProperties.includes(key);
                });
            },

            set: (target, p, newValue) => {
                const property = String(p); // convert symbols to strings
                const isPrimaryKey = this.options?.identifierKeys?.includes(property);

                if (this.#hypermediaProperties.includes(property) || isPrimaryKey) {
                    throw new Error(`The "${property}" property cannot be changed.`);
                }

                // Ultimately, json is always a JSON object and any other field can be re-assigned to a different value.
                (target as JsonObject)[property] = newValue as JsonValue;

                return true;
            },
        });
    }

    private async delete(): Promise<boolean> {
        const queryFilter: Record<string, unknown> = {};

        // the proxy already guarantees that the primaryKeys property is always defined
        for (const key of this.options!.identifierKeys!) {
            queryFilter[key] = this.json[key];
        }

        const request = new MrsBaseObjectDelete(
            this.schema, this.requestPath, { where: queryFilter });
        const res = await request.fetch();

        return res.itemsDeleted > 0;
    }

    private deserialize(): Omit<MrsDownstreamDocumentData<Doc>, "links" | "_metadata"> {
        // We want to change a copy of the underlying json object, not the reference to the original one.
        const partial = { ...this.json as Omit<MrsDownstreamDocumentData<Doc>, "links" | "_metadata"> };
        delete partial.links;
        delete partial._metadata;

        return partial;
    }

    private async update(): Promise<MrsDownstreamDocumentData<Doc>> {
        const request = new MrsBaseObjectUpdate<Doc, Doc, PrimaryKeyFieldNames, BigIntFieldNames, FixedPointFieldNames>(
            // the proxy already guarantees that the primaryKeys property is always defined
            this.schema, this.requestPath, { data: this.json }, this.options);
        const response = await request.fetch();

        return response;
    }
}

/**
 * @template Doc The type representing an MRS Document produced by a REST View.
 * @template KeyFieldNames The set of fields that constitute the identifying key of a REST View.
 */
class MrsDocumentList<Doc, PrimaryKeyFieldNames extends string[], BigIntFieldNames extends string[] = never,
    FixedPointFieldNames extends string[] = never> {
    public constructor (
        private readonly json: MrsDownstreamDocumentListData<Doc>,
        private readonly schema: MrsBaseSchema,
        private readonly requestPath: string,
        private readonly options?: IMrsViewMetadata<PrimaryKeyFieldNames, BigIntFieldNames, FixedPointFieldNames>) {
    }

    /**
     * Create an application-level MRS Document object that hides hypermedia-related properties and prevents the
     * application from changing or deleting them.
     *
     * @see {MrsDownstreamDocumentListData}
     * @returns A list of MRS Documents without hypermedia-related properties.
     */
    public createProxy () {
        return new Proxy(this.json, {
            deleteProperty: (_, p) => {
                throw new Error(`The "${String(p)}" property cannot be deleted.`); // convert symbols to strings
            },

            get: (target: MrsDownstreamDocumentListData<Doc>, key, receiver: MrsDownstreamDocumentListData<Doc>) => {
                const property = String(key); // convert symbols to strings

                if (property !== "toJSON" && property !== "items") {
                    return target[property];
                }

                // .toJSON()
                if (property === "toJSON") {
                    return () => {
                        // Each item is already a Proxy that provides a custom toJSON() handler.
                        // This falls into the scope of the alternative condition below.
                        return receiver.items;
                    };
                }

                // .items
                return target.items.map((item) => {
                    const resource = new MrsDocument(
                        item, this.schema, this.requestPath, this.options);

                    return resource.createProxy();
                });
            },

            has: () => {
                return false;
            },

            ownKeys: () => {
                return [];
            },

            set: (_, p) => {
                throw new Error(`The "${String(p)}" property cannot be changed.`); // convert symbols to strings
            },
        });
    }
}

/**
 * @template Doc The entire set of fields of a given database object.
 * @template Filterable The set of fields of a given database object that can be used in a query filter.
 * @template Sortable The list of names of sortable fields in the corresponding REST object. It is optional
 * @template Iterable An optional set of fields of a given database object that can be used as cursors. It is optional
 * because it is not used by "findAll()" or "findFirst()", both of which, also create an instance of MrsBaseObjectQuery.
 * Creates an object that represents an MRS GET request.
 */
export class MrsBaseObjectQuery<Doc, Filterable, Sortable extends string[] = never, Iterable = never,
    PrimaryKeyFieldNames extends string[] = never, BigIntFieldNames extends string[] = never,
    FixedPointFieldNames extends string[] = never> {
    private where?: MrsQueryFilter<Sortable>;
    private exclude: string[] = [];
    private include: string[] = [];
    private offset?: number;
    private limit?: number;
    private hasCursor = false;

    public constructor(
        private readonly schema: MrsBaseSchema,
        private readonly requestPath: string,
        args?: IFindOptions<Doc, Filterable, Sortable, Iterable>,
        private readonly options?: IMrsViewMetadata<PrimaryKeyFieldNames, BigIntFieldNames, FixedPointFieldNames>) {
        if (args === undefined) {
            return;
        }

        const { cursor, orderBy, readOwnWrites, select, skip, take, where } =
        args as IFindManyOptions<Doc, Filterable, Sortable, Iterable> & { cursor?: Cursor<Iterable> };

        if (where !== undefined) {
            this.where = where;
        }

        if (orderBy !== undefined) {
            this.where = this.where ?? {};
            this.where.$orderby = orderBy;
        }

        if (readOwnWrites === true) {
            const gtid = this.schema.service.session.gtid;

            if (gtid !== undefined) {
                this.where = this.where ?? {};
                this.where.$asof = gtid;
            }
        }

        if (Array.isArray(select)) {
            for (const field of select) {
                this.include.push(field as string);
            }
        } else if (typeof select === "object") {
            this.include = this.fieldsToInclude(select);
            this.exclude = this.fieldsToExclude(select);
        }

        this.offset = skip;
        this.limit = take;

        if (cursor !== undefined) {
            this.hasCursor = true;
            this.where ??= {};

            for (const [key, value] of Object.entries(cursor)) {
                this.where[key] = { $gt: value };
                this.where.$orderby = { ...this.where.$orderby, [key]: "ASC" };
            }
        }
    }

    public fetch = async (): Promise<MrsDownstreamDocumentListData<Doc>> => {
        // Placeholder base URL just to avoid throwing an exception.
        const url = new URL("https://example.com");
        url.pathname = `${this.schema.requestPath}${this.requestPath}`;

        if (this.where !== undefined) {
            url.searchParams.set("q", MrsJSON.stringify(this.where));
        }

        if (this.include.length > 0) {
            url.searchParams.set("f", this.include.join(","));
        } else if (this.exclude.length > 0) {
            url.searchParams.set("f", `!${this.exclude.join(",!")}`);
        }

        if (this.limit !== undefined) {
            url.searchParams.set("limit", `${this.limit}`);
        }

        if (this.offset !== undefined && !this.hasCursor) {
            url.searchParams.set("offset", `${this.offset}`);
        }

        const response = await this.schema.service.session.doFetch({
            input: `${url.pathname}${url.search}`,
            errorMsg: "Failed to fetch items.",
        });

        const responseBody = await response.text();
        const json = MrsJSON.parse<MrsDownstreamDocumentListData<Doc>, BigIntFieldNames, FixedPointFieldNames>(
            responseBody, this.options);

        const collection = new MrsDocumentList(
            json, this.schema, this.requestPath, this.options);

        return collection.createProxy();
    };

    public fetchOne = async (): Promise<MrsDownstreamDocumentData<Doc> | undefined> => {
        const resultList = await this.fetch();

        if (resultList.items.length >= 1) {
            return resultList.items[0];
        } else {
            return undefined;
        }
    };

    private fieldsToInclude = (fields: BooleanFieldMapSelect<Doc>): string[] => {
        return this.fieldsToConsider(fields, true);
    };

    private fieldsToExclude = (fields: BooleanFieldMapSelect<Doc>): string[] => {
        return this.fieldsToConsider(fields, false);
    };

    private fieldsToConsider = (fields: BooleanFieldMapSelect<Doc>, equalTo: boolean,
        prefix = ""): string[] => {
        const consider: string[] = [];

        for (const key in fields) {
            const fullyQualifiedKeyName = `${prefix}${key}`;
            const value = fields[key];

            if (value === equalTo) {
                consider.push(fullyQualifiedKeyName);
            }

            if (typeof value === "object") {
                consider.push(...this.fieldsToConsider(value, equalTo, `${fullyQualifiedKeyName}.`));
            }
        }

        return consider;
    };
}

export class MrsBaseObjectCreate<Input, Output, PrimaryKeyFieldNames extends string[] = never,
    BigIntFieldNames extends string[] = never, FixedPointFieldNames extends string[] = never> {
    public constructor(
        protected schema: MrsBaseSchema,
        protected requestPath: string,
        protected args: ICreateOptions<Input>,
        protected options?: IMrsViewMetadata<PrimaryKeyFieldNames, BigIntFieldNames, FixedPointFieldNames>) {
    }

    public fetch = async (): Promise<MrsDownstreamDocumentData<Output>> => {
        const response = await this.schema.service.session.doFetch({
            input: `${this.schema.requestPath}${this.requestPath}`,
            method: "POST",
            body: MrsJSON.stringify(this.args.data),
            errorMsg: "Failed to create item.",
        });

        const responseBody = await response.text();
        const json = MrsJSON.parse<MrsDownstreamDocumentData<Output>, BigIntFieldNames, FixedPointFieldNames>(
            responseBody, this.options);
        this.schema.service.session.gtid = json._metadata.gtid;

        const resource = new MrsDocument(
            json, this.schema, this.requestPath, this.options);

        return resource.createProxy();
    };
}

export class MrsBaseObjectDelete<Filterable> {
    public constructor(
        protected schema: MrsBaseSchema,
        protected requestPath: string,
        protected options: IDeleteOptions<Filterable, { many: true }>) {
    }

    public fetch = async (): Promise<IMrsDeleteResult> => {
        const url = new URL("https://example.com");
        url.pathname = `${this.schema.requestPath}${this.requestPath}`;

        const { where, readOwnWrites } = this.options;
        const gtid = this.schema.service.session.gtid;

        if (readOwnWrites === true && gtid !== undefined) {
            url.searchParams.set("q", MrsJSON.stringify({ ...where, $asof: gtid }));
        } else {
            url.searchParams.set("q", MrsJSON.stringify(where));
        }

        const response = await this.schema.service.session.doFetch({
            input: `${url.pathname}${url.search}`,
            method: "DELETE",
            errorMsg: "Failed to delete items.",
        });

        const responseBody = await response.json() as IMrsDeleteResult;
        // _metadata is only available if a GTID is being tracked in the server
        this.schema.service.session.gtid = responseBody._metadata?.gtid;

        return responseBody;
    };
}

/**
 * @template InputType A type that enforces only mandatory fields.
 * @template OutputType It is not possible to narrow fields of an update response. So, all fields must be returned.
 */
export class MrsBaseObjectUpdate<InputType, OutputType, PrimaryKeyFieldNames extends string[],
    BigIntFieldNames extends string[] = never, FixedPointFieldNames extends string[] = never> {
    public constructor(
        protected schema: MrsBaseSchema,
        protected requestPath: string,
        protected args: IUpdateOptions<InputType>,
        protected options?: IMrsViewMetadata<PrimaryKeyFieldNames, BigIntFieldNames, FixedPointFieldNames>) {
    }

    public fetch = async (): Promise<MrsDownstreamDocumentData<OutputType>> => {
        const resourceIdComponents: Array<InputType[Extract<keyof InputType, string>]> = [];

        for (const x in this.args.data) {
            if (this.options?.identifierKeys?.includes(x)) {
                resourceIdComponents.push(this.args.data[x]);
            }
        }

        const data = new MrsRequestBody(this.args.data as MrsDownstreamDocumentData<InputType>);
        const dataProxy = data.createProxy();

        const response = await this.schema.service.session.doFetch({
            input: `${this.schema.requestPath}${this.requestPath}/${resourceIdComponents.join(",")}`,
            method: "PUT",
            body: MrsJSON.stringify(dataProxy),
            errorMsg: "Failed to update item.",
        });

        // The REST service returns a single resource, which is an ORDS-compatible object representation decorated with
        // additional fields such as "links" and "_metadata".
        const responseBody = await response.text();
        const json = MrsJSON.parse<MrsDownstreamDocumentData<OutputType>, BigIntFieldNames, FixedPointFieldNames>(
            responseBody, this.options);
        this.schema.service.session.gtid = json._metadata.gtid;

        const resource = new MrsDocument(
            json, this.schema, this.requestPath, this.options);

        return resource.createProxy();
    };
}

class MrsBaseObjectCall<Input, Output extends IMrsCommonRoutineResponse, BigIntParameterNames extends string[] = never,
    FixedPointParameterNames extends string[] = never> {
    protected constructor(
        protected schema: MrsBaseSchema,
        protected requestPath: string,
        protected params?: Input,
        protected options?: IMrsRoutineMetadata<BigIntParameterNames, FixedPointParameterNames>) {
    }

    protected async fetch(): Promise<Output> {
        const input = `${this.schema.requestPath}${this.requestPath}`;
        // If there are no input parameters and/or values, we need to still send a non-zero Content-Length
        // payload that is valid from the mime type standpoint (default: application/json).
        const body = MrsJSON.stringify(this.params ?? {});

        const response = await this.schema.service.session.doFetch({
            input,
            method: "POST",
            body: body,
            errorMsg: "Failed to call item.",
        });

        const responseBody = await response.text();
        const json = MrsJSON.parse<Output, BigIntParameterNames, FixedPointParameterNames>(responseBody, this.options);
        this.schema.service.session.gtid = json._metadata?.gtid;

        const resource = new MrsDocument(
            json, this.schema, this.requestPath);

        return resource.createProxy();
    }
}

export class MrsBaseObjectProcedureCall<InParams, OutParams, ResultSet extends JsonObject,
    BigIntParameterNames extends string[] = never, FixedPointParameterNames extends string[] = never>
    extends MrsBaseObjectCall<InParams, IMrsProcedureResponse<OutParams, ResultSet>, BigIntParameterNames,
        FixedPointParameterNames> {
    public constructor(
        protected override schema: MrsBaseSchema,
        protected override requestPath: string,
        protected override params?: InParams,
        protected override options?: IMrsRoutineMetadata<BigIntParameterNames, FixedPointParameterNames>) {
        super(schema, requestPath, params, options);
    }

    public override async fetch(): Promise<IMrsProcedureResponse<OutParams, ResultSet>> {
        const response = await super.fetch();

        response.resultSets = response.resultSets.map((resultSet) => {
            return (new MrsDocument(resultSet, this.schema, this.requestPath)).createProxy();
        });

        return response;
    }
}

export class MrsBaseObjectFunctionCall<Input, Output, BigIntParameterNames extends string[] = never,
    FixedPointParameterNames extends string[] = never>
    extends MrsBaseObjectCall<Input, IMrsFunctionResponse<Output>, BigIntParameterNames, FixedPointParameterNames> {
    public constructor(
        protected override schema: MrsBaseSchema,
        protected override requestPath: string,
        protected override params?: Input,
        protected override options?: IMrsRoutineMetadata<BigIntParameterNames, FixedPointParameterNames>) {
        super(schema, requestPath, params, options);
    }

    public override async fetch(): Promise<IMrsFunctionResponse<Output>> {
        return super.fetch();
    }
}

export class MrsAuthenticate {
    private static mrsVendorId = "0x30000000000000000000000000000000";

    public constructor(
        private readonly service: MrsBaseService,
        private readonly authApp: string,
        private readonly username: string,
        private readonly password = "",
        private vendorId?: string) {
    }

    public submit = async (): Promise<IMrsLoginResult> => {
        this.vendorId ??= await this.lookupVendorId();

        if (this.vendorId === MrsAuthenticate.mrsVendorId) {
            return this.authenticateUsingMrsNative();
        }

        return this.authenticateUsingMysqlInternal();
    };

    private lookupVendorId = async (): Promise<string> => {
        const authApps = await this.service.getAuthApps();
        const authApp = authApps.find((app) => {
            return app.name === this.authApp;
        });

        if (authApp === undefined) {
            throw new Error("Authentication failed. The authentication app does not exist.");
        }

        return authApp.vendorId;
    };

    private authenticateUsingMrsNative = async (): Promise<IMrsLoginResult> => {
        // SCRAM
        await this.service.session.sendClientFirst(this.authApp, this.username);
        const authenticationResponse = await this.service.session.sendClientFinal(this.password);

        return authenticationResponse;
    };

    private authenticateUsingMysqlInternal = async (): Promise<IMrsLoginResult> => {
        const authenticationResponse = await this.service.session.verifyCredentials({
            username: this.username,
            password: this.password,
            authApp: this.authApp,
        });

        return authenticationResponse;
    };
}

/**
 * Represents a MRS Service base class.
 *
 * MRS Service classes derive from this base class and add public MRS Schema objects that allow the user to work with
 * the MRS Service's MRS Schemas.
 *
 * Each services uses its own MrsBaseSession session to perform all fetch operations.
 */
export class MrsBaseService {
    public session: MrsBaseSession;

    public constructor(
        public readonly serviceUrl: string,
        protected readonly authPath = "/authentication",
        protected readonly defaultTimeout = 8000) {
        this.session = new MrsBaseSession(serviceUrl, authPath, defaultTimeout);
    }

    public async getAuthApps(): Promise<IMrsAuthApp[]> {
        const response = await this.session.doFetch({
            input: `${this.authPath}/authApps`,
            timeout: 3000,
            errorMsg: "Failed to fetch Authentication Apps.",
        });

        if (response.ok) {
            const result = await response.json() as IMrsAuthApp[];

            return result;
        } else {
            let errorInfo: MaybeNull<IMrsErrorResponse> = null;
            try {
                errorInfo = await response.json() as IMrsErrorResponse;
            } catch {
                // Ignore the exception
            }
            const errorDesc = "Failed to fetch Authentication Apps.\n\n" +
                "Please ensure MySQL Router is running and the REST endpoint " +
                `${String(this.serviceUrl)}${this.authPath}/authApps is accessible. `;

            throw new Error(errorDesc + `(${response.status}:${response.statusText})` +
                ((errorInfo !== null) ? ("\n\n" + JSON.stringify(errorInfo, null, 4) + "\n") : ""));
        }
    }

    public async authenticate (options: IAuthenticateOptions): Promise<IMrsLoginResult> {
        const { app, username, password, vendor } = options;
        const request = new MrsAuthenticate(this, app, username, password, vendor);
        const response = await request.submit();

        return response;
    }

    public async deauthenticate (): Promise<void> {
        return this.session.logout();
    }

    public async getMetadata (): Promise<JsonObject> {
        const response = await this.session.doFetch({ input: `/_metadata` });
        const metadata = await response.json() as JsonObject;

        return metadata;
    }
}

/**
 * Represents a MRS Schema base class.
 *
 * All MRS Schema classes derive from this class.
 */
export class MrsBaseSchema {
    public constructor(
        public service: MrsBaseService,
        public requestPath: string) {
    }

    public async getMetadata (): Promise<JsonObject> {
        const response = await this.service.session.doFetch({ input: `${this.requestPath}/_metadata` });
        const metadata = await response.json() as JsonObject;

        return metadata;
    }
}

export class MrsBaseObject {
    public constructor(
        protected schema: MrsBaseSchema,
        protected requestPath: string) {
    }

    public async getMetadata (): Promise<JsonObject> {
        const requestPath = `${this.schema.requestPath}${this.requestPath}/_metadata`;
        const response = await this.schema.service.session.doFetch({ input: requestPath });
        const metadata = await response.json() as JsonObject;

        return metadata;
    }
}

export interface IMrsTaskStartOptions {
    refreshRate?: number;
    timeout?: number;
}

export interface IMrsTaskRunOptions<MrsTaskStatusUpdate, MrsTaskResult> extends IMrsTaskStartOptions {
    progress?(this: void, report: IMrsRunningTaskReport<MrsTaskStatusUpdate, MrsTaskResult>): Promise<void>;
}

interface IMrsTaskStartResponse {
    taskId: string
    message: string
    statusUrl: string
}

type MrsTaskStatusUpdateStage = "SCHEDULED" | "RUNNING" | "COMPLETED" | "ERROR" | "CANCELLED";

interface IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult> {
    data: MrsTaskStatusUpdate | MrsTaskResult
    status: MrsTaskStatusUpdateStage
    message: string
    progress: number
}

interface IMrsScheduledTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    extends Omit<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>, "data" | "progress"> {
    status: "SCHEDULED"
}

export interface IMrsRunningTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    extends IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult> {
    data: MrsTaskStatusUpdate
    status: "RUNNING"
}

export interface IMrsCompletedTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    extends Omit<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>, "progress" | "status"> {
    data: MrsTaskResult
    status: "COMPLETED"
}

interface IMrsCancelledTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    extends Omit<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>, "data" | "progress"> {
    status: "CANCELLED"
}

interface IMrsErrorTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    extends Omit<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>, "data" | "progress"> {
    status: "ERROR"
}

interface IMrsTimedOutTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    extends Omit<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>, "data" | "progress" | "status"> {
    status: "TIMEOUT"
}

export type IMrsTaskReport<MrsTaskStatusUpdate, MrsTaskResult> =
    IMrsScheduledTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    | IMrsRunningTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    | IMrsCompletedTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    | IMrsCancelledTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    | IMrsErrorTaskReport<MrsTaskStatusUpdate, MrsTaskResult>
    | IMrsTimedOutTaskReport<MrsTaskStatusUpdate, MrsTaskResult>;

export class MrsBaseTaskStart<MrsTaskInputParameters, MrsTaskStatusUpdate, MrsTaskResult> {
    public constructor(
        private readonly schema: MrsBaseSchema,
        private readonly requestPath: string,
        private readonly params?: MrsTaskInputParameters,
        private readonly options: IMrsTaskRunOptions<MrsTaskStatusUpdate, MrsTaskResult> = { refreshRate: 2000 }) {
        const { refreshRate = 2000 } = this.options;
        if (typeof refreshRate !== "number" || refreshRate < 500) {
            throw new Error("Refresh rate needs to be a number greater than or equal to 500ms.");
        }
    }

    public async submit(): Promise<IMrsTaskStartResponse> {
        const input = `${this.schema.requestPath}${this.requestPath}`;
        // If there are no input parameters and/or values, we need to still send a non-zero Content-Length
        // payload that is valid from the mime type standpoint (default: application/json).
        const body = MrsJSON.stringify(this.params ?? {});

        const response = await this.schema.service.session.doFetch({
            input,
            method: "POST",
            body,
            errorMsg: "Failed to start task.",
        });

        const responseBody = await response.json() as IMrsTaskStartResponse;

        return responseBody;
    }
}

export class MrsBaseTaskWatch<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames extends string[] = never,
    FixedPointParameterNames extends string[] = never> {
    public constructor(
        private readonly schema: MrsBaseSchema,
        private readonly requestPath: string,
        protected readonly task: MrsTask<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames,
            FixedPointParameterNames>) {
    }

    async #getStatus(): Promise<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>> {
        const input = `${this.schema.requestPath}${this.requestPath}/${this.task.id}`;
        const response = await this.schema.service.session.doFetch({
            input,
            method: "GET",
            errorMsg: "Failed to retrieve the task status report.",
        });

        const responseBody = await response.text();
        const json = MrsJSON.parse<IMrsTaskStatusUpdateResponse<MrsTaskStatusUpdate, MrsTaskResult>,
            BigIntParameterNames, FixedPointParameterNames>(responseBody, this.task.options);

        return json;
    }

    public async* submit(): AsyncGenerator<IMrsTaskReport<MrsTaskStatusUpdate, MrsTaskResult>, void, void> {
        const startedAt = Date.now();
        const { refreshRate = 2000, progress, timeout } = this.task.options;
        // the timeout event should be produced only once
        let timeoutReached = false;

        while (true) {
            if (timeout !== undefined && Date.now() - startedAt > timeout && !timeoutReached) {
                timeoutReached = true;
                // a client-side timeout should not close the producer
                yield { message: `The timeout of ${timeout} ms has been exceeded.`, status: "TIMEOUT" };
            }

            const statusUpdate = await this.#getStatus();

            if (statusUpdate.status === "ERROR" || statusUpdate.status === "CANCELLED") {
                // these are both final status reports so they should close the producer
                const { message, status } = statusUpdate;

                return yield { message, status };
            }

            if (statusUpdate.status === "COMPLETED") {
                // TODO: add support for temporal value conversion
                // also a final status report that should close the producer
                const { message, status } = statusUpdate;

                let data: MrsTaskResult;

                // Procedures with an associated async task currently do not support result sets (see BUG#38039060).
                if (this.task.options.routineType === "FUNCTION") {
                    data = statusUpdate.data as MrsTaskResult;
                } else {
                    data = { resultSets: [], outParameters: statusUpdate.data } as MrsTaskResult;
                }

                return yield { data, message, status };
            }

            if (statusUpdate.status === "SCHEDULED") {
                // this is not a final status report, so the produced must be kept open
                const { message, status } = statusUpdate;

                yield { message, status };
            } else {
                const runningTaskReport = statusUpdate as IMrsRunningTaskReport<MrsTaskStatusUpdate, MrsTaskResult>;

                if (progress) {
                    await progress(runningTaskReport);
                }

                yield runningTaskReport;
            }

            // Ensure potential future status updates are retrieved in subsequent event loop iterations to avoid CPU
            // churn
            await new Promise((resolve) => {
                setTimeout(resolve, refreshRate);
            });
        }
    }
}

export class MrsBaseTaskRun<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames extends string[] = never,
    FixedPointParameterNames extends string[] = never>
    extends MrsBaseTaskWatch<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames, FixedPointParameterNames> {
    // @ts-expect-error undefined is never returned because all non-exception cases are handled in the loop
    public async execute(): Promise<MrsTaskResult> {
        const errorEvents = ["ERROR", "CANCELLED", "TIMEOUT"];

        for await (const response of super.submit()) {
            if (errorEvents.includes(response.status)) {
                if (response.status === "TIMEOUT") {
                    await this.task.kill();
                }

                throw new Error(response.message);
            }

            if (response.status === "COMPLETED") {
                return response.data;
            }
        }
    }
}

export class MrsTask<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames extends string[] = never,
    FixedPointParameterNames extends string[] = never> {
    public constructor(
        private readonly schema: MrsBaseSchema,
        private readonly requestPath: string,
        public readonly id: string,
        public readonly options: IMrsTaskOptions<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames,
            FixedPointParameterNames> = { refreshRate: 2000, routineType: "FUNCTION" }) {
    }

    public async kill(): Promise<void> {
        const input = `${this.schema.requestPath}${this.requestPath}/${this.id}`;
        const _ = await this.schema.service.session.doFetch({
            input,
            method: "DELETE",
            errorMsg: "Failed to kill the task.",
        });

        return;
    }

    public async* watch(): AsyncGenerator<
        IMrsTaskReport<MrsTaskStatusUpdate, MrsTaskResult>, void, unknown> {
        const request = new MrsBaseTaskWatch<MrsTaskStatusUpdate, MrsTaskResult, BigIntParameterNames,
            FixedPointParameterNames>(this.schema, this.requestPath, this);
        for await (const response of request.submit()) {
            yield response;
        }
    }
}
