<?php
/**
 * BitForgeNet Guest Tickets - Ticket template model.
 *
 * A template is a flat background image at exact print dimensions plus a field
 * map describing where each piece of data is engraved. Templates can be produced
 * by the bundled Python designer (HTML5 -> PNG + JSON) or generated in-place by
 * the built-in painter with a chosen colour variation.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

// This file talks to the plugin's own tables. WordPress has no API for them --
// they are not posts and not meta -- so $wpdb is the only way in. The rows
// change on every booking and every scan, which makes an object cache wrong
// more often than it is right. Table names are built from $wpdb->prefix and
// never from a request, so interpolating one carries nothing to inject; every
// value goes through $wpdb->prepare(), and the few identifiers that cannot
// (ORDER BY columns) are matched against a whitelist first.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter


/**
 * Data access for ticket templates.
 */
class BFNET_Template {

	/**
	 * Canonical ticket geometry: 100 x 250 mm at 300 dpi.
	 */
	const WIDTH_PX  = 1181;
	const HEIGHT_PX = 2953;
	const DPI       = 300;

	/**
	 * Table name.
	 *
	 * @return string
	 */
	public static function table() {
		return BFNET_Install::table( 'templates' );
	}

	/**
	 * Fetch one template.
	 *
	 * @param int $id Template id.
	 * @return object|null
	 */
	public static function get( $id ) {
		global $wpdb;
		$table = self::table();
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * All templates.
	 *
	 * @param string $status Optional status filter.
	 * @return array
	 */
	public static function all( $status = 'active' ) {
		global $wpdb;
		$table = self::table();
		if ( $status ) {
			return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY is_default DESC, name ASC", $status ) ); // phpcs:ignore WordPress.DB.PreparedSQL
		}
		return $wpdb->get_results( "SELECT * FROM {$table} ORDER BY is_default DESC, name ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * The template marked as default, falling back to the first active one.
	 *
	 * @return object|null
	 */
	public static function get_default() {
		global $wpdb;

		$table = self::table();
		$row   = $wpdb->get_row( "SELECT * FROM {$table} WHERE is_default = 1 AND status = 'active' LIMIT 1" ); // phpcs:ignore WordPress.DB.PreparedSQL

		if ( $row ) {
			return $row;
		}

		return $wpdb->get_row( "SELECT * FROM {$table} WHERE status = 'active' ORDER BY id ASC LIMIT 1" ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Mark one template as the default.
	 *
	 * @param int $id Template id.
	 * @return void
	 */
	public static function set_default( $id ) {
		global $wpdb;

		$table = self::table();
		$wpdb->query( "UPDATE {$table} SET is_default = 0" ); // phpcs:ignore WordPress.DB.PreparedSQL
		$wpdb->update( $table, array( 'is_default' => 1 ), array( 'id' => (int) $id ), array( '%d' ), array( '%d' ) );

		BFNET_Settings::save( array( 'default_template_id' => (int) $id ) );
		BFNET_Log::audit( 'template', $id, 'template_set_default' );
	}

	/**
	 * Absolute path to a template's background image.
	 *
	 * @param object $template Template row.
	 * @return string
	 */
	public static function background_path( $template ) {
		if ( empty( $template->background_file ) ) {
			return '';
		}
		$storage = BFNET_Install::storage();
		return $storage['dir'] . '/templates/' . basename( $template->background_file );
	}

	/**
	 * Admin-facing preview URL (served through a signed endpoint).
	 *
	 * @param object $template Template row.
	 * @return string
	 */
	public static function preview_url( $template ) {
		$file    = ! empty( $template->preview_file ) ? $template->preview_file : $template->background_file;
		$expires = time() + HOUR_IN_SECONDS;
		$payload = 'template:' . (int) $template->id . ':' . $file;

		return add_query_arg(
			array(
				'bfnet_preview' => (int) $template->id,
				'expires'     => $expires,
				'signature'   => BFNET_Security::sign( $payload, $expires ),
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Decode a template's field map, falling back to the built-in layout.
	 *
	 * @param object|null $template Template row.
	 * @return array
	 */
	/**
	 * The field map, scaled to the design it is being drawn on.
	 *
	 * Positions are recorded in the pixels of the canvas the map was authored
	 * for. Upload a design at a different size — 768 x 1920 instead of
	 * 1181 x 2953, say — and every field lands somewhere off the artwork, or off
	 * the page entirely. Scaling the whole map by the ratio between the two
	 * canvases means a design at any resolution engraves correctly, and a 4K
	 * artwork simply gets crisper text rather than a broken layout.
	 *
	 * @param object $template Template row.
	 * @return array
	 */
	public static function scaled_fieldmap( $template ) {
		$map = self::fieldmap( $template );

		$from_w = isset( $map['canvas']['width'] ) ? (float) $map['canvas']['width'] : self::WIDTH_PX;
		$from_h = isset( $map['canvas']['height'] ) ? (float) $map['canvas']['height'] : self::HEIGHT_PX;

		$to_w = $template ? (float) $template->width_px : self::WIDTH_PX;
		$to_h = $template ? (float) $template->height_px : self::HEIGHT_PX;

		if ( $from_w <= 0 || $from_h <= 0 || $to_w <= 0 || $to_h <= 0 ) {
			return $map;
		}

		$sx = $to_w / $from_w;
		$sy = $to_h / $from_h;

		if ( abs( $sx - 1 ) < 0.001 && abs( $sy - 1 ) < 0.001 ) {
			return $map;
		}

		// Type scales with the smaller axis, so a design of a different shape
		// never ends up with letters stretched out of proportion.
		$type = min( $sx, $sy );

		foreach ( $map['fields'] as $i => $field ) {
			$map['fields'][ $i ]['x'] = (int) round( $field['x'] * $sx );
			$map['fields'][ $i ]['y'] = (int) round( $field['y'] * $sy );
			$map['fields'][ $i ]['w'] = (int) round( $field['w'] * $sx );
			$map['fields'][ $i ]['h'] = (int) round( $field['h'] * $sy );

			if ( isset( $field['size'] ) ) {
				$map['fields'][ $i ]['size'] = max( 6, (int) round( $field['size'] * $type ) );
			}
			if ( isset( $field['letter_spacing'] ) ) {
				$map['fields'][ $i ]['letter_spacing'] = (int) round( $field['letter_spacing'] * $type );
			}
		}

		$map['canvas']['width']  = (int) $to_w;
		$map['canvas']['height'] = (int) $to_h;

		return $map;
	}

	/**
	 * Fields introduced after a design may have been saved.
	 *
	 * A field map is a snapshot. Save one, and it keeps engraving exactly what
	 * the plugin knew how to engrave that day — so a design saved before table
	 * numbers existed has nowhere to put one, and goes on quietly printing
	 * tickets without it. Anything listed here is folded into an older map from
	 * the current default, so a design gains new fields without anyone opening
	 * the JSON.
	 *
	 * @return array
	 */
	public static function late_fields() {
		return array( 'table_number', 'sponsors' );
	}

	public static function fieldmap( $template ) {
		$map = $template ? BFNET_Util::json( $template->fieldmap ) : array();

		if ( empty( $map['fields'] ) ) {
			return self::default_fieldmap();
		}

		$map = self::add_late_fields( $map );
		$map = self::lift_buried_fields( $map );

		return $map;
	}

	/**
	 * Rescue two fields that were being printed underneath the QR panel.
	 *
	 * The built-in map placed the ticket category at y 1748 and "Guest 2 of 5"
	 * at y 1798. The painter draws the stub perforation at 1782 and the white
	 * QR panel from 1800 — so the first was struck through and the second was
	 * invisible, printed under white. Every design generated before this was
	 * fixed carries those coordinates in its stored map.
	 *
	 * Only a map still sitting on those exact numbers is corrected. A design
	 * somebody has moved by hand is left alone: a layout you chose is a layout
	 * you meant, and silently dragging it back would be worse than the bug.
	 *
	 * @param array $map Stored field map.
	 * @return array
	 */
	public static function lift_buried_fields( array $map ) {
		$buried = array(
			'ticket_type' => array( 'was' => 1748, 'y' => 1712, 'h' => 34, 'size' => 26 ),
			'seat_index'  => array( 'was' => 1798, 'y' => 1748, 'h' => 30, 'size' => 23 ),
			'table_number' => array( 'was' => 1650, 'y' => 1640, 'h' => 68 ),
		);

		foreach ( $map['fields'] as $i => $field ) {
			if ( empty( $field['key'] ) || ! isset( $buried[ $field['key'] ] ) ) {
				continue;
			}

			$fix = $buried[ $field['key'] ];

			if ( (int) $field['y'] !== $fix['was'] ) {
				continue;
			}

			$map['fields'][ $i ]['y'] = $fix['y'];
			$map['fields'][ $i ]['h'] = $fix['h'];

			if ( isset( $fix['size'] ) ) {
				$map['fields'][ $i ]['size'] = $fix['size'];
			}
		}

		return $map;
	}

	/**
	 * Fold any newer field into a map that predates it.
	 *
	 * The map is described in its own canvas, and the default in ours, so a
	 * borrowed field is scaled between the two before it is added — it lands
	 * where the design expects it, whatever size that design is.
	 *
	 * @param array $map Stored field map.
	 * @return array
	 */
	public static function add_late_fields( array $map ) {
		$present = array();
		foreach ( $map['fields'] as $field ) {
			if ( isset( $field['key'] ) ) {
				$present[ $field['key'] ] = true;
			}
		}

		$missing = array_diff( self::late_fields(), array_keys( $present ) );
		if ( ! $missing ) {
			return $map;
		}

		$default = self::default_fieldmap();

		$from_w = isset( $default['canvas']['width'] ) ? (float) $default['canvas']['width'] : self::WIDTH_PX;
		$from_h = isset( $default['canvas']['height'] ) ? (float) $default['canvas']['height'] : self::HEIGHT_PX;
		$to_w   = isset( $map['canvas']['width'] ) ? (float) $map['canvas']['width'] : $from_w;
		$to_h   = isset( $map['canvas']['height'] ) ? (float) $map['canvas']['height'] : $from_h;

		$sx   = $from_w > 0 ? $to_w / $from_w : 1;
		$sy   = $from_h > 0 ? $to_h / $from_h : 1;
		$type = min( $sx, $sy );

		foreach ( $default['fields'] as $field ) {
			if ( ! isset( $field['key'] ) || ! in_array( $field['key'], $missing, true ) ) {
				continue;
			}

			$field['x'] = (int) round( $field['x'] * $sx );
			$field['y'] = (int) round( $field['y'] * $sy );
			$field['w'] = (int) round( $field['w'] * $sx );
			$field['h'] = (int) round( $field['h'] * $sy );

			if ( isset( $field['size'] ) ) {
				$field['size'] = max( 6, (int) round( $field['size'] * $type ) );
			}
			if ( isset( $field['letter_spacing'] ) ) {
				$field['letter_spacing'] = (int) round( $field['letter_spacing'] * $type );
			}

			$map['fields'][] = $field;
		}

		return $map;
	}

	/**
	 * Every data key a template may reference.
	 *
	 * @return array key => human label.
	 */
	public static function available_fields() {
		return array(
			'event_series'      => __( 'Event series (e.g. Friday Sessions)', 'bitforgenet-guest-tickets' ),
			'event_title'       => __( 'Event title', 'bitforgenet-guest-tickets' ),
			'event_subtitle'    => __( 'Event subtitle', 'bitforgenet-guest-tickets' ),
			'event_edition'     => __( 'Edition / episode', 'bitforgenet-guest-tickets' ),
			'attendee_name'     => __( 'Attendee full name', 'bitforgenet-guest-tickets' ),
			'attendee_first'    => __( 'Attendee first name', 'bitforgenet-guest-tickets' ),
			'attendee_last'     => __( 'Attendee last name', 'bitforgenet-guest-tickets' ),
			'ticket_serial'     => __( 'Ticket serial number', 'bitforgenet-guest-tickets' ),
			'ticket_code'       => __( 'Ticket code', 'bitforgenet-guest-tickets' ),
			'ticket_type'       => __( 'Ticket category', 'bitforgenet-guest-tickets' ),
			'order_reference'   => __( 'Order reference', 'bitforgenet-guest-tickets' ),
			'seat_index'        => __( 'Guest number (1 of 5)', 'bitforgenet-guest-tickets' ),
			'table_number'      => __( 'Table number (set when the payment is validated)', 'bitforgenet-guest-tickets' ),
			'event_date'        => __( 'Event date', 'bitforgenet-guest-tickets' ),
			'event_time'        => __( 'Event start time', 'bitforgenet-guest-tickets' ),
			'doors_open'        => __( 'Doors open time', 'bitforgenet-guest-tickets' ),
			'venue_name'        => __( 'Venue name', 'bitforgenet-guest-tickets' ),
			'venue_address'     => __( 'Venue address', 'bitforgenet-guest-tickets' ),
			'venue_city'        => __( 'Venue city', 'bitforgenet-guest-tickets' ),
			'organizer_name'    => __( 'Organizer name', 'bitforgenet-guest-tickets' ),
			'organizer_phone'   => __( 'Organizer phone', 'bitforgenet-guest-tickets' ),
			'organizer_email'   => __( 'Organizer email', 'bitforgenet-guest-tickets' ),
			'organizer_whatsapp' => __( 'Organizer WhatsApp', 'bitforgenet-guest-tickets' ),
			'lineup'            => __( 'Line-up summary (DJ, band…)', 'bitforgenet-guest-tickets' ),
			'dress_code'        => __( 'Dress code', 'bitforgenet-guest-tickets' ),
			'price'             => __( 'Ticket price', 'bitforgenet-guest-tickets' ),
			'qr'                => __( 'QR code (required)', 'bitforgenet-guest-tickets' ),
			'logo'              => __( 'Organizer logo', 'bitforgenet-guest-tickets' ),
			'sponsors'          => __( 'Sponsor strip (image, set on the event)', 'bitforgenet-guest-tickets' ),
			'static'            => __( 'Fixed text', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * The layout used by the built-in template and as a safety net.
	 *
	 * Coordinates are in pixels on a 1181 x 2953 canvas (100 x 250 mm at 300 dpi).
	 *
	 * @return array
	 */
	public static function default_fieldmap() {
		return array(
			'version' => 1,
			'canvas'  => array(
				'width'  => self::WIDTH_PX,
				'height' => self::HEIGHT_PX,
				'dpi'    => self::DPI,
			),
			'fields'  => array(
				array( 'key' => 'logo', 'type' => 'image', 'x' => 440, 'y' => 150, 'w' => 300, 'h' => 300, 'fit' => 'contain' ),
				array( 'key' => 'event_series', 'type' => 'text', 'x' => 110, 'y' => 500, 'w' => 961, 'h' => 70, 'align' => 'center', 'font' => 'sans-medium', 'size' => 40, 'color' => '#B89E5D', 'uppercase' => true, 'letter_spacing' => 12 ),
				array( 'key' => 'event_title', 'type' => 'text', 'x' => 90, 'y' => 590, 'w' => 1001, 'h' => 210, 'align' => 'center', 'font' => 'serif-bold', 'size' => 86, 'color' => '#FFFFFF', 'uppercase' => true, 'max_lines' => 2, 'line_height' => 1.15 ),
				array( 'key' => 'event_subtitle', 'type' => 'text', 'x' => 130, 'y' => 806, 'w' => 921, 'h' => 106, 'align' => 'center', 'font' => 'sans-light', 'size' => 34, 'color' => '#E4E8EF', 'max_lines' => 2, 'line_height' => 1.25 ),
				array( 'key' => 'event_date', 'type' => 'text', 'x' => 110, 'y' => 968, 'w' => 961, 'h' => 70, 'align' => 'center', 'font' => 'sans-medium', 'size' => 44, 'color' => '#EADFC4', 'uppercase' => true, 'letter_spacing' => 3 ),
				array( 'key' => 'event_time', 'type' => 'text', 'x' => 110, 'y' => 1046, 'w' => 961, 'h' => 56, 'align' => 'center', 'font' => 'sans-light', 'size' => 32, 'color' => '#D3D9E3' ),
				array( 'key' => 'venue_name', 'type' => 'text', 'x' => 110, 'y' => 1176, 'w' => 961, 'h' => 60, 'align' => 'center', 'font' => 'sans-medium', 'size' => 38, 'color' => '#FFFFFF', 'max_lines' => 1 ),
				array( 'key' => 'venue_address', 'type' => 'text', 'x' => 130, 'y' => 1242, 'w' => 921, 'h' => 90, 'align' => 'center', 'font' => 'sans-light', 'size' => 27, 'color' => '#C3CAD8', 'max_lines' => 2, 'line_height' => 1.25 ),
				array( 'key' => 'lineup', 'type' => 'text', 'x' => 130, 'y' => 1352, 'w' => 921, 'h' => 76, 'align' => 'center', 'font' => 'sans-light', 'size' => 27, 'color' => '#B89E5D', 'max_lines' => 2, 'line_height' => 1.2 ),
				array( 'key' => 'static', 'type' => 'text', 'x' => 110, 'y' => 1452, 'w' => 961, 'h' => 46, 'align' => 'center', 'font' => 'sans-medium', 'size' => 26, 'color' => '#B89E5D', 'uppercase' => true, 'letter_spacing' => 10, 'value' => 'Admit one' ),
				array( 'key' => 'attendee_name', 'type' => 'text', 'x' => 90, 'y' => 1506, 'w' => 1001, 'h' => 130, 'align' => 'center', 'font' => 'serif-bold', 'size' => 62, 'color' => '#FFFFFF', 'max_lines' => 2, 'line_height' => 1.1 ),

				// The table is the first thing a guest looks for and the first thing an
				// usher is asked, so it is set large, in gold, directly under the name.
				array( 'key' => 'table_number', 'type' => 'text', 'x' => 110, 'y' => 1640, 'w' => 961, 'h' => 68, 'align' => 'center', 'font' => 'serif-bold', 'size' => 62, 'color' => '#EADFC4', 'uppercase' => true, 'letter_spacing' => 8, 'max_lines' => 1 ),

				array( 'key' => 'ticket_type', 'type' => 'text', 'x' => 110, 'y' => 1712, 'w' => 961, 'h' => 34, 'align' => 'center', 'font' => 'sans-light', 'size' => 26, 'color' => '#B89E5D', 'uppercase' => true, 'letter_spacing' => 6 ),
				array( 'key' => 'seat_index', 'type' => 'text', 'x' => 110, 'y' => 1748, 'w' => 961, 'h' => 30, 'align' => 'center', 'font' => 'sans-light', 'size' => 23, 'color' => '#B4BDCE' ),

				array( 'key' => 'qr', 'type' => 'qr', 'x' => 391, 'y' => 1876, 'w' => 400, 'h' => 400, 'quiet' => 2, 'dark' => '#000000', 'light' => '#FFFFFF' ),
				array( 'key' => 'ticket_serial', 'type' => 'text', 'x' => 110, 'y' => 2336, 'w' => 961, 'h' => 58, 'align' => 'center', 'font' => 'mono', 'size' => 42, 'color' => '#EADFC4', 'letter_spacing' => 8 ),
				array( 'key' => 'order_reference', 'type' => 'text', 'x' => 110, 'y' => 2394, 'w' => 961, 'h' => 40, 'align' => 'center', 'font' => 'mono', 'size' => 22, 'color' => '#A6B1C6', 'letter_spacing' => 2 ),
				array( 'key' => 'dress_code', 'type' => 'text', 'x' => 130, 'y' => 2452, 'w' => 921, 'h' => 42, 'align' => 'center', 'font' => 'sans-light', 'size' => 24, 'color' => '#C3CAD8', 'max_lines' => 1 ),

				// Everything above stops at 2500 px. What follows is the strip left
				// clear for sponsor logos — roughly 100 x 15 mm of it — with the
				// obligations squeezed into a single quiet line beneath. Drop the logos
				// into the artwork itself and nothing the plugin engraves will touch them.
				array( 'key' => 'sponsors', 'type' => 'image', 'x' => 110, 'y' => 2520, 'w' => 961, 'h' => 180, 'fit' => 'contain', 'optional' => true ),

				array( 'key' => 'organizer_name', 'type' => 'text', 'x' => 110, 'y' => 2742, 'w' => 961, 'h' => 44, 'align' => 'center', 'font' => 'sans-medium', 'size' => 24, 'color' => '#B89E5D', 'uppercase' => true, 'letter_spacing' => 7 ),
				array( 'key' => 'organizer_whatsapp', 'type' => 'text', 'x' => 110, 'y' => 2788, 'w' => 961, 'h' => 34, 'align' => 'center', 'font' => 'sans-light', 'size' => 20, 'color' => '#B4BDCE' ),
				array( 'key' => 'static', 'type' => 'text', 'x' => 130, 'y' => 2828, 'w' => 921, 'h' => 34, 'align' => 'center', 'font' => 'sans-light', 'size' => 17, 'color' => '#93A0B8', 'value' => 'Personal, non-transferable, one entry.', 'max_lines' => 1 ),
			),
		);
	}

	/**
	 * Colour variations offered by the built-in painter.
	 *
	 * @return array
	 */
	public static function palettes() {
		return array(
			'bfnet_navy' => array(
				'label'      => __( 'Deep Navy', 'bitforgenet-guest-tickets' ),
				'background' => '#012254',
				'panel'      => '#0A2F70',
				'primary'    => '#B89E5D',
				'highlight'  => '#EADFC4',
				'text'       => '#FFFFFF',
				'muted'      => '#C3CAD8',
			),
			'rose_gold'  => array(
				'label'      => __( 'Rose Gold', 'bitforgenet-guest-tickets' ),
				'background' => '#14090C',
				'panel'      => '#1E1013',
				'primary'    => '#D08C7A',
				'highlight'  => '#F6D8CE',
				'text'       => '#FFFFFF',
				'muted'      => '#B79A93',
			),
			'emerald'    => array(
				'label'      => __( 'Emerald Night', 'bitforgenet-guest-tickets' ),
				'background' => '#05120D',
				'panel'      => '#0A1D16',
				'primary'    => '#2FA37B',
				'highlight'  => '#A7E8CD',
				'text'       => '#FFFFFF',
				'muted'      => '#8FB3A6',
			),
			'sapphire'   => array(
				'label'      => __( 'Sapphire', 'bitforgenet-guest-tickets' ),
				'background' => '#060B18',
				'panel'      => '#0C1428',
				'primary'    => '#3D6FD8',
				'highlight'  => '#B8CDF7',
				'text'       => '#FFFFFF',
				'muted'      => '#93A3C4',
			),
			'ruby'       => array(
				'label'      => __( 'Ruby', 'bitforgenet-guest-tickets' ),
				'background' => '#14060A',
				'panel'      => '#210A11',
				'primary'    => '#C0304B',
				'highlight'  => '#F5B8C3',
				'text'       => '#FFFFFF',
				'muted'      => '#BE9099',
			),
			'platinum'   => array(
				'label'      => __( 'Platinum', 'bitforgenet-guest-tickets' ),
				'background' => '#101114',
				'panel'      => '#1A1C21',
				'primary'    => '#B9C0CC',
				'highlight'  => '#F0F3F8',
				'text'       => '#FFFFFF',
				'muted'      => '#9AA1AC',
			),
			'ivory'      => array(
				'label'      => __( 'Ivory & Gold (light)', 'bitforgenet-guest-tickets' ),
				'background' => '#FAF6EC',
				'panel'      => '#F2EADA',
				'primary'    => '#B08D18',
				'highlight'  => '#8A6C10',
				'text'       => '#1A1710',
				'muted'      => '#6E6552',
			),
		);
	}

	/* --------------------------------------------------------------------- */
	/* Persistence                                                           */
	/* --------------------------------------------------------------------- */

	/**
	 * Create or update a template row.
	 *
	 * @param array $data Field values.
	 * @return int|WP_Error
	 */
	public static function save( array $data ) {
		global $wpdb;

		$id   = isset( $data['id'] ) ? (int) $data['id'] : 0;
		$name = isset( $data['name'] ) ? sanitize_text_field( $data['name'] ) : '';

		if ( '' === trim( $name ) ) {
			return new WP_Error( 'bfnet_template_name', __( 'The template needs a name.', 'bitforgenet-guest-tickets' ) );
		}

		$fields = array(
			'name'        => $name,
			'description' => isset( $data['description'] ) ? sanitize_textarea_field( $data['description'] ) : '',
			'width_px'    => isset( $data['width_px'] ) ? max( 200, (int) $data['width_px'] ) : self::WIDTH_PX,
			'height_px'   => isset( $data['height_px'] ) ? max( 200, (int) $data['height_px'] ) : self::HEIGHT_PX,
			'dpi'         => isset( $data['dpi'] ) ? max( 72, (int) $data['dpi'] ) : self::DPI,
			'status'      => ( isset( $data['status'] ) && 'inactive' === $data['status'] ) ? 'inactive' : 'active',
			'updated_at'  => current_time( 'mysql' ),
		);

		if ( isset( $data['background_file'] ) ) {
			$fields['background_file'] = basename( sanitize_file_name( $data['background_file'] ) );
		}
		if ( isset( $data['preview_file'] ) ) {
			$fields['preview_file'] = basename( sanitize_file_name( $data['preview_file'] ) );
		}
		if ( isset( $data['fieldmap'] ) ) {
			$map                = is_array( $data['fieldmap'] ) ? $data['fieldmap'] : BFNET_Util::json( $data['fieldmap'] );
			$fields['fieldmap'] = wp_json_encode( self::sanitize_fieldmap( $map, $fields['width_px'], $fields['height_px'] ) );
		}
		if ( isset( $data['palette'] ) ) {
			$fields['palette'] = wp_json_encode( is_array( $data['palette'] ) ? $data['palette'] : BFNET_Util::json( $data['palette'] ) );
		}
		if ( isset( $data['is_generated'] ) ) {
			$fields['is_generated'] = $data['is_generated'] ? 1 : 0;
		}

		if ( $id ) {
			$wpdb->update( self::table(), $fields, array( 'id' => $id ), null, array( '%d' ) );
			BFNET_Log::audit( 'template', $id, 'template_updated', array( 'name' => $name ) );
		} else {
			$fields['created_at'] = current_time( 'mysql' );
			$fields['created_by'] = get_current_user_id();
			$wpdb->insert( self::table(), $fields );
			$id = (int) $wpdb->insert_id;
			BFNET_Log::audit( 'template', $id, 'template_created', array( 'name' => $name ) );
		}

		return $id ? $id : new WP_Error( 'bfnet_template_save', __( 'The template could not be saved.', 'bitforgenet-guest-tickets' ) );
	}

	/**
	 * Delete a template and detach it from any event.
	 *
	 * @param int $id Template id.
	 * @return true|WP_Error
	 */
	public static function delete( $id ) {
		global $wpdb;

		$template = self::get( $id );
		if ( ! $template ) {
			return new WP_Error( 'bfnet_no_template', __( 'Template not found.', 'bitforgenet-guest-tickets' ) );
		}
		if ( $template->is_default ) {
			return new WP_Error( 'bfnet_template_default', __( 'Choose another default template before deleting this one.', 'bitforgenet-guest-tickets' ) );
		}

		$storage = BFNET_Install::storage();
		foreach ( array( $template->background_file, $template->preview_file ) as $file ) {
			if ( $file ) {
				$path = $storage['dir'] . '/templates/' . basename( $file );
				if ( is_file( $path ) ) {
					wp_delete_file( $path );
				}
			}
		}

		$wpdb->update( BFNET_Event::table(), array( 'template_id' => 0 ), array( 'template_id' => (int) $id ), array( '%d' ), array( '%d' ) );
		$wpdb->delete( self::table(), array( 'id' => (int) $id ), array( '%d' ) );

		BFNET_Log::audit( 'template', $id, 'template_deleted', array( 'name' => $template->name ), 'warning' );

		return true;
	}

	/**
	 * Validate and clamp a field map.
	 *
	 * @param array $map    Raw map.
	 * @param int   $width  Canvas width.
	 * @param int   $height Canvas height.
	 * @return array
	 */
	public static function sanitize_fieldmap( $map, $width, $height ) {
		$allowed_keys  = array_keys( self::available_fields() );
		$allowed_types = array( 'text', 'qr', 'image' );
		$allowed_fonts = array( 'sans-light', 'sans-regular', 'sans-medium', 'sans-bold', 'serif', 'serif-bold', 'mono' );
		$allowed_align = array( 'left', 'center', 'right' );

		$clean = array(
			'version' => 1,
			'canvas'  => array(
				'width'  => (int) $width,
				'height' => (int) $height,
				'dpi'    => isset( $map['canvas']['dpi'] ) ? (int) $map['canvas']['dpi'] : self::DPI,
			),
			'fields'  => array(),
		);

		$fields = isset( $map['fields'] ) && is_array( $map['fields'] ) ? $map['fields'] : array();

		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) || empty( $field['key'] ) ) {
				continue;
			}

			$key = sanitize_key( $field['key'] );
			if ( ! in_array( $key, $allowed_keys, true ) ) {
				continue;
			}

			$type = isset( $field['type'] ) ? sanitize_key( $field['type'] ) : 'text';
			if ( ! in_array( $type, $allowed_types, true ) ) {
				$type = 'text';
			}

			$entry = array(
				'key'  => $key,
				'type' => $type,
				'x'    => max( 0, min( (int) $width, isset( $field['x'] ) ? (int) $field['x'] : 0 ) ),
				'y'    => max( 0, min( (int) $height, isset( $field['y'] ) ? (int) $field['y'] : 0 ) ),
				'w'    => max( 1, min( (int) $width, isset( $field['w'] ) ? (int) $field['w'] : 100 ) ),
				'h'    => max( 1, min( (int) $height, isset( $field['h'] ) ? (int) $field['h'] : 40 ) ),
			);

			if ( 'text' === $type ) {
				$font  = isset( $field['font'] ) ? sanitize_text_field( $field['font'] ) : 'sans-regular';
				$align = isset( $field['align'] ) ? sanitize_key( $field['align'] ) : 'left';

				$entry['font']           = in_array( $font, $allowed_fonts, true ) ? $font : 'sans-regular';
				$entry['align']          = in_array( $align, $allowed_align, true ) ? $align : 'left';
				$entry['size']           = max( 6, min( 400, isset( $field['size'] ) ? (int) $field['size'] : 32 ) );
				$entry['color']          = isset( $field['color'] ) ? ( sanitize_hex_color( $field['color'] ) ?: '#FFFFFF' ) : '#FFFFFF';
				$entry['uppercase']      = ! empty( $field['uppercase'] ) ? 1 : 0;
				$entry['letter_spacing'] = max( 0, min( 80, isset( $field['letter_spacing'] ) ? (int) $field['letter_spacing'] : 0 ) );
				$entry['max_lines']      = max( 1, min( 8, isset( $field['max_lines'] ) ? (int) $field['max_lines'] : 1 ) );
				$entry['line_height']    = max( 0.8, min( 2.5, isset( $field['line_height'] ) ? (float) $field['line_height'] : 1.2 ) );
				$entry['shrink_to_fit']  = isset( $field['shrink_to_fit'] ) ? (int) (bool) $field['shrink_to_fit'] : 1;

				if ( 'static' === $key ) {
					$entry['value'] = isset( $field['value'] ) ? sanitize_text_field( $field['value'] ) : '';
				}
			}

			if ( 'qr' === $type ) {
				$entry['quiet'] = max( 0, min( 8, isset( $field['quiet'] ) ? (int) $field['quiet'] : 2 ) );
				$entry['dark']  = isset( $field['dark'] ) ? ( sanitize_hex_color( $field['dark'] ) ?: '#000000' ) : '#000000';
				$entry['light'] = isset( $field['light'] ) ? ( sanitize_hex_color( $field['light'] ) ?: '#FFFFFF' ) : '#FFFFFF';
			}

			if ( 'image' === $type ) {
				$fit          = isset( $field['fit'] ) ? sanitize_key( $field['fit'] ) : 'contain';
				$entry['fit'] = in_array( $fit, array( 'contain', 'cover' ), true ) ? $fit : 'contain';
			}

			$clean['fields'][] = $entry;

			if ( count( $clean['fields'] ) >= 60 ) {
				break;
			}
		}

		return $clean;
	}

	/* --------------------------------------------------------------------- */
	/* Built-in generation                                                   */
	/* --------------------------------------------------------------------- */

	/**
	 * Install the default template on first activation.
	 *
	 * @return int|WP_Error Template id.
	 */
	public static function install_builtin() {
		$id = self::generate( 'bfnet_navy', __( 'Deep Navy (built-in)', 'bitforgenet-guest-tickets' ) );
		if ( ! is_wp_error( $id ) ) {
			self::set_default( $id );
		}
		return $id;
	}

	/**
	 * Paint a new built-in template in the requested palette.
	 *
	 * @param string $palette_key Palette slug.
	 * @param string $name        Template name.
	 * @return int|WP_Error Template id.
	 */
	public static function generate( $palette_key, $name = '' ) {
		$palettes = self::palettes();
		$key      = isset( $palettes[ $palette_key ] ) ? $palette_key : 'bfnet_navy';
		$palette  = $palettes[ $key ];

		$painter = new BFNET_Template_Painter( $palette );
		$result  = $painter->render();

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$name = $name ? $name : sprintf(
			/* translators: %s: palette label. */
			__( 'BitForgeNet — %s', 'bitforgenet-guest-tickets' ),
			$palette['label']
		);

		$fieldmap = self::default_fieldmap();
		$fieldmap = self::recolor_fieldmap( $fieldmap, $palette );

		return self::save(
			array(
				'name'            => $name,
				'description'     => __( 'Generated by the built-in painter. 100 × 250 mm at 300 dpi.', 'bitforgenet-guest-tickets' ),
				'background_file' => $result['background'],
				'preview_file'    => $result['preview'],
				'width_px'        => self::WIDTH_PX,
				'height_px'       => self::HEIGHT_PX,
				'dpi'             => self::DPI,
				'fieldmap'        => $fieldmap,
				'palette'         => $palette,
				'is_generated'    => 1,
			)
		);
	}

	/**
	 * Swap the default field-map colours for a palette's colours.
	 *
	 * @param array $fieldmap Field map.
	 * @param array $palette  Palette definition.
	 * @return array
	 */
	private static function recolor_fieldmap( $fieldmap, $palette ) {
		$swap = array(
			'#B89E5D' => $palette['primary'],
			'#EADFC4' => $palette['highlight'],
			'#FFFFFF' => $palette['text'],
			'#E4E8EF' => $palette['muted'],
			'#D3D9E3' => $palette['muted'],
			'#C3CAD8' => $palette['muted'],
			'#B4BDCE' => $palette['muted'],
			'#A6B1C6' => $palette['muted'],
			'#93A0B8' => $palette['muted'],
		);

		foreach ( $fieldmap['fields'] as $i => $field ) {
			if ( isset( $field['color'] ) && isset( $swap[ strtoupper( $field['color'] ) ] ) ) {
				$fieldmap['fields'][ $i ]['color'] = $swap[ strtoupper( $field['color'] ) ];
			}
		}

		return $fieldmap;
	}
}
