<?php
/**
 * Sidebar Helper Methods
 *
 * Pure utility functions for JSON extraction, block manipulation, content parsing,
 * plan normalization, and SEO pattern detection. All methods are static — no instance
 * state, no $this, no REST context. Each method receives all data as parameters.
 *
 * @package WP_Agentic_Writer
 * @since 0.3.0
 */

/**
 * Class WP_Agentic_Writer_Sidebar_Helpers
 *
 * Utility class containing pure helper functions extracted from
 * WP_Agentic_Writer_Gutenberg_Sidebar.
 *
 * @since 0.3.0
 */
class WP_Agentic_Writer_Sidebar_Helpers
{
    // =========================================================================
    // JSON Extraction Methods
    // =========================================================================

    /**
     * Extract JSON from model response text with multiple fallback strategies.
     *
     * @since 0.2.2
     * @param string $string Raw model response.
     * @return array|null Decoded JSON array or null on failure.
     */
    public static function extract_json($string)
    {
        $string = trim((string) $string);
        if ("" === $string) {
            return null;
        }

        // Method 1: JSON wrapped in markdown code block.
        if (
            preg_match_all("/```(?:json)?\s*([\s\S]*?)```/i", $string, $matches)
        ) {
            foreach ($matches[1] as $candidate) {
                $json = json_decode(trim($candidate), true);
                if (json_last_error() === JSON_ERROR_NONE) {
                    return $json;
                }
            }
        }

        // Method 2: Decode the whole string.
        $json = json_decode($string, true);
        if (json_last_error() === JSON_ERROR_NONE) {
            return $json;
        }

        // Method 3: Extract balanced JSON object/array candidates.
        $candidates = array_merge(
            self::extract_balanced_json_candidates($string, "{", "}"),
            self::extract_balanced_json_candidates($string, "[", "]"),
        );
        foreach ($candidates as $candidate) {
            $json = json_decode($candidate, true);
            if (json_last_error() === JSON_ERROR_NONE) {
                return $json;
            }
        }

        return null;
    }

    /**
     * Extract balanced JSON object/array candidates from model text.
     *
     * @since 0.2.2
     * @param string $string Source text.
     * @param string $open_char Opening character ({ or [).
     * @param string $close_char Closing character (} or ]).
     * @return array List of candidate JSON strings.
     */
    public static function extract_balanced_json_candidates(
        $string,
        $open_char = "{",
        $close_char = "}",
    ) {
        $candidates = [];
        $length = strlen($string);
        $depth = 0;
        $start = null;
        $in_string = false;
        $escaped = false;

        for ($i = 0; $i < $length; $i++) {
            $char = $string[$i];

            if ($in_string) {
                if ($escaped) {
                    $escaped = false;
                } elseif ("\\" === $char) {
                    $escaped = true;
                } elseif ('"' === $char) {
                    $in_string = false;
                }
                continue;
            }

            if ('"' === $char) {
                $in_string = true;
                continue;
            }

            if ($open_char === $char) {
                if (0 === $depth) {
                    $start = $i;
                }
                $depth++;
            } elseif ($close_char === $char && $depth > 0) {
                $depth--;
                if (0 === $depth && null !== $start) {
                    $candidates[] = substr($string, $start, $i - $start + 1);
                    $start = null;
                }
            }
        }

        usort($candidates, function ($a, $b) {
            return strlen($b) - strlen($a);
        });

        return $candidates;
    }

    /**
     * Extract JSON from a streaming chunk.
     *
     * @since 0.3.0
     * @param string $chunk Raw chunk from stream.
     * @return array|null Decoded JSON or null.
     */
    public static function extract_json_from_stream_chunk($chunk)
    {
        return self::extract_json($chunk);
    }

    // =========================================================================
    // Plan Extraction & Normalization Methods
    // =========================================================================

    /**
     * Extract plan from model response using JSON or markdown fallback.
     *
     * @since 0.2.2
     * @param string $content Raw model response.
     * @param string $fallback_title Fallback title/topic.
     * @param array  $previous_plan Previous plan for context.
     * @return array|null Normalized plan or null.
     */
    public static function extract_plan_from_response(
        $content,
        $fallback_title = "",
        $previous_plan = [],
    ) {
        $json = self::extract_json($content);
        $normalized_json_plan = self::normalize_extracted_plan_json(
            $json,
            $fallback_title,
        );
        if (!empty($normalized_json_plan["sections"])) {
            return $normalized_json_plan;
        }

        $markdown_plan = self::build_plan_from_markdown_outline(
            $content,
            $fallback_title,
            $previous_plan,
        );
        if (!empty($markdown_plan["sections"])) {
            return $markdown_plan;
        }

        return null;
    }

    /**
     * Normalize common model outline JSON variants into the required plan schema.
     *
     * @since 0.2.3
     * @param mixed  $json           Decoded model JSON.
     * @param string $fallback_title Fallback title/topic.
     * @return array|null
     */
    public static function normalize_extracted_plan_json($json, $fallback_title = "")
    {
        if (!is_array($json)) {
            return null;
        }

        // Some models return the sections array directly.
        if (array_is_list($json)) {
            $json = [
                "title" => $fallback_title,
                "sections" => $json,
            ];
        }

        // Some models nest the outline under a descriptive top-level key.
        foreach (
            ["plan", "outline", "article_plan", "articlePlan", "data"]
            as $key
        ) {
            if (
                empty($json["sections"]) &&
                isset($json[$key]) &&
                is_array($json[$key])
            ) {
                $nested = self::normalize_extracted_plan_json(
                    $json[$key],
                    $fallback_title,
                );
                if (!empty($nested["sections"])) {
                    return $nested;
                }
            }
        }

        $section_keys = [
            "sections",
            "outline",
            "items",
            "chapters",
            "headings",
            "bagian",
        ];
        $sections = [];
        foreach ($section_keys as $key) {
            if (!empty($json[$key]) && is_array($json[$key])) {
                $sections = $json[$key];
                break;
            }
        }

        if (empty($sections)) {
            return null;
        }

        $title =
            $json["title"] ??
            ($json["judul"] ?? ($json["headline"] ?? $fallback_title));
        $title = self::clean_outline_heading($title);
        if ("" === $title) {
            $title = __("Article Outline", "wp-agentic-writer");
        }

        $normalized_sections = [];
        foreach ($sections as $index => $section) {
            if (is_string($section)) {
                $section = ["heading" => $section];
            }
            if (!is_array($section)) {
                continue;
            }

            $heading =
                $section["heading"] ??
                ($section["title"] ??
                    ($section["judul"] ??
                        ($section["name"] ??
                            ($section["h2"] ??
                                sprintf("Section %d", $index + 1)))));
            $heading = self::clean_outline_heading($heading);
            if ("" === $heading) {
                continue;
            }

            $content_items =
                $section["content"] ??
                ($section["description"] ??
                    ($section["summary"] ??
                        ($section["points"] ?? ($section["bullets"] ?? []))));
            $content = self::normalize_plan_section_content_items(
                $content_items,
            );
            if (empty($content)) {
                $content[] = [
                    "type" => "paragraph",
                    "content" => $heading,
                ];
            }

            $normalized_sections[] = [
                "id" => sanitize_key($section["id"] ?? ""),
                "status" => sanitize_key($section["status"] ?? "pending"),
                "type" => sanitize_key($section["type"] ?? "section"),
                "heading" => $heading,
                "content" => $content,
            ];
        }

        if (empty($normalized_sections)) {
            return null;
        }

        $meta =
            isset($json["meta"]) && is_array($json["meta"])
                ? $json["meta"]
                : [];
        return [
            "title" => $title,
            "meta" => wp_parse_args($meta, [
                "reading_time" => "5 min",
                "difficulty" => "intermediate",
                "cost_estimate" => 0.7,
            ]),
            "sections" => $normalized_sections,
        ];
    }

    /**
     * Normalize varied model section content into plan content items.
     *
     * @since 0.2.3
     * @param mixed $items Section content candidate.
     * @return array
     */
    public static function normalize_plan_section_content_items($items)
    {
        if (is_string($items)) {
            $items = [$items];
        }
        if (!is_array($items)) {
            return [];
        }

        $normalized = [];
        foreach ($items as $item) {
            if (is_string($item)) {
                $text = trim(wp_strip_all_tags($item));
                if ("" !== $text) {
                    $normalized[] = [
                        "type" => "paragraph",
                        "content" => $text,
                    ];
                }
                continue;
            }

            if (!is_array($item)) {
                continue;
            }

            $text =
                $item["content"] ??
                ($item["text"] ??
                    ($item["description"] ?? ($item["point"] ?? "")));
            $text = trim(wp_strip_all_tags((string) $text));
            if ("" === $text) {
                continue;
            }

            $normalized[] = [
                "type" => sanitize_key($item["type"] ?? "paragraph"),
                "content" => $text,
            ];
        }

        return $normalized;
    }

    /**
     * Build a plan schema from markdown/numbered outline output.
     *
     * @since 0.2.2
     * @param string $content Markdown content.
     * @param string $fallback_title Fallback title.
     * @param array  $previous_plan Previous plan for context.
     * @return array|null
     */
    public static function build_plan_from_markdown_outline(
        $content,
        $fallback_title = "",
        $previous_plan = [],
    ) {
        $lines = preg_split('/\r\n|\r|\n/', (string) $content);
        if (!is_array($lines)) {
            return null;
        }

        $title = "";
        $sections = [];
        $current = null;

        foreach ($lines as $raw_line) {
            $line = trim(wp_strip_all_tags((string) $raw_line));
            if ("" === $line) {
                continue;
            }

            $line = preg_replace(
                '/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/',
                '$1',
                $line,
            );
            $heading = "";

            if (preg_match('/^#{1,2}\s+(.+)$/', $line, $matches)) {
                $text = self::clean_outline_heading($matches[1]);
                if ("" === $title) {
                    $title = $text;
                    continue;
                }
                $heading = $text;
            } elseif (preg_match('/^\d+[\.)]\s+(.+)$/', $line, $matches)) {
                $heading = self::clean_outline_heading($matches[1]);
            } elseif (
                preg_match(
                    '/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i',
                    $line,
                    $matches,
                )
            ) {
                $heading = self::clean_outline_heading($matches[1]);
            } elseif (
                "" === $title &&
                !preg_match(
                    "/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i",
                    $line,
                )
            ) {
                $title = self::clean_outline_heading($line);
                continue;
            }

            if (
                "" !== $heading &&
                !preg_match(
                    "/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i",
                    $heading,
                )
            ) {
                if (null !== $current) {
                    $sections[] = $current;
                }
                $current = [
                    "id" => wp_generate_uuid4(),
                    "status" => "pending",
                    "type" => "section",
                    "heading" => $heading,
                    "content" => [],
                ];
                continue;
            }

            if (null !== $current) {
                $detail = preg_replace("/^[-*]\s+/", "", $line);
                if (
                    "" !== $detail &&
                    !preg_match(
                        "/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i",
                        $detail,
                    )
                ) {
                    $current["content"][] = [
                        "type" => "paragraph",
                        "content" => $detail,
                    ];
                }
            }
        }

        if (null !== $current) {
            $sections[] = $current;
        }

        if (empty($sections)) {
            return null;
        }

        if ("" === $title) {
            $title = self::clean_outline_heading($fallback_title);
        }
        if ("" === $title && !empty($previous_plan["title"])) {
            $title = (string) $previous_plan["title"];
        }
        if ("" === $title) {
            $title = __("Article Outline", "wp-agentic-writer");
        }

        return [
            "title" => $title,
            "meta" => [
                "reading_time" => "5 min",
                "difficulty" => "intermediate",
                "cost_estimate" => 0.7,
            ],
            "sections" => $sections,
        ];
    }

    /**
     * Clean markdown decoration from an outline heading.
     *
     * @since 0.2.2
     * @param string $heading Heading text.
     * @return string
     */
    public static function clean_outline_heading($heading)
    {
        $heading = trim((string) $heading);
        $heading = preg_replace('/^\s*["\'`]+|["\'`]+\s*$/', "", $heading);
        $heading = preg_replace("/\*\*(.*?)\*\*/", '$1', $heading);
        $heading = preg_replace("/\s+/", " ", $heading);
        return trim($heading);
    }

    // =========================================================================
    // Block Manipulation Methods
    // =========================================================================

    /**
     * Find block by client ID in parsed blocks array (recursive).
     *
     * @since 0.1.0
     * @param array  $blocks    Parsed blocks array.
     * @param string $client_id Block client ID to find.
     * @return array|null Block data or null if not found.
     */
    public static function find_block_by_client_id($blocks, $client_id)
    {
        foreach ($blocks as $block) {
            if (
                isset($block["attrs"]["clientId"]) &&
                $block["attrs"]["clientId"] === $client_id
            ) {
                return $block;
            }
            // Check inner blocks
            if (
                isset($block["innerBlocks"]) &&
                is_array($block["innerBlocks"])
            ) {
                $found = self::find_block_by_client_id(
                    $block["innerBlocks"],
                    $client_id,
                );
                if ($found) {
                    return $found;
                }
            }
        }
        return null;
    }

    /**
     * Find block index in parsed blocks array.
     *
     * @since 0.1.0
     * @param array  $blocks    Parsed blocks array.
     * @param string $client_id Block client ID to find.
     * @return int Block index or -1 if not found.
     */
    public static function find_block_index($blocks, $client_id)
    {
        foreach ($blocks as $index => $block) {
            if (
                isset($block["attrs"]["clientId"]) &&
                $block["attrs"]["clientId"] === $client_id
            ) {
                return $index;
            }
        }
        return -1;
    }

    /**
     * Extract content from block data.
     *
     * @since 0.1.0
     * @param array $block Block data.
     * @return string Block content.
     */
    public static function extract_block_content($block)
    {
        if (isset($block["attrs"]["content"])) {
            return $block["attrs"]["content"];
        }

        if (isset($block["innerHTML"])) {
            return wp_strip_all_tags($block["innerHTML"]);
        }

        return "";
    }

    /**
     * Extract heading from block.
     *
     * @since 0.1.0
     * @param array $block Block data.
     * @return array|null Heading data or null if not a heading.
     */
    public static function extract_heading_from_block($block)
    {
        if (
            "core/heading" === $block["blockName"] &&
            isset($block["attrs"]["content"])
        ) {
            return [
                "heading" => $block["attrs"]["content"],
            ];
        }
        return null;
    }

    /**
     * Extract content from block attributes based on block type.
     *
     * @since 0.1.0
     * @param string $block_type Block type (e.g., 'core/paragraph').
     * @param array  $attrs Block attributes.
     * @return string Extracted content.
     */
    public static function extract_block_content_from_attrs($block_type, $attrs)
    {
        switch ($block_type) {
            case "core/paragraph":
                return isset($attrs["content"]) ? $attrs["content"] : "";

            case "core/heading":
                return isset($attrs["content"]) ? $attrs["content"] : "";

            case "core/list":
                if (isset($attrs["values"]) && is_array($attrs["values"])) {
                    return implode("\n", $attrs["values"]);
                }
                return "";

            case "core/code":
                return isset($attrs["content"]) ? $attrs["content"] : "";

            case "core/image":
                if (isset($attrs["url"]) && isset($attrs["alt"])) {
                    return "![" . $attrs["alt"] . "](" . $attrs["url"] . ")";
                }
                return "";

            default:
                if (isset($attrs["content"])) {
                    return $attrs["content"];
                }
                if (isset($attrs["value"])) {
                    return $attrs["value"];
                }
                return "";
        }
    }

    /**
     * Serialize block object for consistent handling.
     *
     * @since 0.1.0
     * @param array $block Block data.
     * @return array Serialized block with clientId.
     */
    public static function serialize_block($block)
    {
        if (!isset($block["attrs"]["clientId"])) {
            $block["attrs"]["clientId"] = isset($block["clientId"])
                ? $block["clientId"]
                : uniqid();
        }

        return $block;
    }

    /**
     * Get blocks from post content.
     *
     * @since 0.1.0
     * @param int|string $post_id_or_content Post ID or post content string.
     * @return array Array of block objects.
     */
    public static function select_blocks($post_id_or_content)
    {
        if (is_numeric($post_id_or_content)) {
            $post_id = (int) $post_id_or_content;
            $post = get_post($post_id);
            if (!$post) {
                return [];
            }
            $blocks = parse_blocks($post->post_content);
        } else {
            $blocks = parse_blocks((string) $post_id_or_content);
        }

        return array_filter($blocks, function ($block) {
            return !empty($block["blockName"]);
        });
    }

    // =========================================================================
    // Refinement Methods
    // =========================================================================

    /**
     * Clean refined content by removing conversational text.
     *
     * @since 0.1.0
     * @param string $content Content to clean.
     * @return string Cleaned content.
     */
    public static function clean_refined_content($content)
    {
        $conversational_prefixes = [
            'Certainly! Here\'s',
            'Here\'s',
            "The refined content",
            "Here is the",
            "Below is the",
            "Okay, here",
            "Sure, here",
        ];

        foreach ($conversational_prefixes as $prefix) {
            if (stripos($content, $prefix) === 0) {
                $content = substr($content, strlen($prefix));
                $content = ltrim($content, ":\n\r ");
            }
        }

        // Remove markdown code blocks if present
        $content = preg_replace('/^```(?:text|markdown)?\n*/i', "", $content);
        $content = preg_replace('/```*$/i', "", $content);

        // Remove common analysis scaffolding
        $content = preg_replace("/^\s*Refined version\s*:\s*/im", "", $content);
        $content = preg_replace(
            '/^\s*Key refinements\s*:\s*$/im',
            "",
            $content,
        );
        $content = preg_replace(
            '/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im',
            "",
            $content,
        );

        // Strip meta markers sections
        $meta_markers = [
            "Key refinements:",
            "Changes made:",
            "Explanation:",
            "Rationale:",
        ];
        foreach ($meta_markers as $marker) {
            $pos = stripos($content, $marker);
            if (false !== $pos) {
                $content = substr($content, 0, $pos);
            }
        }

        $content = preg_replace("/^\s*content\s*:\s*/im", "", $content);
        $content = trim($content);

        return $content;
    }

    /**
     * Parse refined payload that may be wrapped in JSON.
     *
     * @since 0.1.0
     * @param string $content Raw model content.
     * @return array Parsed payload with content and optional blockType.
     */
    public static function parse_refined_payload($content)
    {
        $payload = [
            "content" => $content,
        ];

        if (!is_string($content)) {
            return $payload;
        }

        $trimmed = trim($content);
        if ("" === $trimmed) {
            return $payload;
        }

        if ($trimmed[0] !== "{" || substr($trimmed, -1) !== "}") {
            return $payload;
        }

        $decoded = json_decode($trimmed, true);
        if (!is_array($decoded)) {
            return $payload;
        }

        if (isset($decoded["content"]) && is_string($decoded["content"])) {
            $payload["content"] = $decoded["content"];
        }

        $block_type = $decoded["blockType"] ?? ($decoded["type"] ?? null);
        if (is_string($block_type) && 0 === strpos($block_type, "core/")) {
            $payload["blockType"] = $block_type;
        }

        return $payload;
    }

    /**
     * Detect assistant/meta chatter that should never be inserted as block content.
     *
     * @since 0.1.0
     * @param string $content Refined content candidate.
     * @param string $block_type Resolved block type.
     * @return bool
     */
    public static function is_contaminated_refinement_output(
        $content,
        $block_type = "core/paragraph",
    ) {
        $text = trim(wp_strip_all_tags((string) $content));
        if ("" === $text) {
            return true;
        }

        $meta_patterns = [
            "/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i",
            "/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i",
            '/\b(i have kept the heading|if you\'d like me to refine this)\b/i',
        ];
        foreach ($meta_patterns as $pattern) {
            if (preg_match($pattern, $text)) {
                return true;
            }
        }

        // Headings should be concise and single-line.
        if ("core/heading" === $block_type) {
            if (strlen($text) > 180 || substr_count($text, "\n") > 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build a compact section-scoped context window around a block.
     *
     * @since 0.1.0
     * @param array $all_blocks   All serialized editor blocks.
     * @param int   $block_index Current block index.
     * @param int   $max_snippets Max context snippets.
     * @return string
     */
    public static function build_section_context_for_block(
        $all_blocks,
        $block_index,
        $max_snippets = 4,
    ) {
        if (
            !is_array($all_blocks) ||
            $block_index < 0 ||
            !isset($all_blocks[$block_index])
        ) {
            return "";
        }

        $start = $block_index;
        for ($i = $block_index - 1; $i >= 0; $i--) {
            $name =
                $all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? "");
            if ("core/heading" === $name) {
                $start = $i;
                break;
            }
            $start = $i;
        }

        $end = $block_index;
        for ($i = $block_index + 1; $i < count($all_blocks); $i++) {
            $name =
                $all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? "");
            if ("core/heading" === $name) {
                break;
            }
            $end = $i;
        }

        $snippets = [];
        for ($i = $start; $i <= $end; $i++) {
            $block = $all_blocks[$i];
            $name = $block["name"] ?? ($block["blockName"] ?? "");
            if (
                !in_array(
                    $name,
                    [
                        "core/heading",
                        "core/paragraph",
                        "core/list",
                        "core/quote",
                    ],
                    true,
                )
            ) {
                continue;
            }
            $attrs = $block["attributes"] ?? ($block["attrs"] ?? []);
            $text = trim(
                wp_strip_all_tags(
                    self::extract_block_content_from_attrs($name, $attrs),
                ),
            );
            if ("" === $text) {
                continue;
            }
            if (strlen($text) > 180) {
                $text = substr($text, 0, 180) . "...";
            }
            $snippets[] = "- " . $text;
            if (count($snippets) >= $max_snippets) {
                break;
            }
        }

        return implode("\n", $snippets);
    }

    /**
     * Create block structure for refined content.
     *
     * @since 0.1.0
     * @param string $block_id   Block client ID.
     * @param string $block_type Block type.
     * @param string $content    Refined content.
     * @return array Block structure.
     */
    public static function create_block_structure($block_id, $block_type, $content)
    {
        if (
            preg_match(
                '/^!\[(.*?)\]\(([^\s)]+)(?:\s+"[^"]*")?\)\s*$/',
                trim($content),
                $matches,
            )
        ) {
            $alt = trim($matches[1]);
            $url = trim($matches[2]);
            $escaped_alt = esc_attr($alt);
            $escaped_url = esc_url($url);

            return [
                "blockName" => "core/image",
                "attrs" => [
                    "id" => 0,
                    "url" => $escaped_url,
                    "alt" => $alt,
                    "caption" => "",
                    "sizeSlug" => "large",
                    "linkDestination" => "none",
                ],
                "innerHTML" =>
                    '<figure class="wp-block-image size-large"><img src="' .
                    $escaped_url .
                    '" alt="' .
                    $escaped_alt .
                    '" /></figure>',
                "clientId" => $block_id,
            ];
        }

        if ("core/paragraph" === $block_type) {
            return [
                "blockName" => "core/paragraph",
                "attrs" => ["content" => $content],
                "innerHTML" => "<p>" . $content . "</p>",
                "clientId" => $block_id,
            ];
        } elseif ("core/heading" === $block_type) {
            $level = 2;
            if (preg_match("/^(#{1,6})\s/", $content)) {
                $count = strspn($content, "#");
                $level = min($count, 6);
                $content = trim(substr($content, $count));
            }
            $tag = "h" . $level;
            return [
                "blockName" => "core/heading",
                "attrs" => [
                    "level" => $level,
                    "content" => $content,
                ],
                "innerHTML" => "<{$tag}>{$content}</{$tag}>",
                "clientId" => $block_id,
            ];
        } elseif ("core/list" === $block_type) {
            $lines = explode("\n", $content);
            $lines = array_filter(array_map("trim", $lines));

            $inner_blocks = [];
            foreach ($lines as $line) {
                $inner_blocks[] = [
                    "blockName" => "core/list-item",
                    "attrs" => ["content" => $line],
                    "innerHTML" => "<li>" . $line . "</li>",
                ];
            }

            return [
                "blockName" => "core/list",
                "attrs" => ["ordered" => false],
                "innerBlocks" => $inner_blocks,
                "clientId" => $block_id,
            ];
        } elseif ("core/code" === $block_type) {
            $language = "text";
            $code_content = $content;
            if (preg_match("/^```(\w+)?\s*/", $content, $matches)) {
                if (!empty($matches[1])) {
                    $language = $matches[1];
                }
                $code_content = preg_replace(
                    "/^```\w*\s*/",
                    "",
                    $code_content,
                );
                $code_content = preg_replace('/```\s*$/', "", $code_content);
                $code_content = trim($code_content);
            }

            $escaped = htmlspecialchars($code_content, ENT_NOQUOTES, "UTF-8");
            return [
                "blockName" => "core/code",
                "attrs" => [
                    "language" => $language,
                    "content" => $code_content,
                ],
                "innerHTML" =>
                    '<pre class="wp-block-code"><code>' .
                    $escaped .
                    "</code></pre>",
                "clientId" => $block_id,
            ];
        }

        // Fallback to paragraph
        return [
            "blockName" => "core/paragraph",
            "attrs" => ["content" => $content],
            "innerHTML" => "<p>" . $content . "</p>",
            "clientId" => $block_id,
        ];
    }

    /**
     * Sanitize refinement edit plan with allowlist validation.
     *
     * @since 0.1.0
     * @param array $plan_json       Edit plan JSON.
     * @param array $allowed_block_ids Allowed block IDs.
     * @param array $context_blocks  Context blocks for type resolution.
     * @return array Sanitized plan.
     */
    public static function sanitize_refinement_edit_plan(
        $plan_json,
        $allowed_block_ids,
        $context_blocks,
    ) {
        $allowed_actions = [
            "keep",
            "replace",
            "insert_after",
            "insert_before",
            "delete",
            "change_type",
        ];
        $allowed_lookup = array_fill_keys($allowed_block_ids, true);
        $context_by_id = [];

        foreach ($context_blocks as $block) {
            $context_id = sanitize_text_field($block["clientId"] ?? "");
            if ("" === $context_id) {
                continue;
            }
            $context_by_id[$context_id] = sanitize_text_field(
                $block["type"] ?? "core/paragraph",
            );
        }

        $raw_actions = $plan_json["actions"] ?? [];
        if (!is_array($raw_actions)) {
            $raw_actions = [];
        }

        $sanitized_actions = [];
        foreach ($raw_actions as $action) {
            if (!is_array($action)) {
                continue;
            }

            $action_name = sanitize_key($action["action"] ?? "");
            $block_id = sanitize_text_field($action["blockId"] ?? "");
            if (
                "" === $action_name ||
                !in_array($action_name, $allowed_actions, true)
            ) {
                continue;
            }

            if ("" === $block_id || !isset($allowed_lookup[$block_id])) {
                continue;
            }

            $clean_action = [
                "action" => $action_name,
                "blockId" => $block_id,
            ];

            if (
                in_array(
                    $action_name,
                    ["replace", "insert_after", "insert_before", "change_type"],
                    true,
                )
            ) {
                $fallback_type = $context_by_id[$block_id] ?? "core/paragraph";
                $clean_action["blockType"] = sanitize_text_field(
                    $action["blockType"] ?? $fallback_type,
                );
                $clean_action["content"] = isset($action["content"])
                    ? wp_kses_post((string) $action["content"])
                    : "";
            }

            $sanitized_actions[] = $clean_action;
        }

        return [
            "summary" => sanitize_text_field($plan_json["summary"] ?? ""),
            "actions" => $sanitized_actions,
        ];
    }

    /**
     * Parse refined blocks from content split by [Block N] markers.
     *
     * @since 0.1.0
     * @param string $content        Raw content.
     * @param int    $expected_count Expected number of blocks.
     * @return array Array of block contents.
     */
    public static function parse_refined_blocks($content, $expected_count = 0)
    {
        $blocks = [];

        $parts = preg_split("/\[Block\s*\d+\]/i", $content);
        array_shift($parts);

        foreach ($parts as $part) {
            $block_content = trim($part);
            if (!empty($block_content)) {
                $blocks[] = $block_content;
            }
        }

        if (empty($blocks) && !empty(trim($content))) {
            $blocks[] = trim($content);
        }

        return $blocks;
    }

    // =========================================================================
    // SEO & Pattern Detection Methods
    // =========================================================================

    /**
     * Scan for AI-ish patterns in content.
     *
     * @since 0.1.0
     * @param string $raw_content Raw content to scan.
     * @return array Detection results with count and examples.
     */
    public static function scan_ai_ish_patterns($raw_content)
    {
        $normalized = wp_strip_all_tags((string) $raw_content);
        $normalized = preg_replace("/\s+/", " ", $normalized);
        $normalized = trim((string) $normalized);

        if ("" === $normalized) {
            return [
                "count" => 0,
                "examples" => [],
            ];
        }

        $rules = [
            [
                "id" => "double_colon",
                "pattern" => "/[^\s]:\s*:[^\s]/u",
                "label" => "double colon punctuation",
            ],
            [
                "id" => "ai_phrase_not_only_but",
                "pattern" => "/\bbukan sekadar\b|\bnot just\b/i",
                "label" => "formulaic contrast phrase",
            ],
            [
                "id" => "ai_phrase_in_conclusion",
                "pattern" =>
                    "/\b(pada akhirnya|in conclusion|to summarize)\b/i",
                "label" => "template-like conclusion phrase",
            ],
            [
                "id" => "meta_instruction_leak",
                "pattern" =>
                    "/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i",
                "label" => "instructional/meta leakage",
            ],
            [
                "id" => "dash_overuse",
                "pattern" => "/\s[—–-]\s/u",
                "label" => "dash-heavy sentence style",
            ],
        ];

        $matches = [];
        $total = 0;

        foreach ($rules as $rule) {
            if (
                preg_match_all(
                    $rule["pattern"],
                    $normalized,
                    $found,
                    PREG_OFFSET_CAPTURE,
                )
            ) {
                $total += count($found[0]);
                if (count($matches) < 5) {
                    foreach ($found[0] as $entry) {
                        if (count($matches) >= 5) {
                            break;
                        }
                        $matched_text = (string) ($entry[0] ?? "");
                        $offset = (int) ($entry[1] ?? 0);
                        $context_start = max(0, $offset - 48);
                        $context = function_exists("mb_substr")
                            ? mb_substr($normalized, $context_start, 120)
                            : substr($normalized, $context_start, 120);
                        $matches[] = [
                            "type" => $rule["label"],
                            "match" => trim($matched_text),
                            "context" => trim($context),
                        ];
                    }
                }
            }
        }

        return [
            "count" => (int) $total,
            "examples" => $matches,
        ];
    }

    /**
     * Extract unresolved image slots from post content.
     *
     * @since 0.1.0
     * @param string $post_content Post content string.
     * @return array Array of unresolved image slot data.
     */
    public static function extract_unresolved_image_slots($post_content)
    {
        $slots = [];
        $blocks = parse_blocks((string) $post_content);

        $walk = function ($items, $heading_context = "") use (&$walk, &$slots) {
            foreach ($items as $block) {
                $name = $block["blockName"] ?? "";
                $attrs = $block["attrs"] ?? [];

                if ("core/heading" === $name) {
                    $heading = "";
                    if (!empty($attrs["content"])) {
                        $heading = trim(
                            wp_strip_all_tags((string) $attrs["content"]),
                        );
                    } elseif (!empty($block["innerHTML"])) {
                        $heading = trim(
                            wp_strip_all_tags((string) $block["innerHTML"]),
                        );
                    }
                    if ("" !== $heading) {
                        $heading_context = $heading;
                    }
                }

                if ("core/image" === $name) {
                    $image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0;
                    if ($image_id <= 0) {
                        $slots[] = [
                            "section_title" => $heading_context,
                        ];
                    }
                }

                if (
                    !empty($block["innerBlocks"]) &&
                    is_array($block["innerBlocks"])
                ) {
                    $walk($block["innerBlocks"], $heading_context);
                }
            }
        };

        $walk($blocks, "");
        return $slots;
    }

    // =========================================================================
    // Memory & Context Methods
    // =========================================================================

    /**
     * Build a short memory summary from the plan JSON.
     *
     * @since 0.1.0
     * @param array $plan_json Plan data.
     * @return string Summary text.
     */
    public static function build_memory_summary_from_plan($plan_json)
    {
        if (empty($plan_json) || !is_array($plan_json)) {
            return "";
        }

        $title = $plan_json["title"] ?? "";
        $headings = [];
        if (
            !empty($plan_json["sections"]) &&
            is_array($plan_json["sections"])
        ) {
            foreach ($plan_json["sections"] as $section) {
                if (!empty($section["heading"])) {
                    $headings[] = $section["heading"];
                }
            }
        }

        $summary = "";
        if ($title) {
            $summary .= "Title: {$title}\n";
        }
        if (!empty($headings)) {
            $summary .= "Sections: " . implode(" | ", $headings);
        }

        return trim($summary);
    }

    /**
     * Build memory context string for prompts.
     *
     * @since 0.1.0
     * @param int $post_id Post ID.
     * @return string Context string.
     */
    public static function get_post_memory_context($post_id)
    {
        if ($post_id <= 0) {
            return "";
        }

        $memory = get_post_meta($post_id, "_wpaw_memory", true);
        if (empty($memory) || !is_array($memory)) {
            return "";
        }

        $lines = [];
        if (!empty($memory["summary"])) {
            $lines[] = "Summary: " . $memory["summary"];
        }
        if (!empty($memory["last_prompt"])) {
            $lines[] = "Last prompt: " . $memory["last_prompt"];
        }
        if (!empty($memory["last_intent"])) {
            $lines[] = "Last intent: " . $memory["last_intent"];
        }

        if (empty($lines)) {
            return "";
        }

        return "\n\n=== POST MEMORY ===\n" .
            implode("\n", $lines) .
            "\n=== END POST MEMORY ===\n";
    }
}
