<?php
/**
 * BitForgeNet Guest Tickets - GD drawing helpers shared by the template painter and the ticket renderer.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Low-level canvas utilities.
 */
class BFNET_Canvas {

	/**
	 * Map logical font names to bundled files.
	 *
	 * @return array
	 */
	public static function fonts() {
		$dir = BFNET_PATH . 'assets/fonts/';

		return apply_filters(
			'bfnet_ticket_fonts',
			array(
				'sans-light'   => $dir . 'Poppins-Light.ttf',
				'sans-regular' => $dir . 'Poppins-Regular.ttf',
				'sans-medium'  => $dir . 'Poppins-Medium.ttf',
				'sans-bold'    => $dir . 'Poppins-Bold.ttf',
				'serif'        => $dir . 'Lora-Regular.ttf',
				'serif-bold'   => $dir . 'Lora-Regular.ttf',
				'mono'         => $dir . 'Poppins-Medium.ttf',
			)
		);
	}

	/**
	 * Resolve a logical font name to a readable file path.
	 *
	 * @param string $name Logical font name.
	 * @return string Empty string when unavailable.
	 */
	public static function font( $name ) {
		$fonts = self::fonts();
		$path  = isset( $fonts[ $name ] ) ? $fonts[ $name ] : $fonts['sans-regular'];
		return is_readable( $path ) ? $path : '';
	}

	/**
	 * Whether a logical font should be drawn with a faux-bold stroke.
	 *
	 * @param string $name Logical font name.
	 * @return bool
	 */
	public static function is_faux_bold( $name ) {
		return 'serif-bold' === $name;
	}

	/**
	 * Convert a hex colour to an RGB triple.
	 *
	 * @param string $hex Hex colour, with or without '#'.
	 * @return array [r, g, b]
	 */
	public static function rgb( $hex ) {
		$hex = ltrim( (string) $hex, '#' );

		if ( 3 === strlen( $hex ) ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}
		if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
			return array( 255, 255, 255 );
		}

		return array(
			hexdec( substr( $hex, 0, 2 ) ),
			hexdec( substr( $hex, 2, 2 ) ),
			hexdec( substr( $hex, 4, 2 ) ),
		);
	}

	/**
	 * Allocate a colour on an image.
	 *
	 * @param resource|GdImage $img   Image handle.
	 * @param string           $hex   Hex colour.
	 * @param int              $alpha GD alpha 0 (opaque) to 127 (transparent).
	 * @return int
	 */
	public static function color( $img, $hex, $alpha = 0 ) {
		list( $r, $g, $b ) = self::rgb( $hex );
		return imagecolorallocatealpha( $img, $r, $g, $b, max( 0, min( 127, (int) $alpha ) ) );
	}

	/**
	 * Linear interpolation between two hex colours.
	 *
	 * @param string $from  Start colour.
	 * @param string $to    End colour.
	 * @param float  $ratio 0..1.
	 * @return array [r, g, b]
	 */
	public static function mix( $from, $to, $ratio ) {
		$a     = self::rgb( $from );
		$b     = self::rgb( $to );
		$ratio = max( 0, min( 1, (float) $ratio ) );

		return array(
			(int) round( $a[0] + ( $b[0] - $a[0] ) * $ratio ),
			(int) round( $a[1] + ( $b[1] - $a[1] ) * $ratio ),
			(int) round( $a[2] + ( $b[2] - $a[2] ) * $ratio ),
		);
	}

	/**
	 * Measure a single line of text.
	 *
	 * @param string $text    Text.
	 * @param string $font    Font file path.
	 * @param int    $size    Font size in pixels.
	 * @param int    $spacing Extra letter spacing in pixels.
	 * @return array ['width' => int, 'height' => int, 'ascent' => int]
	 */
	public static function measure( $text, $font, $size, $spacing = 0 ) {
		if ( '' === $text || ! $font ) {
			return array( 'width' => 0, 'height' => 0, 'ascent' => 0 );
		}

		if ( $spacing > 0 ) {
			$width = 0;
			$chars = self::split_chars( $text );
			foreach ( $chars as $char ) {
				$box    = imagettfbbox( $size, 0, $font, $char );
				$width += ( $box[2] - $box[0] ) + $spacing;
			}
			$width = max( 0, $width - $spacing );
			$full  = imagettfbbox( $size, 0, $font, $text );

			return array(
				'width'  => (int) $width,
				'height' => (int) abs( $full[5] - $full[1] ),
				'ascent' => (int) abs( $full[5] ),
			);
		}

		$box = imagettfbbox( $size, 0, $font, $text );

		return array(
			'width'  => (int) ( $box[2] - $box[0] ),
			'height' => (int) abs( $box[5] - $box[1] ),
			'ascent' => (int) abs( $box[5] ),
		);
	}

	/**
	 * Split a UTF-8 string into characters.
	 *
	 * @param string $text Text.
	 * @return array
	 */
	public static function split_chars( $text ) {
		$chars = preg_split( '//u', (string) $text, -1, PREG_SPLIT_NO_EMPTY );
		return is_array( $chars ) ? $chars : array();
	}

	/**
	 * Draw a single line of text at a baseline, honouring letter spacing.
	 *
	 * @param resource|GdImage $img     Image handle.
	 * @param string           $text    Text.
	 * @param int              $x       Left x.
	 * @param int              $y       Baseline y.
	 * @param string           $font    Font file path.
	 * @param int              $size    Font size.
	 * @param int              $color   Allocated colour.
	 * @param int              $spacing Letter spacing.
	 * @param bool             $bold    Apply a faux-bold stroke.
	 * @return void
	 */
	public static function draw_line( $img, $text, $x, $y, $font, $size, $color, $spacing = 0, $bold = false ) {
		if ( '' === $text || ! $font ) {
			return;
		}

		$offsets = $bold ? array( array( 0, 0 ), array( 1, 0 ), array( 0, 1 ), array( 1, 1 ) ) : array( array( 0, 0 ) );

		if ( $spacing <= 0 ) {
			foreach ( $offsets as $offset ) {
				imagettftext( $img, $size, 0, (int) $x + $offset[0], (int) $y + $offset[1], $color, $font, $text );
			}
			return;
		}

		$cursor = (int) $x;
		foreach ( self::split_chars( $text ) as $char ) {
			foreach ( $offsets as $offset ) {
				imagettftext( $img, $size, 0, $cursor + $offset[0], (int) $y + $offset[1], $color, $font, $char );
			}
			$box     = imagettfbbox( $size, 0, $font, $char );
			$cursor += ( $box[2] - $box[0] ) + $spacing;
		}
	}

	/**
	 * Wrap text to a pixel width.
	 *
	 * @param string $text      Text.
	 * @param string $font      Font file path.
	 * @param int    $size      Font size.
	 * @param int    $max_width Maximum line width.
	 * @param int    $spacing   Letter spacing.
	 * @param int    $max_lines Maximum number of lines.
	 * @return array Lines.
	 */
	public static function wrap( $text, $font, $size, $max_width, $spacing = 0, $max_lines = 4 ) {
		$text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
		if ( '' === $text ) {
			return array();
		}

		$words = explode( ' ', $text );
		$lines = array();
		$line  = '';

		foreach ( $words as $word ) {
			$candidate = '' === $line ? $word : $line . ' ' . $word;
			$measured  = self::measure( $candidate, $font, $size, $spacing );

			if ( $measured['width'] <= $max_width || '' === $line ) {
				$line = $candidate;
				continue;
			}

			$lines[] = $line;
			$line    = $word;

			if ( count( $lines ) >= $max_lines ) {
				break;
			}
		}

		if ( '' !== $line && count( $lines ) < $max_lines ) {
			$lines[] = $line;
		}

		// Ellipsise the last line when content was dropped.
		if ( count( $lines ) >= $max_lines ) {
			$rendered = implode( ' ', $lines );
			if ( mb_strlen( $rendered ) < mb_strlen( $text ) ) {
				$last  = array_pop( $lines );
				$last .= '…';
				while ( self::measure( $last, $font, $size, $spacing )['width'] > $max_width && mb_strlen( $last ) > 2 ) {
					$last = mb_substr( $last, 0, mb_strlen( $last ) - 2 ) . '…';
				}
				$lines[] = $last;
			}
		}

		return $lines;
	}

	/**
	 * Draw a block of text inside a box, wrapping, shrinking and aligning.
	 *
	 * @param resource|GdImage $img  Image handle.
	 * @param string           $text Text to draw.
	 * @param array            $box  ['x','y','w','h'].
	 * @param array            $opts Drawing options.
	 * @return void
	 */
	public static function draw_text_block( $img, $text, array $box, array $opts ) {
		$text = trim( (string) $text );
		if ( '' === $text ) {
			return;
		}

		$defaults = array(
			'font'           => 'sans-regular',
			'size'           => 32,
			'color'          => '#FFFFFF',
			'align'          => 'left',
			'valign'         => 'middle',
			'letter_spacing' => 0,
			'uppercase'      => 0,
			'max_lines'      => 1,
			'line_height'    => 1.2,
			'shrink_to_fit'  => 1,
		);
		$opts = array_merge( $defaults, $opts );

		$font_path = self::font( $opts['font'] );
		if ( ! $font_path ) {
			return;
		}

		$bold = self::is_faux_bold( $opts['font'] );

		if ( ! empty( $opts['uppercase'] ) ) {
			$text = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $text, 'UTF-8' ) : strtoupper( $text );
		}

		$size      = (int) $opts['size'];
		$spacing   = (int) $opts['letter_spacing'];
		$max_lines = (int) $opts['max_lines'];
		$min_size  = max( 8, (int) round( $size * 0.5 ) );

		$lines = self::wrap( $text, $font_path, $size, (int) $box['w'], $spacing, $max_lines );

		// Shrink until the block fits both horizontally and vertically.
		if ( ! empty( $opts['shrink_to_fit'] ) ) {
			$guard = 0;
			while ( $guard < 40 && $size > $min_size ) {
				$guard++;
				$line_height = (int) round( $size * (float) $opts['line_height'] );
				$total       = count( $lines ) * $line_height;
				$widest      = 0;
				foreach ( $lines as $line ) {
					$widest = max( $widest, self::measure( $line, $font_path, $size, $spacing )['width'] );
				}
				if ( $total <= (int) $box['h'] && $widest <= (int) $box['w'] ) {
					break;
				}
				$size    = (int) round( $size * 0.94 );
				$spacing = (int) round( $spacing * 0.94 );
				$lines   = self::wrap( $text, $font_path, $size, (int) $box['w'], $spacing, $max_lines );
			}
		}

		$line_height = (int) round( $size * (float) $opts['line_height'] );
		$block       = count( $lines ) * $line_height;
		$color       = self::color( $img, $opts['color'] );

		switch ( $opts['valign'] ) {
			case 'top':
				$start_y = (int) $box['y'];
				break;
			case 'bottom':
				$start_y = (int) $box['y'] + (int) $box['h'] - $block;
				break;
			default:
				$start_y = (int) $box['y'] + (int) round( ( (int) $box['h'] - $block ) / 2 );
				break;
		}

		foreach ( $lines as $index => $line ) {
			$measured = self::measure( $line, $font_path, $size, $spacing );

			switch ( $opts['align'] ) {
				case 'center':
					$x = (int) $box['x'] + (int) round( ( (int) $box['w'] - $measured['width'] ) / 2 );
					break;
				case 'right':
					$x = (int) $box['x'] + (int) $box['w'] - $measured['width'];
					break;
				default:
					$x = (int) $box['x'];
					break;
			}

			// Baseline sits at the top of the line plus the font ascent.
			$baseline = $start_y + ( $index * $line_height ) + (int) round( $size * 0.82 );

			self::draw_line( $img, $line, $x, $baseline, $font_path, $size, $color, $spacing, $bold );
		}
	}

	/**
	 * Draw a vertical linear gradient.
	 *
	 * @param resource|GdImage $img  Image handle.
	 * @param array            $box  ['x','y','w','h'].
	 * @param string           $from Top colour.
	 * @param string           $to   Bottom colour.
	 * @return void
	 */
	public static function gradient_v( $img, array $box, $from, $to ) {
		$height = max( 1, (int) $box['h'] );
		for ( $i = 0; $i < $height; $i++ ) {
			list( $r, $g, $b ) = self::mix( $from, $to, $i / $height );
			$color             = imagecolorallocate( $img, $r, $g, $b );
			imagefilledrectangle( $img, (int) $box['x'], (int) $box['y'] + $i, (int) $box['x'] + (int) $box['w'] - 1, (int) $box['y'] + $i, $color );
		}
	}

	/**
	 * Draw a horizontal gradient rule that fades at both ends.
	 *
	 * @param resource|GdImage $img       Image handle.
	 * @param int              $x         Left x.
	 * @param int              $y         Top y.
	 * @param int              $width     Rule width.
	 * @param int              $thickness Rule thickness.
	 * @param string           $hex       Colour.
	 * @return void
	 */
	public static function fading_rule( $img, $x, $y, $width, $thickness, $hex ) {
		list( $r, $g, $b ) = self::rgb( $hex );

		for ( $i = 0; $i < $width; $i++ ) {
			$position = $i / max( 1, $width - 1 );
			$fade     = 1 - abs( ( $position * 2 ) - 1 );
			$alpha    = (int) round( 127 - ( $fade * 127 ) );
			$color    = imagecolorallocatealpha( $img, $r, $g, $b, max( 0, min( 127, $alpha ) ) );
			imagefilledrectangle( $img, $x + $i, $y, $x + $i, $y + max( 1, $thickness ) - 1, $color );
		}
	}

	/**
	 * Load an image file into a truecolor GD handle.
	 *
	 * @param string $path Absolute path.
	 * @return resource|GdImage|null
	 */
	public static function load( $path ) {
		if ( ! is_readable( $path ) ) {
			return null;
		}

		$info = @getimagesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
		if ( ! $info ) {
			return null;
		}

		switch ( $info[2] ) {
			case IMAGETYPE_PNG:
				$img = @imagecreatefrompng( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
				break;
			case IMAGETYPE_JPEG:
				$img = @imagecreatefromjpeg( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
				break;
			case IMAGETYPE_GIF:
				$img = @imagecreatefromgif( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
				break;
			case IMAGETYPE_WEBP:
				$img = function_exists( 'imagecreatefromwebp' ) ? @imagecreatefromwebp( $path ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors
				break;
			default:
				return null;
		}

		if ( ! $img ) {
			return null;
		}

		imagealphablending( $img, true );
		imagesavealpha( $img, true );

		return $img;
	}

	/**
	 * Composite a source image into a box, preserving aspect ratio.
	 *
	 * @param resource|GdImage $dst Destination image.
	 * @param resource|GdImage $src Source image.
	 * @param array            $box ['x','y','w','h'].
	 * @param string           $fit 'contain' or 'cover'.
	 * @return void
	 */
	public static function place( $dst, $src, array $box, $fit = 'contain' ) {
		$sw = imagesx( $src );
		$sh = imagesy( $src );
		if ( ! $sw || ! $sh ) {
			return;
		}

		$scale = ( 'cover' === $fit )
			? max( $box['w'] / $sw, $box['h'] / $sh )
			: min( $box['w'] / $sw, $box['h'] / $sh );

		$dw = max( 1, (int) round( $sw * $scale ) );
		$dh = max( 1, (int) round( $sh * $scale ) );
		$dx = (int) $box['x'] + (int) round( ( (int) $box['w'] - $dw ) / 2 );
		$dy = (int) $box['y'] + (int) round( ( (int) $box['h'] - $dh ) / 2 );

		imagealphablending( $dst, true );
		imagecopyresampled( $dst, $src, $dx, $dy, 0, 0, $dw, $dh, $sw, $sh );
	}

	/**
	 * Draw a rectangle with rounded corners.
	 *
	 * @param resource|GdImage $img    Image handle.
	 * @param int              $x      Left.
	 * @param int              $y      Top.
	 * @param int              $w      Width.
	 * @param int              $h      Height.
	 * @param int              $radius Corner radius.
	 * @param int              $color  Allocated colour.
	 * @return void
	 */
	public static function rounded_rect( $img, $x, $y, $w, $h, $radius, $color ) {
		$radius = max( 0, min( (int) $radius, (int) floor( min( $w, $h ) / 2 ) ) );

		imagefilledrectangle( $img, $x + $radius, $y, $x + $w - $radius - 1, $y + $h - 1, $color );
		imagefilledrectangle( $img, $x, $y + $radius, $x + $w - 1, $y + $h - $radius - 1, $color );

		if ( $radius > 0 ) {
			$d = $radius * 2;
			imagefilledellipse( $img, $x + $radius, $y + $radius, $d, $d, $color );
			imagefilledellipse( $img, $x + $w - $radius - 1, $y + $radius, $d, $d, $color );
			imagefilledellipse( $img, $x + $radius, $y + $h - $radius - 1, $d, $d, $color );
			imagefilledellipse( $img, $x + $w - $radius - 1, $y + $h - $radius - 1, $d, $d, $color );
		}
	}
}
