<?php
/**
 * Front-end rendering for ingested records: the display half of the plugin.
 *
 * Payloads are stored faithfully (see DataStream_Engine::sanitize_payload),
 * so every value rendered here is escaped at output time. If you extend the
 * output via filters, keep that contract.
 *
 * @package DataStream
 */

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

/**
 * Class DataStream_Renderer
 */
class DataStream_Renderer {

	/**
	 * Bundled templates.
	 */
	public const TEMPLATES = array( 'list', 'table', 'cards', 'grid', 'raw' );

	/**
	 * Recognized field format suffixes (fields="price:number,photo:image").
	 */
	public const FORMATS = array( 'text', 'number', 'date', 'url', 'image', 'email', 'bool' );

	/**
	 * Maximum fields auto-derived from a record when none are specified.
	 */
	private const MAX_AUTO_FIELDS = 6;

	/**
	 * Register the front-end stylesheet (enqueued only when something renders).
	 */
	public static function register_assets() {
		wp_register_style(
			'datastream-frontend',
			plugins_url( 'assets/css/datastream-frontend.css', DATASTREAM_PLUGIN_FILE ),
			array(),
			DATASTREAM_VERSION
		);

		wp_register_script(
			'datastream-filter',
			plugins_url( 'assets/js/datastream-filter.js', DATASTREAM_PLUGIN_FILE ),
			array(),
			DATASTREAM_VERSION,
			true
		);
	}

	/**
	 * Fetch and decode records for display.
	 *
	 * @param array<string, mixed> $args {
	 *     @type string $stream Stream type filter ('' = all).
	 *     @type int    $limit  Max records (1–100).
	 * }
	 * @return array<int, array<string, mixed>> Records with decoded `data`.
	 */
	public static function get_records( $args = array() ) {
		$defaults = array(
			'stream' => '',
			'limit'  => 10,
			'offset' => 0,
		);

		$args  = wp_parse_args( $args, $defaults );
		$limit = max( 1, min( 100, absint( $args['limit'] ) ) );

		$dao  = new DataStream_DAO();
		$rows = $dao->get_recent_records( $limit, sanitize_key( (string) $args['stream'] ), absint( $args['offset'] ) );

		$records = array();

		foreach ( $rows as $row ) {
			$data = json_decode( (string) $row['data_payload'], true );

			$records[] = array(
				'external_id' => (string) $row['external_id'],
				'stream_type' => (string) ( $row['stream_type'] ?? 'generic' ),
				'source'      => (string) ( $row['source'] ?? '' ),
				'updated_at'  => (string) ( $row['updated_at'] ?? '' ),
				'data'        => is_array( $data ) ? $data : array(),
			);
		}

		return $records;
	}

	/**
	 * Render a set of records with a bundled template.
	 *
	 * @param array<string, mixed> $args {
	 *     @type string $stream   Stream type filter.
	 *     @type int    $limit    Max records.
	 *     @type string $template One of list|table|cards|raw.
	 *     @type string $fields   Comma-separated field names (dot paths allowed).
	 *     @type string $empty    Message when no records exist.
	 * }
	 * @return string HTML.
	 */
	public static function render_list( $args = array() ) {
		$defaults = array(
			'stream'       => '',
			'limit'        => 10,
			'template'     => 'list',
			'fields'       => '',
			'empty'        => __( 'No records found.', 'datastream-api-sync' ),
			'page_size'    => 0,
			'orderby'      => '',
			'order'        => 'desc',
			'searchable'   => false,
			'style'        => '',
			'image_aspect' => '',
			'list_id'      => '',
		);

		$args     = wp_parse_args( $args, $defaults );
		$template = in_array( (string) $args['template'], self::TEMPLATES, true ) ? (string) $args['template'] : 'list';

		if ( wp_style_is( 'datastream-frontend', 'registered' ) ) {
			wp_enqueue_style( 'datastream-frontend' );
		}

		$page_size = max( 0, min( 100, absint( $args['page_size'] ) ) );
		$page_key  = 'ds_page' . ( '' !== (string) $args['list_id'] ? '_' . sanitize_key( (string) $args['list_id'] ) : '' );
		$page      = isset( $_GET[ $page_key ] ) ? max( 1, absint( $_GET[ $page_key ] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public pagination.
		$total     = 0;

		if ( $page_size > 0 ) {
			$dao   = new DataStream_DAO();
			$total = $dao->count_records( sanitize_key( (string) $args['stream'] ) );

			if ( '' !== (string) $args['orderby'] ) {
				// Sorting needs the whole window in memory: fetch the cap,
				// sort, then slice the requested page from it.
				$records = self::get_records( array(
					'stream' => $args['stream'],
					'limit'  => 100,
				) );
				$records = self::sort_records( $records, (string) $args['orderby'], (string) $args['order'] );
				$total   = min( $total, 100 );
				$records = array_slice( $records, ( $page - 1 ) * $page_size, $page_size );
			} else {
				$records = self::get_records( array(
					'stream' => $args['stream'],
					'limit'  => $page_size,
					'offset' => ( $page - 1 ) * $page_size,
				) );
			}
		} else if ( '' !== (string) $args['orderby'] ) {
			// Sort BEFORE limiting: "top 3 by price" means the top 3 of the
			// whole (capped) set, not the 3 most recent re-ordered.
			$records = self::get_records( array(
				'stream' => $args['stream'],
				'limit'  => 100,
			) );
			$records = self::sort_records( $records, (string) $args['orderby'], (string) $args['order'] );
			$records = array_slice( $records, 0, max( 1, min( 100, absint( $args['limit'] ) ) ) );
		} else {
			$records = self::get_records( $args );
		}

		if ( empty( $records ) && 1 === $page ) {
			$html = '<div class="datastream-empty-state">' . esc_html( (string) $args['empty'] ) . '</div>';
			return (string) apply_filters( 'datastream_render_output', $html, $args, $records );
		}

		$fields = self::resolve_fields( (string) $args['fields'], $records );

		switch ( $template ) {
			case 'table':
				$html = self::render_table( $records, $fields );
				break;
			case 'cards':
				$html = self::render_cards( $records, $fields );
				break;
			case 'grid':
				$html = self::render_grid( $records, $fields );
				break;
			case 'raw':
				$html = self::render_raw( $records );
				break;
			default:
				$html = self::render_ul( $records, $fields );
				break;
		}

		$html = self::wrap_output( $html, $args, $template, $page_size, $page, $total, $page_key );

		/**
		 * Filter the rendered output.
		 *
		 * @param string                            $html    Rendered HTML.
		 * @param array<string, mixed>              $args    Render arguments.
		 * @param array<int, array<string, mixed>>  $records Decoded records.
		 */
		return (string) apply_filters( 'datastream_render_output', $html, $args, $records );
	}

	/**
	 * Sort decoded records by a payload field. Numeric values compare
	 * numerically; everything else case-insensitively.
	 *
	 * @param array<int, array<string, mixed>> $records Records.
	 * @param string                           $orderby Field spec (path or path:format).
	 * @param string                           $order   asc|desc.
	 * @return array<int, array<string, mixed>>
	 */
	public static function sort_records( $records, $orderby, $order ) {
		$spec = self::parse_field_spec( $orderby );
		$dir  = 'asc' === strtolower( $order ) ? 1 : -1;

		usort( $records, static function ( $a, $b ) use ( $spec, $dir ) {
			$va = self::pluck( $a['data'], $spec['path'] );
			$vb = self::pluck( $b['data'], $spec['path'] );

			if ( is_numeric( $va ) && is_numeric( $vb ) ) {
				return ( (float) $va <=> (float) $vb ) * $dir;
			}

			return strcasecmp( (string) $va, (string) $vb ) * $dir;
		} );

		return $records;
	}

	/**
	 * Wrap the template output with the optional search box, style preset
	 * class, aspect variable, and pagination links.
	 *
	 * @param string               $html      Template HTML.
	 * @param array<string, mixed> $args      Render args.
	 * @param string               $template  Template name.
	 * @param int                  $page_size Records per page (0 = off).
	 * @param int                  $page      Current page.
	 * @param int                  $total     Total records.
	 * @param string               $page_key  Query key for this list.
	 * @return string
	 */
	private static function wrap_output( $html, $args, $template, $page_size, $page, $total, $page_key ) {
		$style      = sanitize_key( (string) $args['style'] );
		$searchable = ! empty( $args['searchable'] ) && 'raw' !== $template;
		$aspect     = (string) $args['image_aspect'];
		$classes    = array( 'datastream-output', 'datastream-output--' . $template );

		if ( '' !== $style ) {
			$classes[] = 'datastream-style-' . $style;
		}

		$style_attr = '';

		if ( 1 === preg_match( '#^(1/1|4/3|16/9|3/4)$#', $aspect ) ) {
			$style_attr = ' style="--ds-aspect: ' . esc_attr( $aspect ) . '"';
		}

		$out = '<div class="' . esc_attr( implode( ' ', $classes ) ) . '"' . $style_attr . ( $searchable ? ' data-ds-filterable' : '' ) . '>';

		if ( $searchable ) {
			if ( wp_style_is( 'datastream-filter', 'registered' ) || function_exists( 'wp_enqueue_script' ) ) {
				wp_enqueue_script( 'datastream-filter' );
			}

			$out .= '<p class="datastream-search"><input type="search" class="datastream-search-input" placeholder="'
				. esc_attr__( 'Type to filter…', 'datastream-api-sync' )
				. '" aria-label="' . esc_attr__( 'Filter the list below', 'datastream-api-sync' ) . '" /></p>';
		}

		$out .= $html;

		if ( $page_size > 0 && $total > $page_size ) {
			$pages = (int) ceil( $total / $page_size );
			$out  .= '<nav class="datastream-pagination" aria-label="' . esc_attr__( 'List pages', 'datastream-api-sync' ) . '">';

			for ( $i = 1; $i <= $pages; $i++ ) {
				if ( $i === $page ) {
					$out .= '<span class="datastream-page is-current" aria-current="page">' . esc_html( (string) $i ) . '</span>';
					continue;
				}

				$url  = add_query_arg( array( $page_key => $i ) );
				$out .= '<a class="datastream-page" href="' . esc_url( $url ) . '">' . esc_html( (string) $i ) . '</a>';
			}

			$out .= '</nav>';
		}

		return $out . '</div>';
	}

	/**
	 * Render a single field of a single record.
	 *
	 * @param string $external_id Record identifier.
	 * @param string $field       Field name or dot path within the payload.
	 * @param string $default     Fallback when missing.
	 * @return string Escaped HTML text.
	 */
	public static function render_field( $external_id, $field, $default = '' ) {
		$data = DataStream_Provider::get_data( $external_id );

		if ( null === $data ) {
			return esc_html( $default );
		}

		$value = '' === $field ? null : self::pluck( $data, $field );

		if ( null === $value ) {
			return esc_html( $default );
		}

		/**
		 * Filter a single rendered field value before escaping.
		 *
		 * @param mixed  $value       Raw value.
		 * @param string $field       Requested field path.
		 * @param string $external_id Record identifier.
		 */
		$value = apply_filters( 'datastream_render_field_value', $value, $field, $external_id );

		return esc_html( self::format_value( $value ) );
	}

	/**
	 * Walk a dot path through the payload, matching keys verbatim.
	 *
	 * @param array<string, mixed> $data Decoded payload.
	 * @param string               $path Dot path.
	 * @return mixed Null when unreachable.
	 */
	public static function pluck( $data, $path ) {
		$segments = array_filter( array_map( 'trim', explode( '.', (string) $path ) ), 'strlen' );

		if ( empty( $segments ) ) {
			return null;
		}

		$current = $data;

		foreach ( $segments as $segment ) {
			if ( ! is_array( $current ) || ! array_key_exists( $segment, $current ) ) {
				return null;
			}

			$current = $current[ $segment ];
		}

		return $current;
	}

	/**
	 * Format any payload value as display text.
	 *
	 * @param mixed $value Payload value.
	 * @return string
	 */
	public static function format_value( $value ) {
		if ( is_bool( $value ) ) {
			return $value ? 'true' : 'false';
		}

		if ( is_scalar( $value ) ) {
			return (string) $value;
		}

		if ( is_array( $value ) ) {
			$encoded = wp_json_encode( $value );
			return false !== $encoded ? $encoded : '';
		}

		return '';
	}

	/**
	 * Determine which fields to show: the explicit list, or the scalar keys
	 * of the first record (capped). Each entry is a spec: path plus an
	 * optional ":format" suffix.
	 *
	 * @param string                           $fields  Comma-separated specs.
	 * @param array<int, array<string, mixed>> $records Decoded records.
	 * @return array<int, array{path: string, format: string}>
	 */
	private static function resolve_fields( $fields, $records ) {
		$explicit = array_filter( array_map( 'trim', explode( ',', $fields ) ), 'strlen' );

		if ( ! empty( $explicit ) ) {
			return array_map( array( __CLASS__, 'parse_field_spec' ), array_values( $explicit ) );
		}

		$derived = array();

		foreach ( $records[0]['data'] as $key => $value ) {
			if ( is_scalar( $value ) || null === $value ) {
				$derived[] = array(
					'path'   => (string) $key,
					'format' => 'text',
				);
			}

			if ( count( $derived ) >= self::MAX_AUTO_FIELDS ) {
				break;
			}
		}

		return $derived;
	}

	/**
	 * Split "photo:image" into path + format. A suffix that is not a known
	 * format is treated as part of the path (APIs may use colons in keys).
	 *
	 * @param string $spec Field spec.
	 * @return array{path: string, format: string}
	 */
	public static function parse_field_spec( $spec ) {
		$pos = strrpos( $spec, ':' );

		if ( false !== $pos ) {
			$maybe = strtolower( substr( $spec, $pos + 1 ) );

			if ( in_array( $maybe, self::FORMATS, true ) ) {
				return array(
					'path'   => substr( $spec, 0, $pos ),
					'format' => $maybe,
				);
			}
		}

		return array(
			'path'   => $spec,
			'format' => 'text',
		);
	}

	/**
	 * Render one field value as SAFE HTML according to its format. Text is
	 * escaped; images and links are built from escaped parts.
	 *
	 * @param array<string, mixed> $data Decoded payload.
	 * @param array{path: string, format: string} $spec Field spec.
	 * @return string Safe HTML.
	 */
	public static function render_value_html( $data, $spec ) {
		$value = self::pluck( $data, $spec['path'] );

		if ( null === $value ) {
			return '';
		}

		switch ( $spec['format'] ) {
			case 'image':
				$url = esc_url( (string) $value );
				return '' === $url ? '' : '<img class="datastream-img" src="' . $url . '" alt="' . esc_attr( $spec['path'] ) . '" loading="lazy" />';

			case 'url':
				$url = esc_url( (string) $value );
				return '' === $url ? '' : '<a href="' . $url . '" rel="noopener nofollow">' . esc_html( (string) $value ) . '</a>';

			case 'email':
				$email = sanitize_email( (string) $value );
				return '' === $email ? esc_html( (string) $value ) : '<a href="mailto:' . esc_attr( $email ) . '">' . esc_html( $email ) . '</a>';

			case 'date':
				$timestamp = is_numeric( $value ) ? (int) $value : strtotime( (string) $value );
				return false === $timestamp || 0 === $timestamp
					? esc_html( self::format_value( $value ) )
					: esc_html( date_i18n( (string) get_option( 'date_format', 'F j, Y' ), $timestamp ) );

			case 'number':
				return is_numeric( $value )
					? esc_html( number_format_i18n( (float) $value, ( is_string( $value ) && false !== strpos( $value, '.' ) ) || is_float( $value ) ? 2 : 0 ) )
					: esc_html( self::format_value( $value ) );

			case 'bool':
				return $value ? '&#10003;' : '&#8212;';

			default:
				return esc_html( self::format_value( $value ) );
		}
	}

	/**
	 * Slug-ish class fragment for a field name (presentation only).
	 *
	 * @param string $field Field name.
	 * @return string
	 */
	private static function field_class( $field ) {
		$slug = strtolower( (string) preg_replace( '/[^A-Za-z0-9_-]+/', '-', $field ) );
		return trim( $slug, '-' );
	}

	/**
	 * Unordered-list template.
	 *
	 * @param array<int, array<string, mixed>> $records Records.
	 * @param array<int, string>               $fields  Field names.
	 * @return string
	 */
	private static function render_ul( $records, $fields ) {
		$html = '<ul class="datastream-list">';

		foreach ( $records as $record ) {
			$html .= '<li class="datastream-list-item">';

			foreach ( array_values( $fields ) as $index => $spec ) {
				$value_html = self::render_value_html( $record['data'], $spec );

				if ( 0 === $index ) {
					$html .= '<span class="datastream-item-title">' . $value_html . '</span>';
					continue;
				}

				$html .= '<span class="datastream-item-field datastream-field--' . esc_attr( self::field_class( $spec['path'] ) ) . '">';
				$html .= '<span class="datastream-item-label">' . esc_html( $spec['path'] ) . '</span> ';
				$html .= $value_html;
				$html .= '</span>';
			}

			$html .= '</li>';
		}

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

	/**
	 * Table template.
	 *
	 * @param array<int, array<string, mixed>> $records Records.
	 * @param array<int, string>               $fields  Field names.
	 * @return string
	 */
	private static function render_table( $records, $fields ) {
		$html = '<div class="datastream-table-wrap"><table class="datastream-table"><thead><tr>';

		foreach ( $fields as $spec ) {
			$html .= '<th scope="col">' . esc_html( $spec['path'] ) . '</th>';
		}

		$html .= '</tr></thead><tbody>';

		foreach ( $records as $record ) {
			$html .= '<tr>';

			foreach ( $fields as $spec ) {
				$html .= '<td>' . self::render_value_html( $record['data'], $spec ) . '</td>';
			}

			$html .= '</tr>';
		}

		return $html . '</tbody></table></div>';
	}

	/**
	 * Card-grid template.
	 *
	 * @param array<int, array<string, mixed>> $records Records.
	 * @param array<int, string>               $fields  Field names.
	 * @return string
	 */
	private static function render_cards( $records, $fields ) {
		$html = '<div class="datastream-cards">';

		foreach ( $records as $record ) {
			$html .= '<div class="datastream-card">';

			foreach ( array_values( $fields ) as $index => $spec ) {
				$value_html = self::render_value_html( $record['data'], $spec );

				if ( 0 === $index ) {
					$html .= '<p class="datastream-card-title">' . $value_html . '</p><dl class="datastream-card-fields">';
					continue;
				}

				$html .= '<dt>' . esc_html( $spec['path'] ) . '</dt><dd>' . $value_html . '</dd>';
			}

			if ( count( $fields ) > 1 ) {
				$html .= '</dl>';
			}

			$html .= '</div>';
		}

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

	/**
	 * Grid template: image-led cards. The first :image field becomes the
	 * banner; the first non-image field is the title; the rest are meta.
	 *
	 * @param array<int, array<string, mixed>>              $records Records.
	 * @param array<int, array{path: string, format: string}> $fields  Field specs.
	 * @return string
	 */
	private static function render_grid( $records, $fields ) {
		$image_spec = null;
		$rest       = array();

		foreach ( $fields as $spec ) {
			if ( null === $image_spec && 'image' === $spec['format'] ) {
				$image_spec = $spec;
				continue;
			}
			$rest[] = $spec;
		}

		$html = '<div class="datastream-grid">';

		foreach ( $records as $record ) {
			$html .= '<div class="datastream-grid-item">';

			if ( null !== $image_spec ) {
				$html .= '<div class="datastream-grid-media">' . self::render_value_html( $record['data'], $image_spec ) . '</div>';
			}

			foreach ( array_values( $rest ) as $index => $spec ) {
				$value_html = self::render_value_html( $record['data'], $spec );

				if ( 0 === $index ) {
					$html .= '<p class="datastream-grid-title">' . $value_html . '</p>';
					continue;
				}

				$html .= '<p class="datastream-grid-meta">' . $value_html . '</p>';
			}

			$html .= '</div>';
		}

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

	/**
	 * Raw JSON template (for developers and debugging).
	 *
	 * @param array<int, array<string, mixed>> $records Records.
	 * @return string
	 */
	private static function render_raw( $records ) {
		$payloads = array();

		foreach ( $records as $record ) {
			$payloads[] = array(
				'external_id' => $record['external_id'],
				'stream_type' => $record['stream_type'],
				'updated_at'  => $record['updated_at'],
				'data'        => $record['data'],
			);
		}

		$json = wp_json_encode( $payloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );

		return '<pre class="datastream-raw">' . esc_html( false !== $json ? $json : '[]' ) . '</pre>';
	}
}
