<?php
/**
 * Spacing trait — PHP CSS emit. Owns padding + margin emission for any
 * block declaring `"blocksifySupports": [..., "spacing"]`. Mirrors JS
 * `buildSpacingDecls` in `src/shared/controls/SpacingControl/css.js`
 * byte-for-byte.
 *
 * Output contract:
 *   - `::emit_decls( $attrs, $device )` returns wrapper-rule spacing
 *     decls (padding + margin shorthand or longhand). Caller
 *     (`Blocksify_CSS_Generator::emit_wrapper_rules`) emits the rule on
 *     the doubled-class selector for specificity bump (see preview-css.js
 *     header — beats Gutenberg's `.wp-block { margin:auto }` editor rule).
 *   - `::emit_hover_decls( $attrs )` returns hover-layer spacing decls
 *     read from `*Hover` keys.
 *
 * Two emission modes per prop:
 *   - All 4 sides set → shorthand (`margin:20px 80px;`).
 *   - Partial set (some sides null) → longhand for set sides only
 *     (`margin-top:20px;margin-bottom:20px;`). Null sides cascade
 *     naturally — Gutenberg's `.wp-block { margin:auto }` keeps
 *     auto-centering in editor when only vertical margin is set;
 *     theme's block-spacing governs null sides on frontend.
 *
 * Margin horizontal-zero treatment: for `margin`, `value:0` on `left` /
 * `right` is normalized to null BEFORE emit. Setting `margin-left: 0`
 * would override Gutenberg's auto-center and stick the section to the
 * canvas left edge — almost never user intent. Vertical 0 stays
 * meaningful. Padding is unaffected.
 *
 * @package Blocksify
 */

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

class Blocksify_Spacing_Trait {

	/**
	 * Attribute schema fragment. Mirror of `src/shared/traits/spacing.js`
	 * `attributes` const. Read by `Blocksify_Traits::capture_supports()` to
	 * inject into block metadata at registration time (Phase 7.1 schema
	 * merge — block.json no longer needs to enumerate trait attrs).
	 *
	 * KEEP IN SYNC with the JS trait file. Codegen JS→PHP is the next step
	 * (PHASES.md §7.1) — until then, hand-edit BOTH sides on any schema
	 * change.
	 */
	public static function attributes() {
		return array(
			'padding'        => array( 'type' => array( 'object', 'null' ) ),
			'paddingTablet'  => array( 'type' => array( 'object', 'null' ) ),
			'paddingMobile'  => array( 'type' => array( 'object', 'null' ) ),
			'paddingHover'   => array( 'type' => array( 'object', 'null' ) ),
			'margin'         => array( 'type' => array( 'object', 'null' ) ),
			'marginTablet'   => array( 'type' => array( 'object', 'null' ) ),
			'marginMobile'   => array( 'type' => array( 'object', 'null' ) ),
			'marginHover'    => array( 'type' => array( 'object', 'null' ) ),
		);
	}

	/**
	 * Wrapper-rule spacing 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 = '' ) {
		return self::build_decls( self::collect_bag( $attrs, $device, $part_prefix ) );
	}

	/**
	 * Hover-layer spacing decls (reads `paddingHover` + `marginHover`).
	 *
	 * @param string $part_prefix Optional part name (SPEC-trait-parts §3.3).
	 */
	public static function emit_hover_decls( $attrs, $part_prefix = '' ) {
		$padding_key = Blocksify_Attrs::part_hover_key( $part_prefix, 'padding' );
		$margin_key  = Blocksify_Attrs::part_hover_key( $part_prefix, 'margin' );
		return self::build_decls(
			array(
				'padding' => isset( $attrs[ $padding_key ] ) ? $attrs[ $padding_key ] : null,
				'margin'  => isset( $attrs[ $margin_key ] ) ? $attrs[ $margin_key ] : null,
			)
		);
	}

	/**
	 * Resolve the padding + margin bag for one device.
	 */
	protected static function collect_bag( $attrs, $device, $part_prefix = '' ) {
		$padding_key = Blocksify_Attrs::part_key( $part_prefix, 'padding', $device );
		$margin_key  = Blocksify_Attrs::part_key( $part_prefix, 'margin', $device );
		return array(
			'padding' => isset( $attrs[ $padding_key ] ) ? $attrs[ $padding_key ] : null,
			'margin'  => isset( $attrs[ $margin_key ] ) ? $attrs[ $margin_key ] : null,
		);
	}

	/**
	 * Build the spacing decl string (padding + margin) for one state.
	 */
	protected static function build_decls( $bag ) {
		if ( ! is_array( $bag ) ) {
			return '';
		}
		$decls = '';
		if ( ! empty( $bag['padding'] ) && is_array( $bag['padding'] ) ) {
			$decls .= self::build_prop_decls( 'padding', self::normalize( $bag['padding'] ) );
		}
		if ( ! empty( $bag['margin'] ) && is_array( $bag['margin'] ) ) {
			$decls .= self::build_prop_decls( 'margin', self::normalize( $bag['margin'] ) );
		}
		return $decls;
	}

	/**
	 * Emit a single prop's decls. All-4-set → shorthand; partial set →
	 * longhand for set sides only.
	 */
	protected static function build_prop_decls( $prop, $value ) {
		$sides     = array( 'top', 'right', 'bottom', 'left' );
		$effective = $value;
		if ( 'margin' === $prop ) {
			if ( self::is_zero_side( isset( $value['left'] ) ? $value['left'] : null ) ) {
				$effective['left'] = null;
			}
			if ( self::is_zero_side( isset( $value['right'] ) ? $value['right'] : null ) ) {
				$effective['right'] = null;
			}
		}

		$all_set = true;
		foreach ( $sides as $s ) {
			if ( ! isset( $effective[ $s ] ) || null === $effective[ $s ] ) {
				$all_set = false;
				break;
			}
		}
		if ( $all_set ) {
			$shorthand = self::build_shorthand( $effective );
			return '' !== $shorthand ? $prop . ':' . $shorthand . ';' : '';
		}
		$decls = '';
		foreach ( $sides as $s ) {
			if ( ! isset( $effective[ $s ] ) || null === $effective[ $s ] ) {
				continue;
			}
			$formatted = self::format_side( $effective[ $s ] );
			if ( '' !== $formatted ) {
				$decls .= $prop . '-' . $s . ':' . $formatted . ';';
			}
		}
		return $decls;
	}

	protected static function is_zero_side( $side ) {
		return is_array( $side )
			&& isset( $side['value'] )
			&& is_numeric( $side['value'] )
			&& 0.0 === (float) $side['value'];
	}

	/**
	 * Normalize a stored spacing value into the full default-shaped
	 * array. Tolerates partial / corrupted shapes. Mirrors JS
	 * `normalizeSpacing` in `SpacingControl/helpers.js`.
	 */
	protected static function normalize( $stored ) {
		if ( ! is_array( $stored ) ) {
			return array(
				'top'    => null,
				'right'  => null,
				'bottom' => null,
				'left'   => null,
				'mode'   => 'linked',
			);
		}
		$mode = isset( $stored['mode'] ) && 'per-side' === $stored['mode']
			? 'per-side'
			: 'linked';
		return array(
			'top'    => self::normalize_side( isset( $stored['top'] ) ? $stored['top'] : null ),
			'right'  => self::normalize_side( isset( $stored['right'] ) ? $stored['right'] : null ),
			'bottom' => self::normalize_side( isset( $stored['bottom'] ) ? $stored['bottom'] : null ),
			'left'   => self::normalize_side( isset( $stored['left'] ) ? $stored['left'] : null ),
			'mode'   => $mode,
		);
	}

	protected static function normalize_side( $side ) {
		if ( null === $side ) {
			return null;
		}
		if ( is_numeric( $side ) ) {
			return array(
				'value' => (float) $side,
				'unit'  => 'px',
			);
		}
		if ( is_array( $side ) ) {
			// Mode B token reference: `{ token: 'sm' }` — preserve as-is
			// so `format_side` can emit `var(--blocksify--spacing--{slug},
			// fallback)`. Without this branch, token-shaped saved attrs
			// (block stored via PresetSlider's `emitToken=true`) would
			// hit the fallthrough `return null` and emit no CSS at all,
			// so frontend would render with NO padding/margin while
			// editor (which uses JS-side resolution) shows the correct
			// value.
			if (
				isset( $side['token'] )
				&& is_string( $side['token'] )
				&& '' !== $side['token']
			) {
				return array( 'token' => $side['token'] );
			}
			if (
				isset( $side['value'] )
				&& is_numeric( $side['value'] )
				&& isset( $side['unit'] )
				&& is_string( $side['unit'] )
			) {
				return array(
					'value' => (float) $side['value'],
					'unit'  => $side['unit'],
				);
			}
		}
		return null;
	}

	/**
	 * Compose the CSS shorthand for a 4-side spacing value. Mirrors JS
	 * `buildShorthand` in `SpacingControl/helpers.js`.
	 */
	protected static function build_shorthand( $value ) {
		$t = self::format_side( isset( $value['top'] ) ? $value['top'] : null );
		$r = self::format_side( isset( $value['right'] ) ? $value['right'] : null );
		$b = self::format_side( isset( $value['bottom'] ) ? $value['bottom'] : null );
		$l = self::format_side( isset( $value['left'] ) ? $value['left'] : null );

		if ( '' === $t && '' === $r && '' === $b && '' === $l ) {
			return '';
		}

		// Substitute '0' for null sides — CSS shorthand needs a value at
		// every position. `padding: 10px 0 10px 0` is correct for
		// "top + bottom only".
		$tt = '' === $t ? '0' : $t;
		$rr = '' === $r ? '0' : $r;
		$bb = '' === $b ? '0' : $b;
		$ll = '' === $l ? '0' : $l;

		if ( $tt === $rr && $rr === $bb && $bb === $ll ) {
			return $tt;
		}
		if ( $tt === $bb && $rr === $ll ) {
			return $tt . ' ' . $rr;
		}
		if ( $rr === $ll ) {
			return $tt . ' ' . $rr . ' ' . $bb;
		}
		return $tt . ' ' . $rr . ' ' . $bb . ' ' . $ll;
	}

	/**
	 * Format one side as a CSS length string. Empty when null. `0`
	 * doesn't include a unit (CSS treats unitless 0 as valid).
	 *
	 * Mode B token shape `{ token: 'sm' }` emits a `var()` reference
	 * with the literal value AT EMIT TIME as the fallback, so frontend
	 * renders correctly even before `:root` token declarations ship
	 * (and even if a token is later renamed/removed). When `:root`
	 * IS present, the var resolves to its current value, which is how
	 * Global Spacing Presets edits propagate to frontend.
	 */
	protected static function format_side( $side ) {
		if ( ! is_array( $side ) ) {
			return '';
		}
		if ( isset( $side['token'] ) && is_string( $side['token'] ) && '' !== $side['token'] ) {
			return Blocksify_Tokens::var_expression( 'spacing', $side['token'] );
		}
		if ( ! isset( $side['value'] ) ) {
			return '';
		}
		$num = (float) $side['value'];
		if ( 0.0 === $num ) {
			return '0';
		}
		$unit = isset( $side['unit'] ) && is_string( $side['unit'] ) && '' !== $side['unit']
			? $side['unit']
			: 'px';
		$num_str = ( $num == (int) $num ) ? (string) (int) $num : (string) $num;
		return $num_str . $unit;
	}
}
