<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator;

use Ekesto\CacheInvalidator\Integrations\Builders\BuilderDetector;
use Ekesto\CacheInvalidator\Integrations\Builders\BuildersScanProfile;
use Ekesto\CacheInvalidator\Integrations\Builders\BuildersRegistry;
use Ekesto\CacheInvalidator\Integrations\Builders\UsageContainerCapableInterface;
use Ekesto\CacheInvalidator\Integrations\Multilingual\Internal\ObjectSiblingResolver;
use Ekesto\CacheInvalidator\Support\Debug;

/**
 * Shared SQL prefiltering and block parsing support for reference resolvers.
 */
final class BlockReferenceScanner
{
    /**
     * Collect candidate post IDs by raw post_content LIKE needles.
     *
     * SQL prefilters only reduce the candidate set. All matches must still be
     * confirmed through parsed block inspection.
     *
     * @param string[] $postTypes Post types to scan.
     * @param string[] $needles LIKE needles with `%` wildcards.
     * @return array{post_ids:int[],scan_gap:bool}
     */
    public static function findCandidatePostIdsByContentNeedles(array $postTypes, array $needles): array
    {
        global $wpdb;

        $postTypes = ContentVisibility::normalizePostTypes($postTypes);
        $needles = ContentVisibility::normalizeUrls($needles);

        if (empty($postTypes) || empty($needles)) {
            return [
                'post_ids' => [],
                'scan_gap' => false,
            ];
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholder lists are count-derived from fixed %s templates and all values are bound through $wpdb->prepare(); scanner reads must use live post content.
        $postTypePlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
        $needlePlaceholders = implode(' OR ', array_fill(0, count($needles), 'post_content LIKE %s'));

        $results = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT ID
                 FROM {$wpdb->posts}
                 WHERE post_type IN ($postTypePlaceholders)
                   AND post_status NOT IN ('auto-draft', 'inherit', 'trash')
                   AND ($needlePlaceholders)",
                ...array_merge($postTypes, $needles)
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (!is_array($results)) {
            return [
                'post_ids' => [],
                'scan_gap' => true,
            ];
        }

        return [
            'post_ids' => ContentVisibility::normalizePostIds($results),
            'scan_gap' => false,
        ];
    }

    /**
     * Collect all valid post IDs for the provided post types.
     *
     * @param string[] $postTypes Post types to scan.
     * @return array{post_ids:int[],scan_gap:bool}
     */
    public static function findPostIdsByPostTypes(array $postTypes): array
    {
        global $wpdb;

        $postTypes = ContentVisibility::normalizePostTypes($postTypes);
        if (empty($postTypes)) {
            return [
                'post_ids' => [],
                'scan_gap' => false,
            ];
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholder list is count-derived from a fixed %s template and all values are bound through $wpdb->prepare(); scanner reads must use live post content.
        $postTypePlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));

        $results = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT ID
                 FROM {$wpdb->posts}
                 WHERE post_type IN ($postTypePlaceholders)
                   AND post_status NOT IN ('auto-draft', 'inherit', 'trash')",
                ...$postTypes
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (!is_array($results)) {
            return [
                'post_ids' => [],
                'scan_gap' => true,
            ];
        }

        return [
            'post_ids' => ContentVisibility::normalizePostIds($results),
            'scan_gap' => false,
        ];
    }

    /**
     * Collect candidate post IDs by one postmeta key and raw meta-value LIKE needles.
     *
     * SQL prefilters only reduce the candidate set. All matches must still be
     * confirmed by the calling resolver or provider.
     *
     * @param string[] $postTypes Post types to scan.
     * @param string   $metaKey Postmeta key to scan.
     * @param string[] $needles LIKE needles with `%` wildcards.
     * @return array{post_ids:int[],scan_gap:bool}
     */
    public static function findCandidatePostIdsByPostMetaNeedles(array $postTypes, string $metaKey, array $needles): array
    {
        global $wpdb;

        $postTypes = ContentVisibility::normalizePostTypes($postTypes);
        $metaKey = sanitize_key($metaKey);
        $needles = ContentVisibility::normalizeUrls($needles);

        if (empty($postTypes) || $metaKey === '' || empty($needles)) {
            return [
                'post_ids' => [],
                'scan_gap' => false,
            ];
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholder lists are count-derived from fixed %s templates and all values are bound through $wpdb->prepare(); scanner reads must use live postmeta.
        $postTypePlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
        $needlePlaceholders = implode(' OR ', array_fill(0, count($needles), 'pm.meta_value LIKE %s'));

        $results = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT p.ID
                 FROM {$wpdb->posts} p
                 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
                 WHERE p.post_type IN ($postTypePlaceholders)
                   AND p.post_status NOT IN ('auto-draft', 'inherit', 'trash')
                   AND pm.meta_key = %s
                   AND ($needlePlaceholders)",
                ...array_merge($postTypes, [$metaKey], $needles)
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (!is_array($results)) {
            return [
                'post_ids' => [],
                'scan_gap' => true,
            ];
        }

        return [
            'post_ids' => ContentVisibility::normalizePostIds($results),
            'scan_gap' => false,
        ];
    }

    /**
     * Collect optional builder-provided container IDs and declared locations.
     *
     * Builder hints extend the scan surface only within the resolver-owned scan
     * post types. Invalid hints and lookup failures mark scan gaps.
     *
     * @param \WP_Post $entity Changed entity being resolved.
     * @param string[] $scanPostTypes Resolver-owned scan post types.
     * @return array{post_ids:int[],storage_locations_by_post_id:array<int,array<int,array{type:string,key?:string,value?:string}>>,scan_gap:bool}
     */
    public static function collectBuilderContainerIds(\WP_Post $entity, array $scanPostTypes): array
    {
        $registry = new BuildersRegistry();
        $scanPostTypes = ContentVisibility::normalizePostTypes($scanPostTypes);
        $containerIds = [];
        $locationsByPostId = [];
        $scanGap = false;

        if (empty($scanPostTypes)) {
            return [
                'post_ids' => [],
                'storage_locations_by_post_id' => [],
                'scan_gap' => false,
            ];
        }

        foreach ($registry->all() as $builder) {
            if (!$builder instanceof UsageContainerCapableInterface) {
                continue;
            }

            try {
                $hints = $builder->getUsageContainerIds($entity);
            } catch (\Throwable $throwable) {
                $scanGap = true;
                Debug::warn('builder usage-container hint failed', [
                    'builder' => $builder->key(),
                    'entity_id' => (int) $entity->ID,
                    'entity_type' => sanitize_key((string) $entity->post_type),
                    'exception' => $throwable->getMessage(),
                ]);
                continue;
            }

            foreach ($hints as $hint) {
                if (!$hint instanceof BuilderUsageHint) {
                    $scanGap = true;
                    continue;
                }

                $effectivePostTypes = array_values(array_intersect($scanPostTypes, $hint->postTypes()));
                $effectivePostTypes = ContentVisibility::normalizePostTypes($effectivePostTypes);
                if (empty($effectivePostTypes)) {
                    continue;
                }

                $candidateQuery = self::findCandidatePostIdsByBuilderSelectors(
                    $effectivePostTypes,
                    $hint->containerSelectors()
                );
                $scanGap = $scanGap || (bool) ($candidateQuery['scan_gap'] ?? false);

                foreach ($candidateQuery['post_ids'] as $postId) {
                    $post = get_post($postId);
                    if (!$post instanceof \WP_Post || !ContentVisibility::hasValidResolutionStatus($post)) {
                        continue;
                    }

                    foreach (ObjectSiblingResolver::expandObjectIds((int) $post->ID, (string) $post->post_type) as $expandedId) {
                        $expandedPost = get_post($expandedId);
                        if (!$expandedPost instanceof \WP_Post || !ContentVisibility::hasValidResolutionStatus($expandedPost)) {
                            continue;
                        }

                        if (!in_array((string) $expandedPost->post_type, $effectivePostTypes, true)) {
                            continue;
                        }

                        $containerIds[] = (int) $expandedPost->ID;
                        $locationsByPostId[(int) $expandedPost->ID] = self::mergeDeclaredLocations(
                            $locationsByPostId[(int) $expandedPost->ID] ?? [],
                            $hint->storageLocations()
                        );
                    }
                }
            }
        }

        return [
            'post_ids' => ContentVisibility::normalizePostIds($containerIds),
            'storage_locations_by_post_id' => $locationsByPostId,
            'scan_gap' => $scanGap,
        ];
    }

    /**
     * Determine whether any declared storage location contains one of the needles.
     *
     * @param \WP_Post $post Post object.
     * @param array<int,array{type:string,key?:string,value?:string}> $storageLocations Declared locations.
     * @param string[] $needles Raw text needles without SQL wildcards.
     * @return bool
     */
    public static function postContainsNeedlesInDeclaredLocations(\WP_Post $post, array $storageLocations, array $needles): bool
    {
        $needles = array_values(array_unique(array_filter(array_map('strval', $needles))));
        if (empty($needles) || empty($storageLocations)) {
            return false;
        }

        foreach ($storageLocations as $location) {
            $text = self::resolveDeclaredLocationText($post, $location);
            if ($text === '') {
                continue;
            }

            foreach ($needles as $needle) {
                if ($needle !== '' && strpos($text, $needle) !== false) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Collect scan texts for one candidate post using the current scan profile.
     *
     * This remains storage-oriented only: it materializes the text payloads the
     * resolver may inspect, while keeping matching logic in the resolver layer.
     *
     * @param \WP_Post $post Candidate post object.
     * @param BuildersScanProfile|null $profile Optional precomputed scan profile.
     * @param int $maxMetaFieldBytes Maximum bytes scanned per meta field.
     * @return array{
     *   scan_texts:array<int,array{field:string,text:string}>,
     *   fields_scanned:string[],
     *   meta_too_large:bool
     * }
     */
    public static function collectCandidateScanTexts(
        \WP_Post $post,
        ?BuildersScanProfile $profile = null,
        int $maxMetaFieldBytes = 0
    ): array {
        $profile = $profile ?? (new BuilderDetector())->detectForPost($post);
        $maxMetaFieldBytes = max(0, $maxMetaFieldBytes);
        $fields = $profile->fieldsToScan();
        $fieldsScanned = array_values(array_unique(array_filter(array_map('strval', $fields))));
        $scanTexts = [];
        $metaTooLarge = false;

        foreach ($fieldsScanned as $field) {
            $location = $field === 'post_content'
                ? ['type' => 'post_content']
                : ['type' => 'post_meta', 'key' => $field];

            $text = self::resolveDeclaredLocationText($post, $location);
            if ($field !== 'post_content' && $maxMetaFieldBytes > 0 && strlen($text) > $maxMetaFieldBytes) {
                $metaTooLarge = true;
                continue;
            }

            $scanTexts[] = [
                'field' => $field,
                'text' => $text,
            ];
        }

        return [
            'scan_texts' => $scanTexts,
            'fields_scanned' => $fieldsScanned,
            'meta_too_large' => $metaTooLarge,
        ];
    }

    /**
     * Determine whether a post contains a matching block.
     *
     * @param \WP_Post                         $post Post object.
     * @param callable(array<string,mixed>):bool $matcher Block matcher.
     * @return bool
     */
    public static function postContainsMatchingBlock(\WP_Post $post, callable $matcher): bool
    {
        $blocks = parse_blocks((string) $post->post_content);
        if (!is_array($blocks) || empty($blocks)) {
            return false;
        }

        return self::blocksContainMatch($blocks, $matcher);
    }

    /**
     * Recursively scan parsed blocks for a match.
     *
     * @param array<int,array<string,mixed>>    $blocks Parsed blocks.
     * @param callable(array<string,mixed>):bool $matcher Block matcher.
     * @return bool
     */
    private static function blocksContainMatch(array $blocks, callable $matcher): bool
    {
        foreach ($blocks as $block) {
            if (!is_array($block)) {
                continue;
            }

            if ($matcher($block)) {
                return true;
            }

            $innerBlocks = $block['innerBlocks'] ?? [];
            if (is_array($innerBlocks) && !empty($innerBlocks) && self::blocksContainMatch($innerBlocks, $matcher)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Collect candidate IDs using builder-declared selectors only.
     *
     * @param string[] $postTypes Effective resolver-owned post types.
     * @param array<int,array{type:string,key?:string,value?:string}> $selectors Declared selectors.
     * @return array{post_ids:int[],scan_gap:bool}
     */
    private static function findCandidatePostIdsByBuilderSelectors(array $postTypes, array $selectors): array
    {
        $postTypes = ContentVisibility::normalizePostTypes($postTypes);
        if (empty($postTypes) || empty($selectors)) {
            return [
                'post_ids' => [],
                'scan_gap' => false,
            ];
        }

        $postIds = [];
        $scanGap = false;
        $metaKeys = [];
        $contentNeedles = [];

        foreach ($selectors as $selector) {
            $type = sanitize_key((string) ($selector['type'] ?? ''));
            if ($type === 'meta_exists') {
                $key = sanitize_key((string) ($selector['key'] ?? ''));
                if ($key !== '') {
                    $metaKeys[] = $key;
                }
                continue;
            }

            if ($type === 'post_content_contains') {
                $value = trim((string) ($selector['value'] ?? ''));
                if ($value !== '') {
                    $contentNeedles[] = '%' . $value . '%';
                }
            }
        }

        if (!empty($metaKeys)) {
            $metaQuery = self::findCandidatePostIdsByMetaKeys($postTypes, $metaKeys);
            $postIds = array_merge($postIds, $metaQuery['post_ids']);
            $scanGap = $scanGap || (bool) ($metaQuery['scan_gap'] ?? false);
        }

        if (!empty($contentNeedles)) {
            $contentQuery = self::findCandidatePostIdsByContentNeedles($postTypes, $contentNeedles);
            $postIds = array_merge($postIds, $contentQuery['post_ids']);
            $scanGap = $scanGap || (bool) ($contentQuery['scan_gap'] ?? false);
        }

        return [
            'post_ids' => ContentVisibility::normalizePostIds($postIds),
            'scan_gap' => $scanGap,
        ];
    }

    /**
     * Collect candidate post IDs by existing postmeta keys.
     *
     * @param string[] $postTypes Post types to scan.
     * @param string[] $metaKeys Meta keys that select candidate containers.
     * @return array{post_ids:int[],scan_gap:bool}
     */
    private static function findCandidatePostIdsByMetaKeys(array $postTypes, array $metaKeys): array
    {
        global $wpdb;

        $postTypes = ContentVisibility::normalizePostTypes($postTypes);
        $metaKeys = ContentVisibility::normalizePostTypes($metaKeys);
        if (empty($postTypes) || empty($metaKeys)) {
            return [
                'post_ids' => [],
                'scan_gap' => false,
            ];
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholder lists are count-derived from fixed %s templates and all values are bound through $wpdb->prepare(); builder selector scans must use live postmeta.
        $postTypePlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
        $metaKeyPlaceholders = implode(',', array_fill(0, count($metaKeys), '%s'));

        $results = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT p.ID
                 FROM {$wpdb->posts} p
                 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
                 WHERE p.post_type IN ($postTypePlaceholders)
                   AND p.post_status NOT IN ('auto-draft', 'inherit', 'trash')
                   AND pm.meta_key IN ($metaKeyPlaceholders)",
                ...array_merge($postTypes, $metaKeys)
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (!is_array($results)) {
            return [
                'post_ids' => [],
                'scan_gap' => true,
            ];
        }

        return [
            'post_ids' => ContentVisibility::normalizePostIds($results),
            'scan_gap' => false,
        ];
    }

    /**
     * Merge declared storage locations for one candidate post.
     *
     * @param array<int,array{type:string,key?:string,value?:string}> $existing
     * @param array<int,array{type:string,key?:string,value?:string}> $incoming
     * @return array<int,array{type:string,key?:string,value?:string}>
     */
    private static function mergeDeclaredLocations(array $existing, array $incoming): array
    {
        $merged = [];

        foreach (array_merge($existing, $incoming) as $location) {
            if (!is_array($location)) {
                continue;
            }

            $type = sanitize_key((string) ($location['type'] ?? ''));
            if ($type === '') {
                continue;
            }

            $key = sanitize_key((string) ($location['key'] ?? ''));
            $value = trim((string) ($location['value'] ?? ''));
            $index = $type . ':' . $key . ':' . $value;
            $merged[$index] = array_filter([
                'type' => $type,
                'key' => $key !== '' ? $key : null,
                'value' => $value !== '' ? $value : null,
            ], static fn($entry): bool => $entry !== null);
        }

        return array_values($merged);
    }

    /**
     * Resolve text content from a declared storage location.
     *
     * @param \WP_Post $post Post object.
     * @param array{type:string,key?:string,value?:string} $location Declared location.
     * @return string
     */
    private static function resolveDeclaredLocationText(\WP_Post $post, array $location): string
    {
        $type = sanitize_key((string) ($location['type'] ?? ''));
        if ($type === 'post_content') {
            return (string) $post->post_content;
        }

        if ($type === 'post_meta') {
            $key = sanitize_key((string) ($location['key'] ?? ''));
            if ($key === '') {
                return '';
            }

            $value = get_post_meta((int) $post->ID, $key, true);
            if (!is_scalar($value) && $value !== null) {
                $value = wp_json_encode($value);
            }

            return (string) $value;
        }

        if ($type === 'option') {
            $key = sanitize_key((string) ($location['key'] ?? ''));
            if ($key === '') {
                return '';
            }

            $value = get_option($key, null);
            if (!is_scalar($value) && $value !== null) {
                $value = wp_json_encode($value);
            }

            return (string) $value;
        }

        return '';
    }
}
