<?php

namespace SugarCalendarBookings\Support\Database;

use stdClass;
use SugarCalendarBookings\Support\Database\Relations\AbstractRelation;
use SugarCalendarBookings\Support\Database\Relations\BelongsToManyRelation;
use SugarCalendarBookings\Support\Database\Relations\BelongsToRelation;
use SugarCalendarBookings\Support\Database\Relations\HasManyRelation;
use SugarCalendarBookings\Support\Database\Relations\HasOneRelation;
use SugarCalendarBookings\Support\Database\Relations\MorphManyRelation;

/**
 * RelationsLoader class.
 *
 * Eager load relationships for the given models, supporting nested relations.
 *
 * @since 1.0.0
 */
class RelationsLoader {

	/**
	 * Build the relations tree for eager loading.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string|int, mixed> $relations Array of relationship names (dot notation for nesting) or [relation => constraint].
	 *
	 * @return array Relations tree for eager loading.
	 */
	public function build_relations_tree( $relations ) {

		$tree = [];

		foreach ( $relations as $key => $value ) {
			if ( is_int( $key ) ) {
				$relation   = $value;
				$constraint = null;
			} else {
				$relation   = $key;
				$constraint = $value;
			}

			$parts   = explode( '.', $relation );
			$current = &$tree;

			foreach ( $parts as $i => $part ) {
				if ( ! isset( $current[ $part ] ) ) {
					$current[ $part ] = [
						'constraint' => null,
						'relations'  => [],
					];
				}

				if ( $i === count( $parts ) - 1 ) {
					$current[ $part ]['constraint'] = $constraint;
				}

				$current = &$current[ $part ]['relations'];
			}
		}

		return $tree;
	}

	/**
	 * Eager load relationships for the given models, supporting nested relations.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models         Array of model instances.
	 * @param array                $relations_tree Nested array of relationship names.
	 */
	public function load_relations( $models, $relations_tree ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh, Generic.Metrics.CyclomaticComplexity.MaxExceeded

		if ( empty( $models ) || empty( $relations_tree ) ) {
			return;
		}

		foreach ( $relations_tree as $relation_name => $relation_config ) {
			if ( ! method_exists( $models[0], $relation_name ) ) {
				continue; // Skip if the relation does not exist on the model.
			}

			$relation = $models[0]->{$relation_name}();

			// Apply constraint if present.
			if (
				! empty( $relation_config['constraint'] ) &&
				is_callable( $relation_config['constraint'] )
			) {
				$relation_config['constraint']( $relation );
			}

			$relation_map = $this->load_relation( $models, $relation );

			$this->assign_relation( $models, $relation, $relation_name, $relation_map );

			// Recursively load nested relations.
			if ( ! empty( $relation_config['relations'] ) ) {
				$related_models = $this->collect_related_models( $models, $relation_name );

				if ( ! empty( $related_models ) ) {
					$this->load_relations( $related_models, $relation_config['relations'] );
				}
			}
		}
	}

	/**
	 * Load a relation for the given models.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models   Parent model instances.
	 * @param AbstractRelation     $relation Relation definition object.
	 *
	 * @return mixed Map of parent model key to related model(s).
	 */
	public function load_relation( $models, $relation ) {

		if ( $relation instanceof BelongsToRelation ) {
			return $this->load_belongs_to( $models, $relation );
		} elseif ( $relation instanceof HasManyRelation ) {
			return $this->load_has_many( $models, $relation );
		} elseif ( $relation instanceof HasOneRelation ) {
			return $this->load_has_one( $models, $relation );
		} elseif ( $relation instanceof BelongsToManyRelation ) {
			return $this->load_belongs_to_many( $models, $relation );
		} elseif ( $relation instanceof MorphManyRelation ) {
			return $this->load_morph_many( $models, $relation );
		}

		return $relation->get_default_value();
	}

	/**
	 * Collect all related models for a given relation from a set of parent models.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models        Parent model instances.
	 * @param string               $relation_name Name of the relation.
	 *
	 * @return array<AbstractModel> Flat array of related models.
	 */
	private function collect_related_models( $models, $relation_name ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded

		$related_models = [];

		foreach ( $models as $model ) {
			$value = $model->$relation_name;

			if ( is_array( $value ) ) {
				foreach ( $value as $rel ) {
					if ( ! empty( $rel ) ) {
						$related_models[] = $rel;
					}
				}
			} elseif ( ! empty( $value ) ) {
				$related_models[] = $value;
			}
		}

		return $related_models;
	}

	/**
	 * Fetch related models by key(s), using '=' for one value and 'IN' for multiple.
	 *
	 * @since 1.0.0
	 *
	 * @param ModelQueryBuilder $query  The query builder instance for the related model.
	 * @param string            $key    The column name to match.
	 * @param array<int>        $values The values to match against.
	 *
	 * @return array<AbstractModel> Array of related model instances.
	 */
	private function fetch_related_models( $query, $key, $values ) {

		if ( count( $values ) === 1 ) {
			return $query->where( $key, '=', reset( $values ), '%d' )->get();
		} else {
			return $query->where( $key, 'IN', $values, '%d' )->get();
		}
	}

	/**
	 * Eager load belongs_to relationships.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models   Parent model instances.
	 * @param BelongsToRelation    $relation Relation definition object.
	 */
	private function load_belongs_to( $models, $relation ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$foreign_key = $relation->get_foreign_key();
		$owner_key   = $relation->get_owner_key();
		$foreign_ids = $this->extract_model_keys( $models, $foreign_key );

		if ( empty( $foreign_ids ) ) {
			return [];
		}

		$query = $relation->query();

		$related_models = $this->fetch_related_models( $query, $owner_key, $foreign_ids );
		$related_map    = [];

		foreach ( $related_models as $model ) {
			$related_map[ $model->$owner_key ] = $model;
		}

		$result = [];

		foreach ( $models as $model ) {
			$result[ $model->id ] = $related_map[ $model->$foreign_key ] ?? $relation->get_default_value();
		}

		return $result;
	}

	/**
	 * Eager load has_many relationships.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models   Parent model instances.
	 * @param HasManyRelation      $relation Relation definition object.
	 */
	private function load_has_many( $models, $relation ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$foreign_key = $relation->get_foreign_key();
		$local_key   = $relation->get_local_key();
		$local_ids   = $this->extract_model_keys( $models, $local_key );

		if ( empty( $local_ids ) ) {
			return [];
		}

		$query = $relation->query();

		$related_models = $this->fetch_related_models( $query, $foreign_key, $local_ids );
		$grouped        = [];

		foreach ( $related_models as $model ) {
			$grouped[ $model->$foreign_key ][] = $model;
		}

		$result = [];

		foreach ( $models as $model ) {
			$result[ $model->id ] = $grouped[ $model->$local_key ] ?? $relation->get_default_value();
		}

		return $result;
	}

	/**
	 * Eager load has_one relationships.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models   Parent model instances.
	 * @param HasOneRelation       $relation Relation definition object.
	 */
	private function load_has_one( $models, $relation ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$foreign_key = $relation->get_foreign_key();
		$local_key   = $relation->get_local_key();
		$local_ids   = $this->extract_model_keys( $models, $local_key );

		if ( empty( $local_ids ) ) {
			return [];
		}

		$query = $relation->query();

		$related_models = $this->fetch_related_models( $query, $foreign_key, $local_ids );
		$related_map    = [];

		foreach ( $related_models as $model ) {
			$related_map[ $model->$foreign_key ] = $model;
		}

		$result = [];

		foreach ( $models as $model ) {
			$result[ $model->id ] = $related_map[ $model->$local_key ] ?? $relation->get_default_value();
		}

		return $result;
	}

	/**
	 * Eager load belongs_to_many (many-to-many) relationships.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel>  $models   Parent model instances.
	 * @param BelongsToManyRelation $relation Relation definition object.
	 */
	private function load_belongs_to_many( $models, $relation ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.CyclomaticComplexity.TooHigh

		$pivot_table       = $relation->get_pivot_table();
		$foreign_pivot_key = $relation->get_foreign_pivot_key();
		$related_pivot_key = $relation->get_related_pivot_key();
		$parent_key        = $relation->get_parent_key();
		$related_key       = $relation->get_related_key();
		$parent_ids        = $this->extract_model_keys( $models, $parent_key );

		if ( empty( $parent_ids ) ) {
			return [];
		}

		$pivot_query = ( new QueryBuilder() )
			->table( $pivot_table )
			->where( $foreign_pivot_key, 'IN', $parent_ids, '%d' )
			->select( array_merge( [ $foreign_pivot_key, $related_pivot_key ], $relation->get_with_pivot() ) );

		// Apply pivot_defaults as where clauses.
		$pivot_defaults = $relation->get_pivot_defaults();

		if ( ! empty( $pivot_defaults ) ) {
			foreach ( $pivot_defaults as $col => $def ) {
				$pivot_query->where( $col, '=', $def['value'], $def['placeholder'] );
			}
		}

		$pivot_rows  = $pivot_query->get();
		$related_ids = array_unique( array_column( $pivot_rows, $related_pivot_key ) );

		if ( empty( $related_ids ) ) {
			return [];
		}

		$query = $relation->query();

		$related_models = $this->fetch_related_models( $query, $related_key, $related_ids );
		$related_map    = [];

		foreach ( $related_models as $model ) {
			$related_map[ $model->$related_key ] = $model;
		}

		$parent_to_related  = [];
		$pivot_rows_by_pair = [];

		foreach ( $pivot_rows as $row ) {
			$pid                                = $row[ $foreign_pivot_key ];
			$rid                                = $row[ $related_pivot_key ];
			$pivot_rows_by_pair[ $pid ][ $rid ] = $row;

			if ( ! isset( $parent_to_related[ $pid ] ) ) {
				$parent_to_related[ $pid ] = [];
			}

			if ( isset( $related_map[ $rid ] ) ) {
				$parent_to_related[ $pid ][] = $related_map[ $rid ];
			}
		}

		// Hydrate pivot properties on related models if needed.
		if ( ! empty( $relation->get_with_pivot() ) ) {
			foreach ( $parent_to_related as $parent_id => &$related_models_per_parent ) {
				/*
				 * Clone each related model instance to ensure that the 'pivot' property can be safely assigned.
				 * This prevents shared object references between different parents, which could otherwise
				 * result in incorrect or overwritten pivot data when the same related model is attached to
				 * multiple parents.
				 */
				foreach ( $related_models_per_parent as $i => $model ) {
					$related_models_per_parent[ $i ] = clone $model;
				}

				$this->attach_pivot_data( $relation, $parent_id, $related_models_per_parent, $pivot_rows_by_pair );
			}
			unset( $related_models_per_parent );
		}

		foreach ( $models as $model ) {
			$result[ $model->id ] = $parent_to_related[ $model->$parent_key ] ?? $relation->get_default_value();
		}

		return $result;
	}

	/**
	 * Eager load morph_many (polymorphic one-to-many) relationships.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models   Parent model instances.
	 * @param MorphManyRelation    $relation Relation definition object.
	 */
	private function load_morph_many( $models, $relation ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$morph_type_column = $relation->get_morph_type_column();
		$morph_id_column   = $relation->get_morph_id_column();
		$object_type       = $relation->get_object_type();
		$local_key         = $relation->get_local_key();

		$local_ids = $this->extract_model_keys( $models, $local_key );

		if ( empty( $local_ids ) ) {
			return [];
		}

		$query = $relation->query()->where( $morph_type_column, '=', $object_type, '%s' );

		$related_models = $this->fetch_related_models( $query, $morph_id_column, $local_ids );
		$grouped        = [];

		foreach ( $related_models as $model ) {
			$grouped[ $model->$morph_id_column ][] = $model;
		}

		$result = [];

		foreach ( $models as $model ) {
			$result[ $model->id ] = $grouped[ $model->$local_key ] ?? $relation->get_default_value();
		}

		return $result;
	}

	/**
	 * Extract unique, non-empty values for a given key from an array of models.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models Array of model instances.
	 * @param string               $key    The property name to extract.
	 *
	 * @return array<int> Unique, non-empty values for the given key.
	 */
	private function extract_model_keys( $models, $key ) {

		return array_unique( array_filter( array_map( fn( $m ) => (int) $m->$key, $models ) ) );
	}

	/**
	 * Assign a relation map to the given models.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AbstractModel> $models        Array of model instances.
	 * @param AbstractRelation     $relation      Relation definition object.
	 * @param string               $relation_name Name of the relation.
	 * @param mixed                $relation_map  Map of parent model key to related model(s).
	 */
	private function assign_relation( $models, $relation, $relation_name, $relation_map ) {

		foreach ( $models as $model ) {
			$model->$relation_name = $relation_map[ $model->id ] ?? $relation->get_default_value();
		}
	}

	/**
	 * Attach pivot data as stdClass to related models.
	 *
	 * @since 1.0.0
	 *
	 * @param BelongsToManyRelation $relation   Relation definition object.
	 * @param int                   $parent_id  The parent ID.
	 * @param array<AbstractModel>  $models     Array of related model instances.
	 * @param array<array<string>>  $pivot_rows Array of pivot rows keyed by related key.
	 */
	private function attach_pivot_data( $relation, $parent_id, $models, $pivot_rows ) {

		foreach ( $models as $model ) {
			$related_id = $model->{ $relation->get_related_key() };

			if ( ! isset( $pivot_rows[ $parent_id ][ $related_id ] ) ) {
				continue;
			}

			$pivot_data = $pivot_rows[ $parent_id ][ $related_id ];
			$pivot      = new stdClass();

			foreach ( $relation->get_with_pivot() as $col ) {
				if ( array_key_exists( $col, $pivot_data ) ) {
					$pivot->{ $col } = $pivot_data[ $col ];
				}
			}

			$model->pivot = $pivot;
		}
	}
}
