<?php
/**
 * Border trait — PHP CSS emit. Owns border + border-radius + shadow
 * emission for any block declaring `"blocksifySupports": [..., "border"]`.
 *
 * Shadow's data is logically separate but its emit lives here for now —
 * UX-wise shadow shares the "Border & Radius" panel and emits to the
 * same wrapper rule, so consolidation simplifies the dispatcher. Preset
 * token-linking (`var(--wp--preset--shadow--{slug})`) shipped in Free
 * (#189); a future multi-shadow stack would be the trigger to extract
 * shadow into its own trait.
 *
 * Output contract:
 *   - `::emit_decls( $attrs, $device )` returns wrapper-rule border +
 *     radius + shadow decls. Tablet / mobile pass cascade base for
 *     per-property longhand override semantics. Desktop emits shorthand.
 *   - `::emit_hover_decls( $attrs, $device )` returns hover-layer decls
 *     (passes desktop bag as cascade source so unset properties cascade
 *     through and zero-reset semantics fire correctly).
 *
 * Mirrors JS `buildBorderDecls` + `buildShadowDecls` in
 * `src/shared/controls/{Border,Shadow}Control/css.js` byte-for-byte.
 *
 * @package Blocksify
 */

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

class Blocksify_Border_Trait {

	/**
	 * Attribute schema fragment. Mirror of `src/shared/traits/border.js`
	 * `attributes` const. See spacing trait header for rationale + sync rule.
	 */
	public static function attributes() {
		return array(
			'border'              => array( 'type' => array( 'object', 'null' ) ),
			'borderTablet'        => array( 'type' => array( 'object', 'null' ) ),
			'borderMobile'        => array( 'type' => array( 'object', 'null' ) ),
			'borderHover'         => array( 'type' => array( 'object', 'null' ) ),
			'borderRadius'        => array( 'type' => array( 'object', 'null' ) ),
			'borderRadiusTablet'  => array( 'type' => array( 'object', 'null' ) ),
			'borderRadiusMobile'  => array( 'type' => array( 'object', 'null' ) ),
			'borderRadiusHover'   => array( 'type' => array( 'object', 'null' ) ),
			'shadow'              => array( 'type' => array( 'array', 'null' ) ),
			'shadowTablet'        => array( 'type' => array( 'array', 'null' ) ),
			'shadowMobile'        => array( 'type' => array( 'array', 'null' ) ),
			'shadowHover'         => array( 'type' => array( 'array', 'null' ) ),
		);
	}

	/**
	 * Wrapper-rule border + radius + shadow decls for one device.
	 *
	 * @param string $part_prefix Optional part name (SPEC-trait-parts §3.3).
	 */
	public static function emit_decls( $attrs, $device, $part_prefix = '' ) {
		$base = 'desktop' === $device ? null : self::cascade_base( $attrs, $device, $part_prefix );
		$decls  = self::build_border_radius_decls( self::collect_bag( $attrs, $device, $part_prefix ), $base );
		$decls .= self::build_shadow_decls( self::resolve_shadow( $attrs, $device, $part_prefix ) );
		// TEMP: smooth hover box-shadow transition. Only emit on the desktop
		// (base) rule when shadowHover is set so most blocks pay zero cost.
		// Remove when Global Settings → Transitions ships and owns this
		// per-property timing globally.
		if ( 'desktop' === $device ) {
			$shadow_hover_key = Blocksify_Attrs::part_hover_key( $part_prefix, 'shadow' );
			if ( ! empty( $attrs[ $shadow_hover_key ] ) ) {
				$decls .= 'transition:box-shadow 200ms ease-out;';
			}
		}
		return $decls;
	}

	/**
	 * Hover-layer border + radius + shadow decls. Pass desktop-resolved
	 * bag as cascade source so:
	 *   - Border / radius emit longhand override (per-property cascade
	 *     preserves base's color / style / unchanged corners through to
	 *     the `:hover` rule).
	 *   - Shadow emits explicit `box-shadow: none;` zero-reset when the
	 *     hover bag is `enabled:false` (or zero-everywhere) over a
	 *     meaningful base.
	 */
	public static function emit_hover_decls( $attrs, $part_prefix = '' ) {
		$border_hover_key = Blocksify_Attrs::part_hover_key( $part_prefix, 'border' );
		$radius_hover_key = Blocksify_Attrs::part_hover_key( $part_prefix, 'borderRadius' );
		$shadow_hover_key = Blocksify_Attrs::part_hover_key( $part_prefix, 'shadow' );
		$decls  = self::build_border_radius_decls(
			array(
				'border' => isset( $attrs[ $border_hover_key ] ) ? $attrs[ $border_hover_key ] : null,
				'radius' => isset( $attrs[ $radius_hover_key ] ) ? $attrs[ $radius_hover_key ] : null,
			),
			self::collect_bag( $attrs, 'desktop', $part_prefix )
		);
		$decls .= self::build_shadow_decls(
			isset( $attrs[ $shadow_hover_key ] ) ? $attrs[ $shadow_hover_key ] : null,
			self::resolve_shadow( $attrs, 'desktop', $part_prefix )
		);
		return $decls;
	}

	/**
	 * Resolve { border, radius } for one device.
	 */
	protected static function collect_bag( $attrs, $device, $part_prefix = '' ) {
		$border_key = Blocksify_Attrs::part_key( $part_prefix, 'border', $device );
		$radius_key = Blocksify_Attrs::part_key( $part_prefix, 'borderRadius', $device );
		return array(
			'border' => isset( $attrs[ $border_key ] ) ? $attrs[ $border_key ] : null,
			'radius' => isset( $attrs[ $radius_key ] ) ? $attrs[ $radius_key ] : null,
		);
	}

	/**
	 * Cascade source bag for non-desktop devices. Tablet → desktop;
	 * mobile → tablet (when meaningful) else desktop.
	 */
	protected static function cascade_base( $attrs, $device, $part_prefix = '' ) {
		if ( 'tablet' === $device ) {
			return self::collect_bag( $attrs, 'desktop', $part_prefix );
		}
		$tablet = self::collect_bag( $attrs, 'tablet', $part_prefix );
		$tablet_has = (
			'' !== self::build_border_shorthand( $tablet['border'] )
			|| '' !== self::build_radius_shorthand( $tablet['radius'] )
		);
		if ( $tablet_has ) {
			return $tablet;
		}
		return self::collect_bag( $attrs, 'desktop', $part_prefix );
	}

	/**
	 * Build border + radius decls. Base layer (no $base_bag) → shorthand;
	 * override layer ($base_bag set) → longhand for changed properties
	 * only so unset fields cascade from the base via per-property CSS
	 * cascade.
	 */
	protected static function build_border_radius_decls( $bag, $base_bag = null ) {
		if ( ! is_array( $bag ) ) {
			return '';
		}
		$decls = '';

		if ( ! empty( $bag['border'] ) && is_array( $bag['border'] ) ) {
			if ( null === $base_bag ) {
				$decls .= self::build_border_shorthand( $bag['border'] );
			} else {
				$base_border = is_array( $base_bag ) && isset( $base_bag['border'] ) && is_array( $base_bag['border'] )
					? $base_bag['border']
					: null;
				$decls .= self::build_border_override( $bag['border'], $base_border );
			}
		}

		if ( ! empty( $bag['radius'] ) && is_array( $bag['radius'] ) ) {
			if ( null === $base_bag ) {
				$decls .= self::build_radius_shorthand_decl( $bag['radius'] );
			} else {
				$decls .= self::build_radius_override( $bag['radius'] );
			}
		}

		return $decls;
	}

	/**
	 * Longhand override for a border bag. Per-property emit so unset
	 * fields cascade from the base rule.
	 */
	protected static function build_border_override( $value, $base_value = null ) {
		$mode = isset( $value['mode'] ) && 'per-side' === $value['mode'] ? 'per-side' : 'linked';
		$unit = ! empty( $value['unit'] ) ? $value['unit'] : 'px';

		$base_mode = is_array( $base_value ) && isset( $base_value['mode'] ) && 'per-side' === $base_value['mode']
			? 'per-side'
			: 'linked';
		$base_has = function ( $side = null ) use ( $base_value, $base_mode ) {
			if ( ! is_array( $base_value ) ) {
				return false;
			}
			if ( 'linked' === $base_mode ) {
				return isset( $base_value['width'] ) && (float) $base_value['width'] > 0;
			}
			$key = $side . 'Width';
			return isset( $base_value[ $key ] ) && (float) $base_value[ $key ] > 0;
		};

		$decls = '';
		if ( 'linked' === $mode ) {
			if ( isset( $value['width'] ) && is_numeric( $value['width'] ) ) {
				$w      = (float) $value['width'];
				$decls .= 'border-width:' . $w . $unit . ';';
				if ( $w > 0 && ! $base_has() ) {
					$style  = ! empty( $value['style'] ) ? $value['style'] : 'solid';
					$decls .= 'border-style:' . $style . ';';
				}
			}
			$style = isset( $value['style'] ) ? $value['style'] : '';
			if ( 'dashed' === $style || 'dotted' === $style ) {
				$decls .= 'border-style:' . $style . ';';
			}
			if ( ! empty( $value['color'] ) ) {
				$decls .= 'border-color:' . $value['color'] . ';';
			}
		} else {
			foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
				$w_raw = isset( $value[ $side . 'Width' ] ) ? $value[ $side . 'Width' ] : null;
				$s     = ! empty( $value[ $side . 'Style' ] )
					? $value[ $side . 'Style' ]
					: ( ! empty( $value['style'] ) ? $value['style'] : '' );
				$c     = ! empty( $value[ $side . 'Color' ] ) ? $value[ $side . 'Color' ] : '';
				if ( null !== $w_raw && is_numeric( $w_raw ) ) {
					$w      = (float) $w_raw;
					$decls .= 'border-' . $side . '-width:' . $w . $unit . ';';
					if ( $w > 0 && ! $base_has( $side ) ) {
						$style  = $s ? $s : 'solid';
						$decls .= 'border-' . $side . '-style:' . $style . ';';
					}
				}
				if ( 'dashed' === $s || 'dotted' === $s ) {
					$decls .= 'border-' . $side . '-style:' . $s . ';';
				}
				if ( $c ) {
					$decls .= 'border-' . $side . '-color:' . $c . ';';
				}
			}
		}
		return $decls;
	}

	/**
	 * `border-radius:` shorthand decl wrapper for the base layer.
	 */
	protected static function build_radius_shorthand_decl( $value ) {
		$shorthand = self::build_radius_shorthand( $value );
		return '' === $shorthand ? '' : 'border-radius:' . $shorthand . ';';
	}

	/**
	 * Longhand override for a radius bag.
	 */
	protected static function build_radius_override( $value ) {
		$mode = isset( $value['mode'] ) && 'per-corner' === $value['mode'] ? 'per-corner' : 'linked';
		$unit = ! empty( $value['unit'] ) ? $value['unit'] : 'px';

		if ( 'linked' === $mode ) {
			if ( ! isset( $value['value'] ) || ! is_numeric( $value['value'] ) ) {
				return '';
			}
			$v = (float) $value['value'];
			return 'border-radius:' . $v . $unit . ';';
		}
		$corners = array(
			array( 'topLeft', 'border-top-left-radius' ),
			array( 'topRight', 'border-top-right-radius' ),
			array( 'bottomLeft', 'border-bottom-left-radius' ),
			array( 'bottomRight', 'border-bottom-right-radius' ),
		);
		$decls = '';
		foreach ( $corners as $pair ) {
			list( $field, $prop ) = $pair;
			$c = isset( $value[ $field ] ) ? $value[ $field ] : null;
			if ( null === $c || ! is_numeric( $c ) ) {
				continue;
			}
			$num    = (float) $c;
			$decls .= $prop . ':' . $num . $unit . ';';
		}
		return $decls;
	}

	/**
	 * `border` (or `border-<side>`) shorthand for the base layer.
	 */
	protected static function build_border_shorthand( $value ) {
		if ( ! is_array( $value ) ) {
			return '';
		}
		$mode = isset( $value['mode'] ) && 'per-side' === $value['mode'] ? 'per-side' : 'linked';
		$unit = ! empty( $value['unit'] ) ? $value['unit'] : 'px';

		if ( 'linked' === $mode ) {
			$has_width_key = array_key_exists( 'width', $value );
			$width         = $has_width_key ? (float) $value['width'] : 0;
			if ( $width <= 0 ) {
				// Decision #61 zero-as-explicit (base path): emit
				// `border: 0` when the user explicitly stored
				// `width: 0` so any static-base border on the same
				// selector gets overridden. Mirror of the JS twin
				// fix in `BorderControl/helpers.js`.
				return ( $has_width_key && 0.0 === $width ) ? 'border:0;' : '';
			}
			$style = ! empty( $value['style'] ) ? $value['style'] : 'solid';
			$color = ! empty( $value['color'] ) ? $value['color'] : 'currentColor';
			return 'border:' . $width . $unit . ' ' . $style . ' ' . $color . ';';
		}

		$decls = '';
		foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
			$w_key = $side . 'Width';
			$w     = array_key_exists( $w_key, $value ) ? $value[ $w_key ] : null;
			if ( null === $w || (float) $w <= 0 ) {
				// Same zero-as-explicit rule per side. `null` (not
				// authored) is excluded; only a numeric 0 emits the
				// explicit kill.
				if ( null !== $w && 0.0 === (float) $w ) {
					$decls .= 'border-' . $side . ':0;';
				}
				continue;
			}
			$s = ! empty( $value[ $side . 'Style' ] )
				? $value[ $side . 'Style' ]
				: ( ! empty( $value['style'] ) ? $value['style'] : 'solid' );
			$c = ! empty( $value[ $side . 'Color' ] ) ? $value[ $side . 'Color' ] : 'currentColor';
			$decls .= 'border-' . $side . ':' . (float) $w . $unit . ' ' . $s . ' ' . $c . ';';
		}
		return $decls;
	}

	/**
	 * `border-radius` value for one bag (no `border-radius:` prefix).
	 * Linked emits a single value; per-corner emits TL TR BR BL shorthand.
	 */
	protected static function build_radius_shorthand( $value ) {
		$mode = isset( $value['mode'] ) && 'per-corner' === $value['mode'] ? 'per-corner' : 'linked';
		$unit = ! empty( $value['unit'] ) ? $value['unit'] : 'px';

		if ( 'linked' === $mode ) {
			$v = isset( $value['value'] ) ? (float) $value['value'] : 0;
			if ( $v <= 0 ) {
				return '';
			}
			return $v . $unit;
		}

		$tl = self::format_radius_corner( isset( $value['topLeft'] ) ? $value['topLeft'] : null, $unit );
		$tr = self::format_radius_corner( isset( $value['topRight'] ) ? $value['topRight'] : null, $unit );
		$br = self::format_radius_corner( isset( $value['bottomRight'] ) ? $value['bottomRight'] : null, $unit );
		$bl = self::format_radius_corner( isset( $value['bottomLeft'] ) ? $value['bottomLeft'] : null, $unit );

		if ( '' === $tl && '' === $tr && '' === $br && '' === $bl ) {
			return '';
		}
		return ( '' === $tl ? '0' : $tl ) . ' '
			. ( '' === $tr ? '0' : $tr ) . ' '
			. ( '' === $br ? '0' : $br ) . ' '
			. ( '' === $bl ? '0' : $bl );
	}

	protected static function format_radius_corner( $val, $unit ) {
		if ( null === $val ) {
			return '';
		}
		$num = (float) $val;
		if ( 0.0 === $num ) {
			return '';
		}
		return $num . $unit;
	}

	// ====================================================================
	// Shadow ============================================================
	// ====================================================================

	/**
	 * Resolve the shadow bag for one device. Mobile → Tablet → Desktop
	 * fallback. Returns null when no upstream device holds a meaningful
	 * value for this device's cascade.
	 */
	protected static function resolve_shadow( $attrs, $device, $part_prefix = '' ) {
		$mobile_key  = Blocksify_Attrs::part_key( $part_prefix, 'shadow', 'mobile' );
		$tablet_key  = Blocksify_Attrs::part_key( $part_prefix, 'shadow', 'tablet' );
		$desktop_key = Blocksify_Attrs::part_key( $part_prefix, 'shadow', 'desktop' );
		if ( 'mobile' === $device ) {
			if ( isset( $attrs[ $mobile_key ] ) && is_array( $attrs[ $mobile_key ] ) ) {
				return $attrs[ $mobile_key ];
			}
		}
		if ( 'mobile' === $device || 'tablet' === $device ) {
			if ( isset( $attrs[ $tablet_key ] ) && is_array( $attrs[ $tablet_key ] ) ) {
				return $attrs[ $tablet_key ];
			}
		}
		return isset( $attrs[ $desktop_key ] ) && is_array( $attrs[ $desktop_key ] )
			? $attrs[ $desktop_key ]
			: null;
	}

	/**
	 * Build the box-shadow decl string for one state from a layers
	 * array. Mirrors JS `buildShadowDecls` in
	 * `src/shared/controls/ShadowControl/css.js` byte-for-byte.
	 *
	 * Zero-reset semantics: only fires when `$layers` is a STORED array
	 * (truthy) that RESOLVES to "no CSS" (empty array OR every layer
	 * composes empty). `$layers === null` (no override) returns '' so
	 * the cascade source flows through.
	 *
	 * @param array|null $layers      Layers array for one state.
	 * @param array|null $base_layers Cascade source layers.
	 * @return string
	 */
	protected static function build_shadow_decls( $layers, $base_layers = null ) {
		if ( ! is_array( $layers ) ) {
			return '';
		}
		$css = self::build_shadow_value( $layers );
		if ( '' !== $css ) {
			return 'box-shadow:' . $css . ';';
		}
		if ( is_array( $base_layers ) && '' !== self::build_shadow_value( $base_layers ) ) {
			return 'box-shadow:none;';
		}
		return '';
	}

	/**
	 * Compose the full box-shadow CSS value from a layers array
	 * (comma-joined, no `box-shadow:` prefix). Returns '' for empty
	 * arrays or arrays where every layer composes empty.
	 *
	 * @param array|null $layers
	 * @return string
	 */
	protected static function build_shadow_value( $layers ) {
		if ( ! is_array( $layers ) || empty( $layers ) ) {
			return '';
		}
		$parts = array();
		foreach ( $layers as $layer ) {
			$str = self::layer_to_css( $layer );
			if ( '' !== $str ) {
				$parts[] = $str;
			}
		}
		if ( empty( $parts ) ) {
			return '';
		}
		return implode( ', ', $parts );
	}

	/**
	 * Compose ONE layer's CSS fragment.
	 *
	 * @param mixed $layer
	 * @return string
	 */
	protected static function layer_to_css( $layer ) {
		if ( ! is_array( $layer ) ) {
			return '';
		}
		// Token-referenced layer (Mode B linked preset): emit a CSS var
		// resolving to the global shadow token, with the token's current
		// composed layers baked as the fallback. Mirrors JS
		// `layerToCss` token branch byte-for-byte. The `:root` var is
		// emitted unconditionally by Blocksify_Shadows on wp_head, so the
		// fallback is the safety net for a context that lacks it.
		if ( isset( $layer['token'] ) && is_string( $layer['token'] ) && '' !== $layer['token'] ) {
			$slug         = $layer['token'];
			$token_layers = class_exists( 'Blocksify_Shadows' )
				? Blocksify_Shadows::get_token_layers( $slug )
				: null;
			$fallback     = is_array( $token_layers ) ? self::build_shadow_value( $token_layers ) : '';
			return 'var(--wp--preset--shadow--' . $slug
				. ( '' !== $fallback ? ', ' . $fallback : '' ) . ')';
		}
		$x      = isset( $layer['x'] ) ? (float) $layer['x'] : 0;
		$y      = isset( $layer['y'] ) ? (float) $layer['y'] : 0;
		$blur   = isset( $layer['blur'] ) ? (float) $layer['blur'] : 0;
		$spread = isset( $layer['spread'] ) ? (float) $layer['spread'] : 0;
		$color  = ! empty( $layer['color'] ) ? $layer['color'] : 'rgba(0,0,0,0.1)';
		$inset  = ! empty( $layer['inset'] ) ? 'inset ' : '';
		$unit   = ! empty( $layer['unit'] ) ? $layer['unit'] : 'px';
		return $inset
			. self::num( $x ) . $unit . ' '
			. self::num( $y ) . $unit . ' '
			. self::num( $blur ) . $unit . ' '
			. self::num( $spread ) . $unit . ' '
			. $color;
	}

	/**
	 * Stringify a number, dropping trailing `.0` on whole-number floats
	 * so the serialized CSS reads `4px` not `4.0px`. Matches JS
	 * template-literal output.
	 *
	 * @param float $value
	 * @return string
	 */
	protected static function num( $value ) {
		if ( (float) (int) $value === (float) $value ) {
			return (string) (int) $value;
		}
		return (string) $value;
	}
}
