<?php
/**
 * Input trait
 *
 * @package     AffiliateWP
 * @subpackage  Utils\Traits
 * @copyright   Copyright (c) 2026, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       2.33.0
 */

namespace AffiliateWP\Utils\Traits;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Input trait
 *
 * Renders text-style <input> and <textarea> elements styled to match the AffiliateWP
 * admin Tailwind aesthetic. Consolidates the `px-4 py-2 ... rounded-lg border ...` idiom
 * that was previously copy-pasted across the stripe/paypal/emails/fraud-prevention panels.
 *
 * @since 2.33.0
 */
trait Input {

	/**
	 * Render an <input> element.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args {
	 *     Input arguments.
	 *
	 *     @type string      $name         Input name attribute. Required.
	 *     @type string      $type         HTML input type. Default 'text'. Supports text, number,
	 *                                     email, url, password, tel, search.
	 *     @type string      $value        Current value.
	 *     @type string      $id           ID attribute. Derived from name when empty.
	 *     @type string      $placeholder  Placeholder text.
	 *     @type string      $size         'sm' (text-sm py-1.5) or 'md' (text-base py-2). Default 'md'.
	 *     @type string      $width        'full' (w-full), 'narrow' (w-24), or 'auto' (no width
	 *                                     utility, e.g. for flex children). Default 'full'.
	 *     @type bool        $mono         Render with a monospace font (useful for regex / keys).
	 *     @type bool        $secret       Credential-field hints: autocomplete="new-password",
	 *                                     data-lpignore, data-1p-ignore. Default false.
	 *     @type string      $autocomplete Explicit autocomplete value (overrides $secret's default).
	 *     @type string      $alpine_model Alpine x-model binding.
	 *     @type bool        $disabled     Whether the input is disabled.
	 *     @type bool        $required     Whether the input is required.
	 *     @type int|string  $min          Minimum value (number inputs).
	 *     @type int|string  $max          Maximum value (number inputs).
	 *     @type int|string  $step         Step value (number inputs).
	 *     @type string      $class        Extra classes on the <input>.
	 *     @type array       $attributes   Additional attributes (supports Alpine directives).
	 * }
	 * @return string The rendered input HTML.
	 */
	public function render_input( $args = array() ) {

		$args = $this->normalize_input_args( $args );

		if ( '' === $args['name'] ) {
			return '';
		}

		// Prefixed (e.g. currency) → input-group: bordered wrapper + muted prefix + borderless input.
		if ( '' !== (string) $args['prefix'] ) {
			return $this->render_prefixed_input( $args );
		}

		$attrs = $this->build_input_attributes( $args );

		return sprintf( '<input %s />', $attrs );
	}

	/**
	 * Render an input with a leading unit prefix (e.g. a currency symbol), matching the
	 * Pay Affiliates screen's minimum-earnings field: a bordered group carries the ring
	 * and focus state, the prefix is muted, and the input itself is borderless.
	 *
	 * @since 2.35.0
	 *
	 * @param array $args Normalized args (must include a non-empty `prefix`).
	 * @return string
	 */
	protected function render_prefixed_input( $args ) {

		// Width lives on the WRAPPER; the input flexes to fill it.
		$width_class = 'w-full';
		if ( 'narrow' === $args['width'] ) {
			$width_class = 'w-32';
		} elseif ( 'auto' === $args['width'] ) {
			$width_class = '';
		}

		$text_size   = ( 'sm' === $args['size'] ) ? 'text-sm' : 'text-base';
		$input_attrs = $this->build_input_attributes( $args, false, true );

		return sprintf(
			'<div class="flex items-stretch rounded-lg bg-white ring-1 ring-gray-300 transition-colors hover:ring-gray-400 focus-within:outline-2 focus-within:outline-affwp-brand-500 %1$s"><span class="flex items-center pl-3 %2$s text-gray-500 select-none whitespace-nowrap">%3$s</span><input %4$s /></div>',
			esc_attr( trim( $width_class ) ),
			esc_attr( $text_size ),
			wp_kses_post( $args['prefix'] ),
			$input_attrs
		);
	}

	/**
	 * Render a <textarea> element.
	 *
	 * Same styling vocabulary as render_input(); adds a `rows` argument.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args See render_input(). Additional keys:
	 *     @type int $rows Default 4.
	 * @return string The rendered textarea HTML.
	 */
	public function render_textarea( $args = array() ) {

		$args         = $this->normalize_input_args( $args );
		$args['rows'] = isset( $args['rows'] ) ? (int) $args['rows'] : 4;

		if ( '' === $args['name'] ) {
			return '';
		}

		$value  = isset( $args['value'] ) ? (string) $args['value'] : '';
		$attrs  = $this->build_input_attributes( $args, true );

		return sprintf(
			'<textarea %s rows="%d">%s</textarea>',
			$attrs,
			$args['rows'],
			esc_textarea( $value )
		);
	}

	/**
	 * Normalize shared input/textarea args.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args Raw caller args.
	 * @return array Normalized args.
	 */
	protected function normalize_input_args( $args ) {

		$defaults = array(
			'name'         => '',
			'type'         => 'text',
			'value'        => '',
			'id'           => '',
			'placeholder'  => '',
			'size'         => 'md',
			'width'        => 'full',
			'mono'         => false,
			'secret'       => false,
			'autocomplete' => '',
			'alpine_model' => '',
			'disabled'     => false,
			'required'     => false,
			'min'          => null,
			'max'          => null,
			'step'         => null,
			// Integer-only: block non-digit entry (typing + paste) up front so the value
			// can't become a decimal that trips the browser's step-validation bubble.
			'integer'      => false,
			'class'        => '',
			'attributes'   => array(),
			// Leading unit inside the field (e.g. a currency symbol). Renders the input as a
			// bordered group with a muted prefix and a borderless input. Trusted HTML (entities OK).
			'prefix'       => '',
		);

		$args = wp_parse_args( $args, $defaults );

		if ( empty( $args['id'] ) && ! empty( $args['name'] ) ) {
			$args['id'] = sanitize_key( $args['name'] );
		}

		return $args;
	}

	/**
	 * Build the shared class list for inputs and textareas.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args       Normalized args.
	 * @param bool  $is_textarea Whether rendering a textarea (width handling differs).
	 * @return string
	 */
	protected function get_input_classes( $args, $is_textarea = false, $prefixed = false ) {

		// Borderless variant: the wrapping input-group carries the border/ring/focus.
		if ( $prefixed ) {
			$classes = array( 'min-w-0', 'flex-1', 'bg-transparent', 'border-0!', 'ring-0!', 'focus:outline-hidden', 'focus:ring-0!', 'pl-1.5', 'pr-3', 'text-gray-900', 'placeholder:text-gray-400' );
			$classes[] = ( 'sm' === $args['size'] ) ? 'py-1.5' : 'py-2';
			$classes[] = ( 'sm' === $args['size'] ) ? 'text-sm' : 'text-base';
			if ( 'number' === $args['type'] ) {
				$classes[] = 'tabular-nums';
			}
			if ( $args['mono'] ) {
				$classes[] = 'font-mono';
			}
			if ( $args['disabled'] ) {
				$classes[] = 'opacity-50';
				$classes[] = 'cursor-not-allowed';
			}
			if ( ! empty( $args['class'] ) ) {
				$classes[] = $args['class'];
			}
			return implode( ' ', array_unique( $classes ) );
		}

		$classes = array(
			'px-4',
			'text-gray-900',
			'bg-white',
			'rounded-lg',
			'border',
			'border-gray-300',
			'transition-colors',
			'focus:outline-hidden',
			'focus:border-affwp-brand-500',
			'focus:ring-1',
			'focus:ring-affwp-brand-500',
			'hover:border-gray-400',
		);

		// Size-specific classes.
		if ( 'sm' === $args['size'] ) {
			$classes[] = 'py-1.5';
			$classes[] = 'text-sm';
		} else {
			$classes[] = 'py-2';
			$classes[] = 'text-base';
		}

		// Width utility. Textareas are effectively always full-width.
		if ( $is_textarea ) {
			$classes[] = 'w-full';
		} else {
			switch ( $args['width'] ) {
				case 'narrow':
					$classes[] = 'w-24';
					break;
				case 'auto':
					// No width utility — caller is responsible (e.g. flex-1 via $class).
					break;
				case 'full':
				default:
					$classes[] = 'w-full';
					break;
			}
		}

		if ( $args['mono'] ) {
			$classes[] = 'font-mono';
		}

		if ( $args['disabled'] ) {
			$classes[] = 'opacity-50';
			$classes[] = 'cursor-not-allowed';
		}

		if ( ! empty( $args['class'] ) ) {
			$classes[] = $args['class'];
		}

		return implode( ' ', array_unique( $classes ) );
	}

	/**
	 * Build the attribute string for the input or textarea element.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args        Normalized args.
	 * @param bool  $is_textarea Whether rendering a textarea.
	 * @return string
	 */
	protected function build_input_attributes( $args, $is_textarea = false, $prefixed = false ) {

		$attrs = array(
			'id'   => esc_attr( $args['id'] ),
			'name' => esc_attr( $args['name'] ),
		);

		if ( ! $is_textarea ) {
			$attrs['type']  = esc_attr( $args['type'] );
			$attrs['value'] = esc_attr( (string) $args['value'] );
		}

		$attrs['class'] = esc_attr( $this->get_input_classes( $args, $is_textarea, $prefixed ) );

		if ( '' !== $args['placeholder'] ) {
			$attrs['placeholder'] = esc_attr( $args['placeholder'] );
		}

		// Password-manager-friendly credential fields. Explicit autocomplete wins.
		if ( '' !== $args['autocomplete'] ) {
			$attrs['autocomplete'] = esc_attr( $args['autocomplete'] );
		} elseif ( $args['secret'] ) {
			$attrs['autocomplete'] = 'new-password';
		}

		if ( $args['secret'] ) {
			$attrs['data-lpignore'] = 'true';
			// data-1p-ignore is a valueless attribute; 1Password recognizes presence.
			$attrs['data-1p-ignore'] = '';
		}

		if ( ! empty( $args['alpine_model'] ) ) {
			$attrs['x-model'] = esc_attr( $args['alpine_model'] );
		}

		if ( $args['disabled'] ) {
			$attrs['disabled'] = 'disabled';
		}

		if ( $args['required'] ) {
			$attrs['required'] = 'required';
		}

		// Number-input constraints.
		if ( ! $is_textarea && 'number' === $args['type'] ) {
			if ( null !== $args['min'] ) {
				$attrs['min'] = esc_attr( (string) $args['min'] );
			}
			if ( null !== $args['max'] ) {
				$attrs['max'] = esc_attr( (string) $args['max'] );
			}
			if ( null !== $args['step'] ) {
				$attrs['step'] = esc_attr( (string) $args['step'] );
			}
			/*
			 * Integer-only entry: reject any non-digit insertion (typing or paste) at the
			 * source via beforeinput, so the value can never become a decimal that trips the
			 * browser's step-validation bubble ("nearest valid values are 2 and 3"). Deletions
			 * and the spinner have no inserted data, so they pass. Alpine directive — these
			 * number fields are x-model-bound, so they're already in an Alpine scope.
			 */
			if ( $args['integer'] ) {
				$attrs['inputmode']    = 'numeric';
				$attrs['@beforeinput'] = esc_attr( "\$event.data && /\\D/.test( \$event.data ) && \$event.preventDefault()" );
			}
		}

		// Custom attributes (Alpine directives pass through verbatim).
		if ( ! empty( $args['attributes'] ) && is_array( $args['attributes'] ) ) {
			foreach ( $args['attributes'] as $key => $value ) {
				if ( 0 === strpos( $key, '@' ) || 0 === strpos( $key, 'x-' ) || 0 === strpos( $key, ':' ) ) {
					$attrs[ $key ] = $value;
				} else {
					$attrs[ $key ] = esc_attr( $value );
				}
			}
		}

		$pieces = array();
		foreach ( $attrs as $key => $value ) {
			if ( 'disabled' === $value || 'required' === $value ) {
				$pieces[] = $key;
			} elseif ( '' === $value && in_array( $key, array( 'data-1p-ignore' ), true ) ) {
				// Valueless attribute.
				$pieces[] = $key;
			} else {
				$pieces[] = sprintf( '%s="%s"', $key, $value );
			}
		}

		return implode( ' ', $pieces );
	}
}
