<?php
/**
 * Typography trait — PHP CSS emit. Owns fontFamily / fontWeight /
 * fontStyle / fontSize / lineHeight / letterSpacing / textTransform /
 * textDecoration / textAlign emission for any block declaring
 * `"blocksifySupports": [..., "typography"]`.
 *
 * Output contract:
 *   - `::emit_decls( $attrs, $device )` returns wrapper-rule typography
 *     decls. Caller (`emit_wrapper_rules` in `Blocksify_CSS_Generator`)
 *     appends to its in-progress decl string for the wrapper rule.
 *
 * Mixed-shape emission (mirrors JS `buildTypographyDecls` byte-for-byte):
 *   - Desktop: emit all 9 fields (6 single + 3 responsive at desktop key).
 *   - Tablet / Mobile: emit only the 3 responsive fields when the
 *     corresponding suffix key holds a meaningful value. Single fields
 *     apply uniformly via the desktop rule; emitting them again per
 *     device would be redundant.
 *
 * Slug-based font-family — `font-family: var(--wp--preset--font-family--
 * {slug}, inherit);` so theme swaps with matching slugs migrate
 * gracefully and missing slugs fall back to inherit.
 *
 * No hover layer (per design — typography is not hover-aware). No
 * `emit_hover_decls()` method; `Blocksify_CSS_Generator::emit_hover_
 * wrapper_rules` does not call into this class.
 *
 * @package Blocksify
 */

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

class Blocksify_Typography_Trait {

	/**
	 * CSS generic font-family slugs — emit raw keyword (`sans-serif`)
	 * instead of `var(--wp--preset--font-family--{slug}, inherit)` since
	 * these are CSS-native generics, not theme presets. Mirrors the JS
	 * `GENERIC_FONT_SLUGS` constant in
	 * `src/shared/controls/TypographyControl/helpers.js` + the same list
	 * in `css.js` — three sources must stay in sync (picker offers them
	 * as System options; CSS emit branches on them).
	 */
	const GENERIC_FONT_SLUGS = array(
		'system-ui',
		'sans-serif',
		'serif',
		'monospace',
	);

	/**
	 * Attribute schema fragment. Mirror of `src/shared/traits/typography.js`
	 * `attributes` const. See spacing trait header for rationale + sync rule.
	 *
	 * Mixed-shape: 6 single-value (apply uniformly across devices) + 3
	 * responsive flat-suffix (`fontSize` / `lineHeight` / `textAlign` × 3).
	 */
	public static function attributes() {
		return array(
			// Single-value (no per-device variant)
			'fontFamily'         => array( 'type' => array( 'string', 'null' ) ),
			'fontWeight'         => array( 'type' => array( 'string', 'null' ) ),
			'fontStyle'          => array( 'type' => array( 'string', 'null' ) ),
			'letterSpacing'      => array( 'type' => array( 'object', 'null' ) ),
			'textTransform'      => array( 'type' => array( 'string', 'null' ) ),
			'textDecoration'     => array( 'type' => array( 'string', 'null' ) ),
			// Responsive flat-suffix (Desktop / Tablet / Mobile)
			'fontSize'           => array( 'type' => array( 'object', 'null' ) ),
			'fontSizeTablet'     => array( 'type' => array( 'object', 'null' ) ),
			'fontSizeMobile'     => array( 'type' => array( 'object', 'null' ) ),
			'lineHeight'         => array( 'type' => array( 'object', 'null' ) ),
			'lineHeightTablet'   => array( 'type' => array( 'object', 'null' ) ),
			'lineHeightMobile'   => array( 'type' => array( 'object', 'null' ) ),
			'textAlign'          => array( 'type' => array( 'string', 'null' ) ),
			'textAlignTablet'    => array( 'type' => array( 'string', 'null' ) ),
			'textAlignMobile'    => array( 'type' => array( 'string', 'null' ) ),
		);
	}

	/**
	 * Wrapper-rule typography decls for one device.
	 *
	 * @param array  $attrs       Block attributes (flat-suffix shape).
	 * @param string $device      'desktop' | 'tablet' | 'mobile'.
	 * @param string $part_prefix Optional part name (SPEC-trait-parts §3.3).
	 * @return string Decl string, no selector / braces.
	 */
	public static function emit_decls( $attrs, $device, $part_prefix = '' ) {
		if ( 'desktop' === $device ) {
			return self::build_decls( self::collect_full_bag( $attrs, $part_prefix ), 'all' );
		}
		// Tablet / mobile — only 3 responsive fields, when set on this device.
		return self::build_decls(
			self::collect_responsive_bag( $attrs, $device, $part_prefix ),
			'responsive'
		);
	}

	/**
	 * Collect all 9 fields' desktop-base values for the base wrapper rule.
	 */
	protected static function collect_full_bag( $attrs, $part_prefix = '' ) {
		return array(
			'fontFamily'     => self::str( $attrs, self::pkey( $part_prefix, 'fontFamily' ) ),
			'fontWeight'     => self::str( $attrs, self::pkey( $part_prefix, 'fontWeight' ) ),
			'fontStyle'      => self::str( $attrs, self::pkey( $part_prefix, 'fontStyle' ) ),
			'fontSize'       => self::dim( $attrs, self::pkey( $part_prefix, 'fontSize' ) ),
			'lineHeight'     => self::dim( $attrs, self::pkey( $part_prefix, 'lineHeight' ) ),
			'letterSpacing'  => self::dim( $attrs, self::pkey( $part_prefix, 'letterSpacing' ) ),
			'textTransform'  => self::str( $attrs, self::pkey( $part_prefix, 'textTransform' ) ),
			'textDecoration' => self::str( $attrs, self::pkey( $part_prefix, 'textDecoration' ) ),
			'textAlign'      => self::str( $attrs, self::pkey( $part_prefix, 'textAlign' ) ),
		);
	}

	/**
	 * Collect ONLY the 3 responsive fields' device-specific values. Single
	 * fields are emitted on the base rule and don't appear here.
	 */
	protected static function collect_responsive_bag( $attrs, $device, $part_prefix = '' ) {
		$key_size = Blocksify_Attrs::part_key( $part_prefix, 'fontSize', $device );
		$key_line = Blocksify_Attrs::part_key( $part_prefix, 'lineHeight', $device );
		$key_alig = Blocksify_Attrs::part_key( $part_prefix, 'textAlign', $device );
		return array(
			'fontSize'   => self::raw_dim( isset( $attrs[ $key_size ] ) ? $attrs[ $key_size ] : null ),
			'lineHeight' => self::raw_dim( isset( $attrs[ $key_line ] ) ? $attrs[ $key_line ] : null ),
			'textAlign'  => self::raw_str( isset( $attrs[ $key_alig ] ) ? $attrs[ $key_alig ] : null ),
		);
	}

	/**
	 * Compose the part-prefixed bare base name (no device suffix). Used
	 * by `collect_full_bag` since typography's 6 single-value fields
	 * apply at desktop-only — `Blocksify_Attrs::part_key` would append a
	 * device suffix unnecessarily.
	 */
	protected static function pkey( $part_prefix, $base ) {
		return '' === $part_prefix ? $base : $part_prefix . ucfirst( $base );
	}

	/**
	 * Build the typography decl string from a resolved bag. Mirrors JS
	 * `buildTypographyDecls` byte-for-byte. `$subset` toggles between
	 * "all" (full 9 fields) and "responsive" (3 responsive only).
	 */
	protected static function build_decls( $bag, $subset = 'all' ) {
		if ( ! is_array( $bag ) ) {
			return '';
		}
		$decls = '';

		if ( 'all' === $subset ) {
			$family = isset( $bag['fontFamily'] ) ? $bag['fontFamily'] : null;
			if ( is_string( $family ) && '' !== $family ) {
				$slug = self::sanitize_slug( $family );
				if ( '' !== $slug ) {
					$decls .= 'font-family:' . self::format_font_family( $slug ) . ';';
				}
			}
			$weight = isset( $bag['fontWeight'] ) ? $bag['fontWeight'] : null;
			if ( is_string( $weight ) && '' !== $weight ) {
				$sanitized = self::sanitize_weight( $weight );
				if ( '' !== $sanitized ) {
					$decls .= 'font-weight:' . $sanitized . ';';
				}
			}
			$style = isset( $bag['fontStyle'] ) ? $bag['fontStyle'] : null;
			if ( is_string( $style ) && '' !== $style ) {
				$sanitized = self::sanitize_enum( $style, array( 'normal', 'italic' ) );
				if ( '' !== $sanitized ) {
					$decls .= 'font-style:' . $sanitized . ';';
				}
			}
		}

		$size_str = self::format_font_size( isset( $bag['fontSize'] ) ? $bag['fontSize'] : null );
		if ( '' !== $size_str ) {
			$decls .= 'font-size:' . $size_str . ';';
		}
		$line_str = self::format_dimension( isset( $bag['lineHeight'] ) ? $bag['lineHeight'] : null );
		if ( '' !== $line_str ) {
			$decls .= 'line-height:' . $line_str . ';';
		}

		if ( 'all' === $subset ) {
			$letter_str = self::format_dimension( isset( $bag['letterSpacing'] ) ? $bag['letterSpacing'] : null );
			if ( '' !== $letter_str ) {
				$decls .= 'letter-spacing:' . $letter_str . ';';
			}
			$transform = isset( $bag['textTransform'] ) ? $bag['textTransform'] : null;
			if ( is_string( $transform ) && '' !== $transform ) {
				$sanitized = self::sanitize_enum(
					$transform,
					array( 'none', 'capitalize', 'uppercase', 'lowercase' )
				);
				if ( '' !== $sanitized ) {
					$decls .= 'text-transform:' . $sanitized . ';';
				}
			}
			$decoration = isset( $bag['textDecoration'] ) ? $bag['textDecoration'] : null;
			if ( is_string( $decoration ) && '' !== $decoration ) {
				$sanitized = self::sanitize_enum(
					$decoration,
					array( 'none', 'underline', 'line-through' )
				);
				if ( '' !== $sanitized ) {
					$decls .= 'text-decoration:' . $sanitized . ';';
				}
			}
		}

		$align = isset( $bag['textAlign'] ) ? $bag['textAlign'] : null;
		if ( is_string( $align ) && '' !== $align ) {
			$sanitized = self::sanitize_enum(
				$align,
				array( 'left', 'center', 'right', 'justify' )
			);
			if ( '' !== $sanitized ) {
				$decls .= 'text-align:' . $sanitized . ';';
			}
		}

		return $decls;
	}

	/**
	 * Format a `{ value, unit }` dimension array into a CSS dimension
	 * string. Empty value returns ''. Empty unit returns the bare number
	 * (line-height unitless mode).
	 */
	protected static function format_dimension( $dim ) {
		if ( ! is_array( $dim ) ) {
			return '';
		}
		if ( ! isset( $dim['value'] ) || is_null( $dim['value'] ) ) {
			return '';
		}
		$value = $dim['value'];
		if ( ! is_numeric( $value ) ) {
			return '';
		}
		$unit = isset( $dim['unit'] ) && is_string( $dim['unit'] ) ? $dim['unit'] : '';
		// Constrain unit to known typography units to prevent injection via
		// a malformed bag.
		$allowed = array( 'px', 'em', 'rem', '%', 'vw', 'vh' );
		if ( '' !== $unit && ! in_array( $unit, $allowed, true ) ) {
			$unit = '';
		}
		return (string) $value . $unit;
	}

	/**
	 * Format a font-size value (literal or Mode B token reference) into
	 * a CSS dimension string. Token form emits
	 * `var(--blocksify--font-size--{slug}, fallback)` so Global Font
	 * Size Presets edits cascade via the CSS-var resolution chain.
	 * Literal form delegates to `format_dimension`.
	 */
	protected static function format_font_size( $dim ) {
		if (
			is_array( $dim )
			&& isset( $dim['token'] )
			&& is_string( $dim['token'] )
			&& '' !== $dim['token']
		) {
			return Blocksify_Tokens::var_expression( 'font-size', $dim['token'] );
		}
		return self::format_dimension( $dim );
	}

	/**
	 * Sanitize a font-family slug. WP preset slug convention: lowercase
	 * a-z 0-9 hyphens.
	 */
	protected static function sanitize_slug( $slug ) {
		return preg_replace( '/[^a-z0-9-]/i', '', strtolower( $slug ) );
	}

	/**
	 * Format a font-family slug into its CSS value. Generic slugs emit
	 * raw keywords; preset slugs emit `var(--wp--preset--font-family--
	 * {slug}, inherit)`.
	 */
	protected static function format_font_family( $slug ) {
		if ( in_array( $slug, self::GENERIC_FONT_SLUGS, true ) ) {
			return $slug;
		}
		return 'var(--wp--preset--font-family--' . $slug . ',inherit)';
	}

	/**
	 * Sanitize a font-weight to numeric string 100..900 (step 100).
	 */
	protected static function sanitize_weight( $weight ) {
		$allowed = array( '100', '200', '300', '400', '500', '600', '700', '800', '900' );
		return in_array( $weight, $allowed, true ) ? $weight : '';
	}

	/**
	 * Validate an enum value against an allowed list.
	 */
	protected static function sanitize_enum( $value, $allowed ) {
		return in_array( $value, $allowed, true ) ? $value : '';
	}

	/**
	 * Read a string-valued attr; returns null when missing / non-string.
	 */
	protected static function str( $attrs, $key ) {
		if ( ! isset( $attrs[ $key ] ) || ! is_string( $attrs[ $key ] ) ) {
			return null;
		}
		return $attrs[ $key ];
	}

	/**
	 * Read a `{value, unit}` dimension attr; returns null when missing /
	 * not an array.
	 */
	protected static function dim( $attrs, $key ) {
		return self::raw_dim( isset( $attrs[ $key ] ) ? $attrs[ $key ] : null );
	}

	/**
	 * Coerce a raw value into the dimension shape (or null).
	 */
	protected static function raw_dim( $val ) {
		if ( ! is_array( $val ) ) {
			return null;
		}
		return $val;
	}

	/**
	 * Coerce a raw value into a string (or null).
	 */
	protected static function raw_str( $val ) {
		if ( ! is_string( $val ) ) {
			return null;
		}
		return $val;
	}
}
