<?php
/**
 * Documents index class.
 *
 * Provides helpers for retrieving document/post IDs assigned to particular
 * filter values and for translating between child and parent products.
 *
 * @package FiboFilters\Index
 * @since   1.10.0
 */

declare( strict_types=1 );

namespace FiboFilters\Index;

use FiboFilters\Struct\QueryContext;
use FiboFilters\Util\Helpers;
use FiboFilters\WPDBException;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Documents
 *
 * @since 1.10.0
 */
class Documents extends AbstractIndex {
	/**
	 * {@inheritdoc}
	 */
	public function get_table_type(): string {
		return 'filters_doclist';
	}

	/**
	 * {@inheritdoc}
	 */
	public function get_cache_group(): string {
		return 'documents_query';
	}

	/**
	 * Retrieve post-IDs that match given arguments.
	 *
	 * @param array<string,mixed> $args {
	 *     Query arguments.
	 *
	 * @type bool                 $cache Whether to attempt object‑cache lookup. Default false.
	 * @type string               $cache_key Base cache key. Default 'documents'.
	 * @type int[]                $value_ids Filter value IDs to match.
	 * @type int[]                $post_ids Post-IDs used when `$only_parents` is true.
	 * @type int[]|null           $posts_in Restrict results to these post-IDs.
	 * @type bool                 $only_parents If true, returns parent IDs instead of child IDs.
	 * @type string               $logic Matching logic: 'or', 'and', 'range', 'search'. Default 'or'.
	 * }
	 *
	 * @param QueryContext|null   $ctx Optional query context.
	 *
	 * @return int[] Array of post-IDs.
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function get_post_ids( array $args, ?QueryContext $ctx = null ): array {
		$ctx ??= new QueryContext();
		$wpdb = $this->get_wpdb( $ctx );

		$defaults = [
			'cache'        => true,
			'cache_key'    => 'documents_' . Helpers::generate_cache_hash( $args ),
			'value_ids'    => [],
			'post_ids'     => [],
			'posts_in'     => null,
			'only_parents' => false,
			'logic'        => 'or',
		];

		$args = wp_parse_args( $args, $defaults );

		$cached = $this->get_cached_items( $args['cache_key'], $ctx );
		if ( $args['cache'] && is_array( $cached ) ) {
			return $cached;
		}

		$select   = '';
		$where    = '';
		$group_by = '';
		$having   = '';

		if ( ! empty( $args['value_ids'] ) ) {
			$where = $this->get_where_clause_for_assigned_post_ids( $args['value_ids'], $args['posts_in'], $ctx );

			if ( in_array( $args['logic'], [ 'or', 'range', 'search', 'search-fibosearch-pro', 'range-continuous', 'range-discrete' ], true ) ) {
				$select = 'DISTINCT post_id';
			}

			if ( in_array( $args['logic'], [ 'and' ], true ) ) {
				$select   = 'post_id, COUNT(post_id) as total_products';
				$group_by = 'post_id';
				$having   = $wpdb->prepare( 'total_products = %d', count( $args['value_ids'] ) );
			}
		}

		// Query for parents.
		if ( $args['only_parents'] && ! empty( $args['post_ids'] ) ) {
			$select = 'DISTINCT post_or_parent_id';

			$placeholders = array_fill( 0, count( $args['post_ids'] ), '%d' );
			$format       = implode( ', ', $placeholders );

			$where = $wpdb->prepare( " AND post_id IN ($format)", $args['post_ids'] ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		}

		$table_doclist = $this->get_table_name( $ctx );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$sql = "SELECT $select
				FROM $table_doclist
                WHERE 1 = 1
                $where";
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( ! empty( $group_by ) ) {
			$sql .= " GROUP BY $group_by";
		}
		if ( ! empty( $having ) ) {
			$sql .= " HAVING $having";
		}

		$post_ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		$post_ids = array_map( 'intval', $post_ids );

		if ( $args['cache'] ) {
			$this->set_cached_items( $args['cache_key'], $post_ids, $ctx );
		}

		return $post_ids;
	}

	/**
	 * Return parent post IDs only for provided child/parent IDs.
	 *
	 * @param int[]             $post_or_parent_ids IDs to resolve.
	 * @param QueryContext|null $ctx Query context.
	 *
	 * @return int[] Parent post IDs.
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function get_parent_ids_only( array $post_or_parent_ids, ?QueryContext $ctx = null ): array {
		$ctx = $ctx ?? new QueryContext();

		// Break early.
		if ( empty( $post_or_parent_ids ) ) {
			return [];
		}

		$args = [
			'only_parents' => true,
			'post_ids'     => $post_or_parent_ids,
		];

		return $this->get_post_ids( $args, $ctx );
	}

	/**
	 * Convenience wrapper returning post-IDs for given value IDs.
	 *
	 * @param int[]             $value_ids Value IDs to match.
	 * @param string            $logic Matching logic.
	 * @param int[]|null        $docs_in Limit results to these post-IDs.
	 * @param QueryContext|null $ctx Query context.
	 *
	 * @return int[] Array of post-IDs.
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function get_post_ids_by_value_ids( array $value_ids, string $logic = 'or', array $docs_in = null, ?QueryContext $ctx = null ): array {
		$ctx = $ctx ?? new QueryContext();

		return $this->get_post_ids(
			[
				'posts_in'  => $docs_in,
				'value_ids' => $value_ids,
				'logic'     => $logic,
			],
			$ctx
		);
	}

	/**
	 * Get all distinct value IDs.
	 *
	 * @param bool              $cache Optional.  Use cache when available and store the result for subsequent calls. Default true.
	 * @param QueryContext|null $ctx   Optional.  $ctx Query context.
	 *
	 * @return int[] List of unique `value_id` integers, never `null`.
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function get_all_value_ids( bool $cache = true, ?QueryContext $ctx = null ): array {
		$ctx       = $ctx ?? new QueryContext();
		$wpdb      = $this->get_wpdb( $ctx );
		$cache_key = 'all_value_ids';

		if ( $cache ) {
			$cached = $this->get_cached_items( $cache_key, $ctx );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		$table_doclist = $this->get_table_name( $ctx );

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$value_ids = $wpdb->get_col( 'SELECT DISTINCT value_id FROM ' . $table_doclist );

		$value_ids = array_map( 'intval', $value_ids );

		if ( $cache ) {
			$this->set_cached_items( $cache_key, $value_ids, $ctx );
		}

		return $value_ids;
	}

	/**
	 * Inserts a document record into the custom table.
	 *
	 * Expects at least 'value_id' and 'post_id' in the $args array.
	 * If post_or_parent_id is not provided, this field will default to the same value as post_id.
	 *
	 * @param array             $args {
	 *       Query arguments.
	 *
	 * @type int                $value_id Required. ID of the value.
	 * @type int                $post_id Required. ID of the related post.
	 * @type int                $post_or_parent_id Optional. ID of the post or its parent. Default to 0.
	 * }
	 *
	 * @param QueryContext|null $ctx Optional. Custom query context for DB access.
	 *
	 * @return int Insert‑ID of the new value.
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function save_document( array $args, ?QueryContext $ctx = null ): int {

		if ( empty( $args['value_id'] ) || empty( $args['post_id'] ) ) {
			return 0;
		}

		$ctx   = $ctx ?? new QueryContext();
		$wpdb  = $this->get_wpdb( $ctx );
		$table = $this->get_table_name( $ctx );

		$data = [
			'value_id'          => (int) $args['value_id'],
			'post_id'           => (int) $args['post_id'],
			'post_or_parent_id' => ! empty( $args['post_or_parent_id'] ) ? (int) $args['post_or_parent_id'] : (int) $args['post_id'],
		];

		$wpdb->insert( $table, $data, [ '%d', '%d', '%d' ] );

		return $wpdb->db->insert_id;
	}

	/**
	 * Deletes a document (hard delete) from the database by post ID.
	 *
	 * @param int               $post_id Post ID to delete.
	 * @param QueryContext|null $ctx Optional context (site/network). Default null.
	 *
	 * @return bool                    True on success, false on failure.
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function delete_document( int $post_id, ?QueryContext $ctx = null ): bool {
		if ( $post_id <= 0 ) {
			return false;
		}

		$ctx        = $ctx ?? new QueryContext();
		$wpdb       = $this->get_wpdb( $ctx );
		$table_name = $this->get_table_name( $ctx );

		$result = $wpdb->delete(
			$table_name,
			[ 'post_id' => $post_id ],
			[ '%d' ]
		);

		return $result !== false;
	}

	/**
	 * Build a tree-like index of value IDs to parent IDs to post IDs.
	 *  - fetches DISTINCT (value_id, post_id, post_or_parent_id) pairs for all given value_ids,
	 *  - optionally restricts results to products that belong to a given taxonomy term
	 *    (category or tag) when $taxonomy_value_id > 0,
	 *  - builds an index in the form: [ value_id => [ parent_id => post_ids[] ] ].
	 *
	 * Example returned structure:
	 * [
	 *   123 => [                     value_id
	 *     10 => [101, 102, 103],     parent_id => [post_ids...]
	 *     20 => [201, 202],
	 *   ],
	 *   124 => [
	 *     10 => [104, 105],
	 *   ],
	 * ]
	 *
	 * @param int               $taxonomy_value_id Category/tag value_id (0 = no taxonomy restriction).
	 * @param int[]             $value_ids         All value_ids used in this rule.
	 * @param bool              $cache             Optional. Use cache when available. Default true.
	 * @param QueryContext|null $ctx               Optional context (site/network). Default null.
	 *
	 * @return array<int,array<int,int[]>> Tree index: [ value_id => [ parent_id => post_ids[] ] ].
	 * @throws WPDBException When a database error occurred (only for WPDBSecond).
	 */
	public function build_value_post_tree(
		int $taxonomy_value_id,
		array $value_ids,
		bool $cache = true,
		?QueryContext $ctx = null
	): array {
		$ctx   = $ctx ?? new QueryContext();
		$wpdb  = $this->get_wpdb( $ctx );
		$table = $this->get_table_name( $ctx );

		$value_ids = $this->normalize_value_ids( $value_ids );

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

		$cache_key = null;

		if ( $cache ) {
			$cache_key = 'value_post_tree_' . Helpers::generate_cache_hash(
				[
					'taxonomy_value_id' => $taxonomy_value_id,
					'value_ids'         => $value_ids,
				]
			);

			$cached = $this->get_cached_items( $cache_key, $ctx );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		$placeholders = implode( ',', array_fill( 0, count( $value_ids ), '%d' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		if ( $taxonomy_value_id > 0 ) {
			// Restrict to products that belong to the given taxonomy term (self-join on doclist).
			$sql = "
				SELECT DISTINCT dl.value_id, dl.post_id, dl.post_or_parent_id
				FROM {$table} AS dl
				JOIN {$table} AS tax
					ON tax.post_or_parent_id = dl.post_or_parent_id
					AND tax.value_id = %d
				WHERE dl.value_id IN ($placeholders)
			";

			$params = array_merge( [ $taxonomy_value_id ], $value_ids );
			$query  = $wpdb->prepare( $sql, $params );
		} else {
			$sql = "
				SELECT DISTINCT dl.value_id, dl.post_id, dl.post_or_parent_id
				FROM {$table} AS dl
				WHERE dl.value_id IN ($placeholders)
			";

			$query = $wpdb->prepare( $sql, $value_ids );
		}

		$rows = $wpdb->get_results( $query, ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared

		if ( empty( $rows ) ) {
			if ( $cache ) {
				$this->set_cached_items( $cache_key, [], $ctx );
			}

			return [];
		}

		$index = [];

		foreach ( $rows as $row ) {
			$value_id  = (int) ( $row['value_id'] ?? 0 );
			$parent_id = (int) ( $row['post_or_parent_id'] ?? 0 );
			$post_id   = isset( $row['post_id'] ) ? (int) $row['post_id'] : $parent_id;

			if ( $value_id <= 0 || $parent_id <= 0 || $post_id <= 0 ) {
				continue;
			}

			if ( ! isset( $index[ $value_id ] ) ) {
				$index[ $value_id ] = [];
			}
			if ( ! isset( $index[ $value_id ][ $parent_id ] ) ) {
				$index[ $value_id ][ $parent_id ] = [];
			}

			$index[ $value_id ][ $parent_id ][] = $post_id;
		}

		// Sort and deduplicate post IDs per [value_id][parent_id].
		foreach ( $index as $value_id => $by_parent ) {
			foreach ( $by_parent as $parent_id => $post_list ) {
				$post_list                        = array_map( 'intval', $post_list );
				$post_list                        = array_values( array_unique( $post_list ) );
				$index[ $value_id ][ $parent_id ] = $post_list;
			}
		}

		if ( $cache ) {
			$this->set_cached_items( $cache_key, $index, $ctx );
		}

		return $index;
	}

	/**
	 * Build WHERE clause restricting query to value IDs (and optionally post IDs).
	 *
	 * @param int[]             $value_ids Value IDs.
	 * @param int[]|null        $posts_in Optional list of post-IDs to limit.
	 * @param QueryContext|null $ctx Query context.
	 *
	 * @return string Prepared WHERE fragment appended to the main query.
	 */
	private function get_where_clause_for_assigned_post_ids( array $value_ids, array $posts_in = null, ?QueryContext $ctx = null ): string {
		$ctx ??= new QueryContext();
		$wpdb = $this->get_wpdb( $ctx );

		if ( empty( $value_ids ) ) {
			return '';
		}

		$total_values = count( $value_ids );
		$placeholders = array_fill( 0, $total_values, '%d' );
		$format       = implode( ', ', $placeholders );

		$where = $wpdb->prepare( " AND value_id IN ($format)", $value_ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

		if ( ! is_null( $posts_in ) ) {
			$placeholders = array_fill( 0, count( $posts_in ), '%d' );
			$format       = implode( ', ', $placeholders );

			$where .= $wpdb->prepare( " AND post_id IN ($format)", $posts_in ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		}

		return $where;
	}

	/**
	 * Normalizes and deduplicates value_ids.
	 *
	 * @param array $value_ids Value IDs to normalize.
	 *
	 * @return array
	 */
	private function normalize_value_ids( array $value_ids ): array {
		$value_ids = array_map( 'intval', $value_ids );
		$value_ids = array_filter(
			$value_ids,
			static function ( int $id ): bool {
				return $id > 0;
			}
		);

		return array_values( array_unique( $value_ids ) );
	}
}
