<?php

namespace Miruni\Gutenberg;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}

class Miruni_Gutenberg_Manager
{
    /**
     * Get page content as parsed Gutenberg block objects
     * @param int $post_id
     * @return list<array<string, mixed>> Array of parsed blocks
     * @throws \Exception When post is not found
     */
    public static function get_page_blocks(int $post_id): array
    {
        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            throw new \Exception("Post with ID {$post_id} not found");
        }

        /** @var list<array<string, mixed>> $blocks */
        $blocks = parse_blocks($post->post_content);
        return $blocks;
    }

    /**
     * Find a single block by name and matching attributes
     * @param list<array<string, mixed>> $blocks Array of blocks to search
     * @param string $block_name Block name to find
     * @param array<string, mixed> $match_attrs Attributes that must match
     * @return array{block: array<string, mixed>, path: list<int>} Block and its path
     * @throws \Exception If no block found or multiple blocks found
     */
    public static function find_block_by_criteria(array $blocks, string $block_name, array $match_attrs = [], string $match_str = ""): array
    {
        /** @var list<array{block: array<string, mixed>, path: list<int>}> $found_blocks */
        $found_blocks = [];
        self::search_blocks_recursive($blocks, $block_name, $match_attrs, [], $found_blocks);

        if (empty($found_blocks)) {
            throw new \Exception("No block found with name '{$block_name}' and matching attributes - " . json_encode($match_attrs));
        }

        if (count($found_blocks) > 1 && !empty($match_str)) {
            // If we have multiple blocks, we can try to match by a string in the block
            $found_blocks = array_filter($found_blocks, function ($item) use ($match_str) {
                $block_content = $item['block']['innerHTML'] ?? '';
                return str_contains($block_content, $match_str);
            });
        }

        if (count($found_blocks) > 1) {
            throw new \Exception(count($found_blocks) . " blocks found with name '{$block_name}' and matching attributes. Expected exactly one block. - Found blocks: " . json_encode($blocks));
        }

        return $found_blocks[0];
    }

    /**
     * Recursively search for blocks matching criteria
     * @param list<array<string, mixed>> $blocks
     * @param string $block_name
     * @param array<string, mixed> $match_attrs
     * @param list<int> $current_path
     * @param list<array{block: array<string, mixed>, path: list<int>}> &$found_blocks
     * @return void
     */
    private static function search_blocks_recursive(array $blocks, string $block_name, array $match_attrs, array $current_path, array &$found_blocks): void
    {
        foreach ($blocks as $index => $block) {
            $block_path = array_merge($current_path, [$index]);

            if (self::block_matches_criteria($block, $block_name, $match_attrs)) {
                $found_blocks[] = ['block' => $block, 'path' => $block_path];
            }

            // Search inner blocks
            // @var array<int, array<string, mixed>> $innerBlocks Each block is an associative array with keys:
            //   - blockName: string
            //   - attrs: array<string, mixed>
            //   - innerHTML: string
            //   - innerContent: array<int, string|null>
            //   - innerBlocks: array<int, array<string, mixed>>
            if (isset($block['innerBlocks']) && is_array($block['innerBlocks'])) {
                // Cast to list to satisfy PHPStan
                self::search_blocks_recursive(array_values($block['innerBlocks']), $block_name, $match_attrs, $block_path, $found_blocks);
            }
        }
    }

    /**
     * Check if a block matches the given criteria
     * @param array<string, mixed> $block
     * @param string $block_name
     * @param array<string, mixed> $match_attrs
     * @return bool
     */
    private static function block_matches_criteria(array $block, string $block_name, array $match_attrs): bool
    {
        if (($block['blockName'] ?? '') !== $block_name) {
            return false;
        }

        $block_attrs = $block['attrs'] ?? [];
        foreach ($match_attrs as $attr_key => $attr_value) {
            if (!self::attribute_values_match($attr_key, $attr_value, $block_attrs[$attr_key] ?? null)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Check if attribute values match with special handling for className
     * @param string $attr_key
     * @param mixed $expected_value
     * @param mixed $actual_value
     * @return bool
     */
    private static function attribute_values_match(string $attr_key, mixed $expected_value, mixed $actual_value): bool
    {
        if ($actual_value === $expected_value) {
            return true;
        }

        // Special handling for className which might contain multiple classes
        if ($attr_key === 'className' && is_string($actual_value) && is_string($expected_value)) {
            return in_array($expected_value, explode(' ', $actual_value));
        }

        return false;
    }

    /**
     * Surgically replace a block in post content by finding and replacing the exact block string
     * @param int $post_id
     * @param string $block_name
     * @param array<string, mixed> $match_attrs
     * @param array<string, mixed> $updates
     * @return array{
     *  updated_post: string,
     *  updated_block: string
     * }|false
     * @throws \Exception When post not found or block extraction fails
     */
    public static function update_block_surgically(int $post_id, string $block_name, array $match_attrs, array $updates, bool $update_post = true): array|false
    {
        error_log("Updating block '{$block_name}' in post ID {$post_id} with attributes: " . json_encode($match_attrs) . " and updates: " . json_encode($updates));
        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            throw new \Exception("Post with ID {$post_id} not found");
        }
        $original_content = get_post_field('post_content', $post_id, 'raw');
        error_log("Original content: " . $original_content);
        /** @var list<array<string, mixed>> $blocks */
        $blocks = parse_blocks($original_content);
        $found = self::find_block_by_criteria($blocks, $block_name, $match_attrs);
        $target_block = $found['block'];

        $updated_block = self::apply_block_updates($target_block, $updates);

        $original_block_string = self::extract_block_string_from_content($original_content, $target_block);
        $new_block_string = serialize_block($updated_block);
        $updated_content = str_replace($original_block_string, $new_block_string, $original_content);
        if ($updated_content === $original_content) {
            return false; // No changes made
        }

        $return_object = [
            'updated_post' => $updated_content,
            'updated_block' => $new_block_string
        ];

        if (!$update_post) {
            return $return_object;
        }

        $result = wp_update_post([
            'ID' => $post_id,
            'post_content' => $updated_content
        ]);

        // @phpstan-ignore function.impossibleType
        if ($result === 0 || is_wp_error($result)) {
            return false; // Update failed
        }

        return $return_object;
    }

    /**
     * Summary of preview_block_updates
     * @param int $post_id
     * @param string $block_name
     * @param array<mixed> $match_attrs
     * @param array<mixed> $updates
     * @throws \Exception
     * @return array{
     *  updated_post: string,
     *  updated_block: string
     * }|false
     */
    public static function preview_block_updates(int $post_id, string $block_name, array $match_attrs, array $updates): array|false
    {
        return self::update_block_surgically($post_id, $block_name, $match_attrs, $updates, false);
    }

    /**
     * Apply updates to a block
     * @param array<string, mixed> $block
     * @param array<string, mixed> $updates
     * @return array<string, mixed>
     */
    private static function apply_block_updates(array $block, array $updates): array
    {
        // Update attributes
        if (isset($updates['attrs']) && is_array($updates['attrs'])) {
            $block['attrs'] = array_merge($block['attrs'] ?? [], $updates['attrs']);
        }

        // Replace all content/html
        if (isset($updates['innerHTML']) && isset($updates['innerContent'])) {
            $block['innerHTML'] = $updates['innerHTML'];
            if (is_array($updates['innerContent'])) {
                $block['innerContent'] = $updates['innerContent'];
            } else {
                $block['innerContent'] = [$updates['innerContent']];
            }
        }

        // Find and replace within content/html
        if (isset($updates['content_search']) && isset($updates['content_replace'])) {
            if (isset($block['innerHTML']) && is_string($block['innerHTML'])) {
                $block['innerHTML'] = str_replace(
                    $updates['content_search'],
                    $updates['content_replace'],
                    $block['innerHTML']
                );
            }

            if (isset($block['innerContent']) && is_array($block['innerContent'])) {
                foreach ($block['innerContent'] as &$content_item) {
                    if (is_string($content_item)) {
                        $content_item = str_replace(
                            $updates['content_search'],
                            $updates['content_replace'],
                            $content_item
                        );
                    }
                }
                unset($content_item); // Break reference
            }
        }

        return $block;
    }

    /**
     * Extract the exact block string from content that corresponds to a parsed block
     * @param string $content
     * @param array<string, mixed> $target_block
     * @return string
     * @throws \Exception When block string cannot be extracted
     */
    private static function extract_block_string_from_content(string $content, array $target_block): string
    {
        // For blocks with a blockName, look for the block comment pattern
        if (!empty($target_block['blockName'])) {
            $block_name = $target_block['blockName'];
            $target_attrs = $target_block['attrs'] ?? [];

            // Parse all blocks to get their positions and data
            $all_blocks = parse_blocks($content);
            $matching_blocks = [];
            self::collect_matching_blocks_with_positions($all_blocks, $block_name, $target_attrs, $content, $matching_blocks);

            if (empty($matching_blocks)) {
                throw new \Exception("No matching blocks found in content for '{$block_name}' with attributes: " . json_encode($target_attrs));
            }

            // Find the exact match by comparing the full block structure
            foreach ($matching_blocks as $match) {
                if (self::blocks_are_identical($target_block, $match['block'])) {
                    return $match['block_string'];
                }
            }

            // If no exact match found, return the first one as fallback
            error_log("No exact block match found, using first matching block");
            return $matching_blocks[0]['block_string'];
        }

        // For blocks without blockName (like classic/freeform content)
        if (isset($target_block['innerHTML']) && is_string($target_block['innerHTML'])) {
            return $target_block['innerHTML'];
        }

        throw new \Exception("Unable to extract block string - no blockName or innerHTML found");
    }

    /**
     * Collect matching blocks with their actual block strings from content
     * @param array<array<string, mixed>> $blocks
     * @param string $block_name
     * @param array<string, mixed> $target_attrs
     * @param string $content
     * @param array<array{block: array<string, mixed>, block_string: string}> &$matching_blocks
     * @return void
     */
    private static function collect_matching_blocks_with_positions(array $blocks, string $block_name, array $target_attrs, string $content, array &$matching_blocks): void
    {
        foreach ($blocks as $block) {
            if (self::block_matches_criteria($block, $block_name, $target_attrs)) {
                // Extract the actual block string for this specific block
                $block_string = self::extract_single_block_string($content, $block);
                if ($block_string !== null) {
                    $matching_blocks[] = [
                        'block' => $block,
                        'block_string' => $block_string
                    ];
                }
            }

            // Search inner blocks recursively
            if (isset($block['innerBlocks']) && is_array($block['innerBlocks'])) {
                self::collect_matching_blocks_with_positions($block['innerBlocks'], $block_name, $target_attrs, $content, $matching_blocks);
            }
        }
    }

    /**
     * Extract block string for a specific block by using its innerHTML as a marker
     * @param string $content
     * @param array<string, mixed> $block
     * @return string|null
     */
    private static function extract_single_block_string(string $content, array $block): ?string
    {
        $block_name = $block['blockName'] ?? '';
        if (empty($block_name)) {
            return $block['innerHTML'] ?? null;
        }

        $innerHTML = $block['innerHTML'] ?? '';
        if (empty($innerHTML)) {
            // Handle self-closing blocks
            return serialize_block($block);
        }

        // Use the innerHTML to find the exact position in content
        $innerHTML_pos = strpos($content, $innerHTML);
        if ($innerHTML_pos === false) {
            error_log("Could not find innerHTML in content for block: " . $block_name);
            return serialize_block($block); // Fallback to serialized version
        }

        // Work backwards to find the opening comment
        $search_start = max(0, $innerHTML_pos - 500); // Search up to 500 chars before innerHTML
        $before_innerHTML = substr($content, $search_start, $innerHTML_pos - $search_start);

        $escaped_block_name = preg_quote($block_name, '/');
        $opening_pattern = "/<!-- wp:{$escaped_block_name}(?:\s+[^>]*)?\s*-->/";

        if (preg_match_all($opening_pattern, $before_innerHTML, $matches, PREG_OFFSET_CAPTURE)) {
            // Get the last match (closest to innerHTML)
            $last_match = end($matches[0]);
            if (empty($last_match)) {
                error_log("No opening comment found for block: " . $block_name);
                return serialize_block($block); // Fallback to serialized version
            }
            $opening_pos = $search_start + $last_match[1];

            // Find the closing comment
            $closing_comment = "<!-- /wp:{$block_name} -->";
            $closing_pos = strpos($content, $closing_comment, $innerHTML_pos + strlen($innerHTML));

            if ($closing_pos !== false) {
                $block_length = $closing_pos + strlen($closing_comment) - $opening_pos;
                return substr($content, $opening_pos, $block_length);
            }
        }

        // Fallback: serialize the block
        return serialize_block($block);
    }

    /**
     * Compare two blocks to see if they are identical
     * @param array<string, mixed> $block1
     * @param array<string, mixed> $block2
     * @return bool
     */
    private static function blocks_are_identical(array $block1, array $block2): bool
    {
        // Compare blockName
        if (($block1['blockName'] ?? '') !== ($block2['blockName'] ?? '')) {
            return false;
        }

        // Compare attributes
        if (!self::attributes_match($block1['attrs'] ?? [], $block2['attrs'] ?? [])) {
            return false;
        }

        // Compare innerHTML
        if (($block1['innerHTML'] ?? '') !== ($block2['innerHTML'] ?? '')) {
            return false;
        }

        return true;
    }

    /**
     * Compare two attribute arrays for exact match
     * @param array<string, mixed> $attrs1
     * @param array<string, mixed> $attrs2
     * @return bool
     */
    private static function attributes_match(array $attrs1, array $attrs2): bool
    {
        // Convert both to JSON for deep comparison
        return json_encode($attrs1, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ===
            json_encode($attrs2, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    /**
     * Convenience method: Replace all content in a block
     * @param int $post_id
     * @param string $block_name
     * @param array<string, mixed> $match_attrs
     * @param string $new_content
     * @return bool|array{
     * updated_post: string,
     * updated_block: string
     * }
     * @throws \Exception When post not found or block extraction fails
     */
    public static function replace_block_content(int $post_id, string $block_name, array $match_attrs, string $new_content): bool|array
    {
        return self::update_block_surgically($post_id, $block_name, $match_attrs, [
            'innerHTML' => $new_content,
            'innerContent' => [$new_content]
        ]);
    }

    /**
     * Convenience method: Find and replace text within a block
     * @param int $post_id
     * @param string $block_name
     * @param array<string, mixed> $match_attrs
     * @param string $search
     * @param string $replace
     * @return bool|array{
     * updated_post: string,
     * updated_block: string
     * }
     * @throws \Exception When post not found or block extraction fails
     */
    public static function find_replace_in_block(int $post_id, string $block_name, array $match_attrs, string $search, string $replace): bool|array
    {
        return self::update_block_surgically($post_id, $block_name, $match_attrs, [
            'content_search' => $search,
            'content_replace' => $replace
        ]);
    }

    /**
     * Convenience method: Update block attributes
     * @param int $post_id
     * @param string $block_name
     * @param array<string, mixed> $match_attrs
     * @param array<string, mixed> $new_attrs
     * @return bool|array{
     * updated_post: string,
     * updated_block: string
     * }
     * @throws \Exception When post not found or block extraction fails
     */
    public static function update_block_attributes(int $post_id, string $block_name, array $match_attrs, array $new_attrs): array|bool
    {
        return self::update_block_surgically($post_id, $block_name, $match_attrs, [
            'attrs' => $new_attrs
        ]);
    }
}