<?php
/**
 * Tool: update_page_text
 *
 * Surgical text replacement inside an existing page or post. Walks the
 * Gutenberg block tree and replaces literal occurrences of `find` with
 * `replace` in each block's innerHTML and innerContent strings. Works on
 * Classic content too (parse_blocks returns one freeform block whose
 * innerHTML is the full body).
 *
 * Use this for things like "change the title from X to Y" or "fix that
 * typo in the second paragraph". For full-page redesigns use
 * design_page_preview / apply_page_design instead.
 *
 * Inputs:
 *   page         — id, slug, title, "current", or "home". Same resolver
 *                  as the other tools.
 *   find         — exact substring to match. Required, non-empty.
 *   replace      — replacement string. May be empty (acts as a delete).
 *   case_sensitive — optional bool, defaults to true. Off matches via
 *                    a case-insensitive substring search.
 *   max_replacements — optional int. Defaults to all matches. Use 1 to
 *                      limit when the user wants only the first hit.
 */
if (!defined('ABSPATH')) exit;

function swcp_tool_update_page_text($input, $ctx) {
    $page_arg = isset($input['page']) ? $input['page'] : 'current';
    $find     = isset($input['find']) ? (string) $input['find'] : '';
    $replace  = isset($input['replace']) ? (string) $input['replace'] : '';
    $ci       = !empty($input['case_insensitive']) || (isset($input['case_sensitive']) && !$input['case_sensitive']);
    $max      = isset($input['max_replacements']) ? max(0, (int) $input['max_replacements']) : 0; // 0 = unlimited

    if ($find === '') {
        return ['ok' => false, 'message' => 'I need the exact text to find. Tell me what should change and what it should change to.'];
    }

    $resolved = swcp_resolve_or_error($page_arg, $ctx['post_id'] ?? 0);
    if (is_array($resolved) && isset($resolved['ok']) && !$resolved['ok']) return $resolved;
    $post = $resolved;

    // Refuse if the resolved page is built with Elementor / Divi / etc —
    // our tools work on Gutenberg post_content, theirs lives in meta. The
    // system prompt steers Claude away from these but the resolver does
    // not know the target's builder, so this is the canonical guard.
    $compat = swcp_check_builder_compat($post->ID);
    if ($compat !== null) return $compat;

    // Snapshot the current post_content so the chip can offer a one-click
    // undo. swcp_save_blocks records its own before/after pair to the
    // cloud version log too — this snapshot is the local copy the rail
    // sends straight to restore_page_version.
    $before_html = (string) $post->post_content;

    $blocks    = swcp_get_blocks($post);
    $total     = 0;
    $hits      = []; // [{block_name, before, after}]
    $remaining = $max ?: PHP_INT_MAX;

    // We do find/replace twice per block: once on innerHTML (which drives
    // both the saved string count) and once on every innerContent string
    // (silent — keeps Gutenberg's serialization in sync but doesn't count).
    // Counting only innerHTML matches the user's mental model: 1 visible
    // change = 1 replacement.
    $replace_counted = function ($haystack) use ($find, $replace, $ci, &$total, &$remaining) {
        if (!is_string($haystack) || $haystack === '' || $remaining <= 0) return $haystack;
        if ($ci) {
            $out = '';
            $cursor = 0;
            $find_len = strlen($find);
            while ($remaining > 0) {
                $pos = stripos($haystack, $find, $cursor);
                if ($pos === false) break;
                $out .= substr($haystack, $cursor, $pos - $cursor) . $replace;
                $cursor = $pos + $find_len;
                $total++;
                $remaining--;
            }
            $out .= substr($haystack, $cursor);
            return $out;
        }
        $count = substr_count($haystack, $find);
        if ($count === 0) return $haystack;
        $apply = min($count, $remaining);
        $replaced = preg_replace(
            '/' . preg_quote($find, '/') . '/',
            addcslashes($replace, '\\$'),
            $haystack,
            $apply
        );
        if ($replaced === null) return $haystack;
        $total     += $apply;
        $remaining -= $apply;
        return $replaced;
    };

    $replace_silent = function ($haystack) use ($find, $replace, $ci) {
        if (!is_string($haystack) || $haystack === '') return $haystack;
        if ($ci) {
            return preg_replace(
                '/' . preg_quote($find, '/') . '/i',
                addcslashes($replace, '\\$'),
                $haystack
            );
        }
        return str_replace($find, $replace, $haystack);
    };

    $blocks = swcp_walk_blocks($blocks, function ($block) use (&$hits, $replace_counted, $replace_silent, $find, $ci) {
        $before_html = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';
        $had_match   = $ci
            ? ($before_html !== '' && stripos($before_html, $find) !== false)
            : ($before_html !== '' && strpos($before_html, $find) !== false);

        if (isset($block['innerHTML'])) {
            $block['innerHTML'] = $replace_counted($block['innerHTML']);
        }
        if (isset($block['innerContent']) && is_array($block['innerContent'])) {
            foreach ($block['innerContent'] as &$ic) {
                if (is_string($ic)) $ic = $replace_silent($ic);
            }
            unset($ic);
        }

        if ($had_match) {
            $name = isset($block['blockName']) && $block['blockName']
                ? (string) $block['blockName']
                : 'classic/freeform';
            $hits[] = [
                'block'  => $name,
                'before' => trim(wp_strip_all_tags($before_html)),
                'after'  => trim(wp_strip_all_tags(isset($block['innerHTML']) ? (string) $block['innerHTML'] : '')),
            ];
        }
        return $block;
    });

    if ($total === 0) {
        // Strict-substring single-block match failed. Try the loose pass
        // first: same-block text that's split across HTML tags within one
        // block's innerHTML (e.g. uagb/info-box rendering an h1 + h2 as
        // one block). Build a regex from the find string where each
        // whitespace becomes "any whitespace or HTML tag" so we can match
        // and replace the entire matched span as one unit.
        $loose = swcp_update_page_text_loose_inblock($blocks, $find, $replace, $ci);
        if ($loose['ok']) {
            $blocks = $loose['blocks'];
            $total  = $loose['replacements'];
            $hits   = array_merge($hits, $loose['hits']);
        }
    }

    if ($total === 0) {
        // Still no match. Try the cross-block path: text that lives in
        // N consecutive sibling blocks (typical case: a hero where h1
        // sits in block 1 and the subtitle sits in block 2). Walks
        // recursively so spans inside a wrapper (group/columns) work too.
        $cross = swcp_update_page_text_cross_block($blocks, $find, $replace, $ci);
        if ($cross['ok']) {
            $blocks = $cross['blocks'];
            $total  = $cross['replacements'];
            $hits   = array_merge($hits, $cross['hits']);
        } else {
            // Helpful diagnostic: did the text exist at all in the rendered post?
            // Tag-substitute (not just strip) so phrases split by <br> / </h1>
            // boundaries still register as present.
            $rendered_text = swcp_uptext_normalize_ws(
                preg_replace('/<[^>]+>/', ' ', (string) $post->post_content)
            );
            $find_check = swcp_uptext_normalize_ws($find);
            $present = $ci
                ? (stripos($rendered_text, $find_check) !== false)
                : (strpos($rendered_text, $find_check) !== false);
            if ($present) {
                return [
                    'ok'      => false,
                    'message' => 'I can see "' . esc_html($find) . '" on the page but it is split across more sections than I can rewrite at once. Try a shorter, single-section fragment.',
                    'page_id' => (int) $post->ID,
                ];
            }
            $candidates = swcp_suggest_pages($find, 5);
            $hint = '';
            if ($candidates) {
                $titles = array_map(function ($p) { return get_the_title($p); }, $candidates);
                $hint   = ' That phrase shows up in the title of: "' . implode('", "', $titles) . '". Did you mean one of those pages?';
            }
            return [
                'ok'      => false,
                'message' => 'I could not find "' . esc_html($find) . '" on "' . esc_html(get_the_title($post)) . '".' . $hint,
                'page_id' => (int) $post->ID,
            ];
        }
    }

    $saved = swcp_save_blocks($post->ID, $blocks);
    if (!$saved) {
        return ['ok' => false, 'message' => 'Found ' . $total . ' match(es) but failed to save the page.'];
    }

    $sample = array_slice($hits, 0, 3);
    $sample_str = implode('; ', array_map(function ($h) {
        return '"' . $h['before'] . '" → "' . $h['after'] . '"';
    }, $sample));
    if (count($hits) > 3) $sample_str .= '; …';

    return [
        'ok'               => true,
        'page_id'          => (int) $post->ID,
        'replacements'     => $total,
        'message'          => 'Replaced ' . $total . ' occurrence' . ($total === 1 ? '' : 's')
                              . ' of "' . esc_html($find) . '" on "' . esc_html(get_the_title($post)) . '".'
                              . ($sample_str ? ' ' . $sample_str : ''),
        'view_url'         => swcp_view_url_for($post->ID),
        'edit_url'         => swcp_edit_url_for($post->ID),
        'hits'             => $hits,
        // Tells the rail to reload the visible page so the user sees the
        // change without hitting F5 themselves. The rail handles cancel
        // logic if the user clicks Undo first.
        'requires_refresh' => true,
        // Undo payload. The chip surfaces a single Undo button which calls
        // restore_page_version with this snapshot. We stash the previous
        // post_content here so the rail does not need a round-trip to the
        // cloud version log to perform the rollback.
        'undo' => [
            'tool'  => 'restore_page_version',
            'input' => [
                'post_id'       => (int) $post->ID,
                'snapshot_html' => $before_html,
            ],
            'label' => 'Undo',
        ],
    ];
}

/**
 * Loose match within one block's innerHTML: build a regex where each
 * whitespace in `find` becomes "any whitespace or HTML tag", so phrases
 * like "Swap Burning Out For a Fire Within" can match an innerHTML that
 * renders as `<h1>Swap Burning Out</h1><h2>For a Fire Within</h2>` (the
 * common Spectra/uagb hero pattern). The entire matched span — including
 * the in-between tags — is replaced by `$replace` as a single unit, so
 * the layout collapses to whatever wraps the first matching token.
 *
 * Skips blocks whose stripped text doesn't even contain the find words at
 * the right cadence, so the regex only runs where it has a chance.
 */
function swcp_update_page_text_loose_inblock($blocks, $find, $replace, $ci) {
    $find_n  = swcp_uptext_normalize_ws($find);
    if ($find_n === '') return ['ok' => false];

    $words = preg_split('/\s+/', $find_n);
    if (!$words || count($words) < 2) return ['ok' => false];

    $escaped = array_map(function ($w) { return preg_quote($w, '/'); }, $words);
    // Words are joined by either whitespace or HTML tags (one or more).
    $glue   = '(?:\s|<[^>]*?>)+';
    $regex  = '/' . implode($glue, $escaped) . '/' . ($ci ? 'i' : '') . 's';

    $hits  = [];
    $total = 0;

    $blocks = swcp_walk_blocks($blocks, function ($block) use ($regex, $replace, $find, $ci, &$hits, &$total) {
        if (!isset($block['innerHTML'])) return null;
        $html = (string) $block['innerHTML'];
        if ($html === '') return null;

        // Capture stripped text up front for the hit-summary (after-replace
        // we'll strip again). No pre-filter: tag-stripped text can lack the
        // whitespace the find string assumes (e.g. <br>-separated phrases),
        // so we let the regex itself decide whether this block matches.
        $stripped_before = swcp_uptext_normalize_ws(
            preg_replace('/<[^>]+>/', ' ', $html)
        );

        $new_html = preg_replace($regex, addcslashes($replace, '\\$'), $html, 1, $count);
        if ($new_html === null || $count === 0) return null;

        $block['innerHTML'] = $new_html;

        // Sync innerContent: replace the same span there too. innerContent
        // strings are pieces of the original HTML, so the same regex works.
        if (isset($block['innerContent']) && is_array($block['innerContent'])) {
            // For multi-piece innerContent (innerBlocks placeholders are nulls),
            // try the regex on each string piece. If the match doesn't fit a
            // single piece, fall back to rebuilding the array as one string.
            $matched_in_pieces = false;
            foreach ($block['innerContent'] as $k => $ic) {
                if (!is_string($ic)) continue;
                $rep = preg_replace($regex, addcslashes($replace, '\\$'), $ic, 1, $cn);
                if ($rep !== null && $cn > 0) {
                    $block['innerContent'][$k] = $rep;
                    $matched_in_pieces = true;
                    break;
                }
            }
            if (!$matched_in_pieces) {
                // Couldn't match in any single innerContent piece — collapse
                // to one string so the saved markup stays consistent with
                // innerHTML.
                $block['innerContent'] = [$new_html];
            }
        }

        $name = isset($block['blockName']) && $block['blockName']
            ? (string) $block['blockName']
            : 'classic/freeform';
        $hits[] = [
            'block'  => $name,
            'before' => $stripped_before,
            'after'  => trim(wp_strip_all_tags($new_html)),
        ];
        $total += $count;
        return $block;
    });

    if ($total === 0) return ['ok' => false];
    return ['ok' => true, 'blocks' => $blocks, 'replacements' => $total, 'hits' => $hits];
}

/**
 * Try to satisfy the find/replace by spanning consecutive sibling blocks.
 * Recurses into innerBlocks so a span inside a wrapper (e.g. uagb/info-box,
 * core/group, core/columns) is found alongside top-level spans.
 *
 * On success: replaces the matched fragment in the FIRST hit sibling's
 * innerHTML with the full `replace` string, strips the matched fragments
 * from any subsequent hit siblings, and removes siblings that end up empty.
 *
 * Limitations:
 *   - Spans must be contiguous siblings at the SAME nesting level. We do
 *     NOT cross level boundaries (e.g. a span starting at top-level and
 *     ending inside a nested column) because re-stitching across nesting
 *     reliably enough is more complexity than v1 needs.
 *   - Stops at the first successful span. Multi-instance text only gets
 *     one rewrite per call; the user can call again for the next instance.
 */
function swcp_update_page_text_cross_block($blocks, $find, $replace, $ci) {
    $find_n = swcp_uptext_normalize_ws($find);
    if ($find_n === '') return ['ok' => false];
    $hits = [];
    $ok   = swcp_uptext_walk_for_cross_block($blocks, $find_n, $replace, $ci, $hits);
    if ($ok) {
        return ['ok' => true, 'blocks' => $blocks, 'replacements' => 1, 'hits' => $hits];
    }
    return ['ok' => false];
}

/**
 * Recursive walker. Tries the cross-block span at the current sibling
 * level first; if no match, descends into innerBlocks of each block and
 * retries there. Mutates `$blocks` by reference so the caller's tree is
 * updated in place. Returns true on the first successful span.
 */
function swcp_uptext_walk_for_cross_block(&$blocks, $find_n, $replace, $ci, &$hits) {
    if (swcp_uptext_try_at_level($blocks, $find_n, $replace, $ci, $hits)) {
        return true;
    }
    foreach ($blocks as $idx => $unused) {
        if (!empty($blocks[$idx]['innerBlocks']) && is_array($blocks[$idx]['innerBlocks'])) {
            if (swcp_uptext_walk_for_cross_block($blocks[$idx]['innerBlocks'], $find_n, $replace, $ci, $hits)) {
                return true;
            }
        }
    }
    return false;
}

/**
 * Cross-block search at one nesting level. Walks sibling indices, builds
 * a normalized-whitespace concatenation, looks for `find_n` across at
 * least two siblings, applies the find/replace, and returns true on
 * success. Mutates `$blocks` (siblings) by reference.
 */
function swcp_uptext_try_at_level(&$blocks, $find_n, $replace, $ci, &$hits) {
    $count = count($blocks);
    for ($i = 0; $i < $count - 1; $i++) {
        // Build progressive concat starting at block $i. Stop once we have
        // either matched or ruled out a span starting here.
        $running   = '';
        $segments  = []; // [['index'=>k, 'text'=>$text_n, 'start'=>$running_offset]]
        $matched_j = -1;
        $needle_pos = -1;

        for ($j = $i; $j < $count; $j++) {
            $block = $blocks[$j];
            $text  = swcp_uptext_normalize_ws(swcp_block_text($block));
            if ($text === '') continue; // skip image / spacer / separator-like blocks

            $segments[] = ['index' => $j, 'text' => $text, 'start' => strlen($running)];
            $running   .= ($running === '' ? '' : ' ') . $text;

            $pos = $ci ? stripos($running, $find_n) : strpos($running, $find_n);
            if ($pos !== false && $pos + strlen($find_n) <= strlen($running)) {
                // Match found within the running concat. Confirm it
                // straddles at least 2 segments (otherwise the single-block
                // path would have caught it).
                $end = $pos + strlen($find_n);
                $hit_segments = [];
                foreach ($segments as $seg) {
                    $seg_end = $seg['start'] + strlen($seg['text']);
                    if ($seg_end <= $pos) continue;       // entirely before match
                    if ($seg['start'] >= $end) break;     // entirely after match
                    $hit_segments[] = $seg;
                }
                if (count($hit_segments) < 2) {
                    // Single-segment match — keep scanning further pairs.
                    continue;
                }
                $matched_j  = $j;
                $needle_pos = $pos;
                break;
            }
            // Cheap early-out: if running is already longer than find AND
            // doesn't even start with find's first character pattern, stop
            // extending this start-block.
            if (strlen($running) > strlen($find_n) * 2) break;
        }

        if ($matched_j < 0) continue;

        // Compute the per-block slice of `find` inside each hit segment.
        $end = $needle_pos + strlen($find_n);
        $level_hits = [];
        $first_hit_block_idx = null;
        $strip_block_indices = [];

        foreach ($segments as $seg) {
            $seg_start = $seg['start'];
            $seg_end   = $seg_start + strlen($seg['text']);
            if ($seg_end <= $needle_pos) continue;
            if ($seg_start >= $end)       break;

            // The portion of $running that falls in this segment AND the match
            $slice_start = max($seg_start, $needle_pos) - $seg_start;
            $slice_end   = min($seg_end, $end) - $seg_start;
            $slice       = substr($seg['text'], $slice_start, $slice_end - $slice_start);

            if ($first_hit_block_idx === null) {
                $first_hit_block_idx = $seg['index'];
                $first_slice         = $slice;
            } else {
                $strip_block_indices[] = ['index' => $seg['index'], 'slice' => $slice];
            }
            $level_hits[] = [
                'block_index' => $seg['index'],
                'block'       => isset($blocks[$seg['index']]['blockName']) && $blocks[$seg['index']]['blockName']
                                    ? (string) $blocks[$seg['index']]['blockName']
                                    : 'classic/freeform',
                'slice'       => $slice,
            ];
        }
        if ($first_hit_block_idx === null) continue;

        // Apply replacements. First block: replace its slice with full `replace`.
        $first_block = $blocks[$first_hit_block_idx];
        $first_block_after = swcp_uptext_replace_in_block($first_block, $first_slice, $replace, $ci);
        if (!$first_block_after['ok']) continue;
        $blocks[$first_hit_block_idx] = $first_block_after['block'];

        // Trailing blocks: strip the matching slice (replace with empty).
        $deletions = [];
        foreach ($strip_block_indices as $info) {
            $idx   = $info['index'];
            $slice = $info['slice'];
            $b_after = swcp_uptext_replace_in_block($blocks[$idx], $slice, '', $ci);
            if ($b_after['ok']) {
                $blocks[$idx] = $b_after['block'];
                $rendered = trim(wp_strip_all_tags(isset($blocks[$idx]['innerHTML']) ? (string) $blocks[$idx]['innerHTML'] : ''));
                if ($rendered === '') $deletions[] = $idx;
            }
        }
        // Delete now-empty blocks from highest index down.
        rsort($deletions);
        foreach ($deletions as $del) {
            array_splice($blocks, $del, 1);
        }

        foreach ($level_hits as $h) {
            $hits[] = [
                'block'  => $h['block'],
                'before' => $h['slice'],
                'after'  => '',
            ];
        }
        return true;
    }
    return false;
}

/**
 * Replace the first occurrence of `$needle` in `$block`'s innerHTML and
 * keep its innerContent strings in sync. Tries case-sensitive first; if
 * `$ci` is true, falls back to a case-insensitive regex. Returns ok=false
 * if the needle simply isn't in the innerHTML — caller should skip this
 * block instead of forcing a write.
 */
function swcp_uptext_replace_in_block($block, $needle, $replacement, $ci) {
    if (!isset($block['innerHTML']) || $needle === '') return ['ok' => false, 'block' => $block];
    $html = (string) $block['innerHTML'];
    $pos  = $ci ? stripos($html, $needle) : strpos($html, $needle);
    if ($pos === false) return ['ok' => false, 'block' => $block];

    $needle_len = strlen($needle);
    $block['innerHTML'] = substr_replace($html, $replacement, $pos, $needle_len);

    if (isset($block['innerContent']) && is_array($block['innerContent'])) {
        foreach ($block['innerContent'] as &$ic) {
            if (!is_string($ic)) continue;
            $p = $ci ? stripos($ic, $needle) : strpos($ic, $needle);
            if ($p !== false) {
                $ic = substr_replace($ic, $replacement, $p, $needle_len);
            }
        }
        unset($ic);
    }
    return ['ok' => true, 'block' => $block];
}

function swcp_uptext_normalize_ws($s) {
    return trim(preg_replace('/\s+/', ' ', (string) $s));
}
