<?php
/**
 * Resolve post usage for products shown on the admin products list.
 *
 * Scans WordPress post content for affilun Gutenberg
 * blocks and maps persisted product ids to the posts that reference
 * them. The resolver is intentionally read-only and best-effort: if
 * WordPress database globals are unavailable it returns an empty map,
 * keeping pure-unit and degraded bootstrap paths safe.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

/**
 * Product-id to post-usage resolver for the admin products table.
 */
final class PostProductUsageResolver {

	/**
	 * Provider returning candidate posts to inspect.
	 *
	 * @var callable(list<int>): list<array<string, mixed>>
	 */
	private $postRowsProvider;

	/**
	 * Builds the edit URL for a post id.
	 *
	 * @var callable(int): string
	 */
	private $editUrlResolver;

	/**
	 * @param callable|null $postRowsProvider Optional `(list<int>): list<array<string,mixed>>` provider.
	 * @param callable|null $editUrlResolver  Optional `(int): string` edit URL resolver.
	 *
	 * @phpstan-param (callable(list<int>): list<array<string, mixed>>)|null $postRowsProvider
	 * @phpstan-param (callable(int): string)|null                           $editUrlResolver
	 */
	public function __construct(
		?callable $postRowsProvider = null,
		?callable $editUrlResolver = null
	) {
		$this->postRowsProvider = $postRowsProvider ?? self::defaultPostRowsProvider();
		$this->editUrlResolver  = $editUrlResolver ?? self::defaultEditUrlResolver();
	}

	/**
	 * Resolve post usage grouped by product id.
	 *
	 * @param list<int> $productIds Product ids on the current products page.
	 *
	 * @return array<int, list<array{id:int,title:string,url:string,type:string,status:string}>>
	 */
	public function resolveForProductIds( array $productIds ): array {
		$ids = self::normalizeIds( $productIds );
		if ( $ids === array() ) {
			return array();
		}

		$result = array();
		foreach ( $ids as $id ) {
			$result[ $id ] = array();
		}

		$wanted = array_fill_keys( $ids, true );
		$seen   = array();
		$rows   = ( $this->postRowsProvider )( $ids );

		foreach ( $rows as $row ) {
			$postId = self::positiveInt( $row['ID'] ?? $row['id'] ?? null );
			if ( $postId === null ) {
				continue;
			}

			$content = is_string( $row['post_content'] ?? null ) ? $row['post_content'] : '';
			if ( $content === '' ) {
				continue;
			}

			$referenced = self::normalizeIds( self::extractProductIdsFromContent( $content ) );
			if ( $referenced === array() ) {
				continue;
			}

			$title  = is_string( $row['post_title'] ?? null ) ? $row['post_title'] : '';
			$type   = is_string( $row['post_type'] ?? null ) ? $row['post_type'] : '';
			$status = is_string( $row['post_status'] ?? null ) ? $row['post_status'] : '';
			$url    = ( $this->editUrlResolver )( $postId );

			foreach ( $referenced as $productId ) {
				if ( ! isset( $wanted[ $productId ] ) ) {
					continue;
				}

				$seenKey = $productId . ':' . $postId;
				if ( isset( $seen[ $seenKey ] ) ) {
					continue;
				}
				$seen[ $seenKey ] = true;

				$result[ $productId ][] = array(
					'id'     => $postId,
					'title'  => $title !== '' ? $title : '(タイトルなし)',
					'url'    => $url,
					'type'   => $type,
					'status' => $status,
				);
			}
		}

		return $result;
	}

	/**
	 * Extract product ids from affilun block content.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<int>
	 */
	private static function extractProductIdsFromContent( string $content ): array {
		$fromParser = self::extractProductIdsWithBlockParser( $content );
		if ( $fromParser !== array() ) {
			return $fromParser;
		}

		return self::extractProductIdsWithRegex( $content );
	}

	/**
	 * Use WordPress block parser when available.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<int>
	 */
	private static function extractProductIdsWithBlockParser( string $content ): array {
		if ( ! function_exists( 'parse_blocks' ) ) {
			return array();
		}

		$blocks = call_user_func( 'parse_blocks', $content );
		if ( ! is_array( $blocks ) ) {
			return array();
		}

		return self::extractProductIdsFromBlocks( $blocks );
	}

	/**
	 * Walk parsed blocks recursively.
	 *
	 * @param array<mixed> $blocks Parsed block list.
	 *
	 * @return list<int>
	 */
	private static function extractProductIdsFromBlocks( array $blocks ): array {
		$ids = array();
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}

			$name = is_string( $block['blockName'] ?? null ) ? $block['blockName'] : '';
			if ( $name === 'affilun/product-card' ) {
				$attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
				$ids   = array_merge( $ids, self::extractProductIdsFromAttributes( $attrs ) );
			}

			$inner = is_array( $block['innerBlocks'] ?? null ) ? $block['innerBlocks'] : array();
			if ( $inner !== array() ) {
				$ids = array_merge( $ids, self::extractProductIdsFromBlocks( $inner ) );
			}
		}

		return self::normalizeIds( $ids );
	}

	/**
	 * Fallback parser for serialized Gutenberg comments.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<int>
	 */
	private static function extractProductIdsWithRegex( string $content ): array {
		$ids = array();
		if (
			preg_match_all(
				'/<!--\s+wp:affilun\/product-card\s+(\{.*?\})\s+\/?-->/s',
				$content,
				$matches
			) !== false
		) {
			foreach ( $matches[1] as $json ) {
				$decoded = json_decode( (string) $json, true );
				if ( is_array( $decoded ) ) {
					$ids = array_merge( $ids, self::extractProductIdsFromAttributes( $decoded ) );
				}
			}
		}

		return self::normalizeIds( $ids );
	}

	/**
	 * Extract product ids from block attributes.
	 *
	 * @param array<string, mixed> $attrs Block attributes.
	 *
	 * @return list<int>
	 */
	private static function extractProductIdsFromAttributes( array $attrs ): array {
		$ids = array();

		if ( array_key_exists( 'productId', $attrs ) ) {
			$id = self::positiveInt( $attrs['productId'] );
			if ( $id !== null ) {
				$ids[] = $id;
			}
		}

		if ( isset( $attrs['productIds'] ) && is_array( $attrs['productIds'] ) ) {
			foreach ( $attrs['productIds'] as $raw ) {
				$id = self::positiveInt( $raw );
				if ( $id !== null ) {
					$ids[] = $id;
				}
			}
		}

		return self::normalizeIds( $ids );
	}

	/**
	 * Normalize ids to unique positive integers in input order.
	 *
	 * @param list<int> $ids Raw ids.
	 *
	 * @return list<int>
	 */
	private static function normalizeIds( array $ids ): array {
		$normalized = array();
		$seen       = array();
		foreach ( $ids as $raw ) {
			$id = self::positiveInt( $raw );
			if ( $id === null || isset( $seen[ $id ] ) ) {
				continue;
			}
			$seen[ $id ]  = true;
			$normalized[] = $id;
		}

		return $normalized;
	}

	/**
	 * Convert a scalar-ish value to a positive integer.
	 *
	 * @param mixed $value Raw value.
	 */
	private static function positiveInt( mixed $value ): ?int {
		if ( is_int( $value ) && $value > 0 ) {
			return $value;
		}

		if ( is_string( $value ) && trim( $value ) !== '' && ctype_digit( trim( $value ) ) ) {
			$id = (int) trim( $value );
			return $id > 0 ? $id : null;
		}

		return null;
	}

	/**
	 * Default WordPress-backed post row provider.
	 *
	 * @return callable(list<int>): list<array<string, mixed>>
	 */
	private static function defaultPostRowsProvider(): callable {
		return static function ( array $productIds ): array {
			if ( $productIds === array() ) {
				return array();
			}

			global $wpdb;
			if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->posts ) ) {
				return array();
			}

			if ( (string) $wpdb->posts === '' || ! method_exists( $wpdb, 'get_results' ) ) {
				return array();
			}

			$like = '%' . self::escLike( $wpdb, 'wp:affilun/product-' ) . '%';

			if ( ! method_exists( $wpdb, 'prepare' ) ) {
				return array();
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only admin usage lookup scoped to post-content block comments.
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT ID, post_title, post_type, post_status, post_content FROM %i
					WHERE post_content LIKE %s
					AND post_status NOT IN ('auto-draft', 'trash')
					AND post_type NOT IN ('revision', 'nav_menu_item', 'attachment')
					ORDER BY post_modified_gmt DESC, ID DESC",
					(string) $wpdb->posts,
					$like
				),
				'ARRAY_A'
			);
			if ( ! is_array( $rows ) ) {
				return array();
			}

			$result = array();
			foreach ( $rows as $row ) {
				if ( is_array( $row ) ) {
					/** @var array<string, mixed> $row */
					$result[] = $row;
				}
			}

			return $result;
		};
	}

	/**
	 * Escape a LIKE fragment through `$wpdb->esc_like()` when available.
	 *
	 * @param object $wpdb  WordPress DB object.
	 * @param string $value Raw LIKE fragment.
	 */
	private static function escLike( object $wpdb, string $value ): string {
		if ( method_exists( $wpdb, 'esc_like' ) ) {
			return (string) $wpdb->esc_like( $value );
		}

		return addcslashes( $value, '_%\\' );
	}

	/**
	 * Default edit URL resolver.
	 *
	 * @return callable(int): string
	 */
	private static function defaultEditUrlResolver(): callable {
		return static function ( int $postId ): string {
			if ( function_exists( 'get_edit_post_link' ) ) {
				$url = call_user_func( 'get_edit_post_link', $postId, '' );
				if ( is_string( $url ) && $url !== '' ) {
					return $url;
				}
			}

			$path = 'post.php?post=' . $postId . '&action=edit';
			if ( function_exists( 'admin_url' ) ) {
				return (string) admin_url( $path );
			}

			return 'http://example.test/wp-admin/' . $path;
		};
	}
}
