import { Subject } from '../Subject';
import { OrmUtils } from '../../util/OrmUtils';
import { ObjectLiteral } from '../../common/ObjectLiteral';
import { RelationMetadata } from '../../metadata/RelationMetadata';

/**
 * Builds operations needs to be executed for many-to-many relations of the given subjects.
 *
 * by example: post contains owner many-to-many relation with categories in the property called "categories", e.g.
 *             @ManyToMany(type => Category, category => category.posts) categories: Category[]
 *             If user adds categories into the post and saves post we need to bind them.
 *             This operation requires updation of junction table.
 */
export class ManyToManySubjectBuilder {
	// ---------------------------------------------------------------------
	// Constructor
	// ---------------------------------------------------------------------

	constructor(protected subjects: Subject[]) {}

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

	/**
	 * Builds operations for any changes in the many-to-many relations of the subjects.
	 */
	build(): void {
		this.subjects.forEach((subject) => {
			// if subject doesn't have entity then no need to find something that should be inserted or removed
			if (!subject.entity) return;

			// go through all persistence enabled many-to-many relations and build subject operations for them
			subject.metadata.manyToManyRelations.forEach((relation) => {
				// skip relations for which persistence is disabled
				if (relation.persistenceEnabled === false) return;

				this.buildForSubjectRelation(subject, relation);
			});
		});
	}

	/**
	 * Builds operations for removal of all many-to-many records of all many-to-many relations of the given subject.
	 */
	buildForAllRemoval(subject: Subject) {
		// if subject does not have a database entity then it means it does not exist in the database
		// if it does not exist in the database then we don't have anything for deletion
		if (!subject.databaseEntity) return;

		// go through all persistence enabled many-to-many relations and build subject operations for them
		subject.metadata.manyToManyRelations.forEach((relation) => {
			// skip relations for which persistence is disabled
			if (relation.persistenceEnabled === false) return;

			// get all related entities (actually related entity relation ids) bind to this subject entity
			// by example: returns category ids of the post we are currently working with (subject.entity is post)
			const relatedEntityRelationIdsInDatabase: ObjectLiteral[] = relation.getEntityValue(
				subject.databaseEntity!,
			);

			// go through all related entities and create a new junction subject for each row in junction table
			relatedEntityRelationIdsInDatabase.forEach((relationId) => {
				const junctionSubject = new Subject({
					metadata: relation.junctionEntityMetadata!,
					parentSubject: subject,
					mustBeRemoved: true,
					identifier: this.buildJunctionIdentifier(subject, relation, relationId),
				});

				// we use unshift because we need to perform those operations before post deletion is performed
				// but post deletion was already added as an subject
				// this is temporary solution, later we need to implement proper sorting of subjects before their removal
				this.subjects.push(junctionSubject);
			});
		});
	}

	// ---------------------------------------------------------------------
	// Protected Methods
	// ---------------------------------------------------------------------

	/**
	 * Builds operations for a given subject and relation.
	 *
	 * by example: subject is "post" entity we are saving here and relation is "categories" inside it here.
	 */
	protected buildForSubjectRelation(subject: Subject, relation: RelationMetadata) {
		// load from db all relation ids of inverse entities that are "bind" to the subject's entity
		// this way we gonna check which relation ids are missing and which are new (e.g. inserted or removed)
		let databaseRelatedEntityIds: ObjectLiteral[] = [];

		// if subject don't have database entity it means all related entities in persisted subject are new and must be bind
		// and we don't need to remove something that is not exist
		if (subject.databaseEntity) {
			const databaseRelatedEntityValue = relation.getEntityValue(subject.databaseEntity);
			if (databaseRelatedEntityValue) {
				databaseRelatedEntityIds = databaseRelatedEntityValue.map((e: any) =>
					relation.inverseEntityMetadata.getEntityIdMap(e),
				);
			}
		}

		// extract entity's relation value
		// by example: categories inside our post (subject.entity is post)
		let relatedEntities: ObjectLiteral[] = relation.getEntityValue(subject.entity!);
		if (relatedEntities === null)
			// if value set to null its equal if we set it to empty array - all items must be removed from the database
			relatedEntities = [];
		if (!Array.isArray(relatedEntities)) return;

		// from all related entities find only those which aren't found in the db - for them we will create operation subjects
		relatedEntities.forEach((relatedEntity) => {
			// by example: relatedEntity is category from categories saved with post

			// todo: check how it will work for entities which are saved by cascades, but aren't saved in the database yet

			// extract only relation id from the related entities, since we only need it for comparison
			// by example: extract from category only relation id (category id, or let's say category title, depend on join column options)
			let relatedEntityRelationIdMap =
				relation.inverseEntityMetadata!.getEntityIdMap(relatedEntity);

			// try to find a subject of this related entity, maybe it was loaded or was marked for persistence
			const relatedEntitySubject = this.subjects.find((subject) => {
				return subject.entity === relatedEntity;
			});

			// if subject with entity was found take subject identifier as relation id map since it may contain extra properties resolved
			if (relatedEntitySubject) relatedEntityRelationIdMap = relatedEntitySubject.identifier;

			// if related entity relation id map is empty it means related entity is newly persisted
			if (!relatedEntityRelationIdMap) {
				// we decided to remove this error because it brings complications when saving object with non-saved entities
				// if related entity does not have a subject then it means user tries to bind entity which wasn't saved
				// in this persistence because he didn't pass this entity for save or he did not set cascades
				// but without entity being inserted we cannot bind it in the relation operation, so we throw an exception here
				// we decided to remove this error because it brings complications when saving object with non-saved entities
				// if (!relatedEntitySubject)
				//     throw new TypeORMError(`Many-to-many relation "${relation.entityMetadata.name}.${relation.propertyPath}" contains ` +
				//         `entities which do not exist in the database yet, thus they cannot be bind in the database. ` +
				//         `Please setup cascade insertion or save entities before binding it.`);
				if (!relatedEntitySubject) return;
			}

			// try to find related entity in the database
			// by example: find post's category in the database post's categories
			const relatedEntityExistInDatabase = databaseRelatedEntityIds.find(
				(databaseRelatedEntityRelationId) => {
					return OrmUtils.compareIds(databaseRelatedEntityRelationId, relatedEntityRelationIdMap);
				},
			);

			// if entity is found then don't do anything - it means binding in junction table already exist, we don't need to add anything
			if (relatedEntityExistInDatabase) return;

			const ownerValue = relation.isOwning ? subject : relatedEntitySubject || relatedEntity; // by example: ownerEntityMap is post from subject here
			const inverseValue = relation.isOwning ? relatedEntitySubject || relatedEntity : subject; // by example: inverseEntityMap is category from categories array here

			// create a new subject for insert operation of junction rows
			const junctionSubject = new Subject({
				metadata: relation.junctionEntityMetadata!,
				parentSubject: subject,
				canBeInserted: true,
			});
			this.subjects.push(junctionSubject);

			relation.junctionEntityMetadata!.ownerColumns.forEach((column) => {
				junctionSubject.changeMaps.push({
					column: column,
					value: ownerValue,
					// valueFactory: (value) => column.referencedColumn!.getEntityValue(value) // column.referencedColumn!.getEntityValue(ownerEntityMap),
				});
			});

			relation.junctionEntityMetadata!.inverseColumns.forEach((column) => {
				junctionSubject.changeMaps.push({
					column: column,
					value: inverseValue,
					// valueFactory: (value) => column.referencedColumn!.getEntityValue(value) // column.referencedColumn!.getEntityValue(inverseEntityMap),
				});
			});
		});

		// get all inverse entities relation ids that are "bind" to the currently persisted entity
		const changedInverseEntityRelationIds: ObjectLiteral[] = [];
		relatedEntities.forEach((relatedEntity) => {
			// relation.inverseEntityMetadata!.getEntityIdMap(relatedEntity)
			let relatedEntityRelationIdMap =
				relation.inverseEntityMetadata!.getEntityIdMap(relatedEntity);

			// try to find a subject of this related entity, maybe it was loaded or was marked for persistence
			const relatedEntitySubject = this.subjects.find((subject) => {
				return subject.entity === relatedEntity;
			});

			// if subject with entity was found take subject identifier as relation id map since it may contain extra properties resolved
			if (relatedEntitySubject) relatedEntityRelationIdMap = relatedEntitySubject.identifier;

			if (relatedEntityRelationIdMap !== undefined && relatedEntityRelationIdMap !== null)
				changedInverseEntityRelationIds.push(relatedEntityRelationIdMap);
		});

		// now from all entities in the persisted entity find only those which aren't found in the db
		const removedJunctionEntityIds = databaseRelatedEntityIds.filter((existRelationId) => {
			return !changedInverseEntityRelationIds.find((changedRelationId) => {
				return OrmUtils.compareIds(changedRelationId, existRelationId);
			});
		});

		// finally create a new junction remove operations for missing related entities
		removedJunctionEntityIds.forEach((removedEntityRelationId) => {
			const junctionSubject = new Subject({
				metadata: relation.junctionEntityMetadata!,
				parentSubject: subject,
				mustBeRemoved: true,
				identifier: this.buildJunctionIdentifier(subject, relation, removedEntityRelationId),
			});
			this.subjects.push(junctionSubject);
		});
	}

	/**
	 * Creates identifiers for junction table.
	 * Example: { postId: 1, categoryId: 2 }
	 */
	protected buildJunctionIdentifier(
		subject: Subject,
		relation: RelationMetadata,
		relationId: ObjectLiteral,
	) {
		const ownerEntityMap = relation.isOwning ? subject.entity! : relationId;
		const inverseEntityMap = relation.isOwning ? relationId : subject.entity!;

		const identifier: ObjectLiteral = {};
		relation.junctionEntityMetadata!.ownerColumns.forEach((column) => {
			OrmUtils.mergeDeep(
				identifier,
				column.createValueMap(column.referencedColumn!.getEntityValue(ownerEntityMap)),
			);
		});
		relation.junctionEntityMetadata!.inverseColumns.forEach((column) => {
			OrmUtils.mergeDeep(
				identifier,
				column.createValueMap(column.referencedColumn!.getEntityValue(inverseEntityMap)),
			);
		});
		return identifier;
	}
}
