import {
    IncludeEnum,
    Metadata,
    Metadatas,
    Embedding,
    Embeddings,
    Document,
    Documents,
    Where,
    WhereDocument,
    ID,
    IDs,
    PositiveInteger,
    GetResponse,
    QueryResponse,
    AddResponse,
    CollectionMetadata,
    ConfigOptions
} from "./types";
import { IEmbeddingFunction } from './embeddings/IEmbeddingFunction';
import { ApiApi as DefaultApi } from "./generated";
import { handleError, handleSuccess } from "./utils";
import { toArray, toArrayOfArrays } from "./utils";


export class Collection {
    public name: string;
    public id: string;
    public metadata: CollectionMetadata | undefined;
    /**
     * @ignore
     */
    private api: DefaultApi & ConfigOptions;
    /**
     * @ignore
     */
    public embeddingFunction: IEmbeddingFunction | undefined;

    /**
     * @ignore
     */
    constructor(
        name: string,
        id: string,
        api: DefaultApi,
        metadata?: CollectionMetadata,
        embeddingFunction?: IEmbeddingFunction
    ) {
        this.name = name;
        this.id = id;
        this.metadata = metadata;
        this.api = api;
        if (embeddingFunction !== undefined)
            this.embeddingFunction = embeddingFunction;
    }

    /**
     * @ignore
     */
    private setName(name: string): void {
        this.name = name;
    }
    /**
     * @ignore
     */
    private setMetadata(metadata: CollectionMetadata | undefined): void {
        this.metadata = metadata;
    }

    /**
     * @ignore
     */
    private async validate(
        require_embeddings_or_documents: boolean, // set to false in the case of Update
        ids: string | string[],
        embeddings: number[] | number[][] | undefined,
        metadatas?: object | object[],
        documents?: string | string[],
    ) {

        if (require_embeddings_or_documents) {
            if ((embeddings === undefined) && (documents === undefined)) {
                throw new Error(
                    "embeddings and documents cannot both be undefined",
                );
            }
        }

        if ((embeddings === undefined) && (documents !== undefined)) {
            const documentsArray = toArray(documents);
            if (this.embeddingFunction !== undefined) {
                embeddings = await this.embeddingFunction.generate(documentsArray);
            } else {
                throw new Error(
                    "embeddingFunction is undefined. Please configure an embedding function"
                );
            }
        }
        if (embeddings === undefined)
            throw new Error("embeddings is undefined but shouldnt be");

        const idsArray = toArray(ids);
        const embeddingsArray: number[][] = toArrayOfArrays(embeddings);

        let metadatasArray: object[] | undefined;
        if (metadatas === undefined) {
            metadatasArray = undefined;
        } else {
            metadatasArray = toArray(metadatas);
        }

        let documentsArray: (string | undefined)[] | undefined;
        if (documents === undefined) {
            documentsArray = undefined;
        } else {
            documentsArray = toArray(documents);
        }

        // validate all ids are strings
        for (let i = 0; i < idsArray.length; i += 1) {
            if (typeof idsArray[i] !== "string") {
                throw new Error(
                    `Expected ids to be strings, found ${typeof idsArray[i]} at index ${i}`
                );
            }
        }

        if (
            (embeddingsArray !== undefined &&
                idsArray.length !== embeddingsArray.length) ||
            (metadatasArray !== undefined &&
                idsArray.length !== metadatasArray.length) ||
            (documentsArray !== undefined &&
                idsArray.length !== documentsArray.length)
        ) {
            throw new Error(
                "ids, embeddings, metadatas, and documents must all be the same length"
            );
        }

        const uniqueIds = new Set(idsArray);
        if (uniqueIds.size !== idsArray.length) {
            const duplicateIds = idsArray.filter((item, index) => idsArray.indexOf(item) !== index);
            throw new Error(
                `Expected IDs to be unique, found duplicates for: ${duplicateIds}`,
            );
        }

        return [idsArray, embeddingsArray, metadatasArray, documentsArray]
    }

    /**
     * Add items to the collection
     * @param {Object} params - The parameters for the query.
     * @param {ID | IDs} [params.ids] - IDs of the items to add.
     * @param {Embedding | Embeddings} [params.embeddings] - Optional embeddings of the items to add.
     * @param {Metadata | Metadatas} [params.metadatas] - Optional metadata of the items to add.
     * @param {Document | Documents} [params.documents] - Optional documents of the items to add.
    * @returns {Promise<AddResponse>} - The response from the API. True if successful.
     *
     * @example
     * ```typescript
     * const response = await collection.add({
     *   ids: ["id1", "id2"],
     *   embeddings: [[1, 2, 3], [4, 5, 6]],
     *   metadatas: [{ "key": "value" }, { "key": "value" }],
     *   documents: ["document1", "document2"]
     * });
     * ```
     */
    public async add({
        ids,
        embeddings,
        metadatas,
        documents,
    }: {
        ids: ID | IDs,
        embeddings?: Embedding | Embeddings,
        metadatas?: Metadata | Metadatas,
        documents?: Document | Documents,
    }): Promise<AddResponse> {

        const [idsArray, embeddingsArray, metadatasArray, documentsArray] = await this.validate(
            true,
            ids,
            embeddings,
            metadatas,
            documents
        )

        const response = await this.api.add(this.id,
            {
                // @ts-ignore
                ids: idsArray,
                embeddings: embeddingsArray as number[][], // We know this is defined because of the validate function
                // @ts-ignore
                documents: documentsArray,
                // @ts-ignore
                metadatas: metadatasArray,
            }, this.api.options)
            .then(handleSuccess)
            .catch(handleError);

        return response
    }

    /**
     * Upsert items to the collection
     * @param {Object} params - The parameters for the query.
     * @param {ID | IDs} [params.ids] - IDs of the items to add.
     * @param {Embedding | Embeddings} [params.embeddings] - Optional embeddings of the items to add.
     * @param {Metadata | Metadatas} [params.metadatas] - Optional metadata of the items to add.
     * @param {Document | Documents} [params.documents] - Optional documents of the items to add.
     * @returns {Promise<boolean>} - The response from the API. True if successful.
     *
     * @example
     * ```typescript
     * const response = await collection.upsert({
     *   ids: ["id1", "id2"],
     *   embeddings: [[1, 2, 3], [4, 5, 6]],
     *   metadatas: [{ "key": "value" }, { "key": "value" }],
     *   documents: ["document1", "document2"],
     * });
     * ```
     */
    public async upsert({
        ids,
        embeddings,
        metadatas,
        documents,
    }: {
        ids: ID | IDs,
        embeddings?: Embedding | Embeddings,
        metadatas?: Metadata | Metadatas,
        documents?: Document | Documents,
    }): Promise<boolean> {
        const [idsArray, embeddingsArray, metadatasArray, documentsArray] = await this.validate(
            true,
            ids,
            embeddings,
            metadatas,
            documents
        )

        const response = await this.api.upsert(this.id,
            {
                //@ts-ignore
                ids: idsArray,
                embeddings: embeddingsArray as number[][], // We know this is defined because of the validate function
                //@ts-ignore
                documents: documentsArray,
                //@ts-ignore
                metadatas: metadatasArray,
            },
            this.api.options
        )
            .then(handleSuccess)
            .catch(handleError);

        return response

    }

    /**
     * Count the number of items in the collection
     * @returns {Promise<number>} - The response from the API.
     *
     * @example
     * ```typescript
     * const response = await collection.count();
     * ```
     */
    public async count(): Promise<number> {
        const response = await this.api.count(this.id, this.api.options);
        return handleSuccess(response);
    }

    /**
     * Modify the collection name or metadata
     * @param {Object} params - The parameters for the query.
     * @param {string} [params.name] - Optional new name for the collection.
     * @param {CollectionMetadata} [params.metadata] - Optional new metadata for the collection.
     * @returns {Promise<void>} - The response from the API.
     *
     * @example
     * ```typescript
     * const response = await collection.modify({
     *   name: "new name",
     *   metadata: { "key": "value" },
     * });
     * ```
     */
    public async modify({
        name,
        metadata
    }: {
        name?: string,
        metadata?: CollectionMetadata
    } = {}): Promise<void> {
        const response = await this.api
            .updateCollection(
                this.id,
                {
                    new_name: name,
                    new_metadata: metadata,
                },
                this.api.options
            )
            .then(handleSuccess)
            .catch(handleError);

        this.setName(name || this.name);
        this.setMetadata(metadata || this.metadata);

        return response;

    }

    /**
     * Get items from the collection
     * @param {Object} params - The parameters for the query.
     * @param {ID | IDs} [params.ids] - Optional IDs of the items to get.
     * @param {Where} [params.where] - Optional where clause to filter items by.
     * @param {PositiveInteger} [params.limit] - Optional limit on the number of items to get.
     * @param {PositiveInteger} [params.offset] - Optional offset on the items to get.
     * @param {IncludeEnum[]} [params.include] - Optional list of items to include in the response.
     * @param {WhereDocument} [params.whereDocument] - Optional where clause to filter items by.
     * @returns {Promise<GetResponse>} - The response from the server.
     *
     * @example
     * ```typescript
     * const response = await collection.get({
     *   ids: ["id1", "id2"],
     *   where: { "key": "value" },
     *   limit: 10,
     *   offset: 0,
     *   include: ["embeddings", "metadatas", "documents"],
     *   whereDocument: { $contains: "value" },
     * });
     * ```
     */
    public async get({
        ids,
        where,
        limit,
        offset,
        include,
        whereDocument,
    }: {
        ids?: ID | IDs,
        where?: Where,
        limit?: PositiveInteger,
        offset?: PositiveInteger,
        include?: IncludeEnum[],
        whereDocument?: WhereDocument
    } = {}): Promise<GetResponse> {
        let idsArray = undefined;
        if (ids !== undefined) idsArray = toArray(ids);

        return await this.api
            .aGet(this.id, {
                ids: idsArray,
                where,
                limit,
                offset,
                //@ts-ignore
                include,
                where_document: whereDocument,
            }, this.api.options)
            .then(handleSuccess)
            .catch(handleError);
    }

    /**
     * Update the embeddings, documents, and/or metadatas of existing items
     * @param {Object} params - The parameters for the query.
     * @param {ID | IDs} [params.ids] - The IDs of the items to update.
     * @param {Embedding | Embeddings} [params.embeddings] - Optional embeddings to update.
     * @param {Metadata | Metadatas} [params.metadatas] - Optional metadatas to update.
     * @param {Document | Documents} [params.documents] - Optional documents to update.
     * @returns {Promise<boolean>} - The API Response. True if successful. Else, error.
     *
     * @example
     * ```typescript
     * const response = await collection.update({
     *   ids: ["id1", "id2"],
     *   embeddings: [[1, 2, 3], [4, 5, 6]],
     *   metadatas: [{ "key": "value" }, { "key": "value" }],
     *   documents: ["new document 1", "new document 2"],
     * });
     * ```
     */
    public async update({
        ids,
        embeddings,
        metadatas,
        documents,
    }: {
        ids: ID | IDs,
        embeddings?: Embedding | Embeddings,
        metadatas?: Metadata | Metadatas,
        documents?: Document | Documents,
    }): Promise<boolean> {
        if (
            embeddings === undefined &&
            documents === undefined &&
            metadatas === undefined
        ) {
            throw new Error(
                "embeddings, documents, and metadatas cannot all be undefined"
            );
        } else if (embeddings === undefined && documents !== undefined) {
            const documentsArray = toArray(documents);
            if (this.embeddingFunction !== undefined) {
                embeddings = await this.embeddingFunction.generate(documentsArray);
            } else {
                throw new Error(
                    "embeddingFunction is undefined. Please configure an embedding function"
                );
            }
        }

        // backend expects None if metadatas is undefined
        if (metadatas !== undefined) metadatas = toArray(metadatas);
        if (documents !== undefined) documents = toArray(documents);

        var resp = await this.api
            .update(
                this.id,
                {
                    ids: toArray(ids),
                    embeddings: embeddings ? toArrayOfArrays(embeddings) : undefined,
                    documents: documents,
                    metadatas: metadatas
                },
                this.api.options
            )
            .then(handleSuccess)
            .catch(handleError);

        return resp;
    }

    /**
     * Performs a query on the collection using the specified parameters.
     *
     * @param {Object} params - The parameters for the query.
     * @param {Embedding | Embeddings} [params.queryEmbeddings] - Optional query embeddings to use for the search.
     * @param {PositiveInteger} [params.nResults] - Optional number of results to return (default is 10).
     * @param {Where} [params.where] - Optional query condition to filter results based on metadata values.
     * @param {string | string[]} [params.queryTexts] - Optional query text(s) to search for in the collection.
     * @param {WhereDocument} [params.whereDocument] - Optional query condition to filter results based on document content.
     * @param {IncludeEnum[]} [params.include] - Optional array of fields to include in the result, such as "metadata" and "document".
     *
     * @returns {Promise<QueryResponse>} A promise that resolves to the query results.
     * @throws {Error} If there is an issue executing the query.
     * @example
     * // Query the collection using embeddings
     * const results = await collection.query({
     *   queryEmbeddings: [[0.1, 0.2, ...], ...],
     *   nResults: 10,
     *   where: {"name": {"$eq": "John Doe"}},
     *   include: ["metadata", "document"]
     * });
     * @example
     * ```js
     * // Query the collection using query text
     * const results = await collection.query({
     *   queryTexts: "some text",
     *   nResults: 10,
     *   where: {"name": {"$eq": "John Doe"}},
     *   include: ["metadata", "document"]
     * });
     * ```
     *
     */
    public async query({
        queryEmbeddings,
        nResults,
        where,
        queryTexts,
        whereDocument,
        include,
    }: {
        queryEmbeddings?: Embedding | Embeddings,
        nResults?: PositiveInteger,
        where?: Where,
        queryTexts?: string | string[],
        whereDocument?: WhereDocument, // {"$contains":"search_string"}
        include?: IncludeEnum[] // ["metadata", "document"]
    }): Promise<QueryResponse> {
        if (nResults === undefined) nResults = 10
        if (queryEmbeddings === undefined && queryTexts === undefined) {
            throw new Error(
                "queryEmbeddings and queryTexts cannot both be undefined"
            );
        } else if (queryEmbeddings === undefined && queryTexts !== undefined) {
            const queryTextsArray = toArray(queryTexts);
            if (this.embeddingFunction !== undefined) {
                queryEmbeddings = await this.embeddingFunction.generate(queryTextsArray);
            } else {
                throw new Error(
                    "embeddingFunction is undefined. Please configure an embedding function"
                );
            }
        }
        if (queryEmbeddings === undefined)
            throw new Error("embeddings is undefined but shouldnt be");

        const query_embeddingsArray = toArrayOfArrays(queryEmbeddings);

        return await this.api
            .getNearestNeighbors(this.id, {
                query_embeddings: query_embeddingsArray,
                where,
                n_results: nResults,
                where_document: whereDocument,
                //@ts-ignore
                include: include,
            }, this.api.options)
            .then(handleSuccess)
            .catch(handleError);
    }

    /**
     * Peek inside the collection
     * @param {Object} params - The parameters for the query.
     * @param {PositiveInteger} [params.limit] - Optional number of results to return (default is 10).
     * @returns {Promise<GetResponse>} A promise that resolves to the query results.
     * @throws {Error} If there is an issue executing the query.
     *
     * @example
     * ```typescript
     * const results = await collection.peek({
     *   limit: 10
     * });
     * ```
     */
    public async peek({ limit }: { limit?: PositiveInteger } = {}): Promise<GetResponse> {
        if (limit === undefined) limit = 10;
        const response = await this.api.aGet(this.id, {
            limit: limit,
        }, this.api.options);
        return handleSuccess(response);
    }

    /**
     * Deletes items from the collection.
     * @param {Object} params - The parameters for deleting items from the collection.
     * @param {ID | IDs} [params.ids] - Optional ID or array of IDs of items to delete.
     * @param {Where} [params.where] - Optional query condition to filter items to delete based on metadata values.
     * @param {WhereDocument} [params.whereDocument] - Optional query condition to filter items to delete based on document content.
     * @returns {Promise<string[]>} A promise that resolves to the IDs of the deleted items.
     * @throws {Error} If there is an issue deleting items from the collection.
     *
     * @example
     * ```typescript
     * const results = await collection.delete({
     *   ids: "some_id",
     *   where: {"name": {"$eq": "John Doe"}},
     *   whereDocument: {"$contains":"search_string"}
     * });
     * ```
     */
    public async delete({
        ids,
        where,
        whereDocument
    }: {
        ids?: ID | IDs,
        where?: Where,
        whereDocument?: WhereDocument
    } = {}): Promise<string[]> {
        let idsArray = undefined;
        if (ids !== undefined) idsArray = toArray(ids);
        return await this.api
            .aDelete(this.id, { ids: idsArray, where: where, where_document: whereDocument }, this.api.options)
            .then(handleSuccess)
            .catch(handleError);
    }
}
