<?php
/**
 * The three audit list tables (Search Appearance / Social Appearance /
 * Issues) as WP_List_Table variants. Search and Social list EVERY scanned
 * item; Issues is the filtered view. Rows behave like Pages > All Pages:
 * title links to the editor, hover row actions are inherited from core's
 * post/page row-action filters so builder plugins (Elementor, Divi, ...)
 * appear automatically. Quick Edit is deliberately excluded (core inline-
 * edit JS is welded to the posts screen; agreed 2026-06-10).
 *
 * @package search-social-appearance-audit
 */

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

if ( ! class_exists( 'WP_List_Table' ) ) {
	require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

class SSAA_Results_Table extends WP_List_Table {

	/** Issue codes that belong to the canonical flag column. */
	const CANONICAL_CODES = array( 'duplicate_canonicals', 'canonical_mismatch', 'canonical_not_absolute', 'missing_canonical', 'canonical_noindex_conflict' );

	/** @var string search|social|issues */
	private $tab;

	/**
	 * @param string $tab search|social|issues.
	 */
	public function __construct( $tab ) {
		$this->tab = in_array( $tab, array( 'search', 'social', 'issues' ), true ) ? $tab : 'search';

		parent::__construct( array(
			'singular' => 'ssaa_result',
			'plural'   => 'ssaa_results',
			'ajax'     => false,
		) );
	}

	// -------------------------------------------------------------------------
	// Columns
	// -------------------------------------------------------------------------

	public function get_columns() {
		// Issues sits second (right after Title) on every tab - primary
		// triage signal next to the identifier (agreed 2026-06-11).
		// Every set passes through the ssaa_table_columns extension filter
		// so add-on files can insert their own columns.
		if ( 'social' === $this->tab ) {
			// One "Social preview" cell replaces the old OG Image / OG Title /
			// OG Description / Twitter Card columns - it renders like an actual
			// share card and stops the narrow-screen column collapse.
			return (array) apply_filters( 'ssaa_table_columns', array(
				'cb'             => '<input type="checkbox" />',
				'title'          => __( 'Title', 'search-social-appearance-audit' ),
				'issues'         => __( 'Issues', 'search-social-appearance-audit' ),
				'social_preview' => __( 'Social preview', 'search-social-appearance-audit' ),
				'scanned_at'     => __( 'Scanned (UTC)', 'search-social-appearance-audit' ),
			), $this->tab );
		}

		if ( 'issues' === $this->tab ) {
			return (array) apply_filters( 'ssaa_table_columns', array(
				'cb'         => '<input type="checkbox" />',
				'title'      => __( 'Title', 'search-social-appearance-audit' ),
				'issues'     => __( 'Issues', 'search-social-appearance-audit' ),
				'post_type'  => __( 'Type', 'search-social-appearance-audit' ),
				'areas'      => __( 'Area', 'search-social-appearance-audit' ),
				'summary'    => __( 'Issue summary', 'search-social-appearance-audit' ),
				'scanned_at' => __( 'Scanned (UTC)', 'search-social-appearance-audit' ),
			), $this->tab );
		}

		// Search tab: a single "Search preview" cell rendered like a Google
		// mobile result. Author, Canonical and Robots fold INTO the card as a
		// chip row, so the column set matches the Social tab exactly.
		return (array) apply_filters( 'ssaa_table_columns', array(
			'cb'             => '<input type="checkbox" />',
			'title'          => __( 'Title', 'search-social-appearance-audit' ),
			'issues'         => __( 'Issues', 'search-social-appearance-audit' ),
			'search_preview' => __( 'Search preview', 'search-social-appearance-audit' ),
			'scanned_at'     => __( 'Scanned (UTC)', 'search-social-appearance-audit' ),
		), $this->tab );
	}

	protected function get_sortable_columns() {
		return array(
			'title'      => array( 'post_title', false ),
			'post_type'  => array( 'post_type', false ),
			'issues'     => array( $this->rank_column(), true ),
			'scanned_at' => array( 'scanned_at', true ),
		);
	}

	/**
	 * The severity sort key for this tab. Search and Social sort by their OWN
	 * area's severity (search_rank / social_rank) so the order matches the
	 * per-area badges shown on those tabs; the Findings tab shows every area, so
	 * it sorts by the whole-row severity_rank.
	 *
	 * @return string
	 */
	private function rank_column() {
		if ( 'search' === $this->tab ) {
			return 'search_rank';
		}
		if ( 'social' === $this->tab ) {
			return 'social_rank';
		}
		return 'severity_rank';
	}

	protected function get_bulk_actions() {
		/**
		 * Extension point: add-on files append their own bulk actions
		 * (handled via the matching action in SSAA_Admin::handle_actions).
		 */
		return (array) apply_filters( 'ssaa_bulk_actions', array(
			'ssaa_rescan'  => __( 'Re-scan', 'search-social-appearance-audit' ),
			'ssaa_exclude' => __( 'Exclude from Appearance audit', 'search-social-appearance-audit' ),
		) );
	}

	protected function column_cb( $item ) {
		// The row ref ('post:ID' / 'url:hash') since v9 - archives have no
		// usable post_id. Handlers still accept bare IDs (back-compat with a
		// form cached from an older page).
		$value = ! empty( $item['ref'] ) ? (string) $item['ref'] : (string) $item['post_id'];
		return '<input type="checkbox" name="ssaa_results[]" value="' . esc_attr( $value ) . '" />';
	}

	// -------------------------------------------------------------------------
	// Filters (WordPress-standard: status views + type dropdown)
	// -------------------------------------------------------------------------

	/**
	 * Status filter views (All | Published | Drafts | Scheduled | Pending) - the
	 * WordPress-standard subsubsub tabs core uses on edit.php, via
	 * WP_List_Table::views(). SSAA excludes Private from the audit, so there is no
	 * Private view. Shown only when there is unpublished content to filter, so the
	 * free build (published content only) shows no status tabs. Counts honour the
	 * current tab (the Findings tab counts only rows with issues/errors). The
	 * active `tab` and any type filter are preserved via the current URL so the
	 * axes compose (e.g. Drafts + Products = draft products). The array KEY becomes
	 * the <li> CSS class, so keys are prefixed `ssaa-status-` to avoid clashing
	 * with core admin styles (the precaution LIHA documents).
	 *
	 * @return array
	 */
	protected function get_views() {
		$with_issues = ( 'issues' === $this->tab );
		$counts      = SSAA_Results::status_counts( $with_issues );

		$non_published = 0;
		foreach ( $counts as $st => $n ) {
			if ( 'publish' !== $st ) {
				$non_published += (int) $n;
			}
		}
		if ( $non_published < 1 ) {
			return array(); // Nothing but published content - no status axis to show.
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list filter (GET navigation).
		$current = isset( $_GET['ssaa_status'] ) ? sanitize_key( wp_unslash( $_GET['ssaa_status'] ) ) : '';
		$base    = remove_query_arg( array( 'ssaa_status', 'paged' ) ); // Keeps tab + ssaa_type.
		$total   = array_sum( array_map( 'intval', $counts ) );

		$labels = array(
			'all'     => __( 'All', 'search-social-appearance-audit' ),
			'publish' => __( 'Published', 'search-social-appearance-audit' ),
			'draft'   => __( 'Drafts', 'search-social-appearance-audit' ),
			'future'  => __( 'Scheduled', 'search-social-appearance-audit' ),
			'pending' => __( 'Pending', 'search-social-appearance-audit' ),
		);

		$views = array();
		foreach ( $labels as $status => $label ) {
			$n = ( 'all' === $status ) ? $total : ( isset( $counts[ $status ] ) ? (int) $counts[ $status ] : 0 );
			if ( 'all' !== $status && $n < 1 ) {
				continue; // Hide empty buckets, like core.
			}
			$url           = ( 'all' === $status ) ? $base : add_query_arg( 'ssaa_status', $status, $base );
			$is_all        = ( 'all' === $status ) && ( '' === $current || 'all' === $current );
			$current_class = ( $current === $status || $is_all ) ? ' class="current"' : '';
			$views[ 'ssaa-status-' . $status ] = sprintf(
				'<a href="%s"%s>%s <span class="count">(%s)</span></a>',
				esc_url( $url ),
				$current_class,
				esc_html( $label ),
				esc_html( number_format_i18n( $n ) )
			);
		}
		return $views;
	}

	/**
	 * Type filter as a standard dropdown in the tablenav - the core
	 * extra_tablenav() home for secondary filters (where edit.php puts its
	 * category / date dropdowns). Counts ride in the option labels, like
	 * wp_dropdown_categories( show_count ). Shown only when more than one type
	 * exists. Synthesized archive rows are one "Archives" bucket appended
	 * after the real types (the reserved '__archive__' value; LIHA pattern).
	 * The form (method=get, with hidden page/tab/status) is the dashboard's
	 * results form; the Filter button submits it, preserving the active tab
	 * and status so the axes compose.
	 *
	 * @param string $which 'top' | 'bottom'.
	 */
	protected function extra_tablenav( $which ) {
		if ( 'top' !== $which ) {
			return;
		}
		$counts = SSAA_Results::type_counts( 'issues' === $this->tab );
		if ( count( $counts ) < 2 ) {
			return; // A single type - nothing to filter.
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list filter (GET navigation).
		$current = isset( $_GET['ssaa_type'] ) ? sanitize_key( wp_unslash( $_GET['ssaa_type'] ) ) : '';

		// Real post types, alpha by label; the Archives bucket appended last.
		$options = array();
		foreach ( $counts as $type => $n ) {
			if ( '__archive__' === $type ) {
				continue;
			}
			$object            = get_post_type_object( $type );
			$options[ $type ] = $object ? $object->labels->name : $type;
		}
		asort( $options );
		if ( isset( $counts['__archive__'] ) ) {
			$options['__archive__'] = __( 'Archives', 'search-social-appearance-audit' );
		}

		echo '<div class="alignleft actions">';
		echo '<label class="screen-reader-text" for="ssaa-type-filter">' . esc_html__( 'Filter by content type', 'search-social-appearance-audit' ) . '</label>';
		echo '<select name="ssaa_type" id="ssaa-type-filter">';
		echo '<option value="">' . esc_html__( 'All types', 'search-social-appearance-audit' ) . '</option>';
		foreach ( $options as $type => $label ) {
			printf(
				'<option value="%s"%s>%s (%s)</option>',
				esc_attr( $type ),
				selected( $current, $type, false ),
				esc_html( $label ),
				esc_html( number_format_i18n( (int) $counts[ $type ] ) )
			);
		}
		echo '</select> ';
		submit_button( __( 'Filter', 'search-social-appearance-audit' ), '', 'ssaa_filter', false, array( 'id' => 'ssaa-type-submit' ) );
		echo '</div>';
	}

	// -------------------------------------------------------------------------
	// Data
	// -------------------------------------------------------------------------

	public function prepare_items() {
		// Default sort: true severity order, worst first, then alphabetical by
		// title (the query appends post_title ASC). Search and Social sort by
		// their OWN area's rank (search_rank / social_rank) so the order matches
		// the per-area badges on those tabs; the Findings tab uses the whole-row
		// severity_rank. Revised 2026-06-19 (1.0.14): per-area ranks - previously
		// all tabs used the whole-row rank, so a page with a critical in one area
		// floated to the top of the other area's tab where it only had a notice.
		$default_orderby = $this->rank_column();
		$default_order   = 'DESC';

		// Sorting/paging params are read-only view state; nonce not required.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : $default_orderby;
		if ( isset( $_GET['order'] ) ) {
			$order = ( 'asc' === strtolower( sanitize_key( wp_unslash( $_GET['order'] ) ) ) ) ? 'ASC' : 'DESC';
		} else {
			$order = $default_order;
		}
		$paged = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1;
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$type   = isset( $_GET['ssaa_type'] ) ? sanitize_key( wp_unslash( $_GET['ssaa_type'] ) ) : '';
		$status = isset( $_GET['ssaa_status'] ) ? sanitize_key( wp_unslash( $_GET['ssaa_status'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$per_page = 20;

		/**
		 * Extension point: add-on files adjust the query args (their own
		 * filter controls ride this seam).
		 *
		 * @param array  $query_args Args for SSAA_Results::query().
		 * @param string $tab        Current tab.
		 */
		$query_args = (array) apply_filters( 'ssaa_results_query_args', array(
			'post_type'   => $type,
			// The Site row (AI access) has no page preview - it lives on the
			// Findings tab and the type filter, never the preview tabs.
			'exclude_types' => in_array( $this->tab, array( 'search', 'social' ), true ) ? array( 'site' ) : array(),
			'status'      => $status,
			'with_issues' => ( 'issues' === $this->tab ),
			'orderby'     => $orderby,
			'order'       => $order,
			'per_page'    => $per_page,
			'paged'       => $paged,
		), $this->tab );

		$result = SSAA_Results::query( $query_args );

		$this->items = $result['rows'];

		$this->set_pagination_args( array(
			'total_items' => $result['total'],
			'per_page'    => $per_page,
			'total_pages' => (int) ceil( $result['total'] / $per_page ),
		) );

		$this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns() );
	}

	// -------------------------------------------------------------------------
	// Cells
	// -------------------------------------------------------------------------

	protected function column_title( $item ) {
		$post  = get_post( (int) $item['post_id'] );
		$title = ( '' !== (string) $item['post_title'] ) ? (string) $item['post_title'] : ( $post ? get_the_title( $post ) : '' );
		$title = ( '' !== $title ) ? $title : __( '(no title)', 'search-social-appearance-audit' );

		$edit_link = $post ? get_edit_post_link( $post->ID ) : '';
		$inner     = $edit_link
			? '<a class="row-title" href="' . esc_url( $edit_link ) . '">' . esc_html( $title ) . '</a>'
			: esc_html( $title );

		// WordPress-standard post-state suffix (Draft / Scheduled / Front Page /
		// Privacy Policy Page / states other plugins add). Rendered INSIDE the
		// <strong> like core's WP_Posts_List_Table so it is bold and title-sized.
		// Reuse core's PUBLIC get_post_states() (@since 5.3.0); do not use the
		// private _post_states() or display_post_states() (removed in WP 7.0). Em
		// dash as &mdash; entity so no literal em dash ships in source.
		if ( $post && function_exists( 'get_post_states' ) ) {
			$states = (array) get_post_states( $post );
			if ( ! empty( $states ) ) {
				$sep    = function_exists( 'wp_get_list_item_separator' ) ? wp_get_list_item_separator() : ', ';
				$count  = count( $states );
				$i      = 0;
				$inner .= ' &mdash; ';
				foreach ( $states as $state ) {
					$i++;
					$tail   = ( $i < $count ) ? $sep : '';
					$inner .= '<span class="post-state">' . esc_html( $state ) . esc_html( $tail ) . '</span>';
				}
			}
		}

		$out = '<strong>' . $inner . '</strong>';

		if ( 'error' === $item['scan_status'] ) {
			$out .= ' ' . self::status_indicator( 'error', 'warning', __( 'Scan error', 'search-social-appearance-audit' ), (string) $item['error_message'] );
		}

		return $out . $this->row_actions( $this->build_row_actions( $item, $post ) );
	}

	/**
	 * Own actions plus whatever installed plugins add via core's row-action
	 * filters. Quick Edit ('inline hide-if-no-js') is removed by design.
	 * Archive rows (no post) get View / Re-scan / Exclude keyed by ref - no
	 * Edit (they are not posts) and no inherited core filters. This exceeds
	 * LIHA (archives there are View-only) - flagged for backport.
	 *
	 * @param array        $item Result row.
	 * @param WP_Post|null $post The post, or null for an archive row.
	 * @return array
	 */
	private function build_row_actions( $item, $post ) {
		$actions = array();
		$ref     = ! empty( $item['ref'] ) ? (string) $item['ref'] : 'post:' . (int) $item['post_id'];

		if ( $post ) {
			$edit_link = get_edit_post_link( $post->ID );
			if ( $edit_link ) {
				$actions['edit'] = '<a href="' . esc_url( $edit_link ) . '">' . esc_html__( 'Edit', 'search-social-appearance-audit' ) . '</a>';
			}
		}

		$actions['view'] = '<a href="' . esc_url( (string) $item['permalink'] ) . '" target="_blank" rel="noopener">' . esc_html__( 'View', 'search-social-appearance-audit' ) . '</a>';

		$actions['ssaa_rescan'] = '<a href="#" class="ssaa-rescan" data-post="' . esc_attr( (string) $item['post_id'] ) . '" data-ref="' . esc_attr( $ref ) . '">' . esc_html__( 'Re-scan', 'search-social-appearance-audit' ) . '</a>';

		$exclude_url = wp_nonce_url(
			add_query_arg( array( 'ssaa_action' => 'exclude', 'ref' => rawurlencode( $ref ) ) ),
			'ssaa_exclude_' . $ref
		);
		$actions['ssaa_exclude'] = '<a href="' . esc_url( $exclude_url ) . '">' . esc_html__( 'Exclude', 'search-social-appearance-audit' ) . '</a>';

		if ( $post ) {
			// Inherit plugin-added actions (Edit with Elementor, Divi, ...) from
			// the same filters core's posts/pages screens run.
			$filter  = is_post_type_hierarchical( $post->post_type ) ? 'page_row_actions' : 'post_row_actions';
			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- core filter, deliberately inherited.
			$actions = (array) apply_filters( $filter, $actions, $post );

			// Quick Edit cannot work outside the core posts screen - remove it
			// and core-screen-specific entries if any plugin added them.
			unset( $actions['inline hide-if-no-js'], $actions['inline'] );
		}

		return $actions;
	}

	/**
	 * The count badge(s). This is the triage column: it flags CRITICALS and
	 * WARNINGS, each as its own badge (criticals red, warnings amber). Green
	 * "OK" shows only when a row has no criticals and no warnings. Notices are
	 * advisory - they never appear here and never demote "OK"; they show on the
	 * preview card (Search/Social) or in the "Issue summary" column (Findings).
	 * On Search/Social the badges are area-scoped; on Findings they cover the
	 * whole row. Each badge opens a modal filtered to its severity. Badges track
	 * active (non-ignored) counts; the Ignored tab manages un-ignores.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	protected function column_issues( $item ) {
		if ( 'error' === $item['scan_status'] ) {
			return '<div class="ssaa-issues-cell">'
				. self::status_indicator( 'error', 'warning', __( 'Scan error', 'search-social-appearance-audit' ), (string) $item['error_message'] )
				. '</div>';
		}

		$area   = ( 'search' === $this->tab || 'social' === $this->tab ) ? $this->tab : null;
		$issues = $this->area_issues( $item, $area );

		// The Issues column is the triage column: it flags criticals and
		// warnings, each as its own badge (criticals red, warnings amber).
		// Green "OK" shows only when there are no criticals AND no warnings.
		// Notices are advisory - they never appear here and never demote OK
		// (they live on the card / Findings "Issue summary" instead).
		// On Search/Social the badges are area-scoped; on Findings they cover
		// the whole row. See _standards DECISIONS "Issues column shows
		// criticals AND warnings as badges (2026-06-25)".
		$badges = trim(
			$this->severity_badge( $item, $issues, 'critical' ) . ' '
			. $this->severity_badge( $item, $issues, 'warning' )
		);
		$inner = ( '' !== $badges )
			? $badges
			: self::status_indicator( 'ok', 'yes-alt', __( 'OK', 'search-social-appearance-audit' ) );

		// The Site row additionally carries its "AI access" fact badge - the
		// per-provider robots.txt matrix as modal facts (neutral outline, like
		// the Robots badge: descriptive, no severity colour).
		if ( 'site' === (string) $item['post_type'] ) {
			$inner .= ' ' . $this->ai_access_badge( $item );
		}

		return '<div class="ssaa-issues-cell">' . $inner . '</div>';
	}

	/**
	 * The Site row's "AI access" fact badge: opens the modal with one fact per
	 * documented AI-crawler token - access verdict, the provider's documented
	 * consequence (tier language per SPEC-ai-readiness s.2.2), and the matched
	 * robots.txt lines as the value detail. Free surface; reads the matrix the
	 * scan stored in extras['ai_access'].
	 *
	 * @param array $item The Site result row.
	 * @return string
	 */
	private function ai_access_badge( $item ) {
		$access = isset( $item['extras']['ai_access'] ) ? (array) $item['extras']['ai_access'] : array();
		if ( empty( $access['fetched'] ) || empty( $access['matrix'] ) ) {
			return '';
		}

		$verdicts = array(
			'allowed' => __( 'Allowed', 'search-social-appearance-audit' ),
			'partial' => __( 'Partly limited', 'search-social-appearance-audit' ),
			'blocked' => __( 'Blocked', 'search-social-appearance-audit' ),
		);

		$facts   = array();
		$blocked = 0;

		if ( isset( $access['status'] ) && 'absent' === $access['status'] ) {
			$facts[] = array(
				'label' => 'robots.txt',
				'value' => ! empty( $access['served_with_404'] )
					? __( 'Returned HTTP 404 but still served robots rules. Crawlers are documented to treat a 404 robots.txt as NO restrictions, so those rules likely have no effect - a site configuration worth fixing.', 'search-social-appearance-audit' )
					: __( 'Not present (HTTP 404). Crawlers treat a missing robots.txt as no restrictions - every documented AI crawler is unrestricted.', 'search-social-appearance-audit' ),
			);
		}
		foreach ( (array) $access['matrix'] as $token => $row ) {
			$row     = (array) $row;
			$verdict = isset( $row['access'] ) && isset( $verdicts[ $row['access'] ] ) ? $verdicts[ $row['access'] ] : $verdicts['allowed'];
			if ( isset( $row['access'] ) && 'blocked' === $row['access'] ) {
				$blocked++;
			}
			$value = sprintf(
				/* translators: 1: access verdict, 2: crawler purpose, 3: documented consequence */
				__( '%1$s (%2$s). %3$s', 'search-social-appearance-audit' ),
				$verdict,
				isset( $row['purpose'] ) ? (string) $row['purpose'] : '',
				isset( $row['consequence'] ) ? (string) $row['consequence'] : ''
			);
			if ( ! empty( $row['lines'] ) ) {
				$value .= ' ' . sprintf(
					/* translators: %s: the matching robots.txt lines */
					__( 'Matched: %s', 'search-social-appearance-audit' ),
					implode( ' | ', array_map( 'strval', (array) $row['lines'] ) )
				);
			}
			$facts[] = array(
				'label' => sprintf( '%s (%s)', (string) $token, isset( $row['provider'] ) ? (string) $row['provider'] : '' ),
				'value' => $value,
			);
		}

		if ( null !== $access['llms_txt'] ) {
			$facts[] = array(
				'label' => 'llms.txt',
				'value' => $access['llms_txt']
					? __( 'Present. Informational only - no major provider is verified to consume it, and Google states it is not needed for AI features.', 'search-social-appearance-audit' )
					: __( 'Not present. Informational only - Google states no such file is needed for AI features.', 'search-social-appearance-audit' ),
			);
		}

		// Favicon facts (1.3.0 - the Site row audits the favicon too; the
		// panel states the healthy case so the row's docs claim is always
		// verifiable, not only when something is wrong).
		$favicon  = isset( $item['extras']['favicon'] ) ? (array) $item['extras']['favicon'] : array();
		$declared = isset( $favicon['declared'] ) ? (array) $favicon['declared'] : array();
		// Only an icon Google can CRAWL carries the declared claim; an inline
		// icon is declared but unusable (SSAA_Site_Checks::favicon_audit).
		$crawlable = array_values( array_filter(
			$declared,
			static function ( $icon ) {
				return ! empty( $icon['crawlable'] );
			}
		) );
		if ( ! empty( $crawlable ) ) {
			$first    = (array) $crawlable[0];
			$facts[]  = array(
				'label' => __( 'Favicon', 'search-social-appearance-audit' ),
				'value' => ( isset( $favicon['reachable'] ) && false === $favicon['reachable'] )
					? sprintf(
						/* translators: %s: the declared favicon URL */
						__( 'Declared but its URL does not resolve (%s) - see the warning on this row.', 'search-social-appearance-audit' ),
						isset( $first['href'] ) ? (string) $first['href'] : ''
					)
					: sprintf(
						/* translators: 1: number of declared icon links, 2: the first icon URL */
						_n( '%1$d icon link declared and reachable (%2$s).', '%1$d icon links declared; the primary is reachable (%2$s).', count( $crawlable ), 'search-social-appearance-audit' ),
						count( $crawlable ),
						isset( $first['href'] ) ? (string) $first['href'] : ''
					),
			);
		} elseif ( ! empty( $declared ) ) {
			$facts[] = array(
				'label' => __( 'Favicon', 'search-social-appearance-audit' ),
				'value' => ! empty( $favicon['legacy'] )
					? __( 'An icon link is declared, but inline rather than as a file Google can crawl. The classic /favicon.ico exists, so crawlers fall back to that.', 'search-social-appearance-audit' )
					: __( 'An icon link is declared, but inline rather than as a file Google can crawl, and there is no /favicon.ico. See the warning on this row.', 'search-social-appearance-audit' ),
			);
		} elseif ( array_key_exists( 'legacy', $favicon ) ) {
			$facts[] = array(
				'label' => __( 'Favicon', 'search-social-appearance-audit' ),
				'value' => ! empty( $favicon['legacy'] )
					? __( 'No icon link is declared in the homepage HTML, but the classic /favicon.ico exists - browsers and crawlers fall back to it.', 'search-social-appearance-audit' )
					: __( 'None found - no icon link in the homepage HTML and no /favicon.ico. See the warning on this row.', 'search-social-appearance-audit' ),
			);
		}

		$payload = array(
			'title'   => (string) $item['post_title'],
			'view'    => (string) $item['permalink'],
			'edit'    => '',
			'post'    => 0,
			'ref'     => (string) $item['ref'],
			'facts'   => $facts,
			'issues'  => array(),
			'sources' => array(),
		);

		$label = $blocked > 0
			? sprintf(
				/* translators: %d: number of blocked AI crawler tokens */
				_n( 'AI access (%d blocked)', 'AI access (%d blocked)', $blocked, 'search-social-appearance-audit' ),
				$blocked
			)
			: __( 'AI access', 'search-social-appearance-audit' );

		return sprintf(
			'<button type="button" class="ssaa-issues-btn" data-payload="%s"><span class="ssaa-badge ssaa-badge-fact">%s</span></button>',
			esc_attr( wp_json_encode( $payload ) ),
			esc_html( $label )
		);
	}

	/**
	 * Issues for an item, optionally restricted to one area. Includes ignored
	 * entries (the modal greys them; badges count only active ones).
	 *
	 * @param array       $item Result row.
	 * @param string|null $area 'search'|'social', or null for all areas.
	 * @return array
	 */
	private function area_issues( $item, $area ) {
		$all = (array) $item['issues'];
		if ( null === $area ) {
			return array_values( $all );
		}
		return array_values( array_filter( $all, function ( $issue ) use ( $area ) {
			return isset( $issue['area'] ) && $issue['area'] === $area;
		} ) );
	}

	/**
	 * One severity badge: a button opening a modal filtered to that severity.
	 * Returns '' when no active (non-ignored) issue of that severity exists, so
	 * a fully-ignored severity shows nothing (un-ignore lives on the Ignored
	 * tab). The modal payload still carries every issue of that severity,
	 * including ignored ones, so they stay visible greyed with an inline
	 * un-ignore control. Label counts active issues only.
	 *
	 * @param array  $item     Result row.
	 * @param array  $issues   Area-scoped issue list (active + ignored).
	 * @param string $severity critical|warning|notice.
	 * @return string
	 */
	private function severity_badge( $item, array $issues, $severity ) {
		$of_severity = array_values( array_filter( $issues, function ( $issue ) use ( $severity ) {
			return isset( $issue['severity'] ) && $issue['severity'] === $severity;
		} ) );
		$active = array_values( array_filter( $of_severity, function ( $issue ) {
			return empty( $issue['ignored'] );
		} ) );
		if ( empty( $active ) ) {
			return '';
		}
		$count = count( $active );

		switch ( $severity ) {
			case 'critical':
				$class = 'ssaa-badge-error';
				$label = sprintf(
					/* translators: %d: number of critical issues */
					_n( '%d issue', '%d issues', $count, 'search-social-appearance-audit' ),
					$count
				);
				break;
			case 'warning':
				$class = 'ssaa-badge-warn';
				$label = sprintf(
					/* translators: %d: number of warnings */
					_n( '%d warning', '%d warnings', $count, 'search-social-appearance-audit' ),
					$count
				);
				break;
			default:
				$class = 'ssaa-badge-notice';
				$label = sprintf(
					/* translators: %d: number of notices */
					_n( '%d notice', '%d notices', $count, 'search-social-appearance-audit' ),
					$count
				);
				break;
		}

		$payload = array(
			// Stored title first - archive rows (post_id 0) have no post to
			// read a live title from; posts fall back to the live title.
			'title'   => ( '' !== (string) $item['post_title'] ) ? (string) $item['post_title'] : get_the_title( (int) $item['post_id'] ),
			'view'    => (string) $item['permalink'],
			'edit'    => (string) get_edit_post_link( (int) $item['post_id'], 'raw' ),
			'post'    => (int) $item['post_id'],
			'ref'     => ! empty( $item['ref'] ) ? (string) $item['ref'] : 'post:' . (int) $item['post_id'],
			'issues'  => $of_severity,
			'sources' => isset( $item['extras']['sources'] ) ? (array) $item['extras']['sources'] : array(),
		);

		return sprintf(
			'<button type="button" class="ssaa-issues-btn" data-payload="%s"><span class="ssaa-badge %s">%s</span></button>',
			esc_attr( wp_json_encode( $payload ) ),
			esc_attr( $class ),
			esc_html( $label )
		);
	}

	/**
	 * Notice badge for a preview card's meta row. Criticals and warnings are the
	 * triage signals and live in the Issues column; notices are advisory and have
	 * no home there, so the card is where they show. Scoped to the current tab's
	 * area. Returns '' when the area has no active notices.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function card_severity_badges( $item ) {
		if ( 'error' === $item['scan_status'] ) {
			return '';
		}
		// The card carries notices only. Criticals and warnings are the triage
		// signals and now live in the Issues column; showing the warning badge
		// here as well would duplicate it on the same row. Notices are advisory
		// and have no home in the Issues column, so the card is where they show.
		// See _standards DECISIONS "Issues column shows criticals AND warnings".
		$issues = $this->area_issues( $item, $this->tab );
		return trim( $this->severity_badge( $item, $issues, 'notice' ) );
	}

	/**
	 * Social Appearance preview cell: a share-card-style rendering of the
	 * OG image, title and description, plus a small X-card chip. Replaces the
	 * four cramped OG/Twitter columns. Full title/description on hover and in
	 * the issue modal.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	protected function column_social_preview( $item ) {
		$thumb = self::card_image( $item, '1.91', __( 'Highlighted area is what Facebook and other social platforms show.', 'search-social-appearance-audit' ) );

		$title = (string) $item['og_title'];
		$desc  = (string) $item['og_description'];

		$title_html = ( '' !== $title )
			? '<span class="ssaa-card-title ssaa-clip2" title="' . esc_attr( $title ) . '">' . esc_html( $title ) . '</span>'
			: '<span class="ssaa-card-title">' . self::missing_badge( __( 'Missing OG title', 'search-social-appearance-audit' ) ) . '</span>';

		$desc_html = ( '' !== $desc )
			? '<span class="ssaa-card-desc ssaa-clip2" title="' . esc_attr( $desc ) . '">' . esc_html( $desc ) . '</span>'
			: '<span class="ssaa-card-desc">' . self::missing_badge( __( 'Missing OG description', 'search-social-appearance-audit' ) ) . '</span>';

		// Notice badge (Social area) leads the meta row; criticals and warnings
		// stay in the Issues column. The descriptive chips follow.
		$chips = '';
		$sev   = $this->card_severity_badges( $item );
		if ( '' !== $sev ) {
			$chips .= $sev . ' ';
		}
		$chips .= self::xcard_chip( (string) $item['twitter_card'] );

		return '<div class="ssaa-preview ssaa-preview-social">'
			. $thumb
			. '<div class="ssaa-preview-body">' . $title_html . $desc_html
			. '<div class="ssaa-preview-meta">' . $chips . '</div>'
			. '</div></div>';
	}

	/**
	 * Search Appearance preview cell: a Google-result-style snippet of the
	 * breadcrumb, SEO title and meta description. Replaces the SEO Title and
	 * Meta Description columns. Full text on hover and in the issue modal.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	protected function column_search_preview( $item ) {
		$title = (string) $item['seo_title'];
		$desc  = (string) $item['meta_description'];

		$crumb    = self::serp_breadcrumb( (string) $item['permalink'] );
		$url_html = ( '' !== $crumb ) ? '<span class="ssaa-serp-url ssaa-clip2">' . esc_html( $crumb ) . '</span>' : '';

		$title_html = ( '' !== $title )
			? '<span class="ssaa-serp-title ssaa-clip2" title="' . esc_attr( $title ) . '">' . esc_html( $title ) . '</span>'
			: '<span class="ssaa-serp-title">' . self::missing_badge( __( 'Missing title', 'search-social-appearance-audit' ) ) . '</span>';

		$desc_html = ( '' !== $desc )
			? '<span class="ssaa-serp-desc ssaa-clip2" title="' . esc_attr( $desc ) . '">' . esc_html( $desc ) . '</span>'
			: '<span class="ssaa-serp-desc">' . self::missing_badge( __( 'Missing meta description', 'search-social-appearance-audit' ) ) . '</span>';

		// Meta row: notice badge (Search area) leads, then the Robots flag and
		// the descriptive Author chip. Criticals and warnings stay in the Issues
		// column. The standalone Canonical label/flag was removed (1.0.10) -
		// canonical findings roll into the severity badges by their own severity
		// (criticals/warnings to the Issues column, notices to this card).
		$meta  = '<div class="ssaa-preview-meta">';
		$sev   = $this->card_severity_badges( $item );
		if ( '' !== $sev ) {
			$meta .= $sev . ' ';
		}
		$meta .= $this->render_robots( $item );
		$author = $this->render_author_chip( $item );
		if ( '' !== $author ) {
			$meta .= $author;
		}
		$meta .= '</div>';

		// Same preview-body structure as the Social card (shrinks + clips
		// inside the cell). Mobile thumbnail to the right.
		$body = '<div class="ssaa-preview-body">' . $url_html . $title_html . $desc_html . $meta . '</div>';

		return '<div class="ssaa-preview ssaa-preview-search">' . $body . self::search_mobile_image( $item ) . '</div>';
	}

	/**
	 * Compact author chip for the Search card meta row: the rendered-output
	 * author when exposed (marked "public"), else the WP author dimmed. Empty
	 * string when there is no author at all.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function render_author_chip( $item ) {
		$wp_author = (string) $item['wp_author'];
		$exposed   = (string) $item['exposed_author'];

		if ( '' === $wp_author && '' === $exposed ) {
			return '';
		}

		if ( '' !== $exposed ) {
			return '<span class="ssaa-chip ssaa-chip-muted ssaa-author-chip" title="'
				. esc_attr__( 'Author is exposed in the rendered output.', 'search-social-appearance-audit' )
				. '"><span class="dashicons dashicons-admin-users" aria-hidden="true"></span>' . esc_html( $exposed )
				. ' <span class="ssaa-chip-public-dot">' . esc_html__( 'public', 'search-social-appearance-audit' ) . '</span></span>';
		}

		return '<span class="ssaa-chip ssaa-chip-muted ssaa-author-chip ssaa-author-muted" title="'
			. esc_attr__( 'WordPress author - not detected in the rendered output.', 'search-social-appearance-audit' )
			. '"><span class="dashicons dashicons-admin-users" aria-hidden="true"></span>' . esc_html( $wp_author ) . '</span>';
	}

	/**
	 * Shared card image for both preview cells: the real og:image, else a
	 * neutral placeholder. Premium Enhanced scan replaces the placeholder with
	 * the predicted fallback (flagged "Likely") via ssaa_preview_card_image.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private static function card_image( $item, $crop_ratio = '', $crop_label = '' ) {
		$og_image = (string) $item['og_image_url'];

		if ( '' !== $og_image ) {
			// data-crop / data-crop-label drive the lightbox crop-frame overlay
			// (centre-crop the platform applies). Absent = no frame shown.
			$crop_attrs = '';
			if ( '' !== $crop_ratio ) {
				$crop_attrs = ' data-crop="' . esc_attr( $crop_ratio ) . '" data-crop-label="' . esc_attr( $crop_label ) . '"';
			}
			$thumb = '<img class="ssaa-card-thumb ssaa-zoomable" loading="lazy" src="' . esc_url( $og_image ) . '" data-full="' . esc_url( $og_image ) . '"' . $crop_attrs . ' alt="" />';
		} else {
			$thumb = '<span class="ssaa-card-thumb ssaa-card-thumb-none" aria-hidden="true"><span class="dashicons dashicons-format-image"></span></span>';
		}

		/**
		 * Extension point: replace the preview card image. Premium Enhanced
		 * scan injects the predicted fallback image, flagged "Likely", when no
		 * og:image is set.
		 *
		 * @param string $thumb Default image markup.
		 * @param array  $item  Result row.
		 */
		return (string) apply_filters( 'ssaa_preview_card_image', $thumb, $item );
	}

	/**
	 * Mobile SERP thumbnail for the Search card. Google shows a thumbnail on
	 * mobile results (rarely on desktop), choosing its own image - so this is
	 * labelled "Mobile" and flagged indicative. When max-image-preview:none is
	 * set, Google shows no thumbnail and we say so instead of guessing.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private static function search_mobile_image( $item ) {
		$tag = '<span class="ssaa-mobile-tag"><span class="dashicons dashicons-smartphone" aria-hidden="true"></span>'
			. esc_html__( 'Mobile', 'search-social-appearance-audit' ) . '</span>';

		if ( self::robots_blocks_image( (string) $item['robots'] ) ) {
			$thumb = '<span class="ssaa-card-thumb ssaa-card-thumb-none ssaa-card-thumb-noimg" title="'
				. esc_attr__( 'max-image-preview:none is set, so Google will not show a thumbnail for this page.', 'search-social-appearance-audit' )
				. '"><span class="dashicons dashicons-format-image" aria-hidden="true"></span><span class="ssaa-noimg-note">max-image-preview:none</span></span>';
			return '<div class="ssaa-mobile-shot">' . $thumb . $tag . '</div>';
		}

		$thumb = '<span class="ssaa-mobile-img" title="'
			. esc_attr__( 'Indicative only - Google chooses its own mobile thumbnail and may show this image, a different one, or none.', 'search-social-appearance-audit' )
			. '">' . self::card_image( $item, '1', __( 'Highlighted area is Google\'s square mobile crop.', 'search-social-appearance-audit' ) ) . '</span>';

		return '<div class="ssaa-mobile-shot">' . $thumb . $tag . '</div>';
	}

	/**
	 * Whether the robots directives suppress the SERP image thumbnail.
	 *
	 * @param string $robots Robots directive string.
	 * @return bool
	 */
	private static function robots_blocks_image( $robots ) {
		return ( false !== stripos( $robots, 'max-image-preview:none' ) );
	}

	protected function column_default( $item, $column_name ) {
		switch ( $column_name ) {
			case 'canonical_url':
				return $this->render_canonical_flag( $item );
			case 'author':
				return $this->render_author( $item );
			case 'robots':
				return $this->render_robots( $item );
			case 'post_type':
				return esc_html( $item['post_type'] );
			case 'scanned_at':
				return esc_html( $item['scanned_at'] );
			case 'areas':
				return $this->render_areas( $item );
			case 'summary':
				return $this->render_summary( $item );
			default:
				/**
				 * Extension point: add-on files render their own columns
				 * (registered via ssaa_table_columns).
				 *
				 * @param string $html        Cell markup ('' default).
				 * @param string $column_name Column key.
				 * @param array  $item        Result row.
				 * @param string $tab         Current tab.
				 */
				return (string) apply_filters( 'ssaa_table_cell', '', $column_name, $item, $this->tab );
		}
	}

	/**
	 * Compact canonical flag: OK / issue count / Missing, opening the modal
	 * with the canonical URL as a fact plus the canonical-specific issues.
	 * Mirrors the Issues column to reclaim the real estate the full URL
	 * used to occupy (agreed 2026-06-11).
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function render_canonical_flag( $item ) {
		if ( 'error' === $item['scan_status'] ) {
			return '&#8211;';
		}

		$canonical_issues = array_values( array_filter( (array) $item['issues'], function ( $issue ) {
			return isset( $issue['code'] ) && in_array( $issue['code'], self::CANONICAL_CODES, true );
		} ) );

		// Counting/severity consider only non-ignored; the modal still
		// receives the full list so ignored issues stay visible (greyed).
		$active_canonical = array_values( array_filter( $canonical_issues, function ( $issue ) {
			return empty( $issue['ignored'] );
		} ) );

		$value = (string) $item['canonical_url'];

		if ( empty( $active_canonical ) ) {
			$label = __( 'OK', 'search-social-appearance-audit' );
			$class = 'ssaa-badge-ok';
		} elseif ( '' === $value ) {
			$label = __( 'Missing', 'search-social-appearance-audit' );
			$class = 'ssaa-badge-warn';
		} else {
			$worst = 'notice';
			foreach ( $active_canonical as $issue ) {
				if ( 'critical' === $issue['severity'] ) {
					$worst = 'critical';
					break;
				}
				if ( 'warning' === $issue['severity'] ) {
					$worst = 'warning';
				}
			}
			$label = sprintf(
				/* translators: %d: number of issues */
				_n( '%d issue', '%d issues', count( $active_canonical ), 'search-social-appearance-audit' ),
				count( $active_canonical )
			);
			$class = ( 'critical' === $worst ) ? 'ssaa-badge-error' : 'ssaa-badge-warn';
		}

		$payload = array(
			'title'   => (string) $item['post_title'],
			'view'    => (string) $item['permalink'],
			'edit'    => (string) get_edit_post_link( (int) $item['post_id'], 'raw' ),
			'post'    => (int) $item['post_id'],
			'ref'     => ! empty( $item['ref'] ) ? (string) $item['ref'] : 'post:' . (int) $item['post_id'],
			'facts'   => array(
				array(
					'label' => __( 'Canonical URL', 'search-social-appearance-audit' ),
					'value' => ( '' !== $value ) ? $value : __( '(none declared)', 'search-social-appearance-audit' ),
				),
				array(
					'label' => __( 'Page URL', 'search-social-appearance-audit' ),
					'value' => (string) $item['permalink'],
				),
			),
			'issues'  => $canonical_issues,
			'sources' => array(),
		);

		return sprintf(
			'<button type="button" class="ssaa-issues-btn" data-payload="%s"><span class="ssaa-badge %s">%s</span></button>',
			esc_attr( wp_json_encode( $payload ) ),
			esc_attr( $class ),
			esc_html( $label )
		);
	}

	/**
	 * Compact robots flag: "Indexable" / "noindex", opening the modal with the
	 * full robots directives as a fact. The directive string is reference
	 * detail, not per-row triage, so it lives in the popup (mirrors Canonical).
	 * noindex is already surfaced in the Issues column, so this stays purely
	 * descriptive.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function render_robots( $item ) {
		if ( 'error' === $item['scan_status'] ) {
			return '&#8211;';
		}

		$robots     = (string) $item['robots'];
		$is_noindex = ( false !== stripos( $robots, 'noindex' ) );

		// Robots is a reference fact, not a severity - it renders as a fact badge
		// (neutral outline, clickable). noindex as a problem is already surfaced
		// in the Issues column, so this badge does not carry a severity colour.
		$label = $is_noindex ? __( 'noindex', 'search-social-appearance-audit' ) : __( 'Indexable', 'search-social-appearance-audit' );
		$class = 'ssaa-badge-fact';

		$payload = array(
			'title'   => (string) $item['post_title'],
			'view'    => (string) $item['permalink'],
			'edit'    => (string) get_edit_post_link( (int) $item['post_id'], 'raw' ),
			'post'    => (int) $item['post_id'],
			'ref'     => ! empty( $item['ref'] ) ? (string) $item['ref'] : 'post:' . (int) $item['post_id'],
			'facts'   => array(
				array(
					'label' => __( 'Robots directives', 'search-social-appearance-audit' ),
					'value' => ( '' !== $robots ) ? $robots : __( '(no robots meta tag - default: indexable, follow)', 'search-social-appearance-audit' ),
				),
			),
			'issues'  => array(),
			'sources' => array(),
		);

		return sprintf(
			'<button type="button" class="ssaa-issues-btn" data-payload="%s"><span class="ssaa-badge %s">%s</span></button>',
			esc_attr( wp_json_encode( $payload ) ),
			esc_attr( $class ),
			esc_html( $label )
		);
	}

	/**
	 * Author cell: WP-assigned author, marked "public" when the rendered
	 * page exposes an author (meta/OG/schema), dimmed when it does not.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function render_author( $item ) {
		$wp_author = (string) $item['wp_author'];
		$exposed   = (string) $item['exposed_author'];

		if ( '' === $wp_author && '' === $exposed ) {
			return '&#8211;';
		}

		$name = ( '' !== $exposed ) ? $exposed : $wp_author;

		if ( '' !== $exposed ) {
			$title = ( '' !== $wp_author && $exposed !== $wp_author )
				? sprintf(
					/* translators: %s: WordPress author name */
					__( 'Publicly exposed in the rendered output. WordPress author: %s.', 'search-social-appearance-audit' ),
					$wp_author
				)
				: __( 'Publicly exposed in the rendered output (meta tags or schema).', 'search-social-appearance-audit' );
			return '<span title="' . esc_attr( $title ) . '">' . esc_html( $name )
				. ' <span class="ssaa-chip ssaa-chip-public">' . esc_html__( 'public', 'search-social-appearance-audit' ) . '</span></span>';
		}

		return '<span class="ssaa-author-muted" title="' . esc_attr__( 'WordPress author - not detected in the rendered output.', 'search-social-appearance-audit' ) . '">' . esc_html( $name ) . '</span>';
	}

	/**
	 * Area chips (Search / Social) for the Issues tab.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function render_areas( $item ) {
		if ( 'error' === $item['scan_status'] ) {
			return '<span class="ssaa-chip ssaa-chip-critical">' . esc_html__( 'Scan', 'search-social-appearance-audit' ) . '</span>';
		}

		$active = array_filter( (array) $item['issues'], function ( $issue ) {
			return empty( $issue['ignored'] );
		} );
		$areas  = array_unique( array_filter( wp_list_pluck( $active, 'area' ) ) );
		$labels = array(
			'search' => __( 'Search', 'search-social-appearance-audit' ),
			'social' => __( 'Social', 'search-social-appearance-audit' ),
			'ai'     => __( 'AI', 'search-social-appearance-audit' ),
			'structure' => __( 'Structure', 'search-social-appearance-audit' ),
		);

		$out = array();
		foreach ( $labels as $key => $label ) {
			if ( in_array( $key, $areas, true ) ) {
				$out[] = '<span class="ssaa-chip ssaa-chip-area-' . esc_attr( $key ) . '">' . esc_html( $label ) . '</span>';
			}
		}

		return $out ? implode( ' ', $out ) : '&#8211;';
	}

	/**
	 * Inline issue summary for the Issues tab - triage without the modal.
	 * Shows up to four issues; the count button opens the full details.
	 *
	 * @param array $item Result row.
	 * @return string
	 */
	private function render_summary( $item ) {
		if ( 'error' === $item['scan_status'] ) {
			return esc_html( $item['error_message'] );
		}

		$issues = array_values( array_filter( (array) $item['issues'], function ( $issue ) {
			return empty( $issue['ignored'] );
		} ) );
		if ( empty( $issues ) ) {
			return '&#8211;';
		}

		$out   = '<ul class="ssaa-issue-list">';
		$shown = 0;
		foreach ( $issues as $issue ) {
			if ( $shown >= 4 ) {
				break;
			}
			$severity = isset( $issue['severity'] ) ? $issue['severity'] : 'notice';
			$out     .= '<li><span class="ssaa-chip ssaa-chip-' . esc_attr( $severity ) . '">'
				. esc_html( $this->severity_label( $severity ) ) . '</span> '
				. esc_html( isset( $issue['message'] ) ? $issue['message'] : ( isset( $issue['code'] ) ? $issue['code'] : '' ) )
				. '</li>';
			$shown++;
		}
		$out .= '</ul>';

		$rest = count( $issues ) - $shown;
		if ( $rest > 0 ) {
			$out .= '<span class="description">' . esc_html( sprintf(
				/* translators: %d: number of additional issues */
				_n( '+ %d more - open the issue count for details.', '+ %d more - open the issue count for details.', $rest, 'search-social-appearance-audit' ),
				$rest
			) ) . '</span>';
		}

		return $out;
	}

	/**
	 * Translated severity label.
	 *
	 * @param string $severity critical|warning|notice.
	 * @return string
	 */
	private function severity_label( $severity ) {
		$labels = array(
			'critical' => __( 'Critical', 'search-social-appearance-audit' ),
			'warning'  => __( 'Warning', 'search-social-appearance-audit' ),
			'notice'   => __( 'Notice', 'search-social-appearance-audit' ),
		);
		return isset( $labels[ $severity ] ) ? $labels[ $severity ] : $severity;
	}

	/**
	 * Friendly label for the twitter:card type; raw value in the tooltip.
	 *
	 * @param string $card Raw twitter:card value.
	 * @return string
	 */
	private static function xcard_chip( $card ) {
		$labels = array(
			'summary'             => __( 'X: Summary', 'search-social-appearance-audit' ),
			'summary_large_image' => __( 'X: Large image', 'search-social-appearance-audit' ),
			'app'                 => __( 'X: App', 'search-social-appearance-audit' ),
			'player'              => __( 'X: Player', 'search-social-appearance-audit' ),
		);

		if ( '' === $card ) {
			return '<span class="ssaa-chip ssaa-chip-muted" title="'
				. esc_attr__( 'No twitter:card tag. X falls back to your Open Graph tags.', 'search-social-appearance-audit' )
				. '">' . esc_html__( 'No X card', 'search-social-appearance-audit' ) . '</span>';
		}

		$label = isset( $labels[ $card ] ) ? $labels[ $card ] : 'X: ' . $card;
		return '<span class="ssaa-chip ssaa-chip-muted" title="' . esc_attr( $card ) . '">' . esc_html( $label ) . '</span>';
	}

	/**
	 * Google-style breadcrumb for the Search preview: host then path segments
	 * separated by chevrons (example.com > products > widgets).
	 *
	 * @param string $permalink Item permalink.
	 * @return string
	 */
	private static function serp_breadcrumb( $permalink ) {
		if ( '' === $permalink ) {
			return '';
		}
		$host   = (string) wp_parse_url( $permalink, PHP_URL_HOST );
		$path   = (string) wp_parse_url( $permalink, PHP_URL_PATH );
		$crumbs = array_filter( array_map( 'urldecode', explode( '/', trim( $path, '/' ) ) ) );

		$out = $host;
		if ( ! empty( $crumbs ) ) {
			$out .= ' › ' . implode( ' › ', $crumbs );
		}
		return $out;
	}

	/**
	 * Truncated text, or the amber "Missing" badge (consistent with the
	 * OG Image and Canonical columns - agreed 2026-06-11).
	 *
	 * @param string $value  Value.
	 * @param int    $length Max chars.
	 * @return string
	 */
	private static function text_or_missing( $value, $length ) {
		if ( '' === $value ) {
			return self::missing_badge();
		}
		return esc_html( wp_html_excerpt( $value, $length, '…' ) );
	}

	/**
	 * The amber "Missing" badge. In the preview cards a field has no column
	 * header to give it context, so callers pass the specific field name
	 * (already translated) - e.g. "Missing meta description". Falls back to a
	 * bare "Missing" when no label is given.
	 *
	 * @param string $label Full, translated label (e.g. __( 'Missing title' )).
	 * @return string
	 */
	private static function missing_badge( $label = '' ) {
		if ( '' === $label ) {
			$label = __( 'Missing', 'search-social-appearance-audit' );
		}
		// A field-absence marker is a state, not a clickable badge, so it renders
		// as a status indicator (amber icon + text, no pill, no chevron).
		return self::status_indicator( 'warn', 'warning', $label );
	}

	/**
	 * Status indicator: pure row/field state (OK, Missing, Scan error). Icon +
	 * coloured text, no pill background, no chevron - deliberately NOT styled
	 * like the clickable severity/fact badges, so the user can tell at a glance
	 * that there is nothing to open. See _standards DECISIONS "Badge and
	 * status-indicator system".
	 *
	 * @param string $variant  ok|warn|error (drives the colour).
	 * @param string $dashicon Dashicon slug without the 'dashicons-' prefix.
	 * @param string $label    Translated, unescaped label.
	 * @param string $title    Optional tooltip (e.g. the scan error message).
	 * @return string
	 */
	private static function status_indicator( $variant, $dashicon, $label, $title = '' ) {
		$title_attr = ( '' !== $title ) ? ' title="' . esc_attr( $title ) . '"' : '';
		return '<span class="ssaa-status ssaa-status-' . esc_attr( $variant ) . '"' . $title_attr . '>'
			. '<span class="dashicons dashicons-' . esc_attr( $dashicon ) . '" aria-hidden="true"></span>'
			. esc_html( $label ) . '</span>';
	}

	public function no_items() {
		if ( 'issues' === $this->tab ) {
			esc_html_e( 'No issues found. Either nothing has been scanned yet, or everything is healthy.', 'search-social-appearance-audit' );
		} else {
			esc_html_e( 'No results yet. Run your first scan.', 'search-social-appearance-audit' );
		}
	}
}
