<?php
/**
 * Shared helpers for SailWP Co-pilot tool-handlers.
 * Page resolution, block parse/save wrappers, content extraction.
 */
if (!defined('ABSPATH')) exit;

/**
 * Resolve a page identifier to a WP_Post.
 * Accepts: "current" (uses $current_post_id), "front"/"home"/"homepage",
 * a numeric ID, an exact slug, or an exact/LIKE match on the title.
 *
 * Title-only matching is intentional. WP_Query 's' searches post_content
 * too, which produced cross-page mismatches in the wild (a vague
 * "about-page" hint matched any page that happened to mention the word
 * "about" in its body). Returning null on no-match is the safer default;
 * the caller surfaces a "which page do you mean?" message so the chat
 * asks for clarification rather than guessing.
 */
function swcp_resolve_page($identifier, $current_post_id = 0) {
    $identifier = is_string($identifier) ? trim($identifier) : '';
    $lower = strtolower($identifier);

    if ($identifier === '') return null;

    if ($lower === 'current' && $current_post_id) {
        return get_post((int) $current_post_id);
    }
    if (in_array($lower, ['front', 'home', 'homepage', 'frontpage'], true)) {
        $front_id = (int) get_option('page_on_front');
        if ($front_id) return get_post($front_id);
        // No static front page yet. Look for an existing page slugged "home",
        // and otherwise mint a fresh one so the design flow can proceed
        // without forcing the user to set this up in WP Settings first.
        $existing = get_page_by_path('home', OBJECT, 'page');
        if ($existing instanceof WP_Post) return $existing;
        $new_id = wp_insert_post([
            'post_type'    => 'page',
            'post_status'  => 'publish',
            'post_title'   => 'Home',
            'post_name'    => 'home',
            'post_content' => '',
        ]);
        if (is_wp_error($new_id) || !$new_id) return null;
        update_post_meta($new_id, '_swcp_auto_created_home', 1);
        return get_post($new_id);
    }
    if (ctype_digit($identifier)) {
        return get_post((int) $identifier);
    }
    // Exact slug
    $by_slug = get_page_by_path($lower, OBJECT, ['page', 'post']);
    if ($by_slug instanceof WP_Post) return $by_slug;
    // Exact title (case-insensitive)
    $by_title = get_posts([
        'post_type'      => ['page', 'post'],
        'post_status'    => ['publish', 'draft', 'private'],
        'posts_per_page' => 1,
        'title'          => $identifier,
    ]);
    if (!empty($by_title)) return $by_title[0];
    // Title LIKE — but ONLY on the title column, never the body. We bail
    // out if more than one page matches so the chat can disambiguate
    // instead of silently picking the first one.
    global $wpdb;
    $like = '%' . $wpdb->esc_like($identifier) . '%';
    $matches = $wpdb->get_results($wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts}
         WHERE post_status IN ('publish','draft','private')
           AND post_type IN ('page','post')
           AND post_title LIKE %s
         ORDER BY (post_title = %s) DESC, post_modified DESC
         LIMIT 2",
        $like,
        $identifier
    ));
    if (count($matches) === 1) {
        return get_post((int) $matches[0]->ID);
    }
    // Zero or multiple matches → bail. The caller turns this into a
    // disambiguation prompt rather than guessing wrong.
    return null;
}

function swcp_get_blocks($post) {
    if (!$post instanceof WP_Post) return [];
    return parse_blocks((string) $post->post_content);
}

/**
 * Centralised refusal for tools when the target page is built with a
 * third-party page-builder we cannot safely edit. Returns null when the
 * page is fine (Gutenberg / Classic / empty / unknown) or a tool-result
 * array when it is NOT — caller should return that array directly.
 *
 * Used by every tool that mutates post_content. The proxy's system
 * prompt already steers Claude away from these pages via the
 * pageBuilder context field, but that signal only covers the OPEN page.
 * Tools also get called on RESOLVED named pages (e.g. "rewrite the
 * About page" from Sample Page editor), and the resolver does not know
 * the builder. This guard catches those cases server-side so the worst
 * outcome is a polite refusal instead of a wiped Elementor design.
 */
function swcp_check_builder_compat($post_id) {
    $builder = function_exists('swcp_detect_page_builder')
        ? swcp_detect_page_builder((int) $post_id)
        : 'unknown';
    if (in_array($builder, ['gutenberg', 'classic', 'unknown'], true)) {
        return null;
    }
    $post  = get_post((int) $post_id);
    $title = $post ? get_the_title($post) : 'that page';
    $names = [
        'elementor' => 'Elementor',
        'divi'      => 'Divi',
        'beaver'    => 'Beaver Builder',
        'bricks'    => 'Bricks',
    ];
    $label = isset($names[$builder]) ? $names[$builder] : ucfirst($builder);
    return [
        'ok'             => false,
        'unsupported'    => true,
        'page_builder'   => $builder,
        'page_id'        => (int) $post_id,
        'page_title'     => $title,
        'message'        => '"' . $title . '" is built with ' . $label . ', so I cannot reach it from here. I work with the standard WordPress editor (Gutenberg). Want me to design a fresh Gutenberg page instead, or pick a different page?',
    ];
}

/**
 * Detect which builder a page is using. The Co-pilot tools speak fluent
 * Gutenberg (parse_blocks / serialize_blocks). Page-builder content lives
 * outside post_content (Elementor in `_elementor_data` meta, Divi in
 * `_et_pb_use_builder`, Beaver in `_fl_builder_enabled`) so editing those
 * pages with our Gutenberg tools would silently shadow the live design.
 * The system prompt uses this signal to refuse politely + offer an
 * alternative.
 *
 * Returns one of: 'elementor' | 'divi' | 'beaver' | 'bricks' | 'classic'
 * | 'gutenberg' | 'unknown'.
 */
function swcp_detect_page_builder($post_id) {
    $post_id = (int) $post_id;
    if ($post_id <= 0) return 'unknown';

    // Elementor: writes `_elementor_edit_mode = builder` AND `_elementor_data`
    // JSON; both should be present on real Elementor pages but checking one
    // is enough.
    if (get_post_meta($post_id, '_elementor_edit_mode', true) === 'builder') return 'elementor';
    if (get_post_meta($post_id, '_elementor_data', true))                    return 'elementor';

    // Divi
    if (get_post_meta($post_id, '_et_pb_use_builder', true) === 'on') return 'divi';

    // Beaver Builder
    if (get_post_meta($post_id, '_fl_builder_enabled', true)) return 'beaver';

    // Bricks
    if (get_post_meta($post_id, '_bricks_page_content_2', true)) return 'bricks';

    $post = get_post($post_id);
    if (!$post) return 'unknown';
    $content = (string) $post->post_content;
    if ($content === '') return 'gutenberg'; // empty page — safe to treat as Gutenberg
    // Gutenberg blocks always carry `<!-- wp:` comments. Anything else with
    // real content is classic-editor HTML.
    if (strpos($content, '<!-- wp:') !== false) return 'gutenberg';
    return 'classic';
}

function swcp_save_blocks($post_id, $blocks) {
    $post = get_post((int) $post_id);
    if (!$post) return false;
    // Bail before any wp_update_post if the target uses a third-party
    // builder. Caller treats this the same as a generic save failure —
    // tools that need a tailored refusal copy should check
    // swcp_check_builder_compat() before reaching this point. This is
    // the last-line safety net.
    if (function_exists('swcp_check_builder_compat') && swcp_check_builder_compat($post_id) !== null) {
        return false;
    }
    $before_html = (string) $post->post_content;
    $content = serialize_blocks($blocks);
    if ($content === $before_html) {
        // No change, no version row.
        return true;
    }
    $result  = wp_update_post([
        'ID'           => (int) $post_id,
        'post_content' => $content,
    ], true);
    if (is_wp_error($result)) return false;
    if (function_exists('swcp_log_page_version')) {
        swcp_log_page_version($post_id, $before_html, $content);
    }
    return true;
}

/**
 * Walk all blocks recursively, applying $fn to each. The function may
 * return null to keep the block as-is, false to delete it, or a new
 * block to replace it.
 */
function swcp_walk_blocks($blocks, $fn) {
    $out = [];
    foreach ($blocks as $b) {
        if (!empty($b['innerBlocks'])) {
            $b['innerBlocks'] = swcp_walk_blocks($b['innerBlocks'], $fn);
        }
        $r = $fn($b);
        if ($r === false) continue;
        $out[] = $r === null ? $b : $r;
    }
    return $out;
}

function swcp_flatten_blocks($blocks) {
    $flat = [];
    foreach ($blocks as $b) {
        $flat[] = $b;
        if (!empty($b['innerBlocks'])) {
            $flat = array_merge($flat, swcp_flatten_blocks($b['innerBlocks']));
        }
    }
    return $flat;
}

/**
 * Extract human-readable text from a single block (strips HTML).
 */
function swcp_block_text($block) {
    $html = isset($block['innerHTML']) ? $block['innerHTML'] : '';
    return trim(wp_strip_all_tags($html));
}

/**
 * Heading level from a core/heading block. Defaults to 2.
 */
function swcp_heading_level($block) {
    if (!isset($block['blockName']) || $block['blockName'] !== 'core/heading') return 0;
    if (isset($block['attrs']['level'])) return (int) $block['attrs']['level'];
    // Fall back to parsing the inner HTML for h1..h6
    if (isset($block['innerHTML']) && preg_match('/<h([1-6])\b/i', $block['innerHTML'], $m)) {
        return (int) $m[1];
    }
    return 2;
}

function swcp_set_heading_level(&$block, $new_level) {
    $new_level = max(1, min(6, (int) $new_level));
    $old_level = swcp_heading_level($block);
    $block['attrs']['level'] = $new_level;
    if ($old_level && $old_level !== $new_level) {
        if (!empty($block['innerHTML'])) {
            $block['innerHTML'] = preg_replace(
                '/<h' . $old_level . '\b/i',
                '<h' . $new_level,
                $block['innerHTML'],
                1
            );
            $block['innerHTML'] = preg_replace(
                '/<\/h' . $old_level . '>/i',
                '</h' . $new_level . '>',
                $block['innerHTML'],
                1
            );
        }
        if (!empty($block['innerContent'])) {
            foreach ($block['innerContent'] as &$ic) {
                if (is_string($ic)) {
                    $ic = preg_replace('/<h' . $old_level . '\b/i', '<h' . $new_level, $ic, 1);
                    $ic = preg_replace('/<\/h' . $old_level . '>/i', '</h' . $new_level . '>', $ic, 1);
                }
            }
        }
    }
}

function swcp_edit_url_for($post_id) {
    return admin_url('post.php?post=' . (int) $post_id . '&action=edit');
}

function swcp_view_url_for($post_id) {
    return get_permalink($post_id);
}

/**
 * Collect host-theme style cues so Opus can match the site's existing
 * design language. Uses theme.json (block themes) where available,
 * falls back to the active theme name and a few common WP options.
 *
 * Output is a tight object with only the things Opus can act on; we
 * deliberately keep it small to leave more tokens for the design.
 */
function swcp_collect_site_style_hints() {
    $hints = [];

    $theme = wp_get_theme();
    if ($theme) {
        $hints['theme_name']   = (string) $theme->get('Name');
        $hints['theme_author'] = (string) $theme->get('Author');
    }

    // Block-theme / FSE: theme.json has palette + typography
    if (function_exists('wp_get_global_settings')) {
        $g = wp_get_global_settings();
        $palette = [];
        $themePalette = $g['color']['palette']['theme']    ?? [];
        $defaultPalette = $g['color']['palette']['default'] ?? [];
        foreach (array_merge($themePalette, $defaultPalette) as $c) {
            if (!empty($c['color']) && !empty($c['slug'])) {
                $palette[(string) $c['slug']] = (string) $c['color'];
            }
        }
        if ($palette) $hints['palette'] = $palette;

        $fonts = [];
        $themeFonts = $g['typography']['fontFamilies']['theme']   ?? [];
        $defaultFonts = $g['typography']['fontFamilies']['default'] ?? [];
        foreach (array_merge($themeFonts, $defaultFonts) as $f) {
            if (!empty($f['fontFamily']) && !empty($f['slug'])) {
                $fonts[(string) $f['slug']] = (string) $f['fontFamily'];
            }
        }
        if ($fonts) $hints['fonts'] = $fonts;
    }

    // Common WP customizer fallbacks
    $custom_logo_id = (int) get_theme_mod('custom_logo');
    if ($custom_logo_id) {
        $hints['logo_url'] = (string) wp_get_attachment_image_url($custom_logo_id, 'full');
    }
    $bg = get_theme_mod('background_color');
    if ($bg) $hints['background_color'] = '#' . ltrim($bg, '#');
    $header_text = get_theme_mod('header_textcolor');
    if ($header_text && $header_text !== 'blank') $hints['header_text_color'] = '#' . ltrim($header_text, '#');

    $hints['site_name'] = (string) get_bloginfo('name');
    $hints['tagline']   = (string) get_bloginfo('description');

    return $hints;
}

function swcp_resolve_or_error($identifier, $current_post_id = 0) {
    $post = swcp_resolve_page($identifier, $current_post_id);
    if ($post) return $post;

    // Pickup 2026-05-17: when normal resolution fails but the rail KNOWS
    // what page the user is on (front-end singular, editor screen), use it
    // silently. Otherwise Claude tells Joost/Ellen/Priscilla to "open the
    // page in the editor first" even when they're already viewing it. The
    // chat has already established the page in earlier turns, so falling
    // back to the live context is what they actually expect.
    if ((int) $current_post_id > 0) {
        $fallback = get_post((int) $current_post_id);
        if ($fallback instanceof WP_Post) return $fallback;
    }

    // Resolver bailed (zero or ambiguous matches). Surface the candidates
    // so the chat can ask "did you mean A, B, or C?" instead of guessing.
    $candidates = swcp_suggest_pages((string) $identifier);

    if (!empty($candidates)) {
        $titles = array_map(function ($p) { return get_the_title($p); }, $candidates);
        $list   = '"' . implode('", "', $titles) . '"';
        return [
            'ok'         => false,
            'ambiguous'  => true,
            'message'    => 'I found a few pages that could match: ' . $list . '. Which one did you mean? Tell me the exact title or open the page in the editor and try again.',
            'candidates' => array_map(function ($p) {
                return ['id' => (int) $p->ID, 'title' => get_the_title($p)];
            }, $candidates),
        ];
    }
    return [
        'ok'      => false,
        'message' => 'I could not find a page matching that. Tell me the exact title, or open the page in the editor first and try again.',
    ];
}

/**
 * Title-only LIKE search returning up to 5 candidates. Used to drive
 * disambiguation prompts when swcp_resolve_page bails out.
 */
function swcp_suggest_pages($identifier, $limit = 5) {
    $identifier = trim((string) $identifier);
    if ($identifier === '') return [];
    global $wpdb;
    $like = '%' . $wpdb->esc_like($identifier) . '%';
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts}
         WHERE post_status IN ('publish','draft','private')
           AND post_type IN ('page','post')
           AND post_title LIKE %s
         ORDER BY (post_title = %s) DESC, post_modified DESC
         LIMIT %d",
        $like,
        $identifier,
        max(1, (int) $limit)
    ));
    $out = [];
    foreach ($rows as $r) {
        $p = get_post((int) $r->ID);
        if ($p) $out[] = $p;
    }
    return $out;
}

/**
 * Fire-and-forget — ship a BEFORE-snapshot of a page to the AI proxy so
 * the cloud activity feed can summarise the edit with Haiku and offer a
 * restore button. We pass it the previous post_content + the new
 * post_content; the proxy stores only the BEFORE snapshot (so restore
 * goes back to that state).
 */
function swcp_log_page_version($post_id, $before_html, $after_html) {
    $license = SailWP_Copilot_Plugin::get_license_key();
    if (!$license) return;
    $proxy = SailWP_Copilot_Plugin::get_ai_proxy_url();
    $post  = get_post((int) $post_id);
    $title = $post ? (string) $post->post_title : '';
    wp_remote_post($proxy . '/api/v1/log-version', [
        'timeout'  => 5,
        'blocking' => false,
        'headers'  => [
            'Content-Type'  => 'application/json',
            'X-License-Key' => $license,
        ],
        'body' => wp_json_encode([
            'post_id'     => (int) $post_id,
            'post_title'  => $title,
            'before_html' => (string) $before_html,
            'after_html'  => (string) $after_html,
        ]),
    ]);
}

/**
 * Pull text + image refs from a block tree for design tools.
 * Returns [array $text_pieces, array $image_refs] where each image ref
 * is ['src','alt','caption']. Headings come back prefixed "H<level>: ".
 *
 * Lived in transform-page-design.php (v0.17.0, since deleted). Moved here
 * because design-page-preview.php still depends on it and helpers.php is
 * the right home for cross-tool shared utilities.
 */
function swcp_extract_content_for_design($blocks) {
    $text   = [];
    $images = [];

    $flat = swcp_flatten_blocks($blocks);
    foreach ($flat as $b) {
        $name = isset($b['blockName']) ? $b['blockName'] : '';
        if (!$name) continue;

        if ($name === 'core/heading') {
            $level = swcp_heading_level($b);
            $body  = swcp_block_text($b);
            if ($body !== '') $text[] = 'H' . $level . ': ' . $body;
            continue;
        }
        if ($name === 'core/paragraph') {
            $body = swcp_block_text($b);
            if ($body !== '') $text[] = $body;
            continue;
        }
        if ($name === 'core/list') {
            $items = [];
            if (!empty($b['innerBlocks'])) {
                foreach ($b['innerBlocks'] as $li) {
                    if (isset($li['blockName']) && $li['blockName'] === 'core/list-item') {
                        $items[] = '- ' . swcp_block_text($li);
                    }
                }
            } else {
                $body = swcp_block_text($b);
                if ($body !== '') $items[] = $body;
            }
            if (!empty($items)) $text[] = implode("\n", $items);
            continue;
        }
        if ($name === 'core/quote' || $name === 'core/pullquote') {
            $body = swcp_block_text($b);
            if ($body !== '') $text[] = '“' . $body . '”';
            continue;
        }
        if ($name === 'core/image') {
            $src     = '';
            $alt     = '';
            $caption = '';
            if (isset($b['attrs']['url']))     $src     = (string) $b['attrs']['url'];
            if (isset($b['attrs']['alt']))     $alt     = (string) $b['attrs']['alt'];
            if (isset($b['attrs']['caption'])) $caption = (string) $b['attrs']['caption'];
            if ($src === '' && isset($b['innerHTML'])) {
                if (preg_match('/<img\b[^>]*\bsrc="([^"]+)"/i', $b['innerHTML'], $m)) $src = $m[1];
                if (preg_match('/<img\b[^>]*\balt="([^"]*)"/i', $b['innerHTML'], $m)) $alt = $m[1];
                if (preg_match('/<figcaption[^>]*>(.*?)<\/figcaption>/i', $b['innerHTML'], $m)) {
                    $caption = trim(wp_strip_all_tags($m[1]));
                }
            }
            if ($src !== '') {
                $images[] = ['src' => $src, 'alt' => $alt, 'caption' => $caption];
            }
            continue;
        }
        $fallback = swcp_block_text($b);
        if ($fallback !== '') $text[] = $fallback;
    }
    return [$text, $images];
}
