// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { ModelFieldType, ModelMeta, isFieldAssociation } from '../types';

/**
 * Defines a relationship from a LOCAL model.field to a REMOTE model.field and helps
 * navigate the relationship, providing a simplified peek at the relationship details
 * pertinent to setting FK's and constructing join conditions.
 *
 * Because I mean, relationships are tough.
 *
 */
export class ModelRelationship<T> {
	private localModel: ModelMeta<T>;
	private _field: string;

	/**
	 * @param modelDefinition The "local" model.
	 * @param field The "local" model field.
	 */
	constructor(model: ModelMeta<T>, field: string) {
		if (!isFieldAssociation(model.schema, field)) {
			throw new Error(`${model.schema.name}.${field} is not a relationship.`);
		}
		this.localModel = model;
		this._field = field;
	}

	/**
	 * Returns a ModelRelationship for the the given model and field if the pair
	 * indicates a relationship to another model. Else, returns `null`.
	 *
	 * @param model The model the relationship field exists in.
	 * @param field The field that may relates the local model to the remote model.
	 */
	static from<T>(model: ModelMeta<T>, field: string) {
		if (isFieldAssociation(model.schema, field)) {
			return new this(model, field);
		} else {
			return null;
		}
	}

	/**
	 * Enumerates all valid `ModelRelationship`'s on the given model.
	 *
	 * @param model The model definition to enumerate relationships of.
	 */
	static allFrom<T>(model: ModelMeta<T>) {
		const relationships: ModelRelationship<T>[] = [];
		for (const field of Object.keys(model.schema.fields)) {
			const relationship = ModelRelationship.from(model, field);
			relationship && relationships.push(relationship);
		}

		return relationships;
	}

	private get localDefinition() {
		return this.localModel.schema;
	}

	/**
	 * The virtual/computed field on the local model that should contain
	 * the related model.
	 */
	get field() {
		return this._field;
	}

	/**
	 * The constructor that can be used to query DataStore or create instance for
	 * the local model.
	 */
	get localConstructor() {
		return this.localModel.builder;
	}

	/**
	 * The name/type of the relationship the local model has with the remote model
	 * via the defined local model field.
	 */
	get type() {
		return this.localAssocation.connectionType;
	}

	/**
	 * Raw details about the local FK as-is from the local model's field definition in
	 * the schema. This field requires interpretation.
	 *
	 * @see localJoinFields
	 * @see localAssociatedWith
	 */
	private get localAssocation() {
		return this.localDefinition.fields[this.field].association!;
	}

	/**
	 * The field names on the local model that can be used to query or queried to match
	 * with instances of the remote model.
	 *
	 * Fields are returned in-order to match the order of `this.remoteKeyFields`.
	 */
	get localJoinFields() {
		/**
		 * This is relatively straightforward, actually.
		 *
		 * If we have explicitly stated targetNames, codegen is telling us authoritatively
		 * to use those fields for this relationship. The local model "points to" fields
		 * in the remote one.
		 *
		 * In other cases, the remote model points to this one's
		 */
		if (this.localAssocation.targetName) {
			// This case is theoretically unnecessary going forward.
			return [this.localAssocation.targetName];
		} else if (this.localAssocation.targetNames) {
			return this.localAssocation.targetNames;
		} else {
			return this.localPKFields;
		}
	}

	/**
	 * The field names on the local model that uniquely identify it.
	 *
	 * These fields may or may not be relevant to the join fields.
	 */
	get localPKFields() {
		return this.localModel.pkField;
	}

	get remoteDefinition() {
		return this.remoteModelType.modelConstructor?.schema;
	}

	private get remoteModelType() {
		return this.localDefinition.fields[this.field].type as ModelFieldType;
	}

	/**
	 * Constructor that can be used to query DataStore or create instances for
	 * the remote model.
	 */
	get remoteModelConstructor() {
		return this.remoteModelType.modelConstructor!.builder!;
	}

	/**
	 * The field names on the remote model that uniquely identify it.
	 *
	 * These fields may or may not be relevant to the join fields.
	 */
	get remotePKFields() {
		return this.remoteModelType.modelConstructor?.pkField || ['id'];
	}

	/**
	 * The `associatedWith` fields from the local perspective.
	 *
	 * When present, these fields indicate which fields on the remote model to use
	 * when looking for a remote association and/or determining the final remote
	 * key fields.
	 */
	private get localAssociatedWith() {
		if (
			this.localAssocation.connectionType === 'HAS_MANY' ||
			this.localAssocation.connectionType === 'HAS_ONE'
		) {
			// This de-arraying is theoretically unnecessary going forward.
			return Array.isArray(this.localAssocation.associatedWith)
				? this.localAssocation.associatedWith
				: [this.localAssocation.associatedWith];
		} else {
			return undefined;
		}
	}

	/**
	 * The `remote` model's associated field's `assocation` metadata, if
	 * present.
	 *
	 * This is used when determining if the remote model's associated field
	 * specifies which FK fields to use. If this value is `undefined`, the
	 * name of the remote field (`this.localAssociatedWith`) *is* the remote
	 * key field.
	 */
	private get explicitRemoteAssociation() {
		if (this.localAssociatedWith) {
			if (this.localAssociatedWith.length === 1) {
				return this.remoteDefinition!.fields[this.localAssociatedWith[0]]
					?.association;
			} else {
				return undefined;
			}
		}
	}

	/**
	 * The field names on the remote model that can used to query or queried to match
	 * with instances of the local model.
	 *
	 * Fields are returned in-order to match the order of `this.localKeyFields`.
	 */
	get remoteJoinFields() {
		/**
		 * If the local relationship explicitly names "associated with" fields, we
		 * need to see if this points direction to a reciprocating assocation. If it
		 * does, the remote assocation indicates what fields to use.
		 */

		if (this.explicitRemoteAssociation?.targetName) {
			// This case is theoretically unnecessary going forward.
			return [this.explicitRemoteAssociation.targetName!];
		} else if (this.explicitRemoteAssociation?.targetNames) {
			// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
			return this.explicitRemoteAssociation?.targetNames!;
		} else if (this.localAssociatedWith) {
			return this.localAssociatedWith;
		} else {
			return this.remotePKFields;
		}
	}

	/**
	 * Whether this relationship everything necessary to get, set, and query from
	 * the perspective of the local model provided at instantiation.
	 */
	get isComplete() {
		return this.localJoinFields.length > 0 && this.remoteJoinFields.length > 0;
	}

	/**
	 * Creates an FK mapper object with respect to the given related instance.
	 *
	 * E.g., if the local FK fields are `[parentId, parentName]` and point to
	 * `[customId, name]` on the remote model, `createLocalFKObject(remote)`
	 * will return:
	 *
	 * ```
	 * {
	 * 	parentId: remote.customId,
	 * 	parentName: remote.name
	 * }
	 * ```
	 *
	 * @param remote The remote related instance.
	 */
	createLocalFKObject(remote: any) {
		const fk = {} as Record<string, string>;
		for (let i = 0; i < this.localJoinFields.length; i++) {
			fk[this.localJoinFields[i]] = remote[this.remoteJoinFields[i]];
		}

		return fk;
	}

	/**
	 * Creates an query mapper object to help fetch the remote instance(s) or
	 * `null` if any of the necessary local fields are `null` or `undefined`.
	 *
	 * E.g., if the local FK fields are `[parentId, parentName]` and point to
	 * `[customId, name]` on the remote model, `createLocalFKObject(remote)`
	 * will return:
	 *
	 * ```
	 * {
	 * 	customId: local.parentId
	 * 	name: local.parentName
	 * }
	 * ```
	 *
	 * If the local fields are not populated, returns
	 *
	 * @param local The local instance.
	 */
	createRemoteQueryObject(local: T) {
		const query = {} as Record<string, string>;
		for (let i = 0; i < this.remoteJoinFields.length; i++) {
			const localValue = local[this.localJoinFields[i]];
			if (localValue === null || localValue === undefined) return null;
			query[this.remoteJoinFields[i]] = local[this.localJoinFields[i]];
		}

		return query;
	}
}
