import { DataSource } from '../data-source/DataSource';
import { ObjectLiteral } from '../common/ObjectLiteral';
import { QueryRunner } from '../query-runner/QueryRunner';
import { RelationMetadata } from '../metadata/RelationMetadata';
import { FindOptionsUtils } from '../find-options/FindOptionsUtils';
import { SelectQueryBuilder } from './SelectQueryBuilder';

/**
 * Wraps entities and creates getters/setters for their relations
 * to be able to lazily load relations when accessing these relations.
 */
export class RelationLoader {
	// -------------------------------------------------------------------------
	// Constructor
	// -------------------------------------------------------------------------

	constructor(private connection: DataSource) {}

	// -------------------------------------------------------------------------
	// Public Methods
	// -------------------------------------------------------------------------

	/**
	 * Loads relation data for the given entity and its relation.
	 */
	load(
		relation: RelationMetadata,
		entityOrEntities: ObjectLiteral | ObjectLiteral[],
		queryRunner?: QueryRunner,
		queryBuilder?: SelectQueryBuilder<any>,
	): Promise<any[]> {
		// todo: check all places where it uses non array
		if (queryRunner && queryRunner.isReleased) queryRunner = undefined; // get new one if already closed
		if (relation.isManyToOne || relation.isOneToOneOwner) {
			return this.loadManyToOneOrOneToOneOwner(
				relation,
				entityOrEntities,
				queryRunner,
				queryBuilder,
			);
		} else if (relation.isOneToMany || relation.isOneToOneNotOwner) {
			return this.loadOneToManyOrOneToOneNotOwner(
				relation,
				entityOrEntities,
				queryRunner,
				queryBuilder,
			);
		} else if (relation.isManyToManyOwner) {
			return this.loadManyToManyOwner(relation, entityOrEntities, queryRunner, queryBuilder);
		} else {
			// many-to-many non owner
			return this.loadManyToManyNotOwner(relation, entityOrEntities, queryRunner, queryBuilder);
		}
	}

	/**
	 * Loads data for many-to-one and one-to-one owner relations.
	 *
	 * (ow) post.category<=>category.post
	 * loaded: category from post
	 * example: SELECT category.id AS category_id, category.name AS category_name FROM category category
	 *              INNER JOIN post Post ON Post.category=category.id WHERE Post.id=1
	 */
	loadManyToOneOrOneToOneOwner(
		relation: RelationMetadata,
		entityOrEntities: ObjectLiteral | ObjectLiteral[],
		queryRunner?: QueryRunner,
		queryBuilder?: SelectQueryBuilder<any>,
	): Promise<any> {
		const entities = Array.isArray(entityOrEntities) ? entityOrEntities : [entityOrEntities];

		const joinAliasName = relation.entityMetadata.name;
		const qb = queryBuilder
			? queryBuilder
			: this.connection
					.createQueryBuilder(queryRunner)
					.select(relation.propertyName) // category
					.from(relation.type, relation.propertyName);

		const mainAlias = qb.expressionMap.mainAlias!.name;
		const columns = relation.entityMetadata.primaryColumns;
		const joinColumns = relation.isOwning
			? relation.joinColumns
			: relation.inverseRelation!.joinColumns;
		const conditions = joinColumns
			.map((joinColumn) => {
				return `${relation.entityMetadata.name}.${
					joinColumn.propertyName
				} = ${mainAlias}.${joinColumn.referencedColumn!.propertyName}`;
			})
			.join(' AND ');

		qb.innerJoin(relation.entityMetadata.target as Function, joinAliasName, conditions);

		if (columns.length === 1) {
			qb.where(
				`${joinAliasName}.${columns[0].propertyPath} IN (:...${
					joinAliasName + '_' + columns[0].propertyName
				})`,
			);
			qb.setParameter(
				joinAliasName + '_' + columns[0].propertyName,
				entities.map((entity) => columns[0].getEntityValue(entity, true)),
			);
		} else {
			const condition = entities
				.map((entity, entityIndex) => {
					return columns
						.map((column, columnIndex) => {
							const paramName = joinAliasName + '_entity_' + entityIndex + '_' + columnIndex;
							qb.setParameter(paramName, column.getEntityValue(entity, true));
							return joinAliasName + '.' + column.propertyPath + ' = :' + paramName;
						})
						.join(' AND ');
				})
				.map((condition) => '(' + condition + ')')
				.join(' OR ');
			qb.where(condition);
		}

		FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);

		return qb.getMany();
		// return qb.getOne(); todo: fix all usages
	}

	/**
	 * Loads data for one-to-many and one-to-one not owner relations.
	 *
	 * SELECT post
	 * FROM post post
	 * WHERE post.[joinColumn.name] = entity[joinColumn.referencedColumn]
	 */
	loadOneToManyOrOneToOneNotOwner(
		relation: RelationMetadata,
		entityOrEntities: ObjectLiteral | ObjectLiteral[],
		queryRunner?: QueryRunner,
		queryBuilder?: SelectQueryBuilder<any>,
	): Promise<any> {
		const entities = Array.isArray(entityOrEntities) ? entityOrEntities : [entityOrEntities];
		const columns = relation.inverseRelation!.joinColumns;
		const qb = queryBuilder
			? queryBuilder
			: this.connection
					.createQueryBuilder(queryRunner)
					.select(relation.propertyName)
					.from(relation.inverseRelation!.entityMetadata.target, relation.propertyName);

		const aliasName = qb.expressionMap.mainAlias!.name;

		if (columns.length === 1) {
			qb.where(
				`${aliasName}.${columns[0].propertyPath} IN (:...${
					aliasName + '_' + columns[0].propertyName
				})`,
			);
			qb.setParameter(
				aliasName + '_' + columns[0].propertyName,
				entities.map((entity) => columns[0].referencedColumn!.getEntityValue(entity, true)),
			);
		} else {
			const condition = entities
				.map((entity, entityIndex) => {
					return columns
						.map((column, columnIndex) => {
							const paramName = aliasName + '_entity_' + entityIndex + '_' + columnIndex;
							qb.setParameter(paramName, column.referencedColumn!.getEntityValue(entity, true));
							return aliasName + '.' + column.propertyPath + ' = :' + paramName;
						})
						.join(' AND ');
				})
				.map((condition) => '(' + condition + ')')
				.join(' OR ');
			qb.where(condition);
		}

		FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);

		return qb.getMany();
		// return relation.isOneToMany ? qb.getMany() : qb.getOne(); todo: fix all usages
	}

	/**
	 * Loads data for many-to-many owner relations.
	 *
	 * SELECT category
	 * FROM category category
	 * INNER JOIN post_categories post_categories
	 * ON post_categories.postId = :postId
	 * AND post_categories.categoryId = category.id
	 */
	loadManyToManyOwner(
		relation: RelationMetadata,
		entityOrEntities: ObjectLiteral | ObjectLiteral[],
		queryRunner?: QueryRunner,
		queryBuilder?: SelectQueryBuilder<any>,
	): Promise<any> {
		const entities = Array.isArray(entityOrEntities) ? entityOrEntities : [entityOrEntities];
		const parameters = relation.joinColumns.reduce((parameters, joinColumn) => {
			parameters[joinColumn.propertyName] = entities.map((entity) =>
				joinColumn.referencedColumn!.getEntityValue(entity, true),
			);
			return parameters;
		}, {} as ObjectLiteral);

		const qb = queryBuilder
			? queryBuilder
			: this.connection
					.createQueryBuilder(queryRunner)
					.select(relation.propertyName)
					.from(relation.type, relation.propertyName);

		const mainAlias = qb.expressionMap.mainAlias!.name;
		const joinAlias = relation.junctionEntityMetadata!.tableName;
		const joinColumnConditions = relation.joinColumns.map((joinColumn) => {
			return `${joinAlias}.${joinColumn.propertyName} IN (:...${joinColumn.propertyName})`;
		});
		const inverseJoinColumnConditions = relation.inverseJoinColumns.map((inverseJoinColumn) => {
			return `${joinAlias}.${inverseJoinColumn.propertyName}=${mainAlias}.${
				inverseJoinColumn.referencedColumn!.propertyName
			}`;
		});

		qb.innerJoin(
			joinAlias,
			joinAlias,
			[...joinColumnConditions, ...inverseJoinColumnConditions].join(' AND '),
		).setParameters(parameters);

		FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);

		return qb.getMany();
	}

	/**
	 * Loads data for many-to-many not owner relations.
	 *
	 * SELECT post
	 * FROM post post
	 * INNER JOIN post_categories post_categories
	 * ON post_categories.postId = post.id
	 * AND post_categories.categoryId = post_categories.categoryId
	 */
	loadManyToManyNotOwner(
		relation: RelationMetadata,
		entityOrEntities: ObjectLiteral | ObjectLiteral[],
		queryRunner?: QueryRunner,
		queryBuilder?: SelectQueryBuilder<any>,
	): Promise<any> {
		const entities = Array.isArray(entityOrEntities) ? entityOrEntities : [entityOrEntities];

		const qb = queryBuilder
			? queryBuilder
			: this.connection
					.createQueryBuilder(queryRunner)
					.select(relation.propertyName)
					.from(relation.type, relation.propertyName);

		const mainAlias = qb.expressionMap.mainAlias!.name;
		const joinAlias = relation.junctionEntityMetadata!.tableName;
		const joinColumnConditions = relation.inverseRelation!.joinColumns.map((joinColumn) => {
			return `${joinAlias}.${
				joinColumn.propertyName
			} = ${mainAlias}.${joinColumn.referencedColumn!.propertyName}`;
		});
		const inverseJoinColumnConditions = relation.inverseRelation!.inverseJoinColumns.map(
			(inverseJoinColumn) => {
				return `${joinAlias}.${inverseJoinColumn.propertyName} IN (:...${inverseJoinColumn.propertyName})`;
			},
		);
		const parameters = relation.inverseRelation!.inverseJoinColumns.reduce(
			(parameters, joinColumn) => {
				parameters[joinColumn.propertyName] = entities.map((entity) =>
					joinColumn.referencedColumn!.getEntityValue(entity, true),
				);
				return parameters;
			},
			{} as ObjectLiteral,
		);

		qb.innerJoin(
			joinAlias,
			joinAlias,
			[...joinColumnConditions, ...inverseJoinColumnConditions].join(' AND '),
		).setParameters(parameters);

		FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);

		return qb.getMany();
	}

	/**
	 * Wraps given entity and creates getters/setters for its given relation
	 * to be able to lazily load data when accessing this relation.
	 */
	enableLazyLoad(relation: RelationMetadata, entity: ObjectLiteral, queryRunner?: QueryRunner) {
		const relationLoader = this;
		const dataIndex = '__' + relation.propertyName + '__'; // in what property of the entity loaded data will be stored
		const promiseIndex = '__promise_' + relation.propertyName + '__'; // in what property of the entity loading promise will be stored
		const resolveIndex = '__has_' + relation.propertyName + '__'; // indicates if relation data already was loaded or not, we need this flag if loaded data is empty

		const setData = (entity: ObjectLiteral, value: any) => {
			entity[dataIndex] = value;
			entity[resolveIndex] = true;
			delete entity[promiseIndex];
			return value;
		};
		const setPromise = (entity: ObjectLiteral, value: Promise<any>) => {
			delete entity[resolveIndex];
			delete entity[dataIndex];
			entity[promiseIndex] = value;
			value.then(
				// ensure different value is not assigned yet
				(result) => (entity[promiseIndex] === value ? setData(entity, result) : result),
			);
			return value;
		};

		Object.defineProperty(entity, relation.propertyName, {
			get: function () {
				if (this[resolveIndex] === true || this[dataIndex] !== undefined)
					// if related data already was loaded then simply return it
					return Promise.resolve(this[dataIndex]);

				if (this[promiseIndex])
					// if related data is loading then return a promise relationLoader loads it
					return this[promiseIndex];

				// nothing is loaded yet, load relation data and save it in the model once they are loaded
				const loader = relationLoader
					.load(relation, this, queryRunner)
					.then((result) =>
						relation.isOneToOne || relation.isManyToOne
							? result.length === 0
								? null
								: result[0]
							: result,
					);
				return setPromise(this, loader);
			},
			set: function (value: any | Promise<any>) {
				if (value instanceof Promise) {
					// if set data is a promise then wait for its resolve and save in the object
					setPromise(this, value);
				} else {
					// if its direct data set (non promise, probably not safe-typed)
					setData(this, value);
				}
			},
			configurable: true,
			enumerable: false,
		});
	}
}
