<?php
declare(strict_types=1);

namespace Wohno\Plugin\Render;

use Wohno\Plugin\Support\Config;

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

/**
 * Renders a native (SEO-friendly, theme-styled) grid of listing teaser cards.
 *
 * Phase 1: list/grid only. Each card links OUT to the canonical detail page on
 * `wohno.de/inserat/{slug}` — detail + Bewerbung stay first-party on Wohno
 * (Mappe-/PII-Gate). This outbound-link model is what keeps the makler's list
 * page from cannibalising Wohno's per-listing pages; a per-listing
 * `rel=canonical` only becomes relevant once full detail content is rendered
 * natively (Phase 2).
 */
final class ListingsRenderer {

	/**
	 * Whitelist of resolvable template basenames (theme-overridable).
	 *
	 * @var string[]
	 */
	private const TEMPLATES = array( 'listing-card' );

	/**
	 * Max description length embedded per card. Longer descriptions are sent as
	 * a teaser and lazy-loaded in full when the modal opens.
	 *
	 * @var int
	 */
	private const DESCRIPTION_TEASER = 280;

	/**
	 * Render a grid of listing cards as escaped HTML.
	 *
	 * @param array<int, array<string, mixed>>           $listings Raw ListingPublicDto items.
	 * @param array{columns?: int, show_discrete?: bool} $opts     Layout options.
	 * @return string Escaped HTML, safe to echo.
	 */
	public function grid( array $listings, array $opts = array() ): string {
		$columns       = isset( $opts['columns'] ) ? max( 1, min( 4, (int) $opts['columns'] ) ) : 3;
		$show_discrete = ! empty( $opts['show_discrete'] );
		$layout        = self::sanitize_layout( $opts['layout'] ?? 'grid' );

		$cards = $this->cards_html( $listings, array( 'show_discrete' => $show_discrete ) );

		if ( '' === $cards ) {
			return '<p class="wohno-empty">' . esc_html__( 'Aktuell keine passenden Inserate.', 'wohno' ) . '</p>';
		}

		wp_enqueue_style( 'wohno-listings' );
		wp_enqueue_script( 'wohno-listings' );

		return sprintf(
			'<div class="wohno-listings-wrap"><div class="wohno-listings wohno-cols-%1$d wohno-layout-%2$s" data-wohno-grid>%3$s</div>%4$s%5$s</div>',
			$columns,
			$layout,
			$cards,
			$this->load_more_button( $opts ),
			$this->attribution()
		);
	}

	/**
	 * Normalise a requested collection layout to a known token.
	 *
	 * @param mixed $layout Raw layout value (grid|cards|list; anything else → grid).
	 * @return string One of: grid, cards, list.
	 */
	public static function sanitize_layout( $layout ): string {
		$layout = is_string( $layout ) ? strtolower( $layout ) : 'grid';

		return in_array( $layout, array( 'grid', 'cards', 'list' ), true ) ? $layout : 'grid';
	}

	/**
	 * Render the public search / filter bar (GET form, server-rendered).
	 *
	 * The form submits back to the same page; the listing fetch reads the
	 * `wohno_*` query params ({@see \Wohno\Plugin\Api\ListingQuery::request_values()})
	 * and re-renders the results. No JavaScript and no client-side API access —
	 * the secret key stays server-side.
	 *
	 * @param array<string, mixed>                                      $values    Current effective filter values (for pre-fill).
	 * @param array{action?: string, preserved?: array<string, string>} $opts Form context.
	 * @return string Escaped HTML, safe to echo.
	 */
	public function search_form( array $values, array $opts = array() ): string {
		wp_enqueue_style( 'wohno-listings' );

		$prefix    = \Wohno\Plugin\Api\ListingQuery::REQUEST_PREFIX;
		$action    = isset( $opts['action'] ) && '' !== $opts['action'] ? (string) $opts['action'] : home_url( '/' );
		$preserved = isset( $opts['preserved'] ) && is_array( $opts['preserved'] ) ? $opts['preserved'] : array();

		$cur = static fn ( string $k ): string => isset( $values[ $k ] ) ? (string) $values[ $k ] : '';

		$property_types = array(
			''          => __( 'Alle Typen', 'wohno' ),
			'apartment' => __( 'Wohnung', 'wohno' ),
			'house'     => __( 'Haus', 'wohno' ),
			'studio'    => __( 'Studio', 'wohno' ),
			'wg_room'   => __( 'WG-Zimmer', 'wohno' ),
			'other'     => __( 'Sonstiges', 'wohno' ),
		);
		$room_options   = array(
			''  => __( 'Zimmer (beliebig)', 'wohno' ),
			'1' => __( 'ab 1 Zimmer', 'wohno' ),
			'2' => __( 'ab 2 Zimmer', 'wohno' ),
			'3' => __( 'ab 3 Zimmer', 'wohno' ),
			'4' => __( 'ab 4 Zimmer', 'wohno' ),
			'5' => __( 'ab 5 Zimmer', 'wohno' ),
		);

		$hidden = sprintf( '<input type="hidden" name="%ssearch" value="1" />', esc_attr( $prefix ) );
		foreach ( $preserved as $name => $value ) {
			$hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( (string) $name ), esc_attr( (string) $value ) );
		}

		$type_options = '';
		foreach ( $property_types as $value => $label ) {
			$type_options .= sprintf(
				'<option value="%s"%s>%s</option>',
				esc_attr( $value ),
				selected( $cur( 'property_type' ), $value, false ),
				esc_html( $label )
			);
		}
		$rooms_options = '';
		foreach ( $room_options as $value => $label ) {
			$rooms_options .= sprintf(
				'<option value="%s"%s>%s</option>',
				esc_attr( $value ),
				selected( $cur( 'rooms_min' ), $value, false ),
				esc_html( $label )
			);
		}

		$reset_url = empty( $preserved ) ? $action : add_query_arg( $preserved, $action );

		return sprintf(
			'<form class="wohno-search" method="get" action="%1$s" role="search">%2$s'
			. '<div class="wohno-search__grid">'
			. '<div class="wohno-search__field wohno-search__field--wide wohno-search__field--search">'
			. '<input class="wohno-search__control" type="search" name="%3$sq" value="%4$s" placeholder="%5$s" aria-label="%5$s" />'
			. '</div>'
			. '<div class="wohno-search__field wohno-search__field--pin">'
			. '<input class="wohno-search__control" type="text" name="%3$scity" value="%6$s" placeholder="%7$s" aria-label="%7$s" />'
			. '</div>'
			. '<div class="wohno-search__field">'
			. '<select class="wohno-search__control" name="%3$sproperty_type" aria-label="%8$s">%9$s</select>'
			. '</div>'
			. '<div class="wohno-search__field">'
			. '<select class="wohno-search__control" name="%3$srooms_min" aria-label="%10$s">%11$s</select>'
			. '</div>'
			. '<div class="wohno-search__field wohno-search__field--euro">'
			. '<input class="wohno-search__control" type="number" min="0" step="50" name="%3$srent_max" value="%12$s" placeholder="%13$s" aria-label="%13$s" />'
			. '</div>'
			. '<label class="wohno-search__wbs"><input type="checkbox" name="%3$swbs_only" value="1"%14$s /><span>%15$s</span></label>'
			. '</div>'
			. '<div class="wohno-search__actions">'
			. '<button class="wohno-search__submit" type="submit">%16$s</button>'
			. '<a class="wohno-search__reset" href="%17$s">%18$s</a>'
			. '</div></form>',
			esc_url( $action ),
			$hidden,
			esc_attr( $prefix ),
			esc_attr( $cur( 'q' ) ),
			esc_attr__( 'Suchbegriff …', 'wohno' ),
			esc_attr( $cur( 'city' ) ),
			esc_attr__( 'Stadt', 'wohno' ),
			esc_attr__( 'Objekttyp', 'wohno' ),
			$type_options,
			esc_attr__( 'Zimmer', 'wohno' ),
			$rooms_options,
			esc_attr( $cur( 'rent_max' ) ),
			esc_attr__( 'Max. Miete €', 'wohno' ),
			checked( $cur( 'wbs_only' ), '1', false ),
			esc_html__( 'Nur WBS', 'wohno' ),
			esc_html__( 'Suchen', 'wohno' ),
			esc_url( $reset_url ),
			esc_html__( 'Zurücksetzen', 'wohno' )
		);
	}

	/**
	 * Render just the listing cards (no wrapper/attribution). Used by the grid
	 * and by the "load more" AJAX response.
	 *
	 * @param array<int, array<string, mixed>> $listings Raw ListingPublicDto items.
	 * @param array{show_discrete?: bool}      $opts     Options.
	 * @return string Concatenated escaped card HTML ('' if none after filtering).
	 */
	public function cards_html( array $listings, array $opts = array() ): string {
		// Default: hide discrete listings (Phase 1 the API does not return them
		// anyway; the filter is the forward-compatible safety net for T5).
		if ( empty( $opts['show_discrete'] ) ) {
			$listings = array_filter(
				$listings,
				static fn ( array $l ): bool => empty( $l['isDiscrete'] )
			);
		}

		$cards = '';
		foreach ( $listings as $listing ) {
			$cards .= $this->card( $listing );
		}

		return $cards;
	}

	/**
	 * Render the "load more" button when the API reports another page. Carries
	 * the (sanitised) query + next cursor as data attributes for the JS.
	 *
	 * @param array{query?: array<string, scalar>, pagination?: array<string, mixed>, load_more?: bool, columns?: int} $opts Options.
	 * @return string Button HTML, or '' when there is no next page / disabled.
	 */
	private function load_more_button( array $opts ): string {
		if ( isset( $opts['load_more'] ) && false === $opts['load_more'] ) {
			return '';
		}
		$pagination = isset( $opts['pagination'] ) && is_array( $opts['pagination'] ) ? $opts['pagination'] : array();
		if ( empty( $pagination['has_more'] ) || empty( $pagination['next_cursor'] ) ) {
			return '';
		}

		// The query without cursor/limit-internal noise; JS re-sends it + cursor.
		$query = isset( $opts['query'] ) && is_array( $opts['query'] ) ? $opts['query'] : array();
		unset( $query['cursor'] );

		wp_enqueue_script( 'wohno-load-more' );

		return sprintf(
			'<button type="button" class="wohno-load-more" data-wohno-query="%1$s" data-wohno-cursor="%2$s">%3$s</button>',
			esc_attr( (string) wp_json_encode( $query ) ),
			esc_attr( (string) $pagination['next_cursor'] ),
			esc_html__( 'Mehr laden', 'wohno' )
		);
	}

	/**
	 * Render a single listing as one card (same markup/template as the grid).
	 *
	 * @param array<string, mixed> $listing One ListingPublicDto item.
	 * @return string Escaped HTML, safe to echo.
	 */
	public function single( array $listing ): string {
		if ( array() === $listing ) {
			return '<p class="wohno-empty">' . esc_html__( 'Inserat nicht verfügbar.', 'wohno' ) . '</p>';
		}

		wp_enqueue_style( 'wohno-listings' );
		wp_enqueue_script( 'wohno-listings' );

		return sprintf(
			'<div class="wohno-listings wohno-single">%s</div>%s',
			$this->card( $listing ),
			$this->attribution()
		);
	}

	/**
	 * Render the listings as an interactive map (Leaflet + OpenStreetMap, keyless).
	 *
	 * Only listings with numeric coordinates are placed (discrete listings expose
	 * no precise geo). Falls back to the card grid when nothing is mappable.
	 *
	 * @param array<int, array<string, mixed>> $listings Raw ListingPublicDto items.
	 * @param array<string, mixed>             $opts     Options (show_discrete, …).
	 * @return string Escaped HTML, safe to echo.
	 */
	public function map( array $listings, array $opts = array() ): string {
		$show_discrete = ! empty( $opts['show_discrete'] );

		$points = array();
		foreach ( $listings as $l ) {
			if ( ! $show_discrete && ! empty( $l['isDiscrete'] ) ) {
				continue;
			}
			$lat = $l['latitude'] ?? null;
			$lng = $l['longitude'] ?? null;
			if ( ! is_numeric( $lat ) || ! is_numeric( $lng ) ) {
				continue;
			}
			$points[] = array(
				'lat'   => (float) $lat,
				'lng'   => (float) $lng,
				'title' => isset( $l['title'] ) ? (string) $l['title'] : '',
				'url'   => $this->detail_url( $l ),
				'place' => $this->place( $l ),
				'price' => $this->price( $l ),
			);
		}

		// Nothing geocoded → the grid is the sensible fallback.
		if ( array() === $points ) {
			return $this->grid( $listings, $opts );
		}

		wp_enqueue_style( 'wohno-listings' );
		wp_enqueue_style( 'wohno-leaflet' );
		wp_enqueue_script( 'wohno-map' );

		return sprintf(
			'<div class="wohno-map" data-wohno-map="%s"></div>%s',
			esc_attr( (string) wp_json_encode( $points ) ),
			$this->attribution()
		);
	}

	/**
	 * Render a single listing teaser card.
	 *
	 * The markup lives in a template (`templates/listing-card.php`) which a theme
	 * can override by providing `wohno/listing-card.php`. The prepared (raw) card
	 * data is filterable via `wohno_listing_card_data`; the template is
	 * responsible for escaping on output.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string Escaped card HTML.
	 */
	private function card( array $l ): string {
		/**
		 * Filter the prepared (raw, unescaped) card data before rendering.
		 *
		 * @param array<string, string> $card Card fields: title, url, image, place, meta, price.
		 * @param array<string, mixed>  $l    The raw ListingPublicDto item.
		 */
		$card = apply_filters(
			'wohno_listing_card_data',
			array(
				'title'      => isset( $l['title'] ) ? (string) $l['title'] : '',
				'url'        => $this->detail_url( $l ),
				'image'      => $this->cover_image( $l ),
				'image_alt'  => $this->cover_alt( $l ),
				'place'      => $this->place( $l ),
				'meta'       => $this->meta_line( $l ),
				'rooms'      => $this->rooms_label( $l ),
				'area'       => $this->area_label( $l ),
				'price'      => $this->price( $l ),
				'price_note' => $this->price_note( $l ),
			),
			$l
		);

		// Gallery images (capped) + the detail payload the modal reads from a
		// data attribute — no extra API call needed.
		$card['images']  = array_slice( $this->images_list( $l ), 0, 8 );
		$card['payload'] = (string) wp_json_encode(
			$this->modal_payload( $l ),
			JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
		);

		return $this->load_template( 'listing-card', $card );
	}

	/**
	 * Render a card template, preferring a theme override.
	 *
	 * Looks for `wohno/{name}.php` in the active theme (child theme first), then
	 * falls back to the plugin's bundled `templates/{name}.php`.
	 *
	 * @param string               $name Template basename (without extension).
	 * @param array<string, mixed> $card Escaping-pending card data ($card in template scope).
	 * @return string Rendered HTML.
	 */
	private function load_template( string $name, array $card ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $card is consumed by the included template scope.
		// Defence in depth: only ever resolve a known template name, so the
		// include path can never become caller-/data-controlled.
		if ( ! in_array( $name, self::TEMPLATES, true ) ) {
			return '';
		}

		$template = function_exists( 'locate_template' ) ? locate_template( array( "wohno/{$name}.php" ) ) : '';
		if ( ! is_string( $template ) || '' === $template || ! is_readable( $template ) ) {
			$template = WOHNO_PLUGIN_DIR . "templates/{$name}.php";
		}

		ob_start();
		include $template; // $card is in scope for the template.
		return (string) ob_get_clean();
	}

	/**
	 * Build the canonical Wohno detail URL: prefer the slug, fall back to the
	 * id (which redirects to the slug server-side).
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string Absolute URL.
	 */
	private function detail_url( array $l ): string {
		$base = Config::api_base();
		$slug = isset( $l['slug'] ) && is_string( $l['slug'] ) && '' !== $l['slug'] ? $l['slug'] : null;
		$path = $slug ?? ( isset( $l['id'] ) ? (string) $l['id'] : '' );

		return $base . '/inserat/' . rawurlencode( $path );
	}

	/**
	 * Extract the cover image URL (first image), or an empty string.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function cover_image( array $l ): string {
		if ( empty( $l['images'] ) || ! is_array( $l['images'] ) ) {
			return '';
		}
		$first = $l['images'][0] ?? null;

		return is_array( $first ) && isset( $first['url'] ) ? (string) $first['url'] : '';
	}

	/**
	 * Extract the cover image's alt text (first image), falling back to ''.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function cover_alt( array $l ): string {
		if ( empty( $l['images'] ) || ! is_array( $l['images'] ) ) {
			return '';
		}
		$first = $l['images'][0] ?? null;

		return is_array( $first ) && isset( $first['alt'] ) ? (string) $first['alt'] : '';
	}

	/**
	 * All image URLs (+ alt) for the in-card gallery / modal.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return array<int, array{url: string, alt: string}>
	 */
	private function images_list( array $l ): array {
		if ( empty( $l['images'] ) || ! is_array( $l['images'] ) ) {
			return array();
		}
		$out = array();
		foreach ( $l['images'] as $img ) {
			if ( is_array( $img ) && ! empty( $img['url'] ) ) {
				$out[] = array(
					'url' => (string) $img['url'],
					'alt' => isset( $img['alt'] ) ? (string) $img['alt'] : '',
				);
			}
		}

		return $out;
	}

	/**
	 * Structured payload the detail modal renders client-side (read from a card
	 * data attribute). Built from the list response — no extra API call.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return array<string, mixed>
	 */
	private function modal_payload( array $l ): array {
		// Keep the per-card payload light: embed the full description only when it
		// is short; otherwise embed a teaser and let the modal lazy-load the full
		// text by id on open (saves page weight with many/long listings).
		$full     = isset( $l['description'] ) ? (string) $l['description'] : '';
		$is_short = mb_strlen( $full ) <= self::DESCRIPTION_TEASER;
		$descr    = $is_short ? $full : ( mb_substr( $full, 0, self::DESCRIPTION_TEASER ) . '…' );

		return array(
			'id'              => isset( $l['id'] ) ? (string) $l['id'] : '',
			'title'           => isset( $l['title'] ) ? (string) $l['title'] : '',
			'url'             => $this->detail_url( $l ),
			'images'          => $this->images_list( $l ),
			'price'           => $this->price( $l ),
			'priceNote'       => $this->price_note( $l ),
			'place'           => $this->place( $l ),
			'address'         => $this->address_line( $l ),
			'rooms'           => ! empty( $l['rooms'] ) ? $this->num( $l['rooms'] ) : '',
			'bedrooms'        => isset( $l['bedrooms'] ) && '' !== $l['bedrooms'] ? (string) (int) $l['bedrooms'] : '',
			'bathrooms'       => isset( $l['bathrooms'] ) && '' !== $l['bathrooms'] ? (string) (int) $l['bathrooms'] : '',
			'area'            => ! empty( $l['livingArea'] ) ? $this->num( $l['livingArea'] ) . ' m²' : '',
			'available'       => $this->available_label( $l ),
			'type'            => $this->property_type_label( $l ),
			'costs'           => $this->cost_rows( $l ),
			'description'     => $descr,
			'descriptionFull' => $is_short,
		);
	}

	/**
	 * Full address line for the modal (street, ZIP city, district).
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function address_line( array $l ): string {
		$street   = trim( (string) ( $l['street'] ?? '' ) . ' ' . (string) ( $l['houseNumber'] ?? '' ) );
		$cityline = trim( (string) ( $l['zipCode'] ?? '' ) . ' ' . (string) ( $l['city'] ?? '' ) );
		$district = (string) ( $l['district'] ?? '' );

		$parts = array_filter( array( $street, $cityline, $district ) );

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

	/**
	 * Availability label ("Sofort verfügbar" or a DD.MM.YYYY date), or ''.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function available_label( array $l ): string {
		if ( ! empty( $l['availableImmediately'] ) ) {
			return __( 'Sofort verfügbar', 'wohno' );
		}
		$from = (string) ( $l['availableFrom'] ?? '' );
		if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})/', $from, $m ) === 1 ) {
			// Format in the site's locale/date format (e.g. "1. Juli 2026" / "July 1, 2026").
			$timestamp = gmmktime( 0, 0, 0, (int) $m[2], (int) $m[3], (int) $m[1] );
			return date_i18n( (string) get_option( 'date_format', 'd.m.Y' ), $timestamp );
		}

		return $from;
	}

	/**
	 * Human label for the property type, or '' when unknown.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function property_type_label( array $l ): string {
		$map  = array(
			'apartment' => __( 'Wohnung', 'wohno' ),
			'house'     => __( 'Haus', 'wohno' ),
			'studio'    => __( 'Studio', 'wohno' ),
			'wg_room'   => __( 'WG-Zimmer', 'wohno' ),
			'other'     => __( 'Sonstiges', 'wohno' ),
		);
		$type = (string) ( $l['propertyType'] ?? '' );

		return $map[ $type ] ?? '';
	}

	/**
	 * The room-count label (e.g. "3 Zi."), or '' when unknown.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function rooms_label( array $l ): string {
		if ( empty( $l['rooms'] ) ) {
			return '';
		}

		/* translators: %s: number of rooms */
		return sprintf( __( '%s Zi.', 'wohno' ), $this->num( $l['rooms'] ) );
	}

	/**
	 * The living-area label (e.g. "72,5 m²"), or '' when unknown.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function area_label( array $l ): string {
		if ( empty( $l['livingArea'] ) ) {
			return '';
		}

		return sprintf( '%s m²', $this->num( $l['livingArea'] ) );
	}

	/**
	 * Build the "rooms · area" meta line (kept for theme overrides /
	 * backward compatibility; the default template uses the icon stats).
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function meta_line( array $l ): string {
		$parts = array_filter( array( $this->rooms_label( $l ), $this->area_label( $l ) ) );

		return implode( ' · ', $parts );
	}

	/**
	 * Short qualifier for the headline price (Warm-/Kaltmiete), or '' when the
	 * price is on request / not a rent figure.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function price_note( array $l ): string {
		if ( isset( $l['rentWarm'] ) && '' !== $l['rentWarm'] ) {
			return __( 'Warmmiete', 'wohno' );
		}
		if ( isset( $l['rentCold'] ) && '' !== $l['rentCold'] ) {
			return __( 'Kaltmiete', 'wohno' );
		}

		return '';
	}

	/**
	 * Build the "ZIP City" place line.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function place( array $l ): string {
		$zip  = isset( $l['zipCode'] ) ? (string) $l['zipCode'] : '';
		$city = isset( $l['city'] ) ? (string) $l['city'] : '';

		return trim( $zip . ' ' . $city );
	}

	/**
	 * Format the headline price (warm rent, else cold rent).
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return string
	 */
	private function price( array $l ): string {
		$value = $l['rentWarm'] ?? $l['rentCold'] ?? null;
		if ( null === $value || '' === $value ) {
			return __( 'Preis auf Anfrage', 'wohno' );
		}

		return $this->format_euros( $value );
	}

	/**
	 * Format a money amount. Wohno delivers all prices in CENT
	 * (e.g. 70000 = 700,00 €), so we divide by 100 before formatting.
	 *
	 * @param mixed $cents Amount in cents.
	 * @return string Localised amount with the € sign (e.g. "700,00 €").
	 */
	private function format_euros( $cents ): string {
		return number_format_i18n( (float) $cents / 100, 2 ) . ' €';
	}

	/**
	 * Build the cost breakdown rows (cold rent, utilities, heating, warm rent,
	 * deposit) for the modal — only the values the listing provides.
	 *
	 * @param array<string, mixed> $l One ListingPublicDto item.
	 * @return array<int, array{label: string, value: string}>
	 */
	private function cost_rows( array $l ): array {
		$map = array(
			'rentCold'      => __( 'Kaltmiete', 'wohno' ),
			'rentUtilities' => __( 'Nebenkosten', 'wohno' ),
			'rentHeating'   => __( 'Heizkosten', 'wohno' ),
			'rentWarm'      => __( 'Warmmiete', 'wohno' ),
			'depositAmount' => __( 'Kaution', 'wohno' ),
		);

		$rows = array();
		foreach ( $map as $key => $label ) {
			if ( isset( $l[ $key ] ) && '' !== $l[ $key ] ) {
				$rows[] = array(
					'label' => $label,
					'value' => $this->format_euros( $l[ $key ] ),
				);
			}
		}

		return $rows;
	}

	/**
	 * Format a number, dropping the decimal for whole values.
	 *
	 * @param mixed $value Numeric-ish value.
	 * @return string
	 */
	private function num( $value ): string {
		$f = (float) $value;

		return floor( $f ) === $f ? (string) (int) $f : number_format_i18n( $f, 1 );
	}

	/**
	 * The "powered by Wohno" attribution line.
	 *
	 * Off by default. The site admin must explicitly opt in via the
	 * `wohno_show_attribution` option on the settings page before this public
	 * credit link to wohno.de is rendered.
	 *
	 * @return string Escaped HTML, or '' when not opted in.
	 */
	private function attribution(): string {
		if ( '1' !== (string) get_option( 'wohno_show_attribution', '0' ) ) {
			return '';
		}

		return sprintf(
			'<p class="wohno-attribution">%s <a href="%s" target="_blank" rel="noopener">Wohno</a></p>',
			esc_html__( 'Bereitgestellt von', 'wohno' ),
			esc_url( Config::api_base() )
		);
	}
}
