<?php

namespace SugarCalendarBookings\Support\Database\Relations;

// phpcs:ignore WPForms.PHP.UseStatement.UnusedUseStatement
use SugarCalendarBookings\Support\Database\AbstractModel;
use SugarCalendarBookings\Support\Database\QueryBuilder;

/**
 * Belongs to many (many-to-many) relation class.
 *
 * @since 1.0.0
 */
class BelongsToManyRelation extends AbstractRelation {

	/**
	 * Pivot table name.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	protected $pivot_table;

	/**
	 * Foreign key on the pivot table for the parent model.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	protected $foreign_pivot_key;

	/**
	 * Foreign key on the pivot table for the related model.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	protected $related_pivot_key;

	/**
	 * Parent key on the parent model.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	protected $parent_key;

	/**
	 * Related key on the related model.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	protected $related_key;

	/**
	 * Default pivot values and placeholders for this relation.
	 *
	 * @since 1.0.0
	 *
	 * @var array<string, array{value: mixed, placeholder: string}>
	 */
	protected $pivot_defaults = [];

	/**
	 * Columns to load from the pivot table.
	 *
	 * @since 1.0.0
	 *
	 * @var array<string>
	 */
	protected $with_pivot = [];

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 *
	 * @param AbstractModel               $parent            Parent model instance.
	 * @param class-string<AbstractModel> $related           Related model class.
	 * @param string                      $pivot_table       Pivot table name.
	 * @param string                      $foreign_pivot_key Foreign key for parent model.
	 * @param string                      $related_pivot_key Foreign key for related model.
	 * @param string                      $parent_key        Parent key.
	 * @param string                      $related_key       Related key.
	 * @param array                       $pivot_defaults    Default pivot values and placeholders.
	 */
	public function __construct( $parent, $related, $pivot_table, $foreign_pivot_key, $related_pivot_key, $parent_key, $related_key, $pivot_defaults = [] ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound

		parent::__construct( $parent, $related );

		$this->pivot_table       = $pivot_table;
		$this->foreign_pivot_key = $foreign_pivot_key;
		$this->related_pivot_key = $related_pivot_key;
		$this->parent_key        = $parent_key;
		$this->related_key       = $related_key;
		$this->pivot_defaults    = $pivot_defaults;
	}

	/**
	 * Get the pivot table name.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_pivot_table() {

		return $this->pivot_table;
	}

	/**
	 * Get the foreign pivot key.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_foreign_pivot_key() {

		return $this->foreign_pivot_key;
	}

	/**
	 * Get the related pivot key.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_related_pivot_key() {

		return $this->related_pivot_key;
	}

	/**
	 * Get the parent key.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_parent_key() {

		return $this->parent_key;
	}

	/**
	 * Get the related key.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_related_key() {

		return $this->related_key;
	}

	/**
	 * Get the pivot defaults (value and placeholder) for this relation.
	 *
	 * @since 1.0.0
	 *
	 * @return array<string, array{value: mixed, placeholder: string}>
	 */
	public function get_pivot_defaults() {

		return $this->pivot_defaults;
	}

	/**
	 * Get the default value for the relation.
	 *
	 * @since 1.0.0
	 *
	 * @return mixed
	 */
	public function get_default_value() {

		return [];
	}

	/**
	 * Get a new query builder instance.
	 *
	 * @since 1.0.0
	 *
	 * @return QueryBuilder
	 */
	private function pivot_query() {

		return ( new QueryBuilder() )
			->table( $this->pivot_table );
	}

	/**
	 * Apply pivot default where conditions to a QueryBuilder instance.
	 *
	 * @since 1.0.0
	 *
	 * @param QueryBuilder $query The query builder instance.
	 */
	private function apply_pivot_defaults( QueryBuilder $query ) {

		foreach ( $this->get_pivot_defaults() as $key => $def ) {
			$query->where( $key, '=', $def['value'], $def['placeholder'] );
		}
	}

	/**
	 * Attach one or more related models to the parent model in the pivot table.
	 *
	 * Supports both single and multiple attachments with individual pivot data.
	 *
	 * @since 1.0.0
	 *
	 * @param AbstractModel|int|array $related                 Related model instance, related key value, or array of related IDs => pivot data.
	 * @param array                   $pivot_data              Optional additional pivot data (for single attach).
	 * @param array                   $pivot_data_placeholders Optional array of placeholders for pivot data.
	 *
	 * @return bool
	 */
	public function attach( $related, $pivot_data = [], $pivot_data_placeholders = [] ) {

		$parent_id = $this->parent->{$this->parent_key};
		$related   = $this->parse_related( $related );

		foreach ( $this->get_pivot_defaults() as $key => $def ) {
			if ( ! array_key_exists( $key, $pivot_data ) ) {
				$pivot_data[ $key ]              = $def['value'];
				$pivot_data_placeholders[ $key ] = $def['placeholder'];
			}
		}

		$select_query = $this->pivot_query()
			->where( $this->foreign_pivot_key, '=', $parent_id, '%d' );

		$this->apply_pivot_defaults( $select_query );

		$existing_ids = $select_query->column( $this->related_pivot_key );
		$new_related  = array_diff_key( $related, array_flip( $existing_ids ) );
		$rows         = $this->prepare_attach_rows( $parent_id, $new_related, $pivot_data );

		if ( empty( $rows ) ) {
			return true;
		}

		return $this->pivot_query()->insert( $rows, $pivot_data_placeholders );
	}

	/**
	 * Detach all related models from the parent model in the pivot table.
	 *
	 * Deletes all rows in the pivot table for this parent model.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $related Related model instance, related key value, or array of related IDs. Optional.
	 *
	 * @return bool True if the query was successful, false otherwise.
	 */
	public function detach( $related = null ) {

		$related = $this->parse_related( $related );

		$query = $this->pivot_query()
			->where( $this->foreign_pivot_key, '=', $this->parent->{$this->parent_key}, '%d' );

		$this->apply_pivot_defaults( $query );

		if ( ! empty( $related ) ) {
			$query->where( $this->related_pivot_key, 'IN', array_keys( $related ), '%d' );
		}

		return $query->delete();
	}

	/**
	 * Sync the given related IDs (and optional pivot data) with the parent model.
	 *
	 * Ensures the pivot table contains exactly the set of related IDs specified.
	 * Attaches new, updates changed, and detaches missing relations.
	 * Prevents unnecessary updates if the pivot data is the same.
	 *
	 * @since 1.0.0
	 *
	 * @param array $related                 Array of related IDs or [id => pivotData] pairs.
	 * @param array $pivot_data_placeholders Optional array of placeholders for pivot data.
	 *
	 * @return array{attached: int[], detached: int[], updated: int[]} IDs of attached, detached, and updated relations.
	 */
	public function sync( $related, $pivot_data_placeholders = [] ) {

		$parent_id      = $this->parent->{$this->parent_key};
		$related        = $this->parse_related( $related );
		$update_columns = array_keys( $pivot_data_placeholders );

		$select_query = $this->pivot_query()
			->where( $this->foreign_pivot_key, '=', $parent_id, '%d' );

		$this->apply_pivot_defaults( $select_query );

		$current_rows = $select_query->get();

		$current_ids = array_column( $current_rows, $this->related_pivot_key );
		$new_ids     = array_keys( $related );

		$to_attach_ids = array_diff( $new_ids, $current_ids );
		$to_detach_ids = array_diff( $current_ids, $new_ids );
		$to_update_ids = array_intersect( $new_ids, $current_ids );

		$to_attach = array_filter( $related, fn( $id ) => in_array( $id, $to_attach_ids, true ), ARRAY_FILTER_USE_KEY );
		$to_update = array_filter( $related, fn( $id ) => in_array( $id, $to_update_ids, true ), ARRAY_FILTER_USE_KEY );

		$default_pivot_data = [];

		foreach ( $this->get_pivot_defaults() as $key => $def ) {
			$default_pivot_data[ $key ]      = $def['value'];
			$pivot_data_placeholders[ $key ] = $def['placeholder'];
		}

		$to_attach_rows = $this->prepare_attach_rows( $parent_id, $to_attach, $default_pivot_data );
		$to_update_rows = $this->prepare_attach_rows( $parent_id, $to_update, $default_pivot_data );

		/*
		 * Filter out rows that are already present in the database with the same values.
		 * We need this to avoid unnecessary updates.
		 */
		$to_update_rows = $this->filter_existing_rows( $to_update_rows, $current_rows );

		if ( ! empty( $to_update_rows ) && ! empty( $update_columns ) ) {
			$this->pivot_query()->upsert( array_merge( $to_attach_rows, $to_update_rows ), $update_columns, $pivot_data_placeholders );
		} elseif ( ! empty( $to_attach_rows ) ) {
			$this->pivot_query()->insert( $to_attach_rows, $pivot_data_placeholders );
		}

		// Detach removed.
		if ( ! empty( $to_detach_ids ) ) {
			$this->detach( $to_detach_ids );
		}

		return [
			'attached' => $to_attach_ids,
			'detached' => $to_detach_ids,
			'updated'  => $to_update_ids,
		];
	}

	/**
	 * Normalize the related input to [id => pivot_data] format.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $related Array of related IDs or [id => pivot_data] pairs.
	 *
	 * @return array<int, array> Normalized related array.
	 */
	private function parse_related( $related ) {

		$related = (array) $related;
		$result  = [];

		foreach ( $related as $k => $v ) {
			if ( is_array( $v ) ) {
				$id   = $k;
				$data = $v;
			} else {
				$id   = $v;
				$data = [];
			}

			if ( $id instanceof AbstractModel ) {
				$id = $id->{$this->related_key};
			}

			$result[ (int) $id ] = $data;
		}

		return $result;
	}

	/**
	 * Prepare rows for attach insert.
	 *
	 * @since 1.0.0
	 *
	 * @param int   $parent_id  The parent model key value.
	 * @param array $related    Array of related IDs => pivot data.
	 * @param array $pivot_data Optional additional pivot data (for single attach).
	 *
	 * @return array<array<string, mixed>> Array of rows to insert.
	 */
	private function prepare_attach_rows( $parent_id, $related, array $pivot_data = [] ) {

		$rows = [];

		foreach ( $related as $related_id => $data ) {
			$rows[] = $this->prepare_attach_row( $parent_id, $related_id, array_merge( $data, $pivot_data ) );
		}

		return $rows;
	}

	/**
	 * Prepare a single row for attach insert.
	 *
	 * @since 1.0.0
	 *
	 * @param int   $parent_id  The parent model key value.
	 * @param int   $related_id The related model key value.
	 * @param array $data       Optional additional pivot data.
	 *
	 * @return array<string, mixed> The row to insert.
	 */
	private function prepare_attach_row( $parent_id, $related_id, $data = [] ) {

		$row = [
			$this->foreign_pivot_key => $parent_id,
			$this->related_pivot_key => $related_id,
		];

		foreach ( $data as $key => $value ) {
			$row[ $key ] = $value;
		}

		return $row;
	}

	/**
	 * Filter out rows from $new that are present in $existing (by value).
	 *
	 * @since 1.0.0
	 *
	 * @param array $new_row      New rows (each row is an array).
	 * @param array $existing_row Existing rows (each row is an array).
	 *
	 * @return array Filtered array of rows to update/insert.
	 */
	protected function filter_existing_rows( $new_row, $existing_row ) {
		/*
		 * We don't need to use strict comparison here, since values
		 * types and columns order can be different.
		 */
		return array_filter(
			$new_row,
			// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
			fn( $new_row ) => ! in_array( $new_row, $existing_row )
		);
	}

	/**
	 * Specify additional pivot columns to include when loading related models.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string> $columns Pivot column names.
	 *
	 * @return $this
	 */
	public function with_pivot( $columns ) {

		$this->with_pivot = array_unique( array_merge( $this->with_pivot, $columns ) );

		return $this;
	}

	/**
	 * Get the pivot columns to load.
	 *
	 * @since 1.0.0
	 *
	 * @return array<string>
	 */
	public function get_with_pivot() {

		return $this->with_pivot;
	}
}
