<?php
/**
 * TitanCart Image Set (core, 0.2.27).
 *
 * The `image_set` settings field type: ONE control that art-directs a picture
 * per device — desktop / tablet / phone each take their own image and crop
 * anchor — and the read side that turns the stored value into a `<picture>`
 * plus the CSS custom properties a stylesheet swaps per breakpoint. Themes
 * used to model this as three `image` fields + three `select`s per surface
 * (the All in a Basket hero, S478); this class is that pattern made reusable
 * so any theme surface (hero, promo band, category banner) gets it by
 * declaring `'type' => 'image_set'` and calling titancart_image_set().
 *
 * Stored shape (compact JSON in the settings row, '' when every image is blank):
 *   {"desktop":{"url":"…","focus":"center"},
 *    "tablet": {"url":"…","focus":"top"},
 *    "phone":  {"url":"","focus":"center"}}
 *
 * A plain URL string is ALSO a valid stored value: it is what a field held
 * while it was still type 'image', and it reads as "desktop image, default
 * focus" — so a theme can change a field's type in place with no migration.
 *
 * Cascade (read side): a blank phone image falls back to the tablet one, a
 * blank tablet image to the desktop one, so one upload still renders on every
 * device; the crop anchor cascades the same way. Breakpoints match the
 * All in a Basket reference and the Prism hero block: phone ≤ 767, tablet
 * 768–1199, desktop ≥ 1200.
 *
 * Engine-agnostic: sanitize() / resolve() are pure PHP (Rule 4 — the future
 * standalone build keeps them verbatim). Only picture() / css_vars() touch the
 * WordPress escaping helpers, and only because they emit HTML.
 *
 * @package TitanCart\Storefront
 */

namespace TitanCart\Storefront;

defined( 'ABSPATH' ) || exit;

class ImageSet {

	/** Device slots, largest first — the cascade walks this order. */
	public const DEVICES = [ 'desktop', 'tablet', 'phone' ];

	/** 3×3 crop anchors (CSS object-position / background-position keywords). */
	public const FOCUS = [
		'top-left'     => 'left top',
		'top'          => 'center top',
		'top-right'    => 'right top',
		'left'         => 'left center',
		'center'       => 'center center',
		'right'        => 'right center',
		'bottom-left'  => 'left bottom',
		'bottom'       => 'center bottom',
		'bottom-right' => 'right bottom',
	];

	/** Widest viewport (px) that counts as a phone / a tablet. */
	public const PHONE_MAX  = 767;
	public const TABLET_MAX = 1199;

	/* ======================================================================
	 * WRITE SIDE — SettingsController::sanitize_value( … 'image_set' )
	 * ====================================================================== */

	/**
	 * Normalise a submitted value to the canonical stored string.
	 *
	 * Accepts the JSON string the admin SPA sends, an already-decoded array,
	 * or a plain URL (legacy 'image' value). Unknown focus keys reset to
	 * 'center'; URLs are run through esc_url_raw(). Returns '' when no device
	 * carries an image, so "cleared" reads the same as "never set".
	 *
	 * @param  mixed  $value
	 * @param  string $focus_default KI-127 (0.2.28): the field's declared
	 *                historical crop anchor (a FOCUS key, e.g. Prism banner =
	 *                'right'). Applied ONLY when $value is a plain URL — a legacy
	 *                'image' value the client passed through untouched carries no
	 *                anchor of its own, and persisting the generic 'center' would
	 *                re-anchor the storefront on a no-change save. JSON keeps its
	 *                own anchors; '' / unknown keys leave the default at 'center'.
	 * @return string Compact JSON, or ''.
	 */
	public static function sanitize( $value, string $focus_default = '' ): string {
		$set = self::parse( $value );

		if (
			is_string( $value )
			&& '' !== trim( $value )
			&& '{' !== substr( trim( $value ), 0, 1 )
			&& isset( self::FOCUS[ $focus_default ] )
		) {
			$set['desktop']['focus'] = $focus_default;
		}

		$any = false;
		foreach ( self::DEVICES as $d ) {
			if ( $set[ $d ]['url'] !== '' ) {
				$any = true;
				break;
			}
		}
		if ( ! $any ) {
			return '';
		}

		// 0.2.30 (KI-139): persist `inherit:false` ONLY on a blank slot with an
		// explicit anchor; every other slot stores {url,focus} exactly as before
		// (a slot with its own image has nothing to inherit).
		foreach ( self::DEVICES as $d ) {
			if ( '' === $set[ $d ]['url'] && empty( $set[ $d ]['inherit'] ) ) {
				$set[ $d ]['inherit'] = false;
			} else {
				unset( $set[ $d ]['inherit'] );
			}
		}

		return (string) wp_json_encode( $set, JSON_UNESCAPED_SLASHES );
	}

	/* ======================================================================
	 * READ SIDE — titancart_image_set() and friends
	 * ====================================================================== */

	/**
	 * Decode a stored value into the raw per-device map (NO cascade).
	 *
	 * @param  mixed $value JSON string | plain URL | array | null.
	 * @return array{desktop:array{url:string,focus:string},tablet:array{url:string,focus:string},phone:array{url:string,focus:string}}
	 */
	public static function parse( $value ): array {
		$out = [];
		foreach ( self::DEVICES as $d ) {
			$out[ $d ] = [ 'url' => '', 'focus' => 'center', 'inherit' => true ];
		}

		if ( is_string( $value ) ) {
			$value = trim( $value );
			if ( $value === '' ) {
				return $out;
			}
			if ( $value[0] === '{' ) {
				$decoded = json_decode( $value, true );
				if ( ! is_array( $decoded ) ) {
					return $out;
				}
				$value = $decoded;
			} else {
				// Legacy plain URL = the desktop image.
				$out['desktop']['url'] = self::clean_url( $value );
				return $out;
			}
		}

		if ( ! is_array( $value ) ) {
			return $out;
		}

		foreach ( self::DEVICES as $d ) {
			$slot = $value[ $d ] ?? null;
			if ( is_string( $slot ) ) {
				// Tolerate {"desktop":"url"} as shorthand.
				$slot = [ 'url' => $slot ];
			}
			if ( ! is_array( $slot ) ) {
				continue;
			}
			$out[ $d ]['url']   = self::clean_url( (string) ( $slot['url'] ?? '' ) );
			$focus              = (string) ( $slot['focus'] ?? 'center' );
			$out[ $d ]['focus'] = isset( self::FOCUS[ $focus ] ) ? $focus : 'center';
			// 0.2.30 (KI-139): a blank slot follows the larger device's anchor
			// UNLESS the owner explicitly chose one — stored as `inherit:false`.
			// Absent key (every value saved before 0.2.30) = follow, so nothing
			// stored changes meaning. Slots with their own image ignore the flag.
			$out[ $d ]['inherit'] = ! ( isset( $slot['inherit'] ) && false === $slot['inherit'] );
		}

		return $out;
	}

	/**
	 * Decode + cascade: the shape a template wants.
	 *
	 * Each device slot is guaranteed a `url` (possibly '') and a `position`
	 * (CSS keyword pair). A blank slot inherits the next larger device's image
	 * AND anchor; the desktop slot's anchor falls back to $args['focus'] when
	 * the stored value carries none (a legacy URL), so a theme keeps its
	 * designed default crop (Prism's banner mode anchors right).
	 *
	 * @param  mixed $value Stored value.
	 * @param  array $args  { focus?: string  default anchor key when unset (default 'center') }
	 * @return array{has:bool,desktop:array,tablet:array,phone:array}
	 */
	public static function resolve( $value, array $args = [] ): array {
		$raw     = self::parse( $value );
		$default = (string) ( $args['focus'] ?? 'center' );
		if ( ! isset( self::FOCUS[ $default ] ) ) {
			$default = 'center';
		}

		// A legacy plain-URL value carries no focus at all — apply the theme's
		// designed default rather than the class default. Detect it by shape:
		// parse() only ever leaves 'center' on a legacy string.
		$legacy = is_string( $value ) && trim( $value ) !== '' && trim( $value )[0] !== '{';

		$set = [ 'has' => false ];
		$prev = null;
		foreach ( self::DEVICES as $d ) {
			$slot = $raw[ $d ];
			if ( $slot['url'] === '' ) {
				if ( $prev !== null ) {
					// 0.2.30 (KI-139): inherit the larger device's IMAGE always;
					// inherit its ANCHOR only when this slot has no explicit one.
					$own_focus = $slot['focus'];
					$explicit  = empty( $slot['inherit'] );
					$slot      = $prev;
					if ( $explicit ) {
						$slot['focus'] = $own_focus;
					}
				}
			} elseif ( $d === 'desktop' && $legacy ) {
				$slot['focus'] = $default;
			}
			$slot['position'] = self::FOCUS[ $slot['focus'] ] ?? self::FOCUS['center'];
			$set[ $d ] = $slot;
			if ( $slot['url'] !== '' ) {
				$set['has'] = true;
			}
			$prev = $slot;
		}

		// A desktop-less set (only tablet/phone filled) still needs a desktop
		// image: use the largest device that has one.
		if ( $set['desktop']['url'] === '' ) {
			foreach ( [ 'tablet', 'phone' ] as $d ) {
				if ( $set[ $d ]['url'] !== '' ) {
					$set['desktop'] = $set[ $d ];
					break;
				}
			}
		}

		return $set;
	}

	/**
	 * Render the resolved set as a `<picture>`: one `<source>` per device whose
	 * image differs from the next larger one (so the browser downloads exactly
	 * one file), then the desktop `<img>`. Returns '' when the set is empty.
	 *
	 * @param  array $set   From resolve().
	 * @param  array $attrs `<img>` attributes (alt, class, loading, fetchpriority,
	 *                      decoding, width, height …). `picture_class` is lifted
	 *                      onto the `<picture>` element.
	 * @return string HTML (fully escaped).
	 */
	public static function picture( array $set, array $attrs = [] ): string {
		if ( empty( $set['has'] ) || empty( $set['desktop']['url'] ) ) {
			return '';
		}

		$picture_class = (string) ( $attrs['picture_class'] ?? '' );
		unset( $attrs['picture_class'] );

		$html = '<picture' . ( $picture_class !== '' ? ' class="' . esc_attr( $picture_class ) . '"' : '' ) . '>';

		$phone  = $set['phone']['url']  ?? '';
		$tablet = $set['tablet']['url'] ?? '';
		$desk   = $set['desktop']['url'];

		if ( $phone !== '' && $phone !== $tablet ) {
			$html .= '<source media="(max-width: ' . self::PHONE_MAX . 'px)" srcset="' . esc_url( $phone ) . '">';
		}
		if ( $tablet !== '' && $tablet !== $desk ) {
			$html .= '<source media="(max-width: ' . self::TABLET_MAX . 'px)" srcset="' . esc_url( $tablet ) . '">';
		}

		$attrs = array_merge( [ 'alt' => '', 'decoding' => 'async' ], $attrs );
		$attrs['src'] = $desk;

		$html .= '<img';
		foreach ( $attrs as $k => $v ) {
			$k = (string) $k;
			if ( ! preg_match( '/^[a-z][a-z0-9\-]*$/', $k ) || $v === null || $v === false ) {
				continue;
			}
			$html .= ' ' . $k . '="' . ( $k === 'src' ? esc_url( (string) $v ) : esc_attr( (string) $v ) ) . '"';
		}
		$html .= '>';

		return $html . '</picture>';
	}

	/**
	 * Inline custom properties for the per-breakpoint crop anchor:
	 *   --{prefix}-pos-d:right center;--{prefix}-pos-t:…;--{prefix}-pos-p:…;
	 * A stylesheet then does, per breakpoint,
	 *   object-position: var(--{prefix}-pos, var(--{prefix}-pos-d));
	 * (see Prism theme.css "Theme Options — homepage hero image").
	 *
	 * @param  array  $set    From resolve().
	 * @param  string $prefix CSS custom-property prefix (no leading dashes).
	 * @return string Safe for a style="" attribute (keyword pairs only).
	 */
	public static function css_vars( array $set, string $prefix ): string {
		$prefix = preg_replace( '/[^a-z0-9\-]/', '', strtolower( $prefix ) );
		if ( $prefix === '' ) {
			$prefix = 'tc-img';
		}
		$map = [ 'desktop' => 'd', 'tablet' => 't', 'phone' => 'p' ];
		$css = '';
		foreach ( $map as $device => $suffix ) {
			$pos  = (string) ( $set[ $device ]['position'] ?? self::FOCUS['center'] );
			$css .= '--' . $prefix . '-pos-' . $suffix . ':' . $pos . ';';
		}
		return $css;
	}

	/**
	 * Per-device background-image CSS for surfaces that paint the picture as a
	 * background rather than an `<img>` (Indigo's hero). Emits three rules for
	 * the given selector, largest device first, so the cascade is explicit.
	 *
	 * @param  array  $set      From resolve().
	 * @param  string $selector Full CSS selector (already trusted — theme-authored).
	 * @return string CSS text (URLs escaped), '' when the set is empty.
	 */
	public static function background_css( array $set, string $selector ): string {
		if ( empty( $set['has'] ) ) {
			return '';
		}
		$rule = static function ( array $slot ) use ( $selector ): string {
			return $selector . '{background-image:url(\'' . esc_url( $slot['url'] ) . '\') !important;background-position:' . $slot['position'] . ' !important;}';
		};
		$css  = $rule( $set['desktop'] );
		$css .= '@media (max-width: ' . self::TABLET_MAX . 'px){' . $rule( $set['tablet'] ) . '}';
		$css .= '@media (max-width: ' . self::PHONE_MAX . 'px){' . $rule( $set['phone'] ) . '}';
		return $css;
	}

	/* ======================================================================
	 * INTERNAL
	 * ====================================================================== */

	private static function clean_url( string $url ): string {
		$url = trim( $url );
		if ( $url === '' ) {
			return '';
		}
		// esc_url_raw() keeps the value storable/renderable; a value it rejects
		// (javascript:, data: …) comes back '' and the slot reads as blank.
		return function_exists( 'esc_url_raw' ) ? (string) esc_url_raw( $url ) : filter_var( $url, FILTER_SANITIZE_URL );
	}
}
