<?php
declare(strict_types=1);

if (!defined('ABSPATH')) { exit; }

/**
 * Button element renderer (shared between frontend and editor).
 *
 * Phase 1, step 4 of the renderer-unification refactor. Button has
 * substantial complexity (frosted-glass sealed preset, gradient-ring
 * pseudo border, mouse-driven angle, halo, icon-before/after, width
 * lock, full-width align) but the same shape in both contexts — they
 * diverge mainly on whether values render as inline literals (frontend)
 * vs CSS variables (editor, for live-update binding).
 *
 * Pre-extraction differences and resolutions:
 *
 *   - Settings read: frontend used direct `$settings['key']`; editor
 *     used `impulse_device_val($settings, 'key', $device, default)`
 *     to pre-resolve per-device values server-side.
 *     → Unified: behave the same in each context. Editor still calls
 *     impulse_device_val(); frontend reads direct (responsive overrides
 *     come from the @media emit at the end).
 *
 *   - CSS variables vs literals: editor wraps every panel-driven value
 *     in `var(--impulse-<key>, <fallback>)` so the panel's CSS-var
 *     bridge can mutate the value on slider drag without a refresh.
 *     Frontend emits literal values.
 *     → Preserved per-context.
 *
 *   - URL de-corruption: frontend strips literal `\/` artifacts (from
 *     double JSON encoding on legacy saves) before esc_url(). Editor
 *     did not.
 *     → Applied in both contexts (harmless on clean URLs).
 *
 *   - Wrap id (`impulse-btn-wrap-<id>`): frontend emits one (responsive
 *     align @media targets it). Editor omits.
 *     → Always emit (consistent target for any future per-device align).
 *
 *   - `rel="noopener"` on new-tab links: frontend has it, editor doesn't.
 *     → Unified to ALWAYS emit (security best practice; pre-extraction
 *     editor was an oversight).
 *
 *   - Responsive @media: frontend-only via impulse_responsive_css.
 *
 *   - Frosted bg defensive literal: editor emits an extra
 *     `background:rgba(255,255,255,0.18)!important` after the var-based
 *     declaration in the frosted-glass branch, to defeat a wrapper-level
 *     --impulse-bg-color leak. Frontend doesn't (no wrapper there).
 *     → Preserved in editor context.
 *
 *   - data-border-grad-mouse / GLOBALS flag: both emit the data attrs;
 *     only frontend primes $GLOBALS['impulse_needs_border_grad_mouse']
 *     to signal an asset enqueue downstream.
 *     → Preserved per-context.
 *
 * @param array|object $el       Element node.
 * @param array        $settings Resolved settings (editor) or raw (frontend).
 * @param string       $context  'frontend' or 'editor'.
 *
 * @return string Inner HTML.
 *
 * @sideeffect Frontend emits responsive @media via wp_add_inline_style
 *             and may prime $GLOBALS['impulse_needs_border_grad_mouse'].
 */
function impulse_element_render_button($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    $elId     = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $isEditor = ($context === 'editor');
    $device   = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';

    // ── Settings ──────────────────────────────────────────────────
    // Editor pre-resolves per-device via impulse_device_val; frontend
    // reads direct and emits @media for tablet/mobile overrides at the
    // end of the function.
    $read = function (string $key, $default) use ($settings, $isEditor, $device) {
        if ($isEditor && function_exists('impulse_device_val')) {
            return impulse_device_val($settings, $key, $device, $default);
        }
        return $settings[$key] ?? $default;
    };

    $btnText = !empty($settings['text']) ? $settings['text'] : 'Click Here';

    // URL de-corruption: legacy double-JSON-encoded data stores `\/foo`
    // instead of `/foo`. esc_url() rejects backslashed URLs and emits
    // href="", which silently breaks every affected button.
    $url = (string) ($settings['url'] ?? '#');
    // Normalize over-escaped legacy URLs: \\/foo or \/foo → /foo
    $url = ltrim(str_replace('\\/', '/', $url), '\\');
    if ($url === '') $url = '#';

    $align         = (string) $read('align', 'center');
    $bgColor       = (string) ($settings['bg_color'] ?? '#1d2327');
    $textColor     = (string) ($settings['text_color'] ?? '#ffffff');
    $borderRadius  = intval($read('border_radius', $settings['radius_left'] ?? $settings['radius_right'] ?? 6));
    $weight        = (string) $read('weight', '600');
    $newTab        = !empty($settings['new_tab']);
    $icon          = (string) ($settings['icon'] ?? '');
    $iconPosition  = (string) ($settings['icon_position'] ?? 'before');
    $iconSpacing   = intval($settings['icon_spacing'] ?? 8);
    $borderWidth   = intval($read('border_width', 0));
    $borderColor   = (string) ($settings['border_color'] ?? '#1d2327');

    // Gradient ring settings.
    $gradTo        = (string) $read('border_gradient_to', '');
    $gradAngle     = intval($read('border_gradient_angle', 135));
    $gradMouseSens = intval($read('border_mouse_sens', 0));
    $gradSpinSpeed = intval($read('border_spin_speed', 30));
    $hasGrad = !empty($gradTo) && $gradTo !== 'transparent' && strpos($gradTo, 'rgba(0,0,0,0)') !== 0;

    $iconSize = intval($settings['icon_size'] ?? 0);

    // Single Height slider (replaces S/M/L/XL preset toggle); legacy
    // btn_size enum maps to anchor heights.
    $btnHeightLegacyMap = ['sm' => 36, 'md' => 44, 'lg' => 52, 'xl' => 64];
    $btnHeightFallback  = $btnHeightLegacyMap[$settings['btn_size'] ?? 'md'] ?? 44;
    $btnHeight = intval($read('btn_height', $btnHeightFallback));
    $btnHeight = max(28, min(80, $btnHeight));
    list($fontSize, $paddingY, $paddingX) = impulse_btn_metrics_from_height($btnHeight);
    // Optional explicit horizontal padding override. Padding is normally derived from
    // height; btn_pad_x lets a button breathe wider without going taller or needing a
    // fixed (overflow-prone) btn_min_width. Only applies when set. (Sergei 2026-07-14)
    // 0 (the panel slider's rest position) = AUTO, i.e. keep the height-derived
    // padding — the "Pad X" slider was added 2026-07-24 and its untouched default
    // must not zero a button's padding.
    if (isset($settings['btn_pad_x']) && $settings['btn_pad_x'] !== '' && intval($settings['btn_pad_x']) > 0) {
        $paddingX = intval($settings['btn_pad_x']);
    }
    // Direct sizing model (2026-07-24): the button is its content plus padding.
    // Size (btn_font) and Pad Y (btn_pad_y) override the height-derived metrics
    // when saved; buttons that only carry the old btn_height keep rendering
    // exactly as before (the guard below simply never fires for them).
    $_bf = $read('btn_font', '');
    if ($_bf !== '' && $_bf !== null && intval($_bf) > 0) {
        $fontSize = max(10, min(32, intval($_bf)));
    }
    $_bpy = $read('btn_pad_y', '');
    if ($_bpy !== '' && $_bpy !== null && intval($_bpy) >= 0 && is_numeric($_bpy)) {
        $paddingY = max(0, min(40, intval($_bpy)));
    }

    // Stable IDs so per-device @media (frontend) and live-update CSS
    // injection (editor) can both target the elements.
    $btnId  = 'impulse-btn-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) $elId);
    $wrapId = 'impulse-btn-wrap-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) $elId);

    // ── Frosted Glass — sealed preset ─────────────────────────────
    // Every colour knob (bg, border, gradient stops, angle, mouse sens,
    // spin speed, border width, and the gradient-on toggle itself) is
    // overridden with the hero-reference values. Cannot be tuned while
    // frosted is on; flipping it off restores user values.
    $frostedGlass = !empty($settings['frosted_glass']);
    if ($frostedGlass) {
        $bgColor       = 'rgba(255,255,255,0.18)';
        $borderColor   = '#1c2226';
        $gradTo        = 'rgba(247,247,247,0.57)';
        $gradAngle     = 135;
        $gradMouseSens = 100;
        $gradSpinSpeed = 90;
        $borderWidth   = 1;
        $hasGrad       = true;
    }

    // ── Wrap style ────────────────────────────────────────────────
    $justifyMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end', 'full' => 'stretch'];
    $justifyVal = $justifyMap[$align] ?? 'center';
    if ($isEditor) {
        // CSS var so panel-driven live updates can mutate without iframe
        // refresh. The literal is the fallback.
        $wrapStyle = 'display:flex;justify-content:var(--impulse-btn-justify,' . $justifyVal . ');';
    } else {
        $wrapStyle = 'display:flex;justify-content:' . $justifyVal . ';';
    }

    // ── Button style ──────────────────────────────────────────────
    $btnStyle  = 'display:inline-flex;align-items:center;gap:' . $iconSpacing . 'px;';
    $btnStyle .= 'padding:' . $paddingY . 'px ' . $paddingX . 'px;border-radius:' . $borderRadius . 'px;';
    if ($isEditor) {
        $btnStyle .= 'font-weight:var(--impulse-weight,' . esc_attr($weight) . ');';
    } else {
        $btnStyle .= 'font-weight:' . esc_attr($weight) . ';';
    }
    $btnStyle .= 'text-decoration:none;position:relative;font-size:' . $fontSize . 'px;';
    if ($isEditor) {
        $btnStyle .= 'color:var(--impulse-text-color,' . esc_attr($textColor) . ') !important;';
    } else {
        $btnStyle .= 'color:' . esc_attr($textColor) . ' !important;';
    }

    // Width: when > 0, locks every button in a row to the same exact size.
    // 0 = intrinsic (text-driven). Setting key stays `btn_min_width` for
    // legacy compatibility. CSS var pattern in editor lets live-preview
    // mutate the value without re-emitting the full style string.
    //
    // 2026-05-24: when the user pins a width, we ALSO lift the
    // `.impulse-button { max-width: 100% }` cap (frontend.css) so the
    // button doesn't get silently shrunk to its wrap's width — which
    // would make four "140px" buttons in two parallel rows render at
    // visibly different widths just because their parent flex cells
    // had different intrinsic sizes. Same goes for the wrap: its
    // `max-width:100%; overflow:hidden` protects against text overflow
    // but clips the button when the parent is narrower than the
    // explicit width. Lifting both when the user is in fixed-width
    // mode honours their intent.
    $btnMinW = intval($settings['btn_min_width'] ?? 0);
    $btnStyle .= 'justify-content:center;'; // always-on so live-drag from 0 → N centres correctly
    $btnStyle .= 'text-align:center;'; // wrapped labels (2+ lines) stay centered, not left-aligned — button content is ALWAYS centered (Sergei 2026-06-24)
    if ($isEditor) {
        $btnWidthFallback = $btnMinW > 0 ? $btnMinW . 'px' : 'auto';
        $btnStyle .= 'width:var(--impulse-btn-min-width,' . $btnWidthFallback . ');';
        if ($btnMinW > 0) $btnStyle .= 'max-width:none;';
    } elseif ($btnMinW > 0) {
        $btnStyle .= 'width:' . $btnMinW . 'px;max-width:none;';
    }
    if ($btnMinW > 0) {
        $wrapStyle .= 'max-width:none;overflow:visible;';
    }
    // btn_pin_bottom: push the button to the bottom of its flex-column parent (the
    // section content column). margin:auto on a flex child absorbs the free space, so
    // this pins the CTA to the bottom of a full-height hero on any viewport. The
    // section's padding_bottom sets the gap from the edge. (Sergei 2026-07-14)
    // ...but only if this container has no bottom anchor yet. See
    // impulse_claim_bottom_anchor(): btn_pin_bottom and push_bottom are two
    // spellings of one intent, and a SECOND auto margin in the same flex column
    // takes an equal share of the free space instead of grouping the content, so
    // the column spread into even thirds (Sergei 2026-07-29/30, pages 867/751).
    // The wrap also gets .impulse-btn-pin-bottom so the stylesheet can neutralise
    // THIS margin (and no other) when an earlier sibling already anchors the
    // column - see the "one bottom anchor per container" rules in frontend.css.
    $btnPinBottom = !empty($settings['btn_pin_bottom']);
    if ($btnPinBottom) {
        $wrapStyle .= 'margin-top:auto;';
    }

    $btnExtraClass  = '';
    $gradHasMouse   = false;
    if ($hasGrad) {
        // Gradient ring rendered via ::before pseudo (CSS rule lives in
        // frontend.css for live, in editor-only inline CSS for preview).
        // border-image ignores radius; dual-bg leaks through transparent
        // inner; pseudo+mask gets both. See frontend renderer commentary
        // for full design notes.
        $bgVar = $isEditor ? 'var(--impulse-bg-color,' . esc_attr($bgColor) . ')' : esc_attr($bgColor);
        $btnStyle .= 'background:' . $bgVar . ' !important;';
        // NO `border:Npx solid transparent` here (v2.1.1512). The ::before ring
        // is positioned with inset:0, which resolves to the button's PADDING
        // box. A transparent border pushes that padding box inward by its
        // width, so the ring was drawn N px inside the button edge and read
        // as "border smaller than the button". The ring's thickness already
        // comes from the ::before's own padding (--impulse-border-width), so no
        // border reservation is needed; inset:0 now maps to the true outer edge.
        $btnStyle .= '--impulse-border-gradient-angle-base:' . $gradAngle . 'deg;';
        $btnStyle .= '--impulse-border-mouse-sens:' . $gradMouseSens . ';';
        $btnStyle .= '--impulse-border-spin-speed:' . $gradSpinSpeed . ';';
        $btnStyle .= '--impulse-border-width:' . $borderWidth . 'px;';
        $btnStyle .= '--impulse-border-color:' . esc_attr($borderColor) . ';';
        $btnStyle .= '--impulse-border-gradient-to:' . esc_attr($gradTo) . ';';
        $btnStyle .= 'box-sizing:border-box;isolation:isolate;';
        $btnExtraClass  = ' impulse-grad-border';
        $gradHasMouse   = true;
        if (!$isEditor) {
            $GLOBALS['impulse_needs_border_grad_mouse'] = true;
        }
    } elseif ($borderWidth > 0) {
        $bgVar     = $isEditor ? 'var(--impulse-bg-color,' . esc_attr($bgColor) . ')'     : esc_attr($bgColor);
        $widthVal  = $isEditor ? 'var(--impulse-border-width,' . $borderWidth . 'px)'      : ($borderWidth . 'px');
        $colorVal  = $isEditor ? 'var(--impulse-border-color,' . esc_attr($borderColor) . ')' : esc_attr($borderColor);
        $btnStyle .= 'background:' . $bgVar . ' !important;';
        $btnStyle .= 'border:' . $widthVal . ' solid ' . $colorVal . ';';
    } else {
        $bgVar     = $isEditor ? 'var(--impulse-bg-color,' . esc_attr($bgColor) . ')' : esc_attr($bgColor);
        $btnStyle .= 'background:' . $bgVar . ' !important;';
        $btnStyle .= 'border:none;';
    }

    if ($frostedGlass) {
        $btnStyle .= 'backdrop-filter:blur(20px) saturate(180%) brightness(1.15);';
        $btnStyle .= '-webkit-backdrop-filter:blur(20px) saturate(180%) brightness(1.15);';
        if ($isEditor) {
            // Defensive literal — wrapper-level --impulse-bg-color
            // (written by impulseCSSVars on slider drag) could leak in
            // via the cascade and break the sealed preset. Emit a non-var
            // !important bg AFTER the var-form above so cascade order wins.
            $btnStyle .= 'background:rgba(255,255,255,0.18) !important;';
        }
        $btnStyle .= '--impulse-bg-color:rgba(255,255,255,0.18);';
        // 2026-05-24: text colour is NO LONGER part of the frosted preset.
        // The earlier auto-dark heuristic (drop to #1d2327 when text_color
        // was the default #ffffff) was a guess that overrode user intent
        // — designers picking white on a dark-section frosted button saw
        // their colour silently flipped to dark. The regular text-colour
        // path above already wrote `color:var(--impulse-text-color,$textColor)`,
        // so removing the frosted override hands control back to the user.
        $btnStyle .= '--impulse-border-color:#1c2226;';
        $btnStyle .= '--impulse-border-gradient-to:rgba(247,247,247,0.57);';
        $btnExtraClass .= ' impulse-frosted';
    }

    // Halo — soft text-shadow around the label. Auto-flips dark/light
    // based on text-colour luminance.
    $_btnHalo = max(0, min(100, intval($settings['text_halo'] ?? 0)));
    if ($_btnHalo > 0 && function_exists('impulse_text_halo_css')) {
        $btnStyle .= impulse_text_halo_css($_btnHalo, $textColor);
    }
    if ($align === 'full') $btnStyle .= 'width:100%;justify-content:center;';

    // ── Responsive @media (frontend only) ─────────────────────────
    if (!$isEditor && function_exists('impulse_responsive_css') && function_exists('wp_add_inline_style')) {
        $jMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end', 'full' => 'stretch'];
        $respCss  = impulse_responsive_css($settings, '#' . $wrapId, [
            'align' => function ($v) use ($jMap) {
                $j = $jMap[$v] ?? 'center';
                return 'justify-content:' . $j . '!important;';
            },
        ]);
        $respCss .= impulse_responsive_css($settings, '#' . $btnId, [
            'btn_height' => function ($v) {
                $h = max(28, min(80, intval($v)));
                list($f, $py, $px) = impulse_btn_metrics_from_height($h);
                return 'font-size:' . $f . 'px!important;padding:' . $py . 'px ' . $px . 'px!important;';
            },
            'btn_font'  => function ($v) { return 'font-size:' . max(10, min(32, intval($v))) . 'px!important;'; },
            'btn_pad_y' => function ($v) { return 'padding-top:' . max(0, min(40, intval($v))) . 'px!important;padding-bottom:' . max(0, min(40, intval($v))) . 'px!important;'; },
            'btn_pad_x' => function ($v) { return 'padding-left:' . max(0, min(100, intval($v))) . 'px!important;padding-right:' . max(0, min(100, intval($v))) . 'px!important;'; },
            'weight'        => function ($v) { return 'font-weight:' . intval($v) . '!important;'; },
            'border_width'  => ['border-width', 'px'],
            'border_radius' => ['border-radius', 'px'],
        ]);
        if ($respCss) wp_add_inline_style('instinctor-page-inline', $respCss);
    }

    // ── HTML ──────────────────────────────────────────────────────
    // rel="noopener" on new-tab links is a security best practice; pre-
    // extraction frontend had it, editor did not — unified to always emit.
    $iconHtml = $icon && function_exists('impulse_lucide_svg')
        ? impulse_lucide_svg($icon, $iconSize ?: 16, 'currentColor')
        : '';

    // Action: "Open screen" — when settings.open_screen_id is set, this
    // button reveals a Reveal-on-click section instead of navigating to a
    // URL. The screen engine (impulse-screens.js openScreen) picks up
    // data-open-screen via a delegated click handler. We force href="#" so
    // a hash navigation doesn't fire alongside the engine call.
    $openScreenId = $settings['open_screen_id'] ?? '';
    $hasOpenScreen = ($openScreenId !== '' && $openScreenId !== null && (int) $openScreenId > 0);
    if ($hasOpenScreen) {
        $url = '#';
    }

    ob_start();
    echo '<div id="' . esc_attr($wrapId) . '" class="impulse-button-wrap'
        . ($btnPinBottom ? ' impulse-btn-pin-bottom' : '')
        . '" style="' . esc_attr($wrapStyle) . '">';
    echo '<a id="' . esc_attr($btnId) . '" class="impulse-button' . esc_attr($btnExtraClass) . '" href="' . esc_url($url) . '" style="' . esc_attr($btnStyle) . '"';
    if ($gradHasMouse) {
        echo ' data-border-grad-mouse="' . esc_attr((string) $gradMouseSens) . '" data-border-grad-base="' . esc_attr((string) $gradAngle) . '" data-border-grad-spin="' . esc_attr((string) $gradSpinSpeed) . '"';
    }
    if ($newTab) {
        echo ' target="_blank" rel="noopener"';
    }
    if ($hasOpenScreen) {
        echo ' data-open-screen="' . esc_attr((string) $openScreenId) . '"';
    }
    echo '>';
    if ($icon && $iconPosition === 'before') echo wp_kses($iconHtml, impulse_svg_allowed());
    // Button label honours a small set of inline HTML so designers can bind
    // words with &nbsp; (orphan-line rule), split a label with <br>, or add
    // light emphasis/styling. wp_kses preserves valid entities (&nbsp; etc.)
    // and the safelisted inline tags while stripping anything unsafe.
    echo wp_kses($btnText, [
        'br'     => [],
        'span'   => ['style' => [], 'class' => []],
        'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
        'sub'    => [], 'sup' => [], 'small' => [], 'mark' => [],
    ]);
    if ($icon && $iconPosition === 'after') echo wp_kses($iconHtml, impulse_svg_allowed());
    echo '</a>';
    echo '</div>';
    return ob_get_clean();
}
