<?php
namespace DIFL\Server\Modules\AdvancedBarCounterItem;

use \DIFL\Server\Traits\ModuleUtil;
use \DIFL\Server\Traits\HasProps;
use \DIFL\Server\Traits\IconRenderTrait;
use ET\Builder\Framework\DependencyManagement\Interfaces\DependencyInterface;
use ET\Builder\Packages\Module\Layout\Components\StyleCommon\CommonStyle;
use ET\Builder\Packages\Module\Options\Css\CssStyle;

class AdvancedBarCounterItem implements DependencyInterface {
	public $slug       = 'difl_advancedbarcounteritem';
	public $vb_support = 'on';
	public $icon_path;
	use ModuleUtil;
	use HasProps;
	use IconRenderTrait;

	protected $config_file = 'AdvancedBarCounterItem';

	protected $module_credits = array(
		'module_uri' => '',
		'author'     => 'DiviFlash',
		'author_uri' => '',
	);

	public static function custom_css() {
		return \WP_Block_Type_Registry::get_instance()->get_registered( 'difl/advancedbarcounteritem' )->customCssFields;
	}

	public function get_common_styles( $args ) {
		$attrs      = $args['attrs'] ?? [];
		$attrs      = $this->get_merged_attrs( $args );
		$attrs      = self::build_attr_for_styles( $attrs );
		$attrs      = $this->get_default_value_for_styles( $attrs );
		$orderClass = $args['orderClass'] ?? '';

		$styles        = [];
		$common_styles = [];

		$common_styles[] = CssStyle::style( [
			'selector'  => $orderClass,
			'attr'      => $attrs['css'] ?? [],
			'cssFields' => self::custom_css(),
		] );

		/* Raw number (no `%`). @property is now <number>; SCSS calc()s actual
		   geometry as percent / max_value * 100%. Must stay byte-identical to
		   Styles.js. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => $orderClass,
			'attr'                => $attrs['abc_item_percent']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$value = $props['attrValue'] ?? '';
				return ( $value !== '' && $value !== null )
					? "--df-abc-item-percent: {$value};"
					: '';
			},
		] );

		/* Border radius → clip the bar item's inner content to the rounded corners. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => $orderClass,
			'attr'                => $attrs['module']['decoration']['border'] ?? null,
			'declarationFunction' => function( $props ) {
				$r   = $props['attrValue']['radius'] ?? [];
				$has = isset( $r['topLeft'] ) || isset( $r['topRight'] ) || isset( $r['bottomLeft'] ) || isset( $r['bottomRight'] );
				return $has ? 'overflow: hidden !important;' : '';
			},
		] );

		/* Base + Fill colors emit as CSS custom properties on the order
		   class so all four variants pick them up: line's bar/track,
		   circle's conic-gradient stops, half-circle's conic-gradient
		   stops, and the dot ::before fill all read these vars from the
		   item via inheritance. */

		/* Emitted on `.df_abc_item` (value selector ends there) so per-item hover
		   auto-generates `… .df_abc_item:hover`. Wins over the parent (same element,
		   later emission). FE-only hover. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item",
			'attr'                => $attrs['abc_item_base_color']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? '';
				return ( $v !== '' && $v !== null ) ? "--df-abc-item-track-color: {$v};" : '';
			},
		] );

		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item",
			'attr'                => $attrs['abc_item_fill_color']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? '';
				return ( $v !== '' && $v !== null ) ? "--df-abc-item-fill-color: {$v};" : '';
			},
		] );

		/* Per-item override for the parent's Indicator Background Color
		   (pin / bubble / tag styles). Same CSS variable `--df-abc-percent-bg`
		   the parent emits on `.df_abc_container`; child emits on its own
		   bare order class (deeper element) so the cascade resolves to the
		   child value when set. Empty → no emission → parent's value cascades
		   through. SCSS consumes the var on both the indicator body
		   `background` and the matching `::after` pointer's `border-*-color`. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item",
			'attr'                => $attrs['abc_item_percent_bg_color']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? '';
				return ( $v !== '' && $v !== null ) ? "--df-abc-percent-bg: {$v};" : '';
			},
		] );

		/* Per-item icon color override. Targets the inner .et-pb-icon glyph
		   (matches parent's emission selector); child wins via emission order.
		   Empty value → no emission → parent's color stays in effect. Title /
		   percent / prefix / suffix typography is now handled by the per-item
		   Font groups (abc_item_*_design), auto-emitted by the framework. */
		$common_styles[] = CommonStyle::style( [
			/* Singular `selector` duplicated from selectors.desktop.value
			   purely to silence StyleTrait.php:69's unguarded
			   `$args['selector']` read — the framework prefers `selectors`
			   when both are present (StyleTrait.php:89), so this has no
			   effect on the emitted CSS. Mirrors Divi core's BlurbModule
			   pattern. */
			'selector'  => "{$orderClass} .df_abc_item_icon .et-pb-icon",
			'selectors' => [
				'desktop' => [
					'value' => "{$orderClass} .df_abc_item_icon .et-pb-icon",
					'hover' => "{$orderClass} .df_abc_item:hover .df_abc_item_icon .et-pb-icon",
				],
			],
			'attr'      => $attrs['abc_item_icon_color']['innerContent'] ?? null,
			'property'  => 'color',
		] );

		/* Font Awesome / ETmodules font support — mirrors the Styles.js
		   block. The .et-pb-icon span carries the unicode char + fa-icon
		   class + inline font-weight, but no font-family; emit it here so
		   FA glyphs resolve on the frontend. self::get_font_style() comes
		   via ModuleUtil → StyleUtil trait composition. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item_icon .et-pb-icon",
			'attr'                => $attrs['abc_item_icon']['innerContent'] ?? null,
			'declarationFunction' => function () {
				return self::get_font_style( $this->props['abc_item_icon'] ?? null );
			},
		] );

		/* Multi-step indicator color — emitted on the child's order class so
		   each item independently colors its own markers. SCSS consumers
		   (.df_abc_label_marker_indicator under .df_abc_item) inherit the var
		   via the cascade. Empty → no emission → SCSS fallback chain surfaces
		   --df-abc-item-fill-color. Must stay byte-identical to the Styles.js
		   emission. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item",
			'attr'                => $attrs['abc_item_multi_step_indicator_color']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? '';
				return ( $v !== '' && $v !== null ) ? "--df-abc-multi-step-indicator-color: {$v};" : '';
			},
		] );

		/* Animation duration variable consumed by the stripe / animated /
		   animated_gradient bar style keyframes. `.df_abc_item_bar` only exists
		   for line + multi_step layouts, so the un-qualified selector
		   naturally scopes to the layouts where Animation Speed is visible. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item_bar",
			'attr'                => $attrs['abc_item_animation_speed']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? '';
				return ( $v !== '' && $v !== null ) ? "--df-abc-anim-duration: {$v};" : '';
			},
		] );

		/* Animated Gradient — 3-stop palindrome scrolled by df-abc-gradient-shift.
		   Selector matches line + multi_step naturally. Must stay byte-identical
		   to Styles.js. */
		$common_styles[] = CommonStyle::style( [
			/* Singular `selector` mirrors selectors.desktop.value to silence
			   StyleTrait.php:69 — see same comment on the icon-color call
			   above. */
			'selector'            => "{$orderClass} .df_abc_item.df_abc_style_animated_gradient .df_abc_item_bar",
			'selectors'           => [
				'desktop' => [
					'value' => "{$orderClass} .df_abc_item.df_abc_style_animated_gradient .df_abc_item_bar",
					'hover' => "{$orderClass} .df_abc_item.df_abc_style_animated_gradient:hover .df_abc_item_bar",
				],
			],
			'attr'                => $attrs['abc_item_gradient_color']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? [];
				$p = ( is_array( $v ) && ! empty( $v['gradient_primary'] ) )   ? $v['gradient_primary']   : '#7E57C2';
				$s = ( is_array( $v ) && ! empty( $v['gradient_secondary'] ) ) ? $v['gradient_secondary'] : '#00BFA5';
				return "background-image: linear-gradient(90deg, {$p} 0%, {$s} 50%, {$p} 100%);";
			},
		] );

		/* Static Gradient — simple 2-stop, no animation. background-image only
		   so the bar's background-color (Fill Color) remains painted underneath
		   as a backup. */
		$common_styles[] = CommonStyle::style( [
			/* Singular `selector` mirrors selectors.desktop.value to silence
			   StyleTrait.php:69 — see same comment on the icon-color call
			   above. */
			'selector'            => "{$orderClass} .df_abc_item.df_abc_style_gradient .df_abc_item_bar",
			'selectors'           => [
				'desktop' => [
					'value' => "{$orderClass} .df_abc_item.df_abc_style_gradient .df_abc_item_bar",
					'hover' => "{$orderClass} .df_abc_item.df_abc_style_gradient:hover .df_abc_item_bar",
				],
			],
			'attr'                => $attrs['abc_item_gradient_color']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? [];
				$p = ( is_array( $v ) && ! empty( $v['gradient_primary'] ) )   ? $v['gradient_primary']   : '#7E57C2';
				$s = ( is_array( $v ) && ! empty( $v['gradient_secondary'] ) ) ? $v['gradient_secondary'] : '#00BFA5';
				return "background-image: linear-gradient(90deg, {$p} 0%, {$s} 100%);";
			},
		] );

		/* Circle / half_circle gradient. SVG <stop> consumes these vars via
		   `style="stop-color: var(...)"` — routing colors through CSS lets
		   Divi resolve gcid-* tokens before paint, which SMIL `<animate>`
		   could not. Must stay byte-identical to Styles.js. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass} .df_abc_item",
			'attr'                => $attrs['abc_item_gradient_color_circle']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? [];
				$p = ( is_array( $v ) && ! empty( $v['gradient_primary_circle'] ) )   ? $v['gradient_primary_circle']   : '#7E57C2';
				$s = ( is_array( $v ) && ! empty( $v['gradient_secondary_circle'] ) ) ? $v['gradient_secondary_circle'] : '#00BFA5';
				return "--df-abc-grad-start-circle: {$p}; --df-abc-grad-end-circle: {$s};";
			},
		] );

		/* Animation duration for circle / half_circle gradient keyframes. */
		$common_styles[] = CommonStyle::style( [
			'selector'            => "{$orderClass}",
			'attr'                => $attrs['abc_item_animation_speed_circle']['innerContent'] ?? null,
			'declarationFunction' => function( $props ) {
				$v = $props['attrValue'] ?? '';
				return ( $v !== '' && $v !== null ) ? "--df-abc-anim-duration-circle: {$v};" : '';
			},
		] );

		foreach ( $common_styles as $common_style ) {
			if ( empty( $common_style ) ) {
				continue;
			}
			foreach ( $common_style as $style ) {
				$styles[] = $style;
			}
		}

		return $styles ?? [];
	}

	public function render( $attrs, $content, $render_slug ) {
		/* Read the title from raw $attrs (NOT $this->props) so an explicitly-
		   cleared empty string is preserved. $this->props mirrors getProps in
		   JS: empty strings are dropped as falsy and the module.json
		   `default_value` ("Title") back-fills. Reading raw $attrs lets the
		   existing trim() !== '' guard correctly suppress the span when the
		   user clears the field. The VB is unaffected — Divi's inline editor
		   (inlineEditor: "plainText") overlays the live editor state on top
		   of the rendered span. */
		$raw_title   = $attrs['abc_item_title']['innerContent']['desktop']['value'] ?? null;
		/* Title HTML tag (h1-h6/span/div/p). Allowlisted so the interpolated
		   tag is safe even though the source is a fixed select; falls back to
		   span for any unexpected value. Mirrors Edit.js. */
		$allowed_title_tags = array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'div', 'p' );
		$title_tag          = $attrs['abc_item_title_tag']['innerContent']['desktop']['value'] ?? 'span';
		if ( ! in_array( $title_tag, $allowed_title_tags, true ) ) {
			$title_tag = 'span';
		}
		$percent_raw = $this->props['abc_item_percent'] ?? null;
		/* Display gates (parity with Edit.js): empty / whitespace-only / null
		   suppresses the span. For percent, non-numeric input is also
		   suppressed. Zero is valid. */
		$title_safe    = ( is_string( $raw_title ) && trim( $raw_title ) !== '' ) ? $raw_title : null;
		$percent_str   = ( $percent_raw === null ) ? '' : trim( (string) $percent_raw );
		$percent_valid = $percent_str !== '' && is_numeric( $percent_str );
		/* Raw value retained for bar geometry + aria. */
		$percent   = $percent_raw ?? '50';
		/* $bar_style (layout-aware) is derived AFTER $bar_type is read from
		   the parent — line/multi_step use abc_item_bar_style, circle/
		   half_circle use abc_item_bar_style_circle. Mirrors Edit.js. */
		/* Start / end labels for the half-circle variant. Render-time
		   gating mirrors Edit.js: only emit when parent layout is half_circle
		   AND the user has typed a non-empty value. The sidebar control is
		   also hidden via parent_show_if in module.json. */
		$start_label = $this->props['abc_item_start_label'] ?? '';
		$end_label   = $this->props['abc_item_end_label']   ?? '';

		/* Multi_step rows: array of [label, percent] objects emitted by the
		   difl/labels-repeater field. Non-array / empty → fall back to the
		   schema default (one row) so a fresh insertion is never blank. */
		$labels_raw = $this->props['abci_labels'] ?? null;
		$labels = ( is_array( $labels_raw ) && count( $labels_raw ) > 0 )
			? $labels_raw
			: [ [ 'label' => 'Title', 'percent' => 50 ] ];

		/* Parent-driven content controls. $this->parent_props is auto-populated
		   by the Divi 5 framework (same mechanism used in FaqItem). */
		$parent         = $this->parent_props ?? [];
		$bar_type       = $parent['abc_bar_layout']             ?? 'line';
		/* Layout-aware bar-style read. Line / multi_step use the full 5-option
		   `abc_item_bar_style`; circle / half_circle use the trimmed
		   `abc_item_bar_style_circle`. Mirrors Edit.js `itemStyle` so the
		   `df_abc_style_<value>` wrapper class matches the SCSS animation
		   rule (e.g. `.df_abc_item.df_abc_style_animated_gradient
		   .df_abc_grad_stop_*`). */
		$bar_style = in_array( $bar_type, [ 'circle', 'half_circle' ], true )
			? ( $this->props['abc_item_bar_style_circle'] ?? 'default' )
			: ( $this->props['abc_item_bar_style']        ?? 'default' );
		$line_place     = $parent['abc_line_content_placement'] ?? 'top';
		$bar_place      = $parent['abc_bar_content_placement']  ?? 'top';
		$show_percent   = ( $parent['abc_show_percentage']    ?? 'on'  ) === 'on';
		$show_max       = ( $parent['abc_show_max_value']     ?? 'off' ) === 'on';
		$max_value      =   $parent['abc_max_value']          ?? '100';
		$percent_in_bar_right = ( $parent['abc_percent_in_bar_right'] ?? 'off' ) === 'on';
		/* Layout-gated form — mirrors Edit.js's percentInBarRightActive. The
		   module.json show_if for abc_percent_in_bar_right restricts the UI
		   to line / segment, but show_if hides the UI only — it doesn't
		   clear the stored value. Without this gate, a leftover `on` value
		   would suppress the standard percent span on circle / half_circle /
		   multi_step too, leaving those layouts with no percent visible. */
		$percent_in_bar_right_active = $percent_in_bar_right && in_array( $bar_type, [ 'line', 'segment' ], true );
		/* Track Percent with Fill — line layout only. When on, the percent
		   renders as an indicator inside .df_abc_item_bar (the fill) and the
		   other percent emission paths (content_wrap, inside-bar,
		   percent-in-bar-right) are suppressed. */
		$track_percent_with_fill = ( $parent['abc_track_percent_with_fill'] ?? 'off' ) === 'on';
		$bar_percent_style       =   $parent['abc_bar_percent_style']         ?? 'default';
		$indicator_position      =   $parent['abc_indicator_position']        ?? 'top';
		$percent_position        =   $parent['abc_percent_position']          ?? 'top';
		$is_track_indicator      = ( $track_percent_with_fill && $bar_type === 'line' );

		/* Per-item prefix/suffix wrap the percent number inside textContent.
		   Prefix appears immediately before the integer; suffix immediately
		   after. Default suffix "%" reproduces the original symbol; empty
		   strings render nothing on that side. Whitespace renders verbatim.
		   Only the integer portion of textContent animates (driven by
		   data-target + the frontend regex which captures prefix + integer
		   + suffix).

		   Read raw values from $attrs (not $this->props) so an explicitly-
		   cleared empty string is preserved. $this->props mirrors getProps
		   in JS: empty strings get dropped as falsy and the default value
		   back-fills. The raw attrs path preserves "" exactly as typed. */
		$raw_prefix = $attrs['abc_item_percent_prefix']['innerContent']['desktop']['value'] ?? null;
		$raw_suffix = $attrs['abc_item_percent_suffix']['innerContent']['desktop']['value'] ?? null;
		$prefix     = $raw_prefix === null ? '' : (string) $raw_prefix;
		$suffix     = $raw_suffix === null ? '%' : (string) $raw_suffix;
		$max_num = floatval( $max_value );
		if ( $max_num <= 0 ) {
			$max_num = 100;
		}

		$percent_text = $percent_valid
			? ( $show_max
				? sprintf( '%s%s / %s%s', esc_html( $prefix ), esc_html( $percent_str ), esc_html( $max_value ), esc_html( $suffix ) )
				: sprintf( '%s%s%s',      esc_html( $prefix ), esc_html( $percent_str ), esc_html( $suffix ) ) )
			: null;

		/* Wrapped percent content — prefix / count / suffix as separate
		   spans so the parent's per-side Font controls and child's per-side
		   Color overrides each have a real DOM target. The inner count span
		   is what the frontend animator writes to (preserving the
		   prefix/suffix siblings); `data-max-value` lets the animator
		   re-render the "/ max" portion as the value updates. */
		$render_percent_content = function ( $value ) use ( $prefix, $suffix, $show_max, $max_value ) {
			$out = '';
			if ( $prefix !== '' ) {
				$out .= sprintf( '<span class="df_abc_item_percent_prefix">%s</span>', esc_html( $prefix ) );
			}
			$max_attr = $show_max ? sprintf( ' data-max-value="%s"', esc_attr( $max_value ) ) : '';
			$count    = $show_max
				? sprintf( '%s / %s', esc_html( $value ), esc_html( $max_value ) )
				: esc_html( $value );
			$out .= sprintf( '<span class="df_abc_item_percent_count"%s>%s</span>', $max_attr, $count );
			if ( $suffix !== '' ) {
				$out .= sprintf( '<span class="df_abc_item_percent_suffix">%s</span>', esc_html( $suffix ) );
			}
			return $out;
		};

		/* `data-target` carries the leading integer that the parent's
		   Animate Number toggle counts up to. Decimal input → integer part
		   (matches the splitPercentText regex on the frontend). Harmless
		   when the toggle is off — the JS only reads it when
		   `.df_abc_animate_number_on` is set on the ancestor container. */
		$percent_target_int = $percent_valid ? (int) $percent_str : 0;

		/* Icon / image trio — mirrors Edit.js. `none` → empty string;
		   `icon` → IconRenderTrait::render_icon emits the standard
		   `<span class="et-pb-icon [fa-icon]" style="font-weight:N;">`;
		   `image` → plain `<img>` with esc_url. Suppressed when parent
		   layout is multi_step (no single item-level title). */
		$icon_type  = $this->props['abc_item_icon_type'] ?? 'none';
		$icon_value = $this->props['abc_item_icon']      ?? null;
		/* `abc_item_image` is a `group-items` attribute with a `src` sub-key
		   (canonical Divi pattern used by Blurb / Image / image-reveal — the
		   shape required for the in-panel preview thumbnail to render). Read
		   the URL directly from the raw $attrs storage path; mirrors the
		   Edit.js read so VB preview and saved frontend render identically. */
		$image_src  = $attrs['abc_item_image']['innerContent']['desktop']['value']['src'] ?? '';

		$icon_block = '';
		if ( $bar_type !== 'multi_step' ) {
			if ( $icon_type === 'icon' && ! empty( $icon_value ) ) {
				$icon_block = sprintf(
					'<span class="df_abc_item_icon df_abc_item_icon_icon">%s</span>',
					self::render_icon( $icon_value )
				);
			} elseif ( $icon_type === 'image' && $image_src !== '' ) {
				$icon_block = sprintf(
					'<span class="df_abc_item_icon df_abc_item_icon_image"><img src="%s" alt="" /></span>',
					esc_url( $image_src, array( 'http', 'https', 'data' ) )
				);
			}
		}
		/* Placement per layout (mirrors Edit.js):
		   - line / segment: icon NESTED inside the title span (before
		     the title text). When $title_safe === null, the icon is also
		     suppressed (no anchor).
		   - circle / half_circle: icon as the FIRST child of
		     .df_abc_content_wrap (sibling of title and percent). SCSS uses
		     `order` to keep it pinned at the top regardless of the
		     parent's Swap Title and Percent toggle. */
		$title_nested_icon = ( $bar_type === 'line' || $bar_type === 'segment' )       ? $icon_block : '';
		$circle_icon       = ( $bar_type === 'circle' || $bar_type === 'half_circle' ) ? $icon_block : '';

		/* Percent suppressed here when either:
		   - Percent in Bar Right is on → moves into .df-abc-bar-with-right-value
		   - Track Percent with Fill (line) is on → renders inside .df_abc_item_bar
		   so it doesn't render twice. Mirrors Edit.js's contentWrap gate. */
		$title_span = ( $title_safe !== null )
			? sprintf( '<%1$s class="df_abc_item_title">%2$s<span class="df_abc_item_title_text">%3$s</span></%1$s>', $title_tag, $title_nested_icon, esc_html( $title_safe ) )
			: '';
		$percent_span = ( $show_percent && ! $percent_in_bar_right_active && ! $is_track_indicator && $percent_text !== null )
			? sprintf( '<span class="df_abc_item_percent" data-target="%d">%s</span>', $percent_target_int, $render_percent_content( $percent_str ) )
			: '';
		$content_wrap = sprintf(
			'<div class="df_abc_content_wrap">%s%s%s</div>',
			$circle_icon,
			$title_span,
			$percent_span
		);

		$is_inside = ( $bar_type === 'line' && $line_place === 'inside' );
		$is_bottom = ( $bar_type === 'line' && $line_place === 'bottom' )
		          || ( $bar_type === 'segment'  && $bar_place  === 'bottom' );

		/* Build prefix / suffix <span> markup. Empty value → omit the
		   corresponding span. The wrapper itself is emitted below. */
		$position_labels = '';
		if ( $bar_type === 'half_circle' ) {
			if ( $start_label !== '' ) {
				$position_labels .= sprintf(
					'<span class="df_abc_prefix_label">%s</span>',
					esc_html( $start_label )
				);
			}
			if ( $end_label !== '' ) {
				$position_labels .= sprintf(
					'<span class="df_abc_suffix_label">%s</span>',
					esc_html( $end_label )
				);
			}
		}

		/* Three render paths:
		   - dot: empty wrap; frontend JS populates dot elements at init time.
		   - circle / half_circle: SVG ring render. Track + fill are stroked
		     elements; fill animates via stroke-dashoffset driven by the same
		     --df-abc-item-percent CSS variable the existing JS engine writes
		     per frame. pathLength="100" normalizes the perimeter to 100 units
		     so dashoffset math is independent of radius. All stroke colors,
		     widths, and caps come from CSS variables consumed in styles.scss.
		   - line: classic .df_abc_item_bar fill div (unchanged). */
		if ( $bar_type === 'segment' ) {
			$bar_wrap = sprintf(
				'<div class="df_abc_item_bar_wrap" role="progressbar" aria-valuenow="%s" aria-valuemin="0" aria-valuemax="100"></div>',
				esc_attr( $percent )
			);
		} elseif ( $bar_type === 'circle' || $bar_type === 'half_circle' ) {
			/* SVG ring render. Track + fill are stroked elements; fill animates
			   via stroke-dashoffset driven by --df-abc-item-percent.
			   pathLength="100" normalizes the perimeter to 100 units so dashoffset
			   math is independent of radius.

			   Mirrors Edit.js: when the user picks a non-default bar style
			   (gradient / animated_gradient / stripe / animated_stripe) via
			   `abc_item_bar_style_circle`, build a <defs> block with a
			   <linearGradient> or <pattern> and switch the .df_abc_circle_fill
			   stroke to `url(#df-abc-fill-<id>)`. Default style keeps the
			   existing solid CSS-variable stroke.

			   Gradient stop colors are NOT injected as literals — they're
			   read from CSS variables emitted by the matching CommonStyle
			   block (`--df-abc-grad-start-circle` / `--df-abc-grad-end-circle`).
			   That route lets Divi resolve gcid-* global colors to hex via
			   its CSS pipeline. SMIL `<animate>` reads SVG XML attrs directly
			   (no CSS pipeline), so the animation is delegated to CSS
			   keyframes in styles.scss, triggered by `.df_abc_grad_stop_*`
			   classes on the <stop> elements. */
			$circle_item_style = $this->props['abc_item_bar_style_circle'] ?? 'default';

			/* `$this->order_class` is the per-instance unique selector
			   populated by Divi's render dispatcher. `$args` is NOT in
			   scope inside render() — using it here would collapse every
			   child's <linearGradient id> to "df-abc-fill-default" and
			   the first item's defs would win for the whole page. */
			$fill_uid      = preg_replace( '/\W/', '', ltrim( $this->order_class ?? '', '.' ) ?: 'default' );
			$fill_ref_id   = "df-abc-fill-{$fill_uid}";
			$use_defs_fill = $circle_item_style !== 'default';
			$fill_stroke_attr = $use_defs_fill
				? sprintf( ' style="stroke: url(#%s);"', esc_attr( $fill_ref_id ) )
				: ' style="stroke: var(--df-abc-item-fill-color, #7936ff);"';

			$defs_html = '';
			if ( $use_defs_fill ) {
				if ( $circle_item_style === 'gradient' || $circle_item_style === 'animated_gradient' ) {
					$anim_class_start = ( $circle_item_style === 'animated_gradient' ) ? ' class="df_abc_grad_stop_start"' : '';
					$anim_class_end   = ( $circle_item_style === 'animated_gradient' ) ? ' class="df_abc_grad_stop_end"'   : '';
					$defs_html = sprintf(
						'<defs><linearGradient id="%1$s" x1="0%%" y1="0%%" x2="100%%" y2="0%%"><stop offset="0%%"%2$s style="stop-color: var(--df-abc-grad-start-circle, #7E57C2);"></stop><stop offset="100%%"%3$s style="stop-color: var(--df-abc-grad-end-circle, #00BFA5);"></stop></linearGradient></defs>',
						esc_attr( $fill_ref_id ), $anim_class_start, $anim_class_end
					);
				} elseif ( $circle_item_style === 'stripe' || $circle_item_style === 'animated_stripe' ) {
					$anim_speed = $this->props['abc_item_animation_speed_circle'] ?? '1000ms';
					$animate_pattern = ( $circle_item_style === 'animated_stripe' )
						? sprintf( '<animateTransform attributeName="patternTransform" type="translate" from="0 0" to="10 0" dur="%s" repeatCount="indefinite" additive="sum" />', esc_attr( $anim_speed ) )
						: '';
					$defs_html = sprintf(
						'<defs><pattern id="%1$s" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)"><rect width="10" height="10" style="fill: var(--df-abc-item-fill-color, #7936ff);" /><rect x="5" width="5" height="10" fill="rgba(255,255,255,0.35)" />%2$s</pattern></defs>',
						esc_attr( $fill_ref_id ), $animate_pattern
					);
				}
			}

			/* Unified <circle> for both layouts. Half-circle uses pathLength=200
			   so the SCSS dasharray "100 200" naturally hides the bottom half;
			   viewBox crops to the top and SCSS scaleX(-1) gives L→R fill. */
			$svg_class       = $bar_type === 'circle' ? 'df_abc_circle_svg' : 'df_abc_half_circle_svg';
			$svg_view        = $bar_type === 'circle' ? '0 0 200 200' : '0 0 200 100';
			$svg_path_length = $bar_type === 'circle' ? '100' : '200';
			$svg_inner = $defs_html
				. sprintf( '<circle class="df_abc_circle_track" cx="100" cy="100" r="80" pathLength="%s"></circle>', esc_attr( $svg_path_length ) )
				. sprintf( '<circle class="df_abc_circle_fill" cx="100" cy="100" r="80" pathLength="%s" shape-rendering="geometricPrecision"%s></circle>', esc_attr( $svg_path_length ), $fill_stroke_attr );

			/* content_wrap is rendered INSIDE bar_wrap (sibling of the SVG) so
			   its absolute positioning anchors to the fixed-size bar_wrap, not
			   to the item — decouples content centering from prefix/suffix
			   label margin growth. Mirrors the Edit.js JSX. */
			/* Inner-bg div is rendered BEFORE the SVG so it paints behind
			   the ring stroke. Styled by Divi's background decoration on
			   abc_item_inner_bg ({{baseSelector}} .df_abc_inner_bg).
			   Mirrors the Edit.js JSX. */
			$bar_wrap = sprintf(
				'<div class="df_abc_item_bar_wrap"><div class="df_abc_inner_bg"></div><svg class="%s" viewBox="%s" preserveAspectRatio="xMidYMid meet" role="progressbar" aria-valuenow="%s" aria-valuemin="0" aria-valuemax="100">%s</svg>%s</div>',
				esc_attr( $svg_class ),
				esc_attr( $svg_view ),
				esc_attr( $percent ),
				$svg_inner,
				$content_wrap
			);
		} elseif ( $bar_type === 'multi_step' ) {
			/* Multi_step: single line-style track + fill grown to the max
			   percent across rows. Each marker is a unified group: content
			   (label + percent inline) → connector line → indicator on the
			   bar. Position (top/bottom) and indicator style classes live on
			   the parent container — SCSS handles the flex mirror.
			   Must stay byte-identical to Edit.js's multi_step JSX. */
			$markers_html = '';
			foreach ( $labels as $row ) {
				if ( ! is_array( $row ) ) continue;
				$row_label   = isset( $row['label'] )   ? (string) $row['label']      : '';
				$row_percent = isset( $row['percent'] ) ? floatval( $row['percent'] ) : 0;
				$row_pos_pct = ( $row_percent / $max_num ) * 100;   // marker X scaled by max
				$percent_span = $show_percent
					? sprintf(
						'<span class="df_abc_label_marker_percent df_abc_item_percent" data-target="%d">%s</span>',
						(int) $row_percent,
						$render_percent_content( (string) $row_percent )
					)
					: '';
				$markers_html .= sprintf(
					'<span class="df_abc_label_marker" style="left: %1$s%%;">'
					. '<span class="df_abc_label_marker_content">'
					. '<span class="df_abc_label_marker_text df_abc_item_title"><span class="df_abc_item_title_text">%2$s</span></span>'
					. '%3$s'
					. '</span>'
					. '<span class="df_abc_label_marker_indicator"></span>'
					. '</span>',
					esc_attr( $row_pos_pct ),
					esc_html( $row_label ),
					$percent_span
				);
			}
			/* Bar fill driven by the item's own `abc_item_percent` (same source
			   as line/dot/circle bars). Per-row labels[].percent positions each
			   marker (`left: X%`) but does NOT drive the fill — markers can be
			   ahead of / behind / beyond the fill, useful for milestones.
			   --df-abc-item-percent is emitted by get_common_styles() on the
			   item's order class (a RAW NUMBER — SCSS calc()s the final width
			   as percent / max_value * 100%). NO inline style on .df_abc_item_bar:
			   doing so would re-declare the var on a child element and bypass
			   the @property transition that's set up on .df_abc_item. */
			/* Markers live as siblings of track + fill inside the wrap so
			   their `bottom: 100%` / `top: 100%` anchors land at the WRAP's
			   edges — which equal the fill's edges when fill > base. This
			   lets the line indicator visually meet the fill instead of
			   stopping at the (shorter) track. */
			$bar_wrap = sprintf(
				'<div class="df_abc_item_bar_wrap">'
				. '<div class="df_abc_item_bar_track"></div>'
				. '<div class="df_abc_item_bar" role="progressbar" aria-valuenow="%1$s" aria-valuemin="0" aria-valuemax="%2$s"></div>'
				. '%3$s'
				. '</div>',
				esc_attr( $percent ),
				esc_attr( $max_value ),
				$markers_html
			);
		} else {
			/* For line, the bar's inner content depends on placement:
			   - inside placement → the full content_wrap (title + percent)
			     lives INSIDE .df_abc_item_bar (the fill). Overflow + flex
			     layout on the bar in styles.scss does the rest.
			   - any other placement → bar is empty (percent renders elsewhere
			     in the content_wrap or right-value slot).
			   .df_abc_item_bar_track is the visible track; .df_abc_item_bar
			   is the fill stacked on top. Wrap auto-sizes to max(base, fill). */
			/* Track-percent-with-fill indicator: pinned to the fill's right
			   edge via SCSS (`right: 0; translateX(50%)`). As the fill width
			   animates, the indicator moves with it. The inner span carries
			   `.df_abc_item_percent` + `data-target` so the existing frontend
			   animator counts up its number in sync.

			   Modifier classes:
			   - pin / bubble: `df_abc_bar_percent_position_<top|bottom>` flips
			     the indicator above or below the bar.
			   - default / marker / marker_outline: `df_abc_bar_percent_text_<top|inside|outside|bottom>`
			     places the percent text relative to the fill bar's end. */
			$indicator_extra = '';
			// Pin + Bubble + Tag share a top/bottom indicator position.
			if ( $bar_percent_style === 'pin' || $bar_percent_style === 'bubble' || $bar_percent_style === 'tag' ) {
				$indicator_extra .= ' df_abc_bar_percent_position_' . sanitize_html_class( $indicator_position );
			}
			// Default + Marker + Marker Outline share the percent-text-position modifier.
			if ( $bar_percent_style === 'default' || $bar_percent_style === 'marker' || $bar_percent_style === 'marker_outline' ) {
				$indicator_extra .= ' df_abc_bar_percent_text_' . sanitize_html_class( $percent_position );
			}
			$indicator_span = ( $is_track_indicator && $show_percent && $percent_text !== null )
				? sprintf(
					'<span class="df_abc_bar_percent_indicator df_abc_bar_percent_%1$s%2$s">'
					. '<span class="df_abc_item_percent" data-target="%3$d">%4$s</span>'
					. '</span>',
					esc_attr( $bar_percent_style ),
					esc_attr( $indicator_extra ),
					$percent_target_int,
					$render_percent_content( $percent_str )
				)
				: '';
			$bar_inner_content = $is_inside ? $content_wrap : '';
			$bar_wrap = sprintf(
				'<div class="df_abc_item_bar_wrap">'
				. '<div class="df_abc_item_bar_track"></div>'
				. '<div class="df_abc_item_bar" role="progressbar" aria-valuenow="%s" aria-valuemin="0" aria-valuemax="100">%s%s</div>'
				. '</div>',
				esc_attr( $percent ),
				$bar_inner_content,
				$indicator_span
			);
		}

		/* Prefix / suffix labels — rendered OUTSIDE .df_abc_item_bar_wrap
		   (whose overflow:hidden would clip them) in a sibling flex row sized
		   to the wrap's width. SCSS handles the layout (`justify-content:
		   space-between`); font + spacing come from the parent's
		   `abc_position_labels` and the child's `abc_item_position_labels`
		   Path-A decorations. */
		$position_labels_block = '';
		if ( $bar_type === 'half_circle' && $position_labels !== '' ) {
			$position_labels_block = sprintf(
				'<div class="df_abc_position_labels">%s</div>',
				$position_labels
			);
		}

		$place_class = '';
		if ( $bar_type === 'line' ) $place_class = "df-line-content-{$line_place}";
		if ( $bar_type === 'segment' )  $place_class = "df-bar-content-{$bar_place}";

		/* multi_step drives content via per-row labels repeater (markers).
		   circle / half_circle render content_wrap INSIDE bar_wrap (see the
		   circle / half_circle render branch above) so its absolute
		   positioning anchors to the fixed-size bar_wrap, decoupling from
		   prefix/suffix label margin growth. Both layouts therefore exclude
		   the outer body slot here. */
		$show_outer_content = in_array( $bar_type, [ 'line', 'segment' ], true );

		/* Percent in Bar Right: wrap the base bar and a sibling value span
		   in a flex row. The value's horizontal position is fixed at the
		   bar's right edge regardless of fill percentage. Active for line /
		   segment / multi_step layouts when the parent toggle is on and a
		   percent is visible. Overrides Percent Inside Bar. */
		$right_value_span = ( $show_percent && $percent_text !== null )
			? sprintf( '<span class="df_abc_item_percent" data-target="%d">%s</span>', $percent_target_int, $render_percent_content( $percent_str ) )
			: '';
		$bar_with_right_value = ( $percent_in_bar_right_active && ! $is_track_indicator && $right_value_span !== '' )
			? sprintf( '<div class="df-abc-bar-with-right-value">%s%s</div>', $bar_wrap, $right_value_span )
			: $bar_wrap;

		$body  = ( $show_outer_content && ! $is_inside && ! $is_bottom ) ? $content_wrap : '';
		$body .= $bar_with_right_value;
		$body .= $position_labels_block;
		$body .= ( $show_outer_content && ! $is_inside &&   $is_bottom ) ? $content_wrap : '';

		/* Inserted child modules from Divi's "Add Elements" inserter
		   (`divi/elements` component in module.json). $content is the
		   pre-rendered HTML for the nested children — pass through
		   verbatim; do NOT escape (would break nested module markup).
		   Empty content → omit the wrapper so no stray empty <div> appears. */
		if ( is_string( $content ) && trim( $content ) !== '' ) {
			$body .= sprintf( '<div class="df_abc_item_elements">%s</div>', $content );
		}

		/* `df_abc_priming` locks the item at 0% on first paint so the
		   CSS transition has a value to animate from. Frontend JS
		   (public/js/df-advancedbarcounter.js) removes the class on
		   intersection — that drops the cascade gate and the var
		   reverts to the target value, triggering the transition. */
		return sprintf(
			'<div class="df_abc_item df_abc_priming df_abc_style_%s %s">%s</div>',
			esc_attr( $bar_style ),
			esc_attr( $place_class ),
			$body
		);
	}
}
