<?php
/**
 * Chief Insights — period comparison helpers for KPI deltas.
 *
 * @package Newsroom_AI_Assistant
 */

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

if ( ! function_exists( 'newsai_chief_desk_stats_for_window' ) ) {
	/**
	 * Aggregate desk metrics for posts published in a GMT date window.
	 *
	 * @param string $from_gmt Inclusive start (Y-m-d H:i:s GMT).
	 * @param string $to_gmt   Exclusive end (Y-m-d H:i:s GMT), empty = now.
	 * @return array{post_count:int,avg_words:int,avg_focus:int,avg_discover:int,focus_n:int,disc_n:int,avg_seo_score:int,seo_n:int,seo_plugin:string}
	 */
	function newsai_chief_desk_stats_for_window( $from_gmt, $to_gmt = '' ) {
		global $wpdb;
		$discover_key     = function_exists( 'newsai_discover_score_meta_key' ) ? newsai_discover_score_meta_key() : '_newsai_discover_score';
		$focus_live_key   = function_exists( 'newsai_focus_score_meta_key' ) ? newsai_focus_score_meta_key() : '_newsai_focus_score';
		$focus_pinned_key = function_exists( 'newsai_focus_score_history_meta_key' ) ? newsai_focus_score_history_meta_key() : '_newsai_focus_score_at_publish';

		if ( $to_gmt !== '' ) {
			$post_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->posts}
					WHERE post_type = 'post' AND post_status = 'publish'
					AND post_date_gmt >= %s AND post_date_gmt < %s",
					$from_gmt,
					$to_gmt
				)
			);
		} else {
			$post_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->posts}
					WHERE post_type = 'post' AND post_status = 'publish'
					AND post_date_gmt >= %s",
					$from_gmt
				)
			);
		}

		$total_words = 0;
		$focus_sum   = 0;
		$focus_n     = 0;
		$disc_sum    = 0;
		$disc_n      = 0;
		$seo_sum     = 0;
		$seo_n       = 0;
		$seo_plugin  = function_exists( 'newsai_chief_detect_seo_plugin' ) ? newsai_chief_detect_seo_plugin() : '';
		foreach ( (array) $post_ids as $pid ) {
			$pid     = (int) $pid;
			$content = (string) get_post_field( 'post_content', $pid );
			if ( $content !== '' ) {
				$plain = wp_strip_all_tags( $content );
				if ( preg_match_all( '/[\p{L}\p{N}][\p{L}\p{N}\']*/u', $plain, $m ) ) {
					$total_words += count( $m[0] );
				}
			}
			$focus = max( (int) get_post_meta( $pid, $focus_pinned_key, true ), (int) get_post_meta( $pid, $focus_live_key, true ) );
			if ( $focus > 0 ) {
				$focus_sum += $focus;
				$focus_n++;
			}
			$disc = (int) get_post_meta( $pid, $discover_key, true );
			if ( $disc > 0 ) {
				$disc_sum += $disc;
				$disc_n++;
			}
			if ( $seo_plugin !== '' && function_exists( 'newsai_chief_get_post_seo_score' ) ) {
				$seo = newsai_chief_get_post_seo_score( $pid );
				if ( $seo !== null ) {
					$seo_sum += (int) $seo;
					$seo_n++;
				}
			}
		}
		$count = count( (array) $post_ids );
		return [
			'post_count'    => $count,
			'avg_words'     => $count > 0 ? (int) round( $total_words / max( 1, $count ) ) : 0,
			'avg_focus'     => $focus_n > 0 ? (int) round( $focus_sum / $focus_n ) : 0,
			'avg_discover'  => $disc_n > 0 ? (int) round( $disc_sum / $disc_n ) : 0,
			'focus_n'       => $focus_n,
			'disc_n'        => $disc_n,
			'avg_seo_score' => $seo_n > 0 ? (int) round( $seo_sum / $seo_n ) : 0,
			'seo_n'         => $seo_n,
			'seo_plugin'    => $seo_plugin,
		];
	}
}

if ( ! function_exists( 'newsai_chief_detect_seo_plugin' ) ) {
	/**
	 * @return string yoast|rankmath|''
	 */
	function newsai_chief_detect_seo_plugin() {
		if ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Options' ) ) {
			return 'yoast';
		}
		if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) {
			return 'rankmath';
		}
		return '';
	}
}

if ( ! function_exists( 'newsai_chief_get_post_seo_score' ) ) {
	/**
	 * Read-only SEO score from Yoast or Rank Math (0–100), or null.
	 *
	 * @param int $post_id Post ID.
	 * @return int|null
	 */
	function newsai_chief_get_post_seo_score( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 ) {
			return null;
		}
		$plugin = newsai_chief_detect_seo_plugin();
		if ( $plugin === 'yoast' ) {
			$score = (int) get_post_meta( $post_id, '_yoast_wpseo_linkdex', true );
			return $score > 0 ? $score : null;
		}
		if ( $plugin === 'rankmath' ) {
			$score = (int) get_post_meta( $post_id, 'rank_math_seo_score', true );
			return $score > 0 ? $score : null;
		}
		return null;
	}
}

if ( ! function_exists( 'newsai_chief_gsc_total_clicks' ) ) {
	/**
	 * Sum GSC clicks across web + discover + googleNews for a date range.
	 */
	function newsai_chief_gsc_total_clicks( $start_date, $end_date ) {
		if ( ! function_exists( 'newsai_gsc_data_available' ) || ! newsai_gsc_data_available() ) {
			return 0;
		}
		global $wpdb;
		$table = newsai_gsc_table_name();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COALESCE(SUM(clicks), 0) FROM {$table}
				WHERE data_date >= %s AND data_date <= %s
				AND search_type IN ('web','discover','googleNews')",
				$start_date,
				$end_date
			)
		);
	}
}

if ( ! function_exists( 'newsai_chief_kpi_delta_class' ) ) {
	function newsai_chief_kpi_delta_class( $current, $previous ) {
		$delta = (int) $current - (int) $previous;
		if ( $delta > 0 ) {
			return 'nros-chief-delta--up';
		}
		if ( $delta < 0 ) {
			return 'nros-chief-delta--down';
		}
		return 'nros-chief-delta--flat';
	}
}

if ( ! function_exists( 'newsai_chief_kpi_delta_text' ) ) {
	/**
	 * @param int    $current  Current value.
	 * @param int    $previous Previous period value.
	 * @param int    $days     Window length for label.
	 * @param string $mode     number|percent|words
	 */
	function newsai_chief_kpi_delta_text( $current, $previous, $days, $mode = 'number' ) {
		$current  = (int) $current;
		$previous = (int) $previous;
		$delta    = $current - $previous;
		if ( $mode === 'percent' ) {
			if ( $previous > 0 ) {
				$pct = (int) round( ( $delta / $previous ) * 100 );
				$chip = ( $pct >= 0 ? '+' : '' ) . $pct . '%';
			} elseif ( $current > 0 ) {
				$chip = __( 'new', 'newsroom-ai-assistant' );
			} else {
				$chip = '0%';
			}
		} else {
			$chip = ( $delta >= 0 ? '+' : '' ) . number_format_i18n( $delta );
		}
		return sprintf(
			/* translators: 1: change chip (+N, +N%, etc.), 2: days in comparison window */
			__( '%1$s vs previous %2$d days', 'newsroom-ai-assistant' ),
			$chip,
			(int) $days
		);
	}
}

if ( ! function_exists( 'newsai_chief_gsc_daily_series' ) ) {
	/**
	 * Daily GSC clicks per channel for Chief trend chart (site-wide GSC API data).
	 *
	 * @param int $days Window 7–90.
	 * @return array{labels:list<string>,web:list<int>,discover:list<int>,googleNews:list<int>,has_data:bool,source:string}
	 */
	function newsai_chief_gsc_daily_series( $days ) {
		$empty = [
			'labels'      => [],
			'web'         => [],
			'discover'    => [],
			'googleNews'  => [],
			'has_data'    => false,
			'source'      => 'none',
		];
		if ( ! function_exists( 'newsai_gsc_data_available' ) || ! newsai_gsc_data_available() ) {
			return $empty;
		}
		if ( ! function_exists( 'newsai_gsc_insights_period_bounds' ) ) {
			return $empty;
		}

		$bounds = newsai_gsc_insights_period_bounds( $days );
		$start  = strtotime( $bounds['cur_start'] . ' UTC' );
		$end    = strtotime( $bounds['cur_end'] . ' UTC' );
		if ( ! $start || ! $end || $end < $start ) {
			return $empty;
		}

		$labels = [];
		for ( $t = $start; $t <= $end; $t += DAY_IN_SECONDS ) {
			$labels[] = gmdate( 'Y-m-d', $t );
		}

		$map = [
			'web'        => array_fill_keys( $labels, 0 ),
			'discover'   => array_fill_keys( $labels, 0 ),
			'googleNews' => array_fill_keys( $labels, 0 ),
		];

		global $wpdb;
		$source = 'posts';

		if ( function_exists( 'newsai_gsc_site_daily_table_exists' ) && newsai_gsc_site_daily_table_exists() ) {
			$table  = newsai_gsc_site_daily_table_name();
			$source = 'site';
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT data_date, search_type, clicks FROM {$table}
					 WHERE data_date >= %s AND data_date <= %s",
					$bounds['cur_start'],
					$bounds['cur_end']
				),
				ARRAY_A
			);
		} else {
			$table = newsai_gsc_table_name();
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT data_date, search_type, SUM(clicks) AS clicks FROM {$table}
					 WHERE data_date >= %s AND data_date <= %s
					 GROUP BY data_date, search_type",
					$bounds['cur_start'],
					$bounds['cur_end']
				),
				ARRAY_A
			);
			$source = 'posts';
		}

		foreach ( (array) $rows as $r ) {
			$date = (string) ( $r['data_date'] ?? '' );
			$type = function_exists( 'newsai_gsc_sanitize_search_type' )
				? newsai_gsc_sanitize_search_type( (string) ( $r['search_type'] ?? 'web' ) )
				: 'web';
			if ( ! isset( $map[ $type ], $map[ $type ][ $date ] ) ) {
				continue;
			}
			$map[ $type ][ $date ] = (int) ( $r['clicks'] ?? 0 );
		}

		$web_series = [];
		$disc_series = [];
		$news_series = [];
		$has_data   = false;
		foreach ( $labels as $d ) {
			$w = (int) $map['web'][ $d ];
			$di = (int) $map['discover'][ $d ];
			$n = (int) $map['googleNews'][ $d ];
			$web_series[]  = $w;
			$disc_series[] = $di;
			$news_series[] = $n;
			if ( $w + $di + $n > 0 ) {
				$has_data = true;
			}
		}

		return [
			'labels'     => $labels,
			'web'        => $web_series,
			'discover'   => $disc_series,
			'googleNews' => $news_series,
			'has_data'   => $has_data,
			'source'     => $source,
		];
	}
}

if ( ! function_exists( 'newsai_chief_gsc_device_breakdown' ) ) {
	/**
	 * Device click share from GSC (site-wide sync).
	 *
	 * @param int    $days         Window 7–90.
	 * @param string $search_type  web|discover|googleNews or empty = all channels.
	 * @return list<array{device:string,label:string,clicks:int,pct:float}>
	 */
	function newsai_chief_gsc_device_breakdown( $days, $search_type = 'web' ) {
		if ( ! function_exists( 'newsai_gsc_data_available' ) || ! newsai_gsc_data_available() ) {
			return [];
		}
		if ( ! function_exists( 'newsai_gsc_device_table_exists' ) || ! newsai_gsc_device_table_exists() ) {
			return [];
		}
		if ( ! function_exists( 'newsai_gsc_insights_period_bounds' ) ) {
			return [];
		}

		global $wpdb;
		$bounds = newsai_gsc_insights_period_bounds( $days );
		$table  = newsai_gsc_device_table_name();

		if ( $search_type !== '' ) {
			$search_type = newsai_gsc_sanitize_search_type( $search_type );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT device, SUM(clicks) AS clicks FROM {$table}
					 WHERE data_date >= %s AND data_date <= %s AND search_type = %s
					 GROUP BY device ORDER BY clicks DESC",
					$bounds['cur_start'],
					$bounds['cur_end'],
					$search_type
				),
				ARRAY_A
			);
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT device, SUM(clicks) AS clicks FROM {$table}
					 WHERE data_date >= %s AND data_date <= %s
					 GROUP BY device ORDER BY clicks DESC",
					$bounds['cur_start'],
					$bounds['cur_end']
				),
				ARRAY_A
			);
		}

		$labels = [
			'DESKTOP' => __( 'Desktop', 'newsroom-ai-assistant' ),
			'MOBILE'  => __( 'Mobile', 'newsroom-ai-assistant' ),
			'TABLET'  => __( 'Tablet', 'newsroom-ai-assistant' ),
		];
		$colors = [
			'DESKTOP' => '#2271b1',
			'MOBILE'  => '#059669',
			'TABLET'  => '#d97706',
		];

		$raw = [];
		$total = 0;
		foreach ( (array) $rows as $r ) {
			$dev = strtoupper( (string) ( $r['device'] ?? '' ) );
			$clk = (int) ( $r['clicks'] ?? 0 );
			if ( $clk <= 0 ) {
				continue;
			}
			$raw[] = [
				'device' => $dev,
				'label'  => $labels[ $dev ] ?? $dev,
				'clicks' => $clk,
				'color'  => $colors[ $dev ] ?? '#64748b',
			];
			$total += $clk;
		}
		if ( $total <= 0 ) {
			return [];
		}
		foreach ( $raw as $i => $row ) {
			$raw[ $i ]['pct'] = round( ( $row['clicks'] / $total ) * 100, 1 );
		}
		return $raw;
	}
}

if ( ! function_exists( 'newsai_chief_svg_channel_sparkline' ) ) {
	/**
	 * Build SVG path + fill area for one channel (independent Y scale).
	 *
	 * @param list<int> $vals   Daily clicks.
	 * @param int       $plot_w Plot width.
	 * @param int       $plot_h Plot height.
	 * @param int       $pad_l  Left padding.
	 * @return array{path:string,area:string,max:int}
	 */
	function newsai_chief_svg_channel_sparkline( array $vals, $plot_w, $plot_h, $pad_l = 0 ) {
		$n   = count( $vals );
		$max = max( 1, ...array_map( 'intval', $vals ) );
		$pts = [];
		foreach ( $vals as $i => $v ) {
			$x     = $pad_l + ( $n > 1 ? ( $i / ( $n - 1 ) ) * $plot_w : 0 );
			$y     = $plot_h - ( ( (float) (int) $v / $max ) * $plot_h );
			$pts[] = [ round( $x, 1 ), round( $y, 1 ) ];
		}
		if ( $pts === [] ) {
			return [ 'path' => '', 'area' => '', 'max' => 0 ];
		}
		$line = 'M' . $pts[0][0] . ',' . $pts[0][1];
		for ( $i = 1; $i < count( $pts ); $i++ ) {
			$line .= ' L' . $pts[ $i ][0] . ',' . $pts[ $i ][1];
		}
		$last  = $pts[ count( $pts ) - 1 ];
		$first = $pts[0];
		$area  = $line
			. ' L' . $last[0] . ',' . $plot_h
			. ' L' . $first[0] . ',' . $plot_h
			. ' Z';

		return [ 'path' => $line, 'area' => $area, 'max' => $max ];
	}
}

if ( ! function_exists( 'newsai_chief_render_gsc_trend_chart' ) ) {
	/**
	 * Per-channel SVG sparklines + legend totals for the window.
	 *
	 * @param array $series Output of newsai_chief_gsc_daily_series().
	 * @param int   $days   Window label.
	 * @return string Safe HTML.
	 */
	function newsai_chief_render_gsc_trend_chart( array $series, $days = 30 ) {
		if ( empty( $series['has_data'] ) || empty( $series['labels'] ) ) {
			return '<p class="nros-chief-chart-empty">' . esc_html__( 'No GSC daily data for this window yet. Run Sync now in Settings → Integrations (site-wide chart rows are stored separately).', 'newsroom-ai-assistant' ) . '</p>';
		}

		$channels = [
			[
				'key'   => 'web',
				'label' => __( 'Web', 'newsroom-ai-assistant' ),
				'color' => '#2271b1',
				'vals'  => (array) ( $series['web'] ?? [] ),
			],
			[
				'key'   => 'discover',
				'label' => __( 'Discover', 'newsroom-ai-assistant' ),
				'color' => '#7c3aed',
				'vals'  => (array) ( $series['discover'] ?? [] ),
			],
			[
				'key'   => 'googleNews',
				'label' => __( 'Google News', 'newsroom-ai-assistant' ),
				'color' => '#059669',
				'vals'  => (array) ( $series['googleNews'] ?? [] ),
			],
		];

		$row_h  = 44;
		$pad_l  = 0;
		$plot_w = 520;
		$plot_h = 32;
		$w      = $plot_w;
		$first  = $series['labels'][0] ?? '';
		$last   = $series['labels'][ count( $series['labels'] ) - 1 ] ?? '';
		$source_note = ( $series['source'] ?? '' ) === 'site'
			? __( 'Site-wide totals from Search Console API', 'newsroom-ai-assistant' )
			: __( 'Summed from synced article rows', 'newsroom-ai-assistant' );

		ob_start();
		?>
		<div class="nros-chief-chart nros-chief-chart--trend nros-chief-chart--by-channel">
			<?php foreach ( $channels as $ch ) : ?>
				<?php
				$total   = array_sum( array_map( 'intval', $ch['vals'] ) );
				$spark   = newsai_chief_svg_channel_sparkline( $ch['vals'], $plot_w, $plot_h, $pad_l );
				$row_id  = 'nros-chief-ch-' . esc_attr( $ch['key'] );
				?>
				<div class="nros-chief-channel-row" id="<?php echo esc_attr( $row_id ); ?>">
					<div class="nros-chief-channel-row__label">
						<span class="nros-chief-chart__swatch" style="background:<?php echo esc_attr( $ch['color'] ); ?>"></span>
						<span class="nros-chief-channel-row__name"><?php echo esc_html( $ch['label'] ); ?></span>
						<strong class="nros-chief-channel-row__total" title="<?php echo esc_attr__( 'Total clicks in window', 'newsroom-ai-assistant' ); ?>">
							<?php echo esc_html( number_format_i18n( $total ) ); ?>
						</strong>
					</div>
					<svg class="nros-chief-channel-row__svg" viewBox="0 0 <?php echo (int) $w; ?> <?php echo (int) $plot_h; ?>" preserveAspectRatio="none" aria-hidden="true">
						<line x1="0" y1="<?php echo (int) $plot_h; ?>" x2="<?php echo (int) $plot_w; ?>" y2="<?php echo (int) $plot_h; ?>" stroke="var(--nros-border-subtle,#e2e4e7)" stroke-width="1"/>
						<?php if ( $spark['area'] !== '' ) : ?>
							<path d="<?php echo esc_attr( $spark['area'] ); ?>" fill="<?php echo esc_attr( $ch['color'] ); ?>" fill-opacity="0.12"/>
							<path d="<?php echo esc_attr( $spark['path'] ); ?>" fill="none" stroke="<?php echo esc_attr( $ch['color'] ); ?>" stroke-width="2" vector-effect="non-scaling-stroke"/>
						<?php endif; ?>
					</svg>
				</div>
			<?php endforeach; ?>
			<div class="nros-chief-chart__axis">
				<span><?php echo esc_html( $first ); ?></span>
				<span><?php echo esc_html( $last ); ?></span>
			</div>
			<ul class="nros-chief-chart__legend nros-chief-chart__legend--totals">
				<?php foreach ( $channels as $ch ) : ?>
					<?php $total = array_sum( array_map( 'intval', $ch['vals'] ) ); ?>
					<li>
						<span class="nros-chief-chart__swatch" style="background:<?php echo esc_attr( $ch['color'] ); ?>"></span>
						<span class="nros-chief-chart__legend-name"><?php echo esc_html( $ch['label'] ); ?></span>
						<strong class="nros-chief-chart__legend-val"><?php echo esc_html( number_format_i18n( $total ) ); ?></strong>
					</li>
				<?php endforeach; ?>
			</ul>
			<p class="nros-chief-chart__meta"><?php echo esc_html( $source_note ); ?></p>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_device_donut' ) ) {
	/**
	 * SVG donut + legend for GSC device clicks.
	 *
	 * @param list<array{device:string,label:string,clicks:int,pct:float,color?:string}> $breakdown
	 * @param string                                                                     $channel_label Human channel name.
	 * @return string
	 */
	function newsai_chief_render_device_donut( array $breakdown, $channel_label = '' ) {
		if ( $breakdown === [] ) {
			return '<p class="nros-chief-chart-empty">' . esc_html__( 'No device breakdown yet. Run GSC Sync to pull device × date rows from Search Console.', 'newsroom-ai-assistant' ) . '</p>';
		}

		$cx    = 50;
		$cy    = 50;
		$r     = 38;
		$stroke = 14;
		$circ  = 2 * M_PI * $r;
		$offset = 0;
		$segments = '';

		foreach ( $breakdown as $row ) {
			$pct   = (float) ( $row['pct'] ?? 0 );
			$len   = ( $pct / 100 ) * $circ;
			$color = (string) ( $row['color'] ?? '#64748b' );
			$segments .= sprintf(
				'<circle cx="%1$s" cy="%1$s" r="%2$s" fill="none" stroke="%3$s" stroke-width="%4$s" stroke-dasharray="%5$s %6$s" stroke-dashoffset="%7$s" transform="rotate(-90 %1$s %1$s)"/>',
				$cx,
				$r,
				esc_attr( $color ),
				$stroke,
				esc_attr( round( $len, 2 ) ),
				esc_attr( round( $circ - $len, 2 ) ),
				esc_attr( round( -$offset, 2 ) )
			);
			$offset += $len;
		}

		$title = $channel_label !== ''
			? sprintf(
				/* translators: %s: channel name (Web, Discover, …) */
				__( '%s — clicks by device', 'newsroom-ai-assistant' ),
				$channel_label
			)
			: __( 'Clicks by device', 'newsroom-ai-assistant' );

		ob_start();
		?>
		<div class="nros-chief-chart nros-chief-chart--donut">
			<div class="nros-chief-donut-wrap">
				<svg class="nros-chief-donut__svg" viewBox="0 0 100 100" role="img" aria-label="<?php echo esc_attr( $title ); ?>">
					<circle cx="<?php echo (int) $cx; ?>" cy="<?php echo (int) $cy; ?>" r="<?php echo (int) $r; ?>" fill="none" stroke="var(--nros-border-subtle,#e2e4e7)" stroke-width="<?php echo (int) $stroke; ?>"/>
					<?php echo $segments; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
				</svg>
			</div>
			<ul class="nros-chief-donut__legend">
				<?php foreach ( $breakdown as $row ) : ?>
					<li>
						<span class="nros-chief-chart__swatch" style="background:<?php echo esc_attr( (string) ( $row['color'] ?? '#64748b' ) ); ?>"></span>
						<span class="nros-chief-donut__label"><?php echo esc_html( (string) $row['label'] ); ?></span>
						<strong><?php echo esc_html( number_format_i18n( (int) $row['clicks'] ) ); ?></strong>
						<span class="nros-chief-donut__pct"><?php echo esc_html( number_format_i18n( (float) $row['pct'], 1 ) . '%' ); ?></span>
					</li>
				<?php endforeach; ?>
			</ul>
			<p class="nros-chief-chart__meta"><?php echo esc_html__( 'From GSC device dimension (synced with Search Console).', 'newsroom-ai-assistant' ); ?></p>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_device_channel_labels' ) ) {
	/**
	 * @return array<string, string>
	 */
	function newsai_chief_device_channel_labels() {
		return [
			'web'        => __( 'Web', 'newsroom-ai-assistant' ),
			'discover'   => __( 'Discover', 'newsroom-ai-assistant' ),
			'googleNews' => __( 'Google News', 'newsroom-ai-assistant' ),
		];
	}
}

if ( ! function_exists( 'newsai_ajax_chief_device_donut' ) ) {
	function newsai_ajax_chief_device_donut() {
		check_ajax_referer( 'newsai_ajax_nonce', 'nonce' );
		if ( ! current_user_can( 'edit_others_posts' ) ) {
			wp_send_json_error( [ 'message' => 'forbidden' ], 403 );
		}
		if ( ! function_exists( 'newsai_is_pro' ) || ! newsai_is_pro() ) {
			wp_send_json_error( [ 'message' => 'pro_required' ], 403 );
		}
		$days_in = isset( $_POST['days'] ) ? (int) $_POST['days'] : 30;
		$days    = in_array( $days_in, [ 7, 30, 90 ], true ) ? $days_in : 30;
		$ch_in   = isset( $_POST['channel'] ) ? sanitize_key( (string) wp_unslash( $_POST['channel'] ) ) : 'web';
		$labels  = newsai_chief_device_channel_labels();
		if ( ! isset( $labels[ $ch_in ] ) ) {
			$ch_in = 'web';
		}
		$breakdown = function_exists( 'newsai_chief_gsc_device_breakdown' )
			? newsai_chief_gsc_device_breakdown( $days, $ch_in )
			: [];
		$html = function_exists( 'newsai_chief_render_device_donut' )
			? newsai_chief_render_device_donut( $breakdown, $labels[ $ch_in ] )
			: '';
		wp_send_json_success(
			[
				'html'      => $html,
				'breakdown' => $breakdown,
				'channel'   => $ch_in,
				'days'      => $days,
			]
		);
	}
}
add_action( 'wp_ajax_newsai_chief_device_donut', 'newsai_ajax_chief_device_donut' );

if ( ! function_exists( 'newsai_admin_analytics_cache_ttl' ) ) {
	/**
	 * TTL for Chief Insights + Author Performance grid aggregates.
	 * Default: 24h (GSC sync is typically daily).
	 *
	 * @return int Seconds.
	 */
	function newsai_admin_analytics_cache_ttl() {
		return (int) apply_filters( 'newsai_admin_analytics_cache_ttl', DAY_IN_SECONDS );
	}
}

if ( ! function_exists( 'newsai_admin_analytics_cache_stamp' ) ) {
	/**
	 * Cache-bust token: UTC date + last GSC sync (when present).
	 *
	 * @return string
	 */
	function newsai_admin_analytics_cache_stamp() {
		$gsc = (string) get_option( 'newsai_gsc_last_sync', '' );
		return gmdate( 'Ymd' ) . '_' . substr( md5( $gsc ), 0, 8 );
	}
}

if ( ! function_exists( 'newsai_chief_gsc_resolve_channel' ) ) {
	/**
	 * @param string $channel all|web|discover|googleNews
	 * @return string
	 */
	function newsai_chief_gsc_resolve_channel( $channel ) {
		$channel = sanitize_key( (string) $channel );
		$allowed = [ 'all', 'web', 'discover', 'googleNews' ];
		return in_array( $channel, $allowed, true ) ? $channel : 'all';
	}
}

if ( ! function_exists( 'newsai_chief_gsc_channel_types' ) ) {
	/**
	 * @param string $channel all|web|discover|googleNews
	 * @return list<string>
	 */
	function newsai_chief_gsc_channel_types( $channel ) {
		$channel = newsai_chief_gsc_resolve_channel( $channel );
		if ( $channel === 'all' ) {
			return [ 'web', 'discover', 'googleNews' ];
		}
		return [ $channel ];
	}
}

if ( ! function_exists( 'newsai_chief_gsc_aggregate_window' ) ) {
	/**
	 * Sum clicks/impressions (+ weighted position) for date range and channel(s).
	 *
	 * @param string       $start  Y-m-d
	 * @param string       $end    Y-m-d
	 * @param list<string> $types  search_type values
	 * @return array{clicks:int,impressions:int,ctr:float,position:float,keywords:int}
	 */
	function newsai_chief_gsc_aggregate_window( $start, $end, array $types ) {
		$out = [
			'clicks'      => 0,
			'impressions' => 0,
			'ctr'         => 0.0,
			'position'    => 0.0,
			'keywords'    => 0,
		];
		if ( $types === [] || ! function_exists( 'newsai_gsc_data_available' ) || ! newsai_gsc_data_available() ) {
			return $out;
		}

		global $wpdb;
		$types = array_values( array_filter( array_map( static function ( $t ) {
			return function_exists( 'newsai_gsc_sanitize_search_type' ) ? newsai_gsc_sanitize_search_type( (string) $t ) : '';
		}, $types ) ) );
		if ( $types === [] ) {
			return $out;
		}

		$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
		$args         = array_merge( [ $start, $end ], $types );

		$used_site = false;
		if ( function_exists( 'newsai_gsc_site_daily_table_exists' ) && newsai_gsc_site_daily_table_exists() ) {
			$table = newsai_gsc_site_daily_table_name();
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT COALESCE(SUM(clicks), 0) AS clicks, COALESCE(SUM(impressions), 0) AS impressions
					 FROM {$table}
					 WHERE data_date >= %s AND data_date <= %s AND search_type IN ({$placeholders})",
					...$args
				),
				ARRAY_A
			);
			if ( is_array( $row ) ) {
				$out['clicks']      = (int) ( $row['clicks'] ?? 0 );
				$out['impressions'] = (int) ( $row['impressions'] ?? 0 );
				$used_site          = true;
			}
		}

		if ( ! $used_site ) {
			$table = newsai_gsc_table_name();
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT COALESCE(SUM(clicks), 0) AS clicks, COALESCE(SUM(impressions), 0) AS impressions
					 FROM {$table}
					 WHERE data_date >= %s AND data_date <= %s AND search_type IN ({$placeholders})",
					...$args
				),
				ARRAY_A
			);
			if ( is_array( $row ) ) {
				$out['clicks']      = (int) ( $row['clicks'] ?? 0 );
				$out['impressions'] = (int) ( $row['impressions'] ?? 0 );
			}
		}

		$post_table = newsai_gsc_table_name();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$pos_row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT COALESCE(SUM(impressions), 0) AS impressions,
					COALESCE(SUM(position * impressions), 0) AS pos_weight
				 FROM {$post_table}
				 WHERE data_date >= %s AND data_date <= %s AND search_type IN ({$placeholders}) AND impressions > 0",
				...$args
			),
			ARRAY_A
		);
		$pos_impr = (int) ( $pos_row['impressions'] ?? 0 );
		if ( $pos_impr > 0 ) {
			$out['position'] = round( (float) ( $pos_row['pos_weight'] ?? 0 ) / $pos_impr, 2 );
		}

		if ( function_exists( 'newsai_gsc_query_table_exists' ) && newsai_gsc_query_table_exists() && in_array( 'web', $types, true ) ) {
			$qtable = newsai_gsc_query_table_name();
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$out['keywords'] = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(DISTINCT query_hash) FROM {$qtable}
					 WHERE data_date >= %s AND data_date <= %s AND search_type = 'web' AND impressions > 0",
					$start,
					$end
				)
			);
		}

		$out['ctr'] = $out['impressions'] > 0 ? round( $out['clicks'] / $out['impressions'], 4 ) : 0.0;
		return $out;
	}
}

if ( ! function_exists( 'newsai_chief_gsc_channel_kpis' ) ) {
	/**
	 * KPI bundle for one channel with period comparison and sparkline series.
	 *
	 * @param int    $days    7–90
	 * @param string $channel all|web|discover|googleNews
	 * @return array<string, mixed>
	 */
	function newsai_chief_gsc_channel_kpis( $days, $channel = 'all' ) {
		$days    = max( 7, min( 90, (int) $days ) );
		$channel = newsai_chief_gsc_resolve_channel( $channel );
		$types   = newsai_chief_gsc_channel_types( $channel );
		$bounds  = function_exists( 'newsai_gsc_insights_period_bounds' )
			? newsai_gsc_insights_period_bounds( $days )
			: [
				'cur_start'  => gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ),
				'cur_end'    => gmdate( 'Y-m-d', time() ),
				'prev_start' => gmdate( 'Y-m-d', time() - ( 2 * $days * DAY_IN_SECONDS ) ),
				'prev_end'   => gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) - DAY_IN_SECONDS ),
			];

		$cur  = newsai_chief_gsc_aggregate_window( $bounds['cur_start'], $bounds['cur_end'], $types );
		$prev = newsai_chief_gsc_aggregate_window( $bounds['prev_start'], $bounds['prev_end'], $types );

		$spark_clicks = [];
		$spark_impr   = [];
		if ( function_exists( 'newsai_chief_gsc_daily_series' ) ) {
			$series = newsai_chief_gsc_daily_series( $days );
			$labels = (array) ( $series['labels'] ?? [] );
			foreach ( $labels as $i => $d ) {
				unset( $d );
				$clk = 0;
				$imp = 0;
				foreach ( $types as $t ) {
					$clk += (int) ( $series[ $t ][ $i ] ?? 0 );
				}
				$spark_clicks[] = $clk;
			}
			if ( function_exists( 'newsai_gsc_site_daily_table_exists' ) && newsai_gsc_site_daily_table_exists() ) {
				global $wpdb;
				$table = newsai_gsc_site_daily_table_name();
				$ph    = implode( ',', array_fill( 0, count( $types ), '%s' ) );
				$args  = array_merge( [ $bounds['cur_start'], $bounds['cur_end'] ], $types );
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$impr_rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT data_date, SUM(impressions) AS impressions FROM {$table}
						 WHERE data_date >= %s AND data_date <= %s AND search_type IN ({$ph})
						 GROUP BY data_date ORDER BY data_date ASC",
						...$args
					),
					ARRAY_A
				);
				$impr_map = [];
				foreach ( (array) $impr_rows as $r ) {
					$impr_map[ (string) $r['data_date'] ] = (int) ( $r['impressions'] ?? 0 );
				}
				foreach ( $labels as $label ) {
					$spark_impr[] = (int) ( $impr_map[ $label ] ?? 0 );
				}
			}
		}

		return [
			'channel'         => $channel,
			'clicks'          => (int) $cur['clicks'],
			'impressions'     => (int) $cur['impressions'],
			'ctr'             => (float) $cur['ctr'],
			'position'        => (float) $cur['position'],
			'keywords'        => (int) $cur['keywords'],
			'prev_clicks'     => (int) $prev['clicks'],
			'prev_impressions'=> (int) $prev['impressions'],
			'prev_ctr'        => (float) $prev['ctr'],
			'prev_position'   => (float) $prev['position'],
			'prev_keywords'   => (int) $prev['keywords'],
			'spark_clicks'    => $spark_clicks,
			'spark_impr'      => $spark_impr,
		];
	}
}

if ( ! function_exists( 'newsai_chief_gsc_post_movers' ) ) {
	/**
	 * Winning or losing posts by click delta for a GSC channel.
	 *
	 * @param int    $days        7–90
	 * @param string $search_type web|discover|googleNews|all
	 * @param string $direction   win|lose
	 * @param int    $limit       Max rows.
	 * @return list<array<string, mixed>>
	 */
	function newsai_chief_gsc_post_movers( $days, $search_type = 'web', $direction = 'win', $limit = 5 ) {
		if ( ! function_exists( 'newsai_gsc_data_available' ) || ! newsai_gsc_data_available() ) {
			return [];
		}
		global $wpdb;

		$days      = max( 7, min( 90, (int) $days ) );
		$limit     = max( 1, min( 10, (int) $limit ) );
		$direction = $direction === 'lose' ? 'lose' : 'win';
		if ( $search_type === 'all' ) {
			$types = newsai_chief_gsc_channel_types( 'all' );
		} else {
			$types = [ function_exists( 'newsai_gsc_sanitize_search_type' ) ? newsai_gsc_sanitize_search_type( $search_type ) : 'web' ];
		}
		$bounds = newsai_gsc_insights_period_bounds( $days );
		$table  = newsai_gsc_table_name();
		$ph     = implode( ',', array_fill( 0, count( $types ), '%s' ) );

		$sql = "
			SELECT p.ID AS post_id, p.post_title AS title,
				SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s THEN g.clicks ELSE 0 END) AS clicks,
				SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s THEN g.impressions ELSE 0 END) AS impressions,
				SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s THEN g.clicks ELSE 0 END) AS prev_clicks,
				SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s THEN g.impressions ELSE 0 END) AS prev_impressions,
				CASE WHEN SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s AND g.impressions > 0 THEN g.impressions ELSE 0 END) > 0
					THEN SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s THEN g.position * g.impressions ELSE 0 END)
						/ SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s AND g.impressions > 0 THEN g.impressions ELSE 0 END)
					ELSE 0 END AS position,
				CASE WHEN SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s AND g.impressions > 0 THEN g.impressions ELSE 0 END) > 0
					THEN SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s THEN g.position * g.impressions ELSE 0 END)
						/ SUM(CASE WHEN g.data_date >= %s AND g.data_date <= %s AND g.impressions > 0 THEN g.impressions ELSE 0 END)
					ELSE 0 END AS prev_position
			FROM {$table} g
			INNER JOIN {$wpdb->posts} p ON p.ID = g.post_id
			WHERE g.search_type IN ({$ph})
				AND g.data_date >= %s AND g.data_date <= %s
				AND p.post_status = 'publish' AND p.post_type = 'post'
			GROUP BY p.ID, p.post_title
			HAVING clicks > 0 OR prev_clicks > 0";

		$args = array_merge(
			[
				$bounds['cur_start'], $bounds['cur_end'],
				$bounds['cur_start'], $bounds['cur_end'],
				$bounds['prev_start'], $bounds['prev_end'],
				$bounds['prev_start'], $bounds['prev_end'],
				$bounds['cur_start'], $bounds['cur_end'],
				$bounds['cur_start'], $bounds['cur_end'],
				$bounds['cur_start'], $bounds['cur_end'],
				$bounds['prev_start'], $bounds['prev_end'],
				$bounds['prev_start'], $bounds['prev_end'],
				$bounds['prev_start'], $bounds['prev_end'],
			],
			$types,
			[ $bounds['prev_start'], $bounds['cur_end'] ]
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$args ), ARRAY_A );

		$scored = [];
		foreach ( (array) $rows as $r ) {
			$clicks = (int) ( $r['clicks'] ?? 0 );
			$prev   = (int) ( $r['prev_clicks'] ?? 0 );
			$delta  = $clicks - $prev;
			if ( $direction === 'win' && $delta <= 0 ) {
				continue;
			}
			if ( $direction === 'lose' && $delta >= 0 ) {
				continue;
			}
			$scored[] = [
				'post_id'          => (int) ( $r['post_id'] ?? 0 ),
				'title'            => (string) ( $r['title'] ?? '' ),
				'clicks'           => $clicks,
				'impressions'      => (int) ( $r['impressions'] ?? 0 ),
				'prev_clicks'      => $prev,
				'prev_impressions' => (int) ( $r['prev_impressions'] ?? 0 ),
				'delta_clicks'     => $delta,
				'position'         => round( (float) ( $r['position'] ?? 0 ), 1 ),
				'prev_position'    => round( (float) ( $r['prev_position'] ?? 0 ), 1 ),
				'delta_position'   => round( (float) ( $r['position'] ?? 0 ) - (float) ( $r['prev_position'] ?? 0 ), 1 ),
				'trend'            => function_exists( 'newsai_gsc_insights_trend_key' )
					? newsai_gsc_insights_trend_key( $delta, $prev, $clicks )
					: ( $delta > 0 ? 'up' : 'down' ),
				'spark'            => [],
			];
		}

		usort(
			$scored,
			static function ( $a, $b ) use ( $direction ) {
				$da = (int) ( $a['delta_clicks'] ?? 0 );
				$db = (int) ( $b['delta_clicks'] ?? 0 );
				return $direction === 'win' ? $db <=> $da : $da <=> $db;
			}
		);
		$scored = array_slice( $scored, 0, $limit );

		if ( $scored !== [] ) {
			$ids = array_values( array_filter( array_map( static function ( $row ) {
				return (int) ( $row['post_id'] ?? 0 );
			}, $scored ) ) );
			$id_ph = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
			$spark_args = array_merge(
				[ $bounds['cur_start'], $bounds['cur_end'] ],
				$types,
				$ids
			);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$spark_rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT g.post_id, g.data_date, SUM(g.clicks) AS clicks
					 FROM {$table} g
					 WHERE g.data_date >= %s AND g.data_date <= %s
					 AND g.search_type IN ({$ph}) AND g.post_id IN ({$id_ph})
					 GROUP BY g.post_id, g.data_date ORDER BY g.data_date ASC",
					...$spark_args
				),
				ARRAY_A
			);
			$spark_map = [];
			foreach ( (array) $spark_rows as $sr ) {
				$pid = (int) ( $sr['post_id'] ?? 0 );
				if ( ! isset( $spark_map[ $pid ] ) ) {
					$spark_map[ $pid ] = [];
				}
				$spark_map[ $pid ][] = (int) ( $sr['clicks'] ?? 0 );
			}
			foreach ( $scored as $i => $row ) {
				$scored[ $i ]['spark'] = $spark_map[ (int) $row['post_id'] ] ?? [];
			}
		}

		return $scored;
	}
}

if ( ! function_exists( 'newsai_chief_gsc_keyword_movers' ) ) {
	/**
	 * Winning or losing search queries (web) from synced query rows.
	 *
	 * @param int    $days      7–90
	 * @param string $direction win|lose
	 * @param int    $limit     Max rows.
	 * @return list<array<string, mixed>>
	 */
	function newsai_chief_gsc_keyword_movers( $days, $direction = 'win', $limit = 5 ) {
		if ( ! function_exists( 'newsai_gsc_query_table_exists' ) || ! newsai_gsc_query_table_exists() ) {
			return [];
		}
		if ( ! function_exists( 'newsai_gsc_insights_period_bounds' ) ) {
			return [];
		}

		global $wpdb;
		$days      = max( 7, min( 90, (int) $days ) );
		$limit     = max( 1, min( 10, (int) $limit ) );
		$direction = $direction === 'lose' ? 'lose' : 'win';
		$bounds    = newsai_gsc_insights_period_bounds( $days );
		$table     = newsai_gsc_query_table_name();

		$sql = "
			SELECT query_text,
				SUM(CASE WHEN data_date >= %s AND data_date <= %s THEN clicks ELSE 0 END) AS clicks,
				SUM(CASE WHEN data_date >= %s AND data_date <= %s THEN impressions ELSE 0 END) AS impressions,
				SUM(CASE WHEN data_date >= %s AND data_date <= %s THEN clicks ELSE 0 END) AS prev_clicks,
				CASE WHEN SUM(CASE WHEN data_date >= %s AND data_date <= %s AND impressions > 0 THEN impressions ELSE 0 END) > 0
					THEN SUM(CASE WHEN data_date >= %s AND data_date <= %s THEN position * impressions ELSE 0 END)
						/ SUM(CASE WHEN data_date >= %s AND data_date <= %s AND impressions > 0 THEN impressions ELSE 0 END)
					ELSE 0 END AS position,
				CASE WHEN SUM(CASE WHEN data_date >= %s AND data_date <= %s AND impressions > 0 THEN impressions ELSE 0 END) > 0
					THEN SUM(CASE WHEN data_date >= %s AND data_date <= %s THEN position * impressions ELSE 0 END)
						/ SUM(CASE WHEN data_date >= %s AND data_date <= %s AND impressions > 0 THEN impressions ELSE 0 END)
					ELSE 0 END AS prev_position
			FROM {$table}
			WHERE search_type = 'web' AND data_date >= %s AND data_date <= %s
			GROUP BY query_text
			HAVING clicks > 0 OR prev_clicks > 0";

		$args = [
			$bounds['cur_start'], $bounds['cur_end'],
			$bounds['cur_start'], $bounds['cur_end'],
			$bounds['prev_start'], $bounds['prev_end'],
			$bounds['cur_start'], $bounds['cur_end'],
			$bounds['cur_start'], $bounds['cur_end'],
			$bounds['cur_start'], $bounds['cur_end'],
			$bounds['prev_start'], $bounds['prev_end'],
			$bounds['prev_start'], $bounds['prev_end'],
			$bounds['prev_start'], $bounds['prev_end'],
			$bounds['prev_start'], $bounds['cur_end'],
		];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$args ), ARRAY_A );

		$scored = [];
		foreach ( (array) $rows as $r ) {
			$clicks = (int) ( $r['clicks'] ?? 0 );
			$prev   = (int) ( $r['prev_clicks'] ?? 0 );
			$delta  = $clicks - $prev;
			if ( $direction === 'win' && $delta <= 0 ) {
				continue;
			}
			if ( $direction === 'lose' && $delta >= 0 ) {
				continue;
			}
			$pos      = round( (float) ( $r['position'] ?? 0 ), 1 );
			$prev_pos = round( (float) ( $r['prev_position'] ?? 0 ), 1 );
			$scored[] = [
				'query'          => (string) ( $r['query_text'] ?? '' ),
				'clicks'         => $clicks,
				'impressions'    => (int) ( $r['impressions'] ?? 0 ),
				'prev_clicks'    => $prev,
				'delta_clicks'   => $delta,
				'position'       => $pos,
				'prev_position'  => $prev_pos,
				'delta_position' => round( $pos - $prev_pos, 1 ),
			];
		}

		usort(
			$scored,
			static function ( $a, $b ) use ( $direction ) {
				$da = (int) ( $a['delta_clicks'] ?? 0 );
				$db = (int) ( $b['delta_clicks'] ?? 0 );
				return $direction === 'win' ? $db <=> $da : $da <=> $db;
			}
		);

		$seen   = [];
		$unique = [];
		foreach ( $scored as $item ) {
			$key = mb_strtolower( trim( (string) ( $item['query'] ?? '' ) ) );
			if ( $key === '' || isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$unique[]     = $item;
		}

		return array_slice( $unique, 0, $limit );
	}
}

if ( ! function_exists( 'newsai_chief_render_mini_sparkline' ) ) {
	/**
	 * @param list<int|float> $vals  Series values.
	 * @param string          $color Stroke/fill color.
	 * @param int             $w     Width.
	 * @param int             $h     Height.
	 * @return string SVG HTML.
	 */
	function newsai_chief_render_mini_sparkline( array $vals, $color = '#2271b1', $w = 72, $h = 22 ) {
		if ( $vals === [] || ! function_exists( 'newsai_chief_svg_channel_sparkline' ) ) {
			return '';
		}
		$spark = newsai_chief_svg_channel_sparkline( array_map( 'intval', $vals ), $w, $h );
		if ( $spark['path'] === '' ) {
			return '';
		}
		return '<svg class="nros-chief-mini-spark" viewBox="0 0 ' . (int) $w . ' ' . (int) $h . '" width="' . (int) $w . '" height="' . (int) $h . '" aria-hidden="true">'
			. '<path d="' . esc_attr( $spark['area'] ) . '" fill="' . esc_attr( $color ) . '" fill-opacity="0.15"/>'
			. '<path d="' . esc_attr( $spark['path'] ) . '" fill="none" stroke="' . esc_attr( $color ) . '" stroke-width="1.5" vector-effect="non-scaling-stroke"/>'
			. '</svg>';
	}
}

if ( ! function_exists( 'newsai_chief_kpi_delta_chip' ) ) {
	/**
	 * @param float|int $current
	 * @param float|int $previous
	 * @param string    $mode number|percent|position|ctr
	 * @return array{class:string,text:string}
	 */
	function newsai_chief_kpi_delta_chip( $current, $previous, $mode = 'number' ) {
		$cur  = (float) $current;
		$prev = (float) $previous;
		$delta = $cur - $prev;

		if ( $mode === 'position' ) {
			if ( $prev <= 0 && $cur > 0 ) {
				return [ 'class' => 'nros-chief-delta--flat', 'text' => __( 'new rank', 'newsroom-ai-assistant' ) ];
			}
			if ( $cur <= 0 && $prev <= 0 ) {
				return [ 'class' => 'nros-chief-delta--flat', 'text' => '—' ];
			}
			$class = $delta < 0 ? 'nros-chief-delta--up' : ( $delta > 0 ? 'nros-chief-delta--down' : 'nros-chief-delta--flat' );
			$text  = ( $delta >= 0 ? '+' : '' ) . number_format_i18n( $delta, 1 );
			return [ 'class' => $class, 'text' => $text ];
		}

		if ( $mode === 'ctr' ) {
			$class = $delta > 0 ? 'nros-chief-delta--up' : ( $delta < 0 ? 'nros-chief-delta--down' : 'nros-chief-delta--flat' );
			$text  = ( $delta >= 0 ? '+' : '' ) . number_format_i18n( $delta * 100, 2 ) . 'pp';
			return [ 'class' => $class, 'text' => $text ];
		}

		if ( $mode === 'percent' ) {
			$class = $delta > 0 ? 'nros-chief-delta--up' : ( $delta < 0 ? 'nros-chief-delta--down' : 'nros-chief-delta--flat' );
			if ( $prev > 0 ) {
				$pct = (int) round( ( $delta / $prev ) * 100 );
				$text = ( $pct >= 0 ? '+' : '' ) . $pct . '%';
			} else {
				$text = $delta > 0 ? __( 'new', 'newsroom-ai-assistant' ) : '0%';
			}
			return [ 'class' => $class, 'text' => $text ];
		}

		$class = $delta > 0 ? 'nros-chief-delta--up' : ( $delta < 0 ? 'nros-chief-delta--down' : 'nros-chief-delta--flat' );
		$text  = ( $delta >= 0 ? '+' : '' ) . number_format_i18n( (int) round( $delta ) );
		return [ 'class' => $class, 'text' => $text ];
	}
}

if ( ! function_exists( 'newsai_chief_render_kpi_card' ) ) {
	/**
	 * @param string               $label
	 * @param string               $value
	 * @param array{class:string,text:string} $delta
	 * @param string               $spark_html
	 * @param string               $hint Optional sublabel.
	 * @param string               $variant default|hero|alert|success
	 * @return string
	 */
	function newsai_chief_render_kpi_card( $label, $value, array $delta, $spark_html = '', $hint = '', $variant = 'default' ) {
		$variant = sanitize_key( (string) $variant );
		if ( ! in_array( $variant, [ 'default', 'hero', 'alert', 'success' ], true ) ) {
			$variant = 'default';
		}
		ob_start();
		?>
		<div class="nros-chief-kpi-card nros-chief-kpi-card--<?php echo esc_attr( $variant ); ?>">
			<div class="nros-chief-kpi-card__label"><?php echo esc_html( $label ); ?></div>
			<div class="nros-chief-kpi-card__row">
				<div class="nros-chief-kpi-card__val"><?php echo esc_html( $value ); ?></div>
				<?php if ( ! empty( $delta['text'] ) ) : ?>
					<span class="nros-chief-kpi-card__delta <?php echo esc_attr( (string) ( $delta['class'] ?? '' ) ); ?>"><?php echo esc_html( (string) $delta['text'] ); ?></span>
				<?php endif; ?>
			</div>
			<?php if ( $spark_html !== '' ) : ?>
				<div class="nros-chief-kpi-card__spark"><?php echo $spark_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
			<?php endif; ?>
			<?php if ( $hint !== '' ) : ?>
				<div class="nros-chief-kpi-card__hint"><?php echo esc_html( $hint ); ?></div>
			<?php endif; ?>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_mover_delta' ) ) {
	/**
	 * Signed period change chip (not a duplicate of the headline value).
	 *
	 * @param float|int      $delta
	 * @param string         $mode clicks|position
	 * @param float|int|null $previous Optional previous value for "new" detection.
	 * @return string
	 */
	function newsai_chief_render_mover_delta( $delta, $mode = 'clicks', $previous = null ) {
		$delta = (float) $delta;
		$prev  = $previous === null ? null : (float) $previous;

		if ( $mode === 'position' ) {
			if ( $delta === 0.0 ) {
				return '<span class="nros-chief-mover-delta nros-chief-delta--flat">—</span>';
			}
			$class = $delta < 0 ? 'nros-chief-delta--up' : 'nros-chief-delta--down';
			$sign  = $delta > 0 ? '+' : '';
			$text  = $sign . number_format_i18n( $delta, 1 );
			return '<span class="nros-chief-mover-delta ' . esc_attr( $class ) . '" title="' . esc_attr__( 'Position change vs previous period', 'newsroom-ai-assistant' ) . '">' . esc_html( $text ) . '</span>';
		}

		if ( $prev !== null && $prev <= 0 && $delta > 0 ) {
			return '<span class="nros-chief-mover-delta nros-chief-delta--up">' . esc_html__( 'new', 'newsroom-ai-assistant' ) . '</span>';
		}
		if ( $delta === 0.0 ) {
			return '<span class="nros-chief-mover-delta nros-chief-delta--flat">0</span>';
		}
		$class = $delta > 0 ? 'nros-chief-delta--up' : 'nros-chief-delta--down';
		$sign  = $delta > 0 ? '+' : '';
		$text  = $sign . number_format_i18n( (int) round( $delta ) );
		return '<span class="nros-chief-mover-delta ' . esc_attr( $class ) . '" title="' . esc_attr__( 'Click change vs previous period', 'newsroom-ai-assistant' ) . '">' . esc_html( $text ) . '</span>';
	}
}

if ( ! function_exists( 'newsai_chief_render_post_movers_table' ) ) {
	/**
	 * @param list<array<string,mixed>> $rows
	 * @param string                    $empty_msg
	 * @return string
	 */
	function newsai_chief_render_post_movers_table( array $rows, $empty_msg = '' ) {
		if ( $empty_msg === '' ) {
			$empty_msg = __( 'No movers in this window yet.', 'newsroom-ai-assistant' );
		}
		$show_seo = function_exists( 'newsai_chief_detect_seo_plugin' ) && newsai_chief_detect_seo_plugin() !== '';
		$seo_head = $show_seo
			? ( newsai_chief_detect_seo_plugin() === 'rankmath' ? __( 'RM', 'newsroom-ai-assistant' ) : __( 'Yoast', 'newsroom-ai-assistant' ) )
			: '';
		ob_start();
		?>
		<table class="nros-chief-movers-table">
			<thead>
				<tr>
					<th><?php echo esc_html__( 'Article', 'newsroom-ai-assistant' ); ?></th>
					<th><?php echo esc_html__( 'Position', 'newsroom-ai-assistant' ); ?></th>
					<th><?php echo esc_html__( 'Clicks (Δ)', 'newsroom-ai-assistant' ); ?></th>
					<?php if ( $show_seo ) : ?>
					<th><?php echo esc_html( $seo_head ); ?></th>
					<?php endif; ?>
					<th><?php echo esc_html__( 'Trend', 'newsroom-ai-assistant' ); ?></th>
				</tr>
			</thead>
			<tbody>
			<?php if ( $rows === [] ) : ?>
				<tr><td colspan="<?php echo $show_seo ? 5 : 4; ?>"><?php echo esc_html( $empty_msg ); ?></td></tr>
			<?php else : ?>
				<?php foreach ( $rows as $row ) : ?>
					<tr>
						<td class="nros-chief-movers-table__title">
							<?php
							$edit = ! empty( $row['post_id'] ) ? get_edit_post_link( (int) $row['post_id'], 'raw' ) : '';
							if ( $edit ) :
								?>
								<a href="<?php echo esc_url( $edit ); ?>"><?php echo esc_html( (string) ( $row['title'] ?? '' ) ); ?></a>
							<?php else : ?>
								<?php echo esc_html( (string) ( $row['title'] ?? '' ) ); ?>
							<?php endif; ?>
						</td>
						<td>
							<?php
							$pos      = (float) ( $row['position'] ?? 0 );
							$prev_pos = (float) ( $row['prev_position'] ?? 0 );
							if ( $pos > 0 || $prev_pos > 0 ) {
								echo esc_html( number_format_i18n( $prev_pos, 1 ) . ' → ' . number_format_i18n( $pos, 1 ) );
								echo wp_kses(
									newsai_chief_render_mover_delta( (float) ( $row['delta_position'] ?? 0 ), 'position' ),
									[ 'span' => [ 'class' => [], 'title' => [] ] ]
								);
							} else {
								echo '<span class="nros-chief-mover-delta nros-chief-delta--flat">—</span>';
							}
							?>
						</td>
						<td>
							<strong><?php echo esc_html( number_format_i18n( (int) ( $row['clicks'] ?? 0 ) ) ); ?></strong>
							<?php
							echo wp_kses(
								newsai_chief_render_mover_delta( (int) ( $row['delta_clicks'] ?? 0 ), 'clicks', (int) ( $row['prev_clicks'] ?? 0 ) ),
								[ 'span' => [ 'class' => [], 'title' => [] ] ]
							);
							?>
						</td>
						<?php if ( $show_seo ) : ?>
						<td>
							<?php
							if ( isset( $row['seo_score'] ) ) {
								echo esc_html( (int) $row['seo_score'] );
							} else {
								echo '<span class="nros-chief-mover-delta nros-chief-delta--flat">—</span>';
							}
							?>
						</td>
						<?php endif; ?>
						<td>
							<?php
							echo newsai_chief_render_mini_sparkline( (array) ( $row['spark'] ?? [] ), '#2271b1', 64, 20 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
							?>
						</td>
					</tr>
				<?php endforeach; ?>
			<?php endif; ?>
			</tbody>
		</table>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_post_movers_list' ) ) {
	/**
	 * Compact list view for winning/losing posts (SaaS bento sidebar).
	 *
	 * @param list<array<string,mixed>> $rows
	 * @param string                    $empty_msg
	 * @return string
	 */
	function newsai_chief_render_post_movers_list( array $rows, $empty_msg = '' ) {
		if ( $empty_msg === '' ) {
			$empty_msg = __( 'No movers in this window yet.', 'newsroom-ai-assistant' );
		}
		ob_start();
		?>
		<ul class="nros-chief-movers-list">
			<?php if ( $rows === [] ) : ?>
				<li class="nros-chief-movers-list__empty"><?php echo esc_html( $empty_msg ); ?></li>
			<?php else : ?>
				<?php foreach ( array_slice( $rows, 0, 6 ) as $row ) : ?>
					<?php
					$delta    = (int) ( $row['delta_clicks'] ?? 0 );
					$clicks   = (int) ( $row['clicks'] ?? 0 );
					$delta_cls = $delta > 0 ? 'is-up' : ( $delta < 0 ? 'is-down' : 'is-flat' );
					$edit     = ! empty( $row['post_id'] ) ? get_edit_post_link( (int) $row['post_id'], 'raw' ) : '';
					$pct      = 0;
					$prev     = (int) ( $row['prev_clicks'] ?? 0 );
					if ( $prev > 0 && $delta !== 0 ) {
						$pct = (int) round( ( $delta / $prev ) * 100 );
					} elseif ( $prev <= 0 && $delta > 0 ) {
						$pct = 100;
					}
					?>
					<li class="nros-chief-movers-list__item">
						<div class="nros-chief-movers-list__main">
							<?php if ( $edit ) : ?>
								<a class="nros-chief-movers-list__title" href="<?php echo esc_url( $edit ); ?>"><?php echo esc_html( (string) ( $row['title'] ?? '' ) ); ?></a>
							<?php else : ?>
								<span class="nros-chief-movers-list__title"><?php echo esc_html( (string) ( $row['title'] ?? '' ) ); ?></span>
							<?php endif; ?>
							<span class="nros-chief-movers-list__clicks"><?php echo esc_html( number_format_i18n( $clicks ) ); ?> <?php echo esc_html__( 'clicks', 'newsroom-ai-assistant' ); ?></span>
						</div>
						<?php if ( $delta !== 0 || $pct !== 0 ) : ?>
							<span class="nros-chief-movers-list__badge <?php echo esc_attr( $delta_cls ); ?>">
								<?php echo $delta > 0 ? '↗' : '↘'; ?>
								<?php echo esc_html( $pct !== 0 ? ( ( $pct > 0 ? '+' : '' ) . $pct . '%' ) : ( ( $delta > 0 ? '+' : '' ) . number_format_i18n( $delta ) ) ); ?>
							</span>
						<?php endif; ?>
					</li>
				<?php endforeach; ?>
			<?php endif; ?>
		</ul>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_channel_click_bars' ) ) {
	/**
	 * Horizontal bar chart for site-wide channel clicks.
	 *
	 * @param array<string,array{clicks:int,ctr:float}> $gsc
	 * @param array<string,string>                      $labels
	 * @return string
	 */
	function newsai_chief_render_channel_click_bars( array $gsc, array $labels ) {
		$colors = [
			'web'        => 'linear-gradient(90deg, #6366f1 0%, #818cf8 100%)',
			'discover'   => 'linear-gradient(90deg, #a855f7 0%, #ec4899 100%)',
			'googleNews' => 'linear-gradient(90deg, #f59e0b 0%, #f97316 100%)',
		];
		$max = 1;
		foreach ( array_keys( $labels ) as $key ) {
			$max = max( $max, (int) ( $gsc[ $key ]['clicks'] ?? 0 ) );
		}
		ob_start();
		?>
		<div class="nros-chief-channel-bars">
			<?php foreach ( $labels as $key => $label ) : ?>
				<?php
				$clicks = (int) ( $gsc[ $key ]['clicks'] ?? 0 );
				$pct    = $max > 0 ? round( ( $clicks / $max ) * 100, 1 ) : 0;
				$ctr    = isset( $gsc[ $key ]['ctr'] ) ? round( (float) $gsc[ $key ]['ctr'] * 100, 1 ) : 0;
				?>
				<div class="nros-chief-channel-bars__row">
					<div class="nros-chief-channel-bars__meta">
						<span class="nros-chief-channel-bars__label"><?php echo esc_html( $label ); ?></span>
						<span class="nros-chief-channel-bars__val"><?php echo esc_html( number_format_i18n( $clicks ) ); ?></span>
					</div>
					<div class="nros-chief-channel-bars__track" aria-hidden="true">
						<div class="nros-chief-channel-bars__fill" style="width:<?php echo esc_attr( (string) max( 4, $pct ) ); ?>%;background:<?php echo esc_attr( $colors[ $key ] ?? '#6366f1' ); ?>"></div>
					</div>
					<?php if ( $ctr > 0 ) : ?>
						<span class="nros-chief-channel-bars__ctr"><?php echo esc_html( sprintf( __( 'CTR %s%%', 'newsroom-ai-assistant' ), number_format_i18n( $ctr, 1 ) ) ); ?></span>
					<?php endif; ?>
				</div>
			<?php endforeach; ?>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_seasonal_highlight' ) ) {
	/**
	 * Prominent seasonal opportunity card (first signal).
	 *
	 * @return string
	 */
	function newsai_chief_render_seasonal_highlight() {
		if ( ! function_exists( 'newsai_seasonal_radar_enabled' ) || ! newsai_seasonal_radar_enabled() ) {
			return '';
		}
		if ( ! function_exists( 'newsai_seasonal_radar_get_cached' ) ) {
			return '';
		}
		$data  = newsai_seasonal_radar_get_cached();
		$items = isset( $data['items'] ) && is_array( $data['items'] ) ? $data['items'] : [];
		$first = $items[0] ?? null;
		ob_start();
		?>
		<div class="nros-chief-seasonal-card">
			<div class="nros-chief-seasonal-card__icon" aria-hidden="true">⚡</div>
			<h4 class="nros-chief-seasonal-card__title"><?php echo esc_html__( 'Seasonal radar', 'newsroom-ai-assistant' ); ?></h4>
			<?php if ( ! $first ) : ?>
				<p class="nros-chief-seasonal-card__body"><?php echo esc_html__( 'No seasonal signals yet. Run GSC sync and publish tagged articles for a few weeks.', 'newsroom-ai-assistant' ); ?></p>
			<?php else : ?>
				<p class="nros-chief-seasonal-card__body">
					<strong><?php echo esc_html( (string) ( $first['term'] ?? '' ) ); ?></strong>
					<?php echo esc_html( (string) ( $first['hint'] ?? '' ) ); ?>
					<?php if ( ! empty( $first['yoy_clicks'] ) ) : ?>
						<span class="nros-chief-seasonal-card__stat"><?php echo esc_html( sprintf( __( '%s YoY clicks', 'newsroom-ai-assistant' ), number_format_i18n( (int) $first['yoy_clicks'] ) ) ); ?></span>
					<?php endif; ?>
				</p>
				<a href="#nros-chief-seasonal-full" class="nros-chief-seasonal-card__cta"><?php echo esc_html__( 'View all opportunities', 'newsroom-ai-assistant' ); ?></a>
			<?php endif; ?>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_keyword_movers_table' ) ) {
	/**
	 * @param list<array<string,mixed>> $rows
	 * @param string                    $empty_msg
	 * @return string
	 */
	function newsai_chief_render_keyword_movers_table( array $rows, $empty_msg = '' ) {
		if ( $empty_msg === '' ) {
			$empty_msg = __( 'No query data yet. Enable GSC query sync and run Sync now.', 'newsroom-ai-assistant' );
		}
		ob_start();
		?>
		<table class="nros-chief-movers-table nros-chief-movers-table--kw">
			<thead>
				<tr>
					<th><?php echo esc_html__( 'Keyword', 'newsroom-ai-assistant' ); ?></th>
					<th><?php echo esc_html__( 'Pos.', 'newsroom-ai-assistant' ); ?></th>
					<th><?php echo esc_html__( 'Clicks (Δ)', 'newsroom-ai-assistant' ); ?></th>
				</tr>
			</thead>
			<tbody>
			<?php if ( $rows === [] ) : ?>
				<tr><td colspan="3"><?php echo esc_html( $empty_msg ); ?></td></tr>
			<?php else : ?>
				<?php foreach ( $rows as $row ) : ?>
					<tr>
						<td class="nros-chief-movers-table__title"><?php echo esc_html( (string) ( $row['query'] ?? '' ) ); ?></td>
						<td>
							<?php
							$pos      = (float) ( $row['position'] ?? 0 );
							$prev_pos = (float) ( $row['prev_position'] ?? 0 );
							if ( $pos > 0 || $prev_pos > 0 ) {
								echo esc_html( number_format_i18n( $prev_pos, 1 ) . ' → ' . number_format_i18n( $pos, 1 ) );
								echo wp_kses(
									newsai_chief_render_mover_delta( (float) ( $row['delta_position'] ?? 0 ), 'position' ),
									[ 'span' => [ 'class' => [], 'title' => [] ] ]
								);
							} else {
								echo '<span class="nros-chief-mover-delta nros-chief-delta--flat">—</span>';
							}
							?>
						</td>
						<td>
							<strong><?php echo esc_html( number_format_i18n( (int) ( $row['clicks'] ?? 0 ) ) ); ?></strong>
							<?php
							echo wp_kses(
								newsai_chief_render_mover_delta( (int) ( $row['delta_clicks'] ?? 0 ), 'clicks', (int) ( $row['prev_clicks'] ?? 0 ) ),
								[ 'span' => [ 'class' => [], 'title' => [] ] ]
							);
							?>
						</td>
					</tr>
				<?php endforeach; ?>
			<?php endif; ?>
			</tbody>
		</table>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_render_gsc_line_chart' ) ) {
	/**
	 * Multi-line area chart filtered by channel.
	 *
	 * @param array  $series From newsai_chief_gsc_daily_series().
	 * @param int    $days
	 * @param string $channel all|web|discover|googleNews
	 * @return string
	 */
	function newsai_chief_render_gsc_line_chart( array $series, $days = 30, $channel = 'all' ) {
		if ( empty( $series['has_data'] ) || empty( $series['labels'] ) ) {
			return '<p class="nros-chief-chart-empty">' . esc_html__( 'No GSC daily data for this window yet. Run Sync now in Settings → Integrations.', 'newsroom-ai-assistant' ) . '</p>';
		}

		$channel = newsai_chief_gsc_resolve_channel( $channel );
		$all_ch  = [
			'web'        => [ 'label' => __( 'Web', 'newsroom-ai-assistant' ), 'color' => '#6366f1' ],
			'discover'   => [ 'label' => __( 'Discover', 'newsroom-ai-assistant' ), 'color' => '#a855f7' ],
			'googleNews' => [ 'label' => __( 'Google News', 'newsroom-ai-assistant' ), 'color' => '#f97316' ],
		];
		$channels = [];
		if ( $channel === 'all' ) {
			foreach ( $all_ch as $key => $meta ) {
				$channels[] = array_merge( $meta, [ 'key' => $key, 'vals' => (array) ( $series[ $key ] ?? [] ) ] );
			}
		} else {
			$channels[] = array_merge( $all_ch[ $channel ] ?? $all_ch['web'], [ 'key' => $channel, 'vals' => (array) ( $series[ $channel ] ?? [] ) ] );
		}

		$plot_w = 640;
		$plot_h = 140;
		$pad_l  = 4;
		$pad_r  = 4;
		$max    = 1;
		foreach ( $channels as $ch ) {
			$max = max( $max, max( 1, ...array_map( 'intval', $ch['vals'] ) ) );
		}

		$labels = (array) $series['labels'];
		$n      = count( $labels );
		$paths  = '';
		foreach ( $channels as $ch ) {
			$pts = [];
			foreach ( $ch['vals'] as $i => $v ) {
				$x     = $pad_l + ( $n > 1 ? ( $i / ( $n - 1 ) ) * ( $plot_w - $pad_l - $pad_r ) : 0 );
				$y     = $plot_h - ( ( (float) (int) $v / $max ) * $plot_h );
				$pts[] = [ round( $x, 1 ), round( $y, 1 ) ];
			}
			if ( $pts === [] ) {
				continue;
			}
			$line = 'M' . $pts[0][0] . ',' . $pts[0][1];
			for ( $i = 1; $i < count( $pts ); $i++ ) {
				$line .= ' L' . $pts[ $i ][0] . ',' . $pts[ $i ][1 ];
			}
			$last = $pts[ count( $pts ) - 1 ];
			$area = $line . ' L' . $last[0] . ',' . $plot_h . ' L' . $pts[0][0] . ',' . $plot_h . ' Z';
			$paths .= '<path d="' . esc_attr( $area ) . '" fill="' . esc_attr( $ch['color'] ) . '" fill-opacity="0.22"/>';
			$paths .= '<path d="' . esc_attr( $line ) . '" fill="none" stroke="' . esc_attr( $ch['color'] ) . '" stroke-width="2" vector-effect="non-scaling-stroke"/>';
		}

		$first = $labels[0] ?? '';
		$last  = $labels[ count( $labels ) - 1 ] ?? '';

		ob_start();
		?>
		<div class="nros-chief-chart nros-chief-chart--line nros-chief-chart--area">
			<svg class="nros-chief-chart__svg nros-chief-chart__svg--line" viewBox="0 0 <?php echo (int) $plot_w; ?> <?php echo (int) $plot_h; ?>" preserveAspectRatio="none" role="img" aria-label="<?php echo esc_attr__( 'GSC clicks trend', 'newsroom-ai-assistant' ); ?>">
				<line x1="0" y1="<?php echo (int) $plot_h; ?>" x2="<?php echo (int) $plot_w; ?>" y2="<?php echo (int) $plot_h; ?>" stroke="var(--nros-border-subtle,#e2e4e7)" stroke-width="1"/>
				<?php echo $paths; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</svg>
			<div class="nros-chief-chart__axis">
				<span><?php echo esc_html( $first ); ?></span>
				<span><?php echo esc_html( $last ); ?></span>
			</div>
			<ul class="nros-chief-chart__legend nros-chief-chart__legend--totals">
				<?php foreach ( $channels as $ch ) : ?>
					<li>
						<span class="nros-chief-chart__swatch" style="background:<?php echo esc_attr( $ch['color'] ); ?>"></span>
						<span><?php echo esc_html( $ch['label'] ); ?></span>
						<strong class="nros-chief-chart__legend-val"><?php echo esc_html( number_format_i18n( array_sum( array_map( 'intval', $ch['vals'] ) ) ) ); ?></strong>
					</li>
				<?php endforeach; ?>
			</ul>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_chief_insights_collect_data' ) ) {
	/**
	 * Expensive Chief Insights aggregates (posts, desk stats, GSC, categories/tags).
	 *
	 * @param int  $days   Window 7–90.
	 * @param bool $is_pro Pro active.
	 * @return array<string, mixed>
	 */
	function newsai_chief_insights_collect_data( $days, $is_pro ) {
		global $wpdb;
		$days = max( 7, min( 90, (int) $days ) );

		$post_date_from = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		$posts          = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT ID, post_title, post_date, post_author
				 FROM {$wpdb->posts}
				 WHERE post_type = 'post' AND post_status = 'publish' AND post_date_gmt >= %s
				 ORDER BY post_date_gmt DESC
				 LIMIT 500",
				$post_date_from
			),
			ARRAY_A
		);
		$post_ids   = array_values( array_filter( array_map( 'intval', wp_list_pluck( (array) $posts, 'ID' ) ) ) );
		$post_count = count( $post_ids );

		$desk_stats = function_exists( 'newsai_chief_desk_stats_for_window' )
			? newsai_chief_desk_stats_for_window( $post_date_from )
			: [
				'post_count'   => $post_count,
				'avg_words'    => 0,
				'avg_focus'    => 0,
				'avg_discover' => 0,
				'focus_n'      => 0,
				'disc_n'       => 0,
			];

		$prev_post_count   = 0;
		$prev_avg_words    = 0;
		$prev_avg_focus    = 0;
		$prev_avg_discover = 0;
		$prev_avg_seo_score = 0;
		$prev_gsc_clicks   = 0;
		if ( $is_pro && function_exists( 'newsai_chief_desk_stats_for_window' ) ) {
			$prev_from_gmt = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $days * DAY_IN_SECONDS ) );
			$prev_desk     = newsai_chief_desk_stats_for_window( $prev_from_gmt, $post_date_from );
			$prev_post_count   = (int) $prev_desk['post_count'];
			$prev_avg_words    = (int) $prev_desk['avg_words'];
			$prev_avg_focus    = (int) $prev_desk['avg_focus'];
			$prev_avg_discover = (int) $prev_desk['avg_discover'];
			$prev_avg_seo_score = (int) ( $prev_desk['avg_seo_score'] ?? 0 );
		}

		$top_categories = [];
		$top_tags       = [];
		if ( ! empty( $post_ids ) ) {
			$placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
			$ids_sql      = array_map( 'intval', $post_ids );

			$cat_sql = "
				SELECT t.name, COUNT(*) AS cnt
				FROM {$wpdb->term_relationships} tr
				INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
				INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
				WHERE tt.taxonomy = 'category' AND tr.object_id IN ({$placeholders})
				GROUP BY t.term_id, t.name
				ORDER BY cnt DESC
				LIMIT 8";
			$top_categories = $wpdb->get_results( $wpdb->prepare( $cat_sql, ...$ids_sql ), ARRAY_A );

			$tag_sql = "
				SELECT t.name, COUNT(*) AS cnt
				FROM {$wpdb->term_relationships} tr
				INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
				INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
				WHERE tt.taxonomy = 'post_tag' AND tr.object_id IN ({$placeholders})
				GROUP BY t.term_id, t.name
				ORDER BY cnt DESC
				LIMIT 10";
			$top_tags = $wpdb->get_results( $wpdb->prepare( $tag_sql, ...$ids_sql ), ARRAY_A );
		}

		$gsc = [
			'web'        => [ 'clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0 ],
			'discover'   => [ 'clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0 ],
			'googleNews' => [ 'clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0 ],
		];
		$top_articles    = [];
		$top_discover    = [];
		$top_google_news = [];
		$gsc_ready       = $is_pro && function_exists( 'newsai_gsc_data_available' ) && newsai_gsc_data_available() && function_exists( 'newsai_gsc_table_name' );

		if ( $gsc_ready ) {
			$gsc_table = newsai_gsc_table_name();
			$date_from = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
			$date_end  = gmdate( 'Y-m-d', time() );
			if ( function_exists( 'newsai_chief_gsc_aggregate_window' ) ) {
				foreach ( array_keys( $gsc ) as $type ) {
					$agg          = newsai_chief_gsc_aggregate_window( $date_from, $date_end, [ $type ] );
					$gsc[ $type ] = [
						'clicks'      => (int) $agg['clicks'],
						'impressions' => (int) $agg['impressions'],
						'ctr'         => (float) $agg['ctr'],
						'position'    => round( (float) $agg['position'], 1 ),
					];
				}
			} else {
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT search_type, SUM(clicks) AS clicks, SUM(impressions) AS impressions, AVG(position) AS position
						 FROM {$gsc_table}
						 WHERE data_date >= %s
						 GROUP BY search_type",
						$date_from
					),
					ARRAY_A
				);
				foreach ( (array) $rows as $r ) {
					$type = (string) ( $r['search_type'] ?? 'web' );
					if ( ! isset( $gsc[ $type ] ) ) {
						continue;
					}
					$clicks       = (int) ( $r['clicks'] ?? 0 );
					$impr         = (int) ( $r['impressions'] ?? 0 );
					$gsc[ $type ] = [
						'clicks'      => $clicks,
						'impressions' => $impr,
						'ctr'         => ( $impr > 0 ) ? ( $clicks / $impr ) : 0.0,
						'position'    => round( (float) ( $r['position'] ?? 0 ), 1 ),
					];
				}
			}

			if ( function_exists( 'newsai_gsc_insights_top_posts_with_trends' ) ) {
				$top_articles    = newsai_gsc_insights_top_posts_with_trends( $days, 'web', 8, 1 );
				$top_discover    = newsai_gsc_insights_top_posts_with_trends( $days, 'discover', 8, 1 );
				$top_google_news = newsai_gsc_insights_top_posts_with_trends( $days, 'googleNews', 8, 1 );
			} else {
				$top_articles = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT p.ID AS post_id, p.post_title AS title, SUM(g.clicks) AS clicks, SUM(g.impressions) AS impressions
						 FROM {$gsc_table} g
						 INNER JOIN {$wpdb->posts} p ON p.ID = g.post_id
						 WHERE g.data_date >= %s AND g.search_type = 'web' AND p.post_status = 'publish' AND p.post_type = 'post'
						 GROUP BY p.ID, p.post_title
						 ORDER BY clicks DESC
						 LIMIT 8",
						$date_from
					),
					ARRAY_A
				);
			}
		} else {
			foreach ( array_slice( (array) $posts, 0, 8 ) as $p ) {
				$top_articles[] = [
					'post_id'     => (int) $p['ID'],
					'title'       => (string) $p['post_title'],
					'clicks'      => 0,
					'impressions' => 0,
				];
			}
		}

		if ( ! $is_pro ) {
			$demo_post_count   = max( 87, (int) $desk_stats['post_count'] );
			$demo_avg_words    = max( 532, (int) $desk_stats['avg_words'] );
			$demo_avg_focus    = max( 78, (int) $desk_stats['avg_focus'] );
			$demo_avg_discover = max( 74, (int) $desk_stats['avg_discover'] );
			$desk_stats['post_count']   = $demo_post_count;
			$desk_stats['avg_words']    = $demo_avg_words;
			$desk_stats['avg_focus']    = $demo_avg_focus;
			$desk_stats['avg_discover'] = $demo_avg_discover;
			$gsc['web']        = [ 'clicks' => 18440, 'impressions' => 481100, 'ctr' => 0.038, 'position' => 8.7 ];
			$gsc['discover']   = [ 'clicks' => 6270, 'impressions' => 170400, 'ctr' => 0.0368, 'position' => 7.9 ];
			$gsc['googleNews'] = [ 'clicks' => 3910, 'impressions' => 119800, 'ctr' => 0.0326, 'position' => 9.4 ];
			$top_discover      = [
				[ 'post_id' => 0, 'title' => __( 'Demo: Regional infrastructure story', 'newsroom-ai-assistant' ), 'clicks' => 412, 'impressions' => 18200, 'prev_clicks' => 280, 'delta_clicks' => 132, 'trend' => 'up' ],
				[ 'post_id' => 0, 'title' => __( 'Demo: Election night live blog', 'newsroom-ai-assistant' ), 'clicks' => 318, 'impressions' => 14100, 'prev_clicks' => 0, 'delta_clicks' => 318, 'trend' => 'new' ],
				[ 'post_id' => 0, 'title' => __( 'Demo: Heatwave safety guide', 'newsroom-ai-assistant' ), 'clicks' => 205, 'impressions' => 9800, 'prev_clicks' => 240, 'delta_clicks' => -35, 'trend' => 'down' ],
			];
			$top_google_news = [
				[ 'post_id' => 0, 'title' => __( 'Demo: Parliament vote explainer', 'newsroom-ai-assistant' ), 'clicks' => 188, 'impressions' => 7200, 'prev_clicks' => 120, 'delta_clicks' => 68, 'trend' => 'up' ],
				[ 'post_id' => 0, 'title' => __( 'Demo: Morning business briefing', 'newsroom-ai-assistant' ), 'clicks' => 142, 'impressions' => 6100, 'prev_clicks' => 142, 'delta_clicks' => 0, 'trend' => 'flat' ],
			];
			foreach ( (array) $top_articles as $i => $row ) {
				$top_articles[ $i ]['prev_clicks']  = max( 0, (int) ( $row['clicks'] ?? 0 ) - ( $i % 3 === 0 ? 12 : -8 ) );
				$top_articles[ $i ]['delta_clicks'] = (int) ( $row['clicks'] ?? 0 ) - (int) $top_articles[ $i ]['prev_clicks'];
				$top_articles[ $i ]['trend']        = $top_articles[ $i ]['delta_clicks'] > 0 ? 'up' : ( $top_articles[ $i ]['delta_clicks'] < 0 ? 'down' : 'flat' );
			}
		}

		$total_clicks = (int) $gsc['web']['clicks'] + (int) $gsc['discover']['clicks'] + (int) $gsc['googleNews']['clicks'];
		if ( $is_pro && $gsc_ready && function_exists( 'newsai_chief_gsc_total_clicks' ) ) {
			$prev_gsc_end    = gmdate( 'Y-m-d', time() - ( ( $days + 1 ) * DAY_IN_SECONDS ) );
			$prev_gsc_start  = gmdate( 'Y-m-d', time() - ( ( 2 * $days ) * DAY_IN_SECONDS ) );
			$prev_gsc_clicks = (int) newsai_chief_gsc_total_clicks( $prev_gsc_start, $prev_gsc_end );
		}

		$chief_gsc_series  = [];
		$chief_gsc_devices = [];
		$gsc_kpis          = [];
		$post_movers       = [];
		$keyword_movers    = [ 'win' => [], 'lose' => [] ];
		if ( $is_pro && $gsc_ready ) {
			if ( function_exists( 'newsai_chief_gsc_daily_series' ) ) {
				$chief_gsc_series = newsai_chief_gsc_daily_series( $days );
			}
			if ( function_exists( 'newsai_chief_gsc_device_breakdown' ) ) {
				$chief_gsc_devices = newsai_chief_gsc_device_breakdown( $days, 'web' );
			}
			if ( function_exists( 'newsai_chief_gsc_channel_kpis' ) ) {
				foreach ( [ 'all', 'web', 'discover', 'googleNews' ] as $ch_key ) {
					$gsc_kpis[ $ch_key ] = newsai_chief_gsc_channel_kpis( $days, $ch_key );
				}
			}
			if ( function_exists( 'newsai_chief_gsc_post_movers' ) ) {
				foreach ( [ 'all', 'web', 'discover', 'googleNews' ] as $ch_key ) {
					$post_movers[ $ch_key ] = [
						'win'  => newsai_chief_gsc_post_movers( $days, $ch_key, 'win', 5 ),
						'lose' => newsai_chief_gsc_post_movers( $days, $ch_key, 'lose', 5 ),
					];
				}
			}
			if ( function_exists( 'newsai_chief_gsc_keyword_movers' ) ) {
				$keyword_movers = [
					'win'  => newsai_chief_gsc_keyword_movers( $days, 'win', 5 ),
					'lose' => newsai_chief_gsc_keyword_movers( $days, 'lose', 5 ),
				];
			}
		}

		if ( function_exists( 'newsai_chief_get_post_seo_score' ) && function_exists( 'newsai_chief_detect_seo_plugin' ) && newsai_chief_detect_seo_plugin() !== '' ) {
			foreach ( $post_movers as $ch_key => $pack ) {
				foreach ( [ 'win', 'lose' ] as $dir ) {
					if ( empty( $pack[ $dir ] ) || ! is_array( $pack[ $dir ] ) ) {
						continue;
					}
					foreach ( $pack[ $dir ] as $idx => $row ) {
						$pid = (int) ( $row['post_id'] ?? 0 );
						if ( $pid > 0 ) {
							$seo = newsai_chief_get_post_seo_score( $pid );
							if ( $seo !== null ) {
								$post_movers[ $ch_key ][ $dir ][ $idx ]['seo_score'] = (int) $seo;
							}
						}
					}
				}
			}
		}

		return [
			'post_count'        => (int) $desk_stats['post_count'],
			'avg_words'         => (int) $desk_stats['avg_words'],
			'avg_focus'         => (int) $desk_stats['avg_focus'],
			'avg_discover'      => (int) $desk_stats['avg_discover'],
			'focus_n'           => (int) $desk_stats['focus_n'],
			'disc_n'            => (int) $desk_stats['disc_n'],
			'avg_seo_score'     => (int) ( $desk_stats['avg_seo_score'] ?? 0 ),
			'seo_n'             => (int) ( $desk_stats['seo_n'] ?? 0 ),
			'seo_plugin'        => (string) ( $desk_stats['seo_plugin'] ?? '' ),
			'prev_post_count'   => $prev_post_count,
			'prev_avg_words'    => $prev_avg_words,
			'prev_avg_focus'    => $prev_avg_focus,
			'prev_avg_discover' => $prev_avg_discover,
			'prev_avg_seo_score'=> $prev_avg_seo_score,
			'prev_gsc_clicks'   => $prev_gsc_clicks,
			'top_categories'    => $top_categories,
			'top_tags'          => $top_tags,
			'gsc'               => $gsc,
			'top_articles'      => $top_articles,
			'top_discover'      => $top_discover,
			'top_google_news'   => $top_google_news,
			'gsc_ready'         => $gsc_ready,
			'total_clicks'      => $total_clicks,
			'chief_gsc_series'  => $chief_gsc_series,
			'chief_gsc_devices' => $chief_gsc_devices,
			'gsc_kpis'          => $gsc_kpis,
			'post_movers'       => $post_movers,
			'keyword_movers'    => $keyword_movers,
			'cached_at'         => time(),
		];
	}
}

if ( ! function_exists( 'newsai_chief_insights_flush_cache' ) ) {
	/**
	 * Drop Chief Insights transients (manual refresh from admin).
	 */
	function newsai_chief_insights_flush_cache() {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				$wpdb->esc_like( '_transient_newsai_chief_insights_' ) . '%',
				$wpdb->esc_like( '_transient_timeout_newsai_chief_insights_' ) . '%'
			)
		);
	}
}

if ( ! function_exists( 'newsai_chief_insights_get_cached_data' ) ) {
	/**
	 * Cached wrapper for {@see newsai_chief_insights_collect_data()}.
	 *
	 * @param int  $days   Window 7–90.
	 * @param bool $is_pro Pro active.
	 * @return array<string, mixed>
	 */
	function newsai_chief_insights_get_cached_data( $days, $is_pro ) {
		$days  = max( 7, min( 90, (int) $days ) );
		$pro   = $is_pro ? '1' : '0';
		$stamp = newsai_admin_analytics_cache_stamp();
		$key   = 'newsai_chief_insights_v3_' . $days . '_' . $pro . '_' . $stamp;
		$cached = get_transient( $key );
		if ( is_array( $cached ) && isset( $cached['post_count'] ) ) {
			return $cached;
		}
		$data = newsai_chief_insights_collect_data( $days, $is_pro );
		set_transient( $key, $data, newsai_admin_analytics_cache_ttl() );
		return $data;
	}
}

if ( ! function_exists( 'newsai_chief_daily_brief_payload' ) ) {
	/**
	 * Slim metrics payload for AI Daily Brief (server-built; avoids huge HTML data attributes).
	 *
	 * @param int $days Window 7–90.
	 * @return array<string, mixed>
	 */
	function newsai_chief_daily_brief_payload( $days ) {
		$days    = max( 7, min( 90, (int) $days ) );
		$is_pro  = function_exists( 'newsai_is_pro' ) && newsai_is_pro();
		$insights = function_exists( 'newsai_chief_insights_get_cached_data' )
			? newsai_chief_insights_get_cached_data( $days, $is_pro )
			: [];

		$slim_titles = static function ( $rows, $limit = 5 ) {
			$out = [];
			foreach ( array_slice( (array) $rows, 0, $limit ) as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$out[] = [
					'title'  => sanitize_text_field( (string) ( $row['title'] ?? '' ) ),
					'clicks' => (int) ( $row['clicks'] ?? 0 ),
				];
			}
			return $out;
		};

		return [
			'days' => $days,
			'kpis' => [
				'published_articles' => (int) ( $insights['post_count'] ?? 0 ),
				'avg_words'          => (int) ( $insights['avg_words'] ?? 0 ),
				'avg_focus'          => (int) ( $insights['avg_focus'] ?? 0 ),
				'avg_discover'       => (int) ( $insights['avg_discover'] ?? 0 ),
				'total_gsc_clicks'   => (int) ( $insights['total_clicks'] ?? 0 ),
			],
			'channels'       => (array) ( $insights['gsc'] ?? [] ),
			'top_categories' => array_slice( (array) ( $insights['top_categories'] ?? [] ), 0, 5 ),
			'top_tags'       => array_slice( (array) ( $insights['top_tags'] ?? [] ), 0, 8 ),
			'top_articles'   => $slim_titles( $insights['top_articles'] ?? [], 5 ),
			'seasonal'       => ( $is_pro && function_exists( 'newsai_seasonal_radar_get_cached' ) )
				? array_slice( (array) ( newsai_seasonal_radar_get_cached()['items'] ?? [] ), 0, 5 )
				: [],
		];
	}
}
