<?php
/**
 * Livada SEO — OG Image Auto-Generator
 *
 * Renders a 1200×630 PNG dynamically for any post that lacks a custom
 * `_livada_seo_og_image`. The image is the post title rendered on a brand-
 * coloured canvas with a small site-name footer, returned via a virtual
 * REST endpoint and cached in transients (24 h).
 *
 * Font handling:
 *   - Tries TTF paths in this order: setting `og_image_font_path`, then a
 *     short list of common Linux/macOS fonts.
 *   - Falls back to GD's built-in font 5 (poor quality, last resort).
 *
 * To opt out, leave the setting `og_image_enabled` off OR set a custom OG
 * image on the post (the meta override always wins).
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_OG_Image {

	const REST_NAMESPACE = 'livada-seo/v1';
	const CACHE_TTL      = DAY_IN_SECONDS;
	const WIDTH          = 1200;
	const HEIGHT         = 630;

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	public static function init(): void {
		if ( ! Livada_SEO_Loader::get_setting( 'og_image_enabled', false ) ) {
			return;
		}

		// Fail-closed if GD isn't available — the rendered image would error
		// at request time, so silently skip the integration entirely.
		if ( ! function_exists( 'imagecreatetruecolor' ) || ! function_exists( 'imagepng' ) ) {
			return;
		}

		// REST endpoint that streams the rendered PNG.
		add_action( 'rest_api_init', [ self::class, 'register_route' ] );

		// Filter the meta og:image to inject our generated URL when no custom
		// image is set on the post. Hooked at priority 20 so any explicit
		// override from the OpenGraph module wins.
		add_filter( 'livada_seo_og_image_url', [ self::class, 'maybe_inject' ], 20, 2 );
	}

	public static function register_route(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			'/og-image/(?P<id>\d+)\.png',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ self::class, 'serve' ],
					'permission_callback' => '__return_true', // Public — like a sitemap.
				],
			]
		);
	}

	/**
	 * Returns the public URL for a post's generated OG image. Used by both the
	 * filter on og:image and as a callable target from outside (MCP previews).
	 */
	public static function image_url( int $post_id ): string {
		return rest_url( self::REST_NAMESPACE . '/og-image/' . $post_id . '.png' );
	}

	/**
	 * Filter callback: returns the generated image URL when no custom one is
	 * set. The `livada_seo_og_image_url` filter must be wired into the
	 * OpenGraph module separately.
	 *
	 * @param string $url     Existing URL (may be empty).
	 * @param int    $post_id Post being rendered.
	 */
	public static function maybe_inject( string $url, int $post_id ): string {
		if ( '' !== $url ) {
			return $url;
		}
		if ( $post_id <= 0 ) {
			return $url;
		}
		return self::image_url( $post_id );
	}

	// ── Serve handler ─────────────────────────────────────────────────────────

	/**
	 * REST callback: responds with image/png. Caches the rendered binary in a
	 * transient keyed by post_id + a short hash of (title + brand colour) so a
	 * settings change invalidates the cache without a manual flush.
	 */
	public static function serve( WP_REST_Request $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );
		if ( ! $post || 'publish' !== $post->post_status ) {
			return new WP_Error( 'not_found', 'Post not found.', [ 'status' => 404 ] );
		}

		$brand   = (string) Livada_SEO_Loader::get_setting( 'og_image_brand_color', '#1d2327' );
		$accent  = (string) Livada_SEO_Loader::get_setting( 'og_image_accent_color', '#dba617' );
		$site    = (string) ( Livada_SEO_Loader::get_setting( 'site_name', '' ) ?: get_bloginfo( 'name' ) );
		$title   = (string) get_the_title( $post_id );

		$cache_key = 'livada_seo_og_' . $post_id . '_' . substr( md5( $title . $brand . $accent . $site ), 0, 10 );
		$cached    = get_transient( $cache_key );

		if ( false === $cached ) {
			$cached = self::render( $title, $site, $brand, $accent );
			set_transient( $cache_key, $cached, self::CACHE_TTL );
		}

		// Stream the bytes — bypass WP's REST JSON wrapping.
		nocache_headers();
		header( 'Content-Type: image/png' );
		header( 'Content-Length: ' . strlen( $cached ) );
		header( 'Cache-Control: public, max-age=' . (int) self::CACHE_TTL );
		echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		exit;
	}

	// ── Rendering ─────────────────────────────────────────────────────────────

	/**
	 * Returns the raw PNG bytes for a 1200×630 OG image.
	 */
	private static function render( string $title, string $site, string $brand, string $accent ): string {
		$img   = imagecreatetruecolor( self::WIDTH, self::HEIGHT );
		$bg    = self::allocate_color( $img, $brand,  [ 29, 35, 39 ] );
		$fg    = imagecolorallocate( $img, 255, 255, 255 );
		$muted = imagecolorallocate( $img, 200, 200, 200 );
		$acc   = self::allocate_color( $img, $accent, [ 219, 166, 23 ] );

		imagefilledrectangle( $img, 0, 0, self::WIDTH, self::HEIGHT, $bg );

		// Accent bar on the left edge.
		imagefilledrectangle( $img, 0, 0, 14, self::HEIGHT, $acc );

		$font_path = self::resolve_font();

		if ( null !== $font_path && function_exists( 'imagettftext' ) ) {
			self::draw_ttf_text( $img, $title, $site, $font_path, $fg, $muted );
		} else {
			self::draw_built_in_text( $img, $title, $site, $fg, $muted );
		}

		ob_start();
		imagepng( $img, null, 4 ); // compression 0–9, 4 = good size/speed trade-off
		$bytes = (string) ob_get_clean();

		imagedestroy( $img );
		return $bytes;
	}

	/**
	 * Renders title + footer using a TTF font. Performs simple word-wrap so a
	 * long title spans multiple lines without overflowing the canvas.
	 */
	private static function draw_ttf_text( $img, string $title, string $site, string $font_path, int $fg, int $muted ): void {
		$padding_x  = 80;
		$canvas_w   = self::WIDTH - ( $padding_x * 2 );
		$title_size = 56;

		$lines = self::wrap_text( $title, $font_path, $title_size, $canvas_w );
		$lines = array_slice( $lines, 0, 5 );

		$line_height = (int) round( $title_size * 1.25 );
		$total_h     = $line_height * count( $lines );
		$y           = (int) ( ( self::HEIGHT - $total_h ) / 2 ) + $title_size;

		foreach ( $lines as $line ) {
			imagettftext( $img, $title_size, 0, $padding_x, $y, $fg, $font_path, $line );
			$y += $line_height;
		}

		// Footer with site name.
		if ( '' !== $site ) {
			imagettftext( $img, 22, 0, $padding_x, self::HEIGHT - 60, $muted, $font_path, $site );
		}
	}

	/**
	 * Last-resort renderer using GD's built-in bitmap font 5. Crude but always
	 * available; used when no TTF is found on the system.
	 */
	private static function draw_built_in_text( $img, string $title, string $site, int $fg, int $muted ): void {
		$padding_x  = 80;
		$line_chars = 36; // approximate width at GD font 5

		$lines = self::wrap_text_chars( $title, $line_chars, 5 );
		$y     = 200;
		foreach ( $lines as $line ) {
			imagestring( $img, 5, $padding_x, $y, $line, $fg );
			$y += 50;
		}
		if ( '' !== $site ) {
			imagestring( $img, 4, $padding_x, self::HEIGHT - 50, $site, $muted );
		}
	}

	/**
	 * Word-wraps text using imagettfbbox to measure pixel widths exactly.
	 *
	 * @return string[] Lines.
	 */
	private static function wrap_text( string $text, string $font_path, int $size, int $max_width ): array {
		$words = preg_split( '/\s+/u', trim( $text ) ) ?: [];
		if ( empty( $words ) ) {
			return [];
		}

		$lines  = [];
		$buffer = '';

		foreach ( $words as $word ) {
			$candidate = '' === $buffer ? $word : $buffer . ' ' . $word;
			$bbox      = imagettfbbox( $size, 0, $font_path, $candidate );
			$width     = is_array( $bbox ) ? (int) ( $bbox[2] - $bbox[0] ) : 0;

			if ( $width > $max_width && '' !== $buffer ) {
				$lines[] = $buffer;
				$buffer  = $word;
			} else {
				$buffer = $candidate;
			}
		}

		if ( '' !== $buffer ) {
			$lines[] = $buffer;
		}
		return $lines;
	}

	/**
	 * Character-count-based fallback wrapping for the built-in font path.
	 *
	 * @return string[]
	 */
	private static function wrap_text_chars( string $text, int $line_chars, int $max_lines ): array {
		$wrapped = wordwrap( trim( $text ), $line_chars, "\n", true );
		$lines   = explode( "\n", $wrapped );
		return array_slice( $lines, 0, $max_lines );
	}

	/**
	 * Resolves a usable TTF font path: honours the `og_image_font_path` setting
	 * first, then probes a small list of common system fonts. Returns null
	 * when nothing is found, in which case the renderer falls back to GD's
	 * built-in font.
	 */
	private static function resolve_font(): ?string {
		$configured = (string) Livada_SEO_Loader::get_setting( 'og_image_font_path', '' );
		if ( '' !== $configured && file_exists( $configured ) ) {
			return $configured;
		}

		$candidates = [
			'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
			'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
			'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
			'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
			'/Library/Fonts/Arial Bold.ttf',
			'/System/Library/Fonts/Helvetica.ttc',
			'C:/Windows/Fonts/arialbd.ttf',
			'C:/Windows/Fonts/arial.ttf',
		];

		foreach ( $candidates as $path ) {
			if ( file_exists( $path ) ) {
				return $path;
			}
		}

		return null;
	}

	/**
	 * Allocates a GD colour from a hex string. Falls back to the supplied RGB
	 * triplet on parse failure.
	 *
	 * @param resource|GdImage $img
	 * @param string           $hex     "#rrggbb" or "rrggbb"
	 * @param array{int,int,int} $fallback
	 */
	private static function allocate_color( $img, string $hex, array $fallback ): int {
		$hex = ltrim( trim( $hex ), '#' );
		if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) {
			return imagecolorallocate( $img, $fallback[0], $fallback[1], $fallback[2] );
		}
		return imagecolorallocate(
			$img,
			(int) hexdec( substr( $hex, 0, 2 ) ),
			(int) hexdec( substr( $hex, 2, 2 ) ),
			(int) hexdec( substr( $hex, 4, 2 ) )
		);
	}
}
