<?php
/**
 * Author performance analytics (Pro).
 *
 * Phase 1 (no external API): aggregates the data we already control:
 *   - posts published in the last N days (and trend vs the prior period)
 *   - tasks done / open from `wp_newsai_alerts`
 *   - average client-side Focus Score persisted per post in `_newsai_focus_score`
 *   - average Discover Score + weak-spot hints from `_newsai_discover_score_breakdown`
 *   - top categories / tags the writer covers
 *   - top recent articles (most recent or by comment volume as a proxy)
 *
 * GSC traffic (Pro + sync enabled): Discover / Google News / web clicks from
 * `wp_newsai_gsc_performance`, wired via filters and `newsai_gsc_*` helpers.
 *
 * Everything is Pro-gated and the dashboard tab is restricted to chiefs
 * (`edit_others_posts`). Writers see their own card via "My Inbox" eventually.
 *
 * @package Newsroom_AI_Assistant
 */

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

// ============================================================
// Persisted Focus Score (set by the sidebar Decision Engine).
// ============================================================

if ( ! function_exists( 'newsai_focus_score_meta_key' ) ) {
	function newsai_focus_score_meta_key() {
		return '_newsai_focus_score';
	}
}

if ( ! function_exists( 'newsai_focus_score_history_meta_key' ) ) {
	function newsai_focus_score_history_meta_key() {
		return '_newsai_focus_score_at_publish';
	}
}

if ( ! function_exists( 'newsai_ajax_save_focus_score' ) ) {
	/**
	 * Persist the live Focus Score for the post currently open in the editor.
	 *
	 * Throttled on the client (max 1 write / 10s) so we are not hammering DB on every
	 * keystroke. Once the post becomes "publish", the score also gets pinned into
	 * `_newsai_focus_score_at_publish` so the leaderboard sees the publish-time value
	 * even if the writer later goes back and degrades the article.
	 */
	function newsai_ajax_save_focus_score() {
		check_ajax_referer( 'newsai_ajax_nonce', 'nonce' );

		$post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
		$score   = isset( $_POST['score'] ) ? (int) $_POST['score'] : -1;
		if ( $post_id < 1 || $score < 0 ) {
			wp_send_json_error( [ 'message' => 'invalid_input' ], 400 );
		}
		if ( $score > 100 ) {
			$score = 100;
		}
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error( [ 'message' => 'forbidden' ], 403 );
		}

		$prev = (int) get_post_meta( $post_id, newsai_focus_score_meta_key(), true );
		update_post_meta( $post_id, newsai_focus_score_meta_key(), $score );

		// "Best score since publish": once a post is live, the pinned analytics value
		// tracks the highest Focus Score this article has ever reached. We never let it
		// drop (so a writer that briefly degrades the post mid-edit doesn't trash their
		// average), but we always let it climb when the article actually improves.
		// This is what we surface on the chief dashboard and in My Inbox.
		$status = get_post_status( $post_id );
		if ( $status === 'publish' ) {
			$pinned_raw = get_post_meta( $post_id, newsai_focus_score_history_meta_key(), true );
			$pinned     = ( $pinned_raw === '' || $pinned_raw === false ) ? 0 : (int) $pinned_raw;
			if ( $score > $pinned ) {
				update_post_meta( $post_id, newsai_focus_score_history_meta_key(), $score );
			}
		}

		// Bust author-performance caches when the score actually moves.
		if ( $prev !== $score && function_exists( 'newsai_pro_invalidate_author_performance' ) ) {
			newsai_pro_invalidate_author_performance();
		}

		wp_send_json_success( [ 'score' => $score ] );
	}
}
add_action( 'wp_ajax_newsai_save_focus_score', 'newsai_ajax_save_focus_score' );

if ( ! function_exists( 'newsai_pin_focus_score_on_publish' ) ) {
	/**
	 * When a post transitions to "publish", anchor the pinned Focus Score. We use
	 * MAX(existing_pinned, current_live) so that re-publishing a post never drops a
	 * previously higher score (and a fresh publish seeds the pin from the live value).
	 *
	 * @param string  $new_status New post status.
	 * @param string  $old_status Previous post status.
	 * @param WP_Post $post       Post object.
	 */
	function newsai_pin_focus_score_on_publish( $new_status, $old_status, $post ) {
		if ( $new_status !== 'publish' || $old_status === 'publish' ) {
			return;
		}
		if ( ! $post || $post->post_type !== 'post' ) {
			return;
		}
		$live       = (int) get_post_meta( $post->ID, newsai_focus_score_meta_key(), true );
		$pinned_raw = get_post_meta( $post->ID, newsai_focus_score_history_meta_key(), true );
		$pinned     = ( $pinned_raw === '' || $pinned_raw === false ) ? 0 : (int) $pinned_raw;
		$best       = max( $pinned, $live );
		if ( $best > 0 ) {
			update_post_meta( $post->ID, newsai_focus_score_history_meta_key(), $best );
		}
		if ( function_exists( 'newsai_pro_invalidate_author_performance' ) ) {
			newsai_pro_invalidate_author_performance();
		}
	}
}
add_action( 'transition_post_status', 'newsai_pin_focus_score_on_publish', 10, 3 );

// ============================================================
// Cache management
// ============================================================

if ( ! function_exists( 'newsai_pro_invalidate_author_performance' ) ) {
	function newsai_pro_invalidate_author_performance() {
		if ( function_exists( 'newsai_delete_transients_by_prefix' ) ) {
			newsai_delete_transients_by_prefix( 'newsai_pro_author_perf_' );
		}
	}
}

// Re-aggregate on any author-relevant action.
add_action( 'newsai_task_completed', 'newsai_pro_invalidate_author_performance' );
add_action( 'newsai_task_assigned', 'newsai_pro_invalidate_author_performance' );
// Invalidate when a post is published/updated/trashed so the panel reflects reality.
add_action(
	'transition_post_status',
	static function ( $new_status, $old_status, $post ) {
		if ( ! $post || $post->post_type !== 'post' ) {
			return;
		}
		if ( $new_status === $old_status ) {
			return;
		}
		if ( in_array( 'publish', [ $new_status, $old_status ], true ) ) {
			newsai_pro_invalidate_author_performance();
		}
	},
	20,
	3
);

// ============================================================
// Aggregation helpers
// ============================================================

if ( ! function_exists( 'newsai_pro_author_post_counts' ) ) {
	/**
	 * @param int $user_id WP user ID.
	 * @param int $days    Window in days (default 30).
	 * @return array{recent:int, prior:int, trend:string}
	 */
	function newsai_pro_author_post_counts( $user_id, $days = 30 ) {
		global $wpdb;
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$since_recent      = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );
		$since_prior_end   = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );
		$since_prior_start = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days * 2, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . ( $days * 2 ) . ' days' ) );
		$recent  = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->posts}
				 WHERE post_type = 'post' AND post_status = 'publish' AND post_author = %d
				 AND post_date_gmt > %s",
				$user_id,
				$since_recent
			)
		);
		$prior = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->posts}
				 WHERE post_type = 'post' AND post_status = 'publish' AND post_author = %d
				 AND post_date_gmt <= %s
				 AND post_date_gmt > %s",
				$user_id,
				$since_prior_end,
				$since_prior_start
			)
		);
		$trend = 'flat';
		if ( $recent > $prior ) {
			$trend = 'up';
		} elseif ( $prior > 0 && $recent < $prior ) {
			$trend = 'down';
		}
		return [
			'recent' => $recent,
			'prior'  => $prior,
			'trend'  => $trend,
		];
	}
}

if ( ! function_exists( 'newsai_pro_author_task_counts' ) ) {
	/**
	 * @param int $user_id WP user ID.
	 * @return array{done:int, open:int, overdue:int}
	 */
	function newsai_pro_author_task_counts( $user_id ) {
		global $wpdb;
		$user_id = (int) $user_id;
		$table   = $wpdb->prefix . 'newsai_alerts';
		// Skip silently when alerts table is missing (e.g. fresh install).
		$exists = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) )
		);
		if ( ! $exists ) {
			return [ 'done' => 0, 'open' => 0, 'overdue' => 0 ];
		}
		$done = (int) $wpdb->get_var(
			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE author_id = %d AND status = 'done'", $user_id )
		);
		$open_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT day, month, year, lead_days FROM {$table}
				 WHERE author_id = %d AND status != 'done'",
				$user_id
			)
		);
		$open    = 0;
		$overdue = 0;
		$now_ts  = strtotime( gmdate( 'Y-m-d' ) );
		foreach ( (array) $open_rows as $r ) {
			$open++;
			$y = (int) $r->year > 0 ? (int) $r->year : (int) gmdate( 'Y' );
			$m = (int) $r->month > 0 ? (int) $r->month : (int) gmdate( 'm' );
			$d = max( 1, (int) $r->day );
			$task_ts = strtotime( sprintf( '%04d-%02d-%02d', $y, $m, $d ) );
			if ( $task_ts && $task_ts < $now_ts ) {
				$overdue++;
			}
		}
		return [
			'done'    => $done,
			'open'    => $open,
			'overdue' => $overdue,
		];
	}
}

if ( ! function_exists( 'newsai_pro_author_avg_focus_score' ) ) {
	/**
	 * Average publish-time Focus Score for posts by this author in the window.
	 * Falls back to the live score when the publish-time pin is missing.
	 *
	 * @param int $user_id WP user ID.
	 * @param int $days    Window in days.
	 * @return array{avg:int, sample:int}
	 */
	function newsai_pro_author_avg_focus_score( $user_id, $days = 30 ) {
		global $wpdb;
		$user_id  = (int) $user_id;
		$days     = max( 1, (int) $days );
		$since    = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );
		$pinned   = newsai_focus_score_history_meta_key();
		$live     = newsai_focus_score_meta_key();
		// Pull both meta values for the writer's published posts in the window.
		// We use MAX(pinned, live) so the dashboard always reflects the best state
		// the article has reached: a writer that improved the post after publish
		// sees the higher number (matches what they see in the editor), and a writer
		// that briefly degraded it mid-edit still keeps the publish-time pinned value.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT p.ID AS post_id, pm1.meta_value AS pinned, pm2.meta_value AS live
				 FROM {$wpdb->posts} p
				 LEFT JOIN {$wpdb->postmeta} pm1 ON pm1.post_id = p.ID AND pm1.meta_key = %s
				 LEFT JOIN {$wpdb->postmeta} pm2 ON pm2.post_id = p.ID AND pm2.meta_key = %s
				 WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_author = %d
				 AND p.post_date_gmt > %s",
				$pinned,
				$live,
				$user_id,
				$since
			)
		);
		$sum    = 0;
		$count  = 0;
		foreach ( (array) $rows as $r ) {
			$p_val = ( $r->pinned !== null && $r->pinned !== '' ) ? (int) $r->pinned : 0;
			$l_val = ( $r->live   !== null && $r->live   !== '' ) ? (int) $r->live   : 0;
			$score = max( $p_val, $l_val );
			if ( $score > 0 ) {
				$sum += $score;
				$count++;
			}
		}
		return [
			'avg'    => $count > 0 ? (int) round( $sum / $count ) : 0,
			'sample' => $count,
		];
	}
}

if ( ! function_exists( 'newsai_pro_author_avg_discover_score' ) ) {
	/**
	 * Average persisted Discover Score for published posts in the window.
	 *
	 * @param int $user_id WP user ID.
	 * @param int $days    Window in days.
	 * @return array{avg:int, sample:int}
	 */
	function newsai_pro_author_avg_discover_score( $user_id, $days = 30 ) {
		global $wpdb;
		if ( ! function_exists( 'newsai_discover_score_meta_key' ) ) {
			return [ 'avg' => 0, 'sample' => 0 ];
		}
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$since   = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );
		$meta    = newsai_discover_score_meta_key();
		$rows    = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT pm.meta_value FROM {$wpdb->posts} p
				 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
				 WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_author = %d
				 AND p.post_date_gmt > %s
				 AND pm.meta_value != '' AND pm.meta_value IS NOT NULL",
				$meta,
				$user_id,
				$since
			)
		);
		$sum   = 0;
		$count = 0;
		foreach ( (array) $rows as $val ) {
			$score = (int) $val;
			if ( $score > 0 ) {
				$sum += $score;
				$count++;
			}
		}
		return [
			'avg'    => $count > 0 ? (int) round( $sum / $count ) : 0,
			'sample' => $count,
		];
	}
}

if ( ! function_exists( 'newsai_pro_parse_discover_breakdown_row' ) ) {
	/**
	 * @param string $raw JSON from `_newsai_discover_score_breakdown`.
	 * @return array|null
	 */
	function newsai_pro_parse_discover_breakdown_row( $raw ) {
		if ( ! is_string( $raw ) || $raw === '' ) {
			return null;
		}
		$decoded = json_decode( $raw, true );
		return is_array( $decoded ) ? $decoded : null;
	}
}

if ( ! function_exists( 'newsai_pro_author_discover_weak_spots' ) ) {
	/**
	 * Top coaching gaps for a writer (aggregated from saved Discover breakdowns).
	 *
	 * @param int $user_id WP user ID.
	 * @param int $days    Window in days.
	 * @return array<int, array{id:string, pct:int|null, label:string}>
	 */
	function newsai_pro_author_discover_weak_spots( $user_id, $days = 30 ) {
		global $wpdb;
		if ( ! function_exists( 'newsai_discover_score_breakdown_meta_key' ) ) {
			return [];
		}
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$since   = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );
		$bk_meta = newsai_discover_score_breakdown_meta_key();
		$rows    = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT pm.meta_value FROM {$wpdb->posts} p
				 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
				 WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_author = %d
				 AND p.post_date_gmt > %s
				 AND pm.meta_value != '' AND pm.meta_value IS NOT NULL",
				$bk_meta,
				$user_id,
				$since
			)
		);

		$counts = [
			'image_16x9'   => 0,
			'headline'     => 0,
			'topic'        => 0,
			'og'           => 0,
		];
		$sample = 0;

		foreach ( (array) $rows as $raw ) {
			$pack = newsai_pro_parse_discover_breakdown_row( (string) $raw );
			if ( ! $pack || empty( $pack['breakdown'] ) || ! is_array( $pack['breakdown'] ) ) {
				continue;
			}
			$sample++;
			$b = $pack['breakdown'];

			$img = isset( $b['image'] ) && is_array( $b['image'] ) ? $b['image'] : [];
			if ( ! empty( $img['status'] ) && $img['status'] !== 'missing' && empty( $img['ratio_16x9'] ) ) {
				$counts['image_16x9']++;
			}

			$hl_max = 25;
			$hl     = isset( $pack['headline']['score'] ) ? (int) $pack['headline']['score'] : 0;
			if ( isset( $pack['headline']['max'] ) ) {
				$hl_max = max( 1, (int) $pack['headline']['max'] );
			}
			if ( $hl < (int) round( $hl_max * 0.72 ) ) {
				$counts['headline']++;
			}

			$ent = isset( $b['entities'] ) && is_array( $b['entities'] ) ? $b['entities'] : [];
			$ent_w   = isset( $ent['weighted'] ) ? (int) $ent['weighted'] : 0;
			$ent_max = isset( $ent['weight_max'] ) ? (int) $ent['weight_max'] : 15;
			if ( $ent_max > 0 && $ent_w < (int) round( $ent_max * 0.34 ) ) {
				$counts['topic']++;
			}

			$og = isset( $b['og'] ) && is_array( $b['og'] ) ? $b['og'] : [];
			$og_w = isset( $og['weighted'] ) ? (int) $og['weighted'] : 0;
			if ( $og_w < 1 ) {
				$counts['og']++;
			}
		}

		$spots = [];
		if ( $sample > 0 ) {
			$min_pct = 25;
			$labels  = [
				'image_16x9' => __( '%d%% of articles: hero image not 16:9', 'newsroom-ai-assistant' ),
				'headline'   => __( '%d%% of articles: weak Discover headline', 'newsroom-ai-assistant' ),
				'topic'      => __( '%d%% of articles: thin topic signals (tags/category)', 'newsroom-ai-assistant' ),
				'og'         => __( '%d%% of articles: no Open Graph image (featured or SEO social)', 'newsroom-ai-assistant' ),
			];
			foreach ( $counts as $id => $n ) {
				if ( $n < 1 ) {
					continue;
				}
				$pct = (int) round( ( $n / $sample ) * 100 );
				if ( $pct < $min_pct ) {
					continue;
				}
				$spots[] = [
					'id'    => $id,
					'pct'   => $pct,
					'label' => sprintf( $labels[ $id ], $pct ),
				];
			}
		}

		if ( function_exists( 'nros_discover_score_author_eeat' ) ) {
			$auth = nros_discover_score_author_eeat( $user_id );
			$a_max = isset( $auth['max'] ) ? (int) $auth['max'] : 5;
			$a_scr = isset( $auth['score'] ) ? (int) $auth['score'] : 0;
			if ( $a_max > 0 && $a_scr < (int) round( $a_max * 0.6 ) ) {
				$spots[] = [
					'id'    => 'author_profile',
					'pct'   => null,
					'label' => __( 'Author profile: add bio, job title, or social links', 'newsroom-ai-assistant' ),
				];
			}
		}

		usort(
			$spots,
			static function ( $a, $b ) {
				$ap = isset( $a['pct'] ) && $a['pct'] !== null ? (int) $a['pct'] : 1000;
				$bp = isset( $b['pct'] ) && $b['pct'] !== null ? (int) $b['pct'] : 1000;
				return $bp <=> $ap;
			}
		);

		return array_slice( $spots, 0, 3 );
	}
}

if ( ! function_exists( 'newsai_pro_author_discover_readiness' ) ) {
	/**
	 * @param int $user_id WP user ID.
	 * @param int $days    Window.
	 * @return array{avg:int, sample:int, weak_spots:array}
	 */
	function newsai_pro_author_discover_readiness( $user_id, $days = 30 ) {
		$avg = newsai_pro_author_avg_discover_score( $user_id, $days );
		return [
			'avg'         => (int) $avg['avg'],
			'sample'      => (int) $avg['sample'],
			'weak_spots'  => newsai_pro_author_discover_weak_spots( $user_id, $days ),
		];
	}
}

if ( ! function_exists( 'newsai_pro_format_gsc_ctr' ) ) {
	/**
	 * @param float $ctr Ratio 0–1 from GSC aggregates.
	 * @return string
	 */
	function newsai_pro_format_gsc_ctr( $ctr ) {
		return number_format_i18n( (float) $ctr * 100, 1 ) . '%';
	}
}

if ( ! function_exists( 'newsai_pro_score_tone_class' ) ) {
	/**
	 * CSS tone class for score KPIs (good / warn / bad / neutral).
	 *
	 * @param int $pct  Percentage 0–100.
	 * @param int $high Green threshold.
	 * @param int $mid  Amber threshold.
	 * @return string
	 */
	function newsai_pro_score_tone_class( $pct, $high = 85, $mid = 60 ) {
		$pct = (int) $pct;
		if ( $pct <= 0 ) {
			return 'is-neutral';
		}
		if ( $pct >= $high ) {
			return 'is-good';
		}
		if ( $pct >= $mid ) {
			return 'is-warn';
		}
		return 'is-bad';
	}
}

if ( ! function_exists( 'newsai_pro_enqueue_author_perf_assets' ) ) {
	/**
	 * Author Performance dashboard styles (Settings → Authors tab).
	 *
	 * @param string $hook Admin page hook suffix.
	 */
	function newsai_pro_enqueue_author_perf_assets( $hook ) {
		if ( strpos( (string) $hook, 'newsai-settings' ) === false ) {
			return;
		}
		if ( function_exists( 'newsai_admin_current_tab' ) && newsai_admin_current_tab() !== 'perf' ) {
			return;
		}
		$rel = 'assets/author-performance.css';
		if ( ! file_exists( NEWSAI_PATH . $rel ) ) {
			return;
		}
		if ( function_exists( 'newsai_enqueue_nros_theme_styles' ) ) {
			newsai_enqueue_nros_theme_styles();
		}
		$deps = [ 'newsai-nros-theme' ];
		if ( wp_style_is( 'newsai-style', 'registered' ) ) {
			$deps[] = 'newsai-style';
		}
		wp_enqueue_style(
			'newsai-author-performance',
			NEWSAI_URL . $rel,
			$deps,
			defined( 'NEWSAI_VERSION' ) ? NEWSAI_VERSION : '1.0'
		);

		$js_rel = 'assets/author-performance.js';
		if ( file_exists( NEWSAI_PATH . $js_rel ) ) {
			wp_enqueue_script(
				'newsai-author-performance',
				NEWSAI_URL . $js_rel,
				[ 'jquery' ],
				defined( 'NEWSAI_VERSION' ) ? NEWSAI_VERSION : '1.0',
				true
			);
			if ( function_exists( 'newsai_get_script_vars' ) ) {
				wp_localize_script( 'newsai-author-performance', 'newsai_vars', newsai_get_script_vars() );
			}
		}
	}
}
add_action( 'admin_enqueue_scripts', 'newsai_pro_enqueue_author_perf_assets', 25 );

if ( ! function_exists( 'newsai_pro_author_bundle_web_clicks' ) ) {
	/**
	 * Total web search clicks from GSC bundle slice.
	 *
	 * @param array $bundle Author performance bundle.
	 * @return int
	 */
	function newsai_pro_author_bundle_web_clicks( $bundle ) {
		if ( ! is_array( $bundle ) || empty( $bundle['web'] ) || ! is_array( $bundle['web'] ) ) {
			return 0;
		}
		return (int) ( $bundle['web']['clicks'] ?? 0 );
	}
}

if ( ! function_exists( 'newsai_pro_author_bundle_total_clicks' ) ) {
	/**
	 * Sum of GSC clicks across web, Discover, and Google News for an author bundle.
	 *
	 * @param array $bundle Author performance bundle.
	 * @return int
	 */
	function newsai_pro_author_bundle_total_clicks( $bundle ) {
		if ( ! is_array( $bundle ) ) {
			return 0;
		}
		$total = 0;
		foreach ( [ 'web', 'discover', 'gnews' ] as $ch ) {
			if ( ! empty( $bundle[ $ch ] ) && is_array( $bundle[ $ch ] ) ) {
				$total += (int) ( $bundle[ $ch ]['clicks'] ?? 0 );
			}
		}
		return $total;
	}
}

if ( ! function_exists( 'newsai_pro_format_compact_clicks' ) ) {
	/**
	 * Compact click count for list rows (e.g. 12.5k).
	 *
	 * @param int $clicks Raw click count.
	 * @return string
	 */
	function newsai_pro_format_compact_clicks( $clicks ) {
		$clicks = (int) $clicks;
		if ( $clicks >= 1000000 ) {
			return number_format_i18n( $clicks / 1000000, 1 ) . 'M';
		}
		if ( $clicks >= 1000 ) {
			return number_format_i18n( $clicks / 1000, 1 ) . 'k';
		}
		return number_format_i18n( $clicks );
	}
}

if ( ! function_exists( 'newsai_pro_author_readiness_composite' ) ) {
	/**
	 * Composite AI Readiness: average of Focus + Discover when both exist.
	 *
	 * @param array $score_pack Focus bundle from newsai_pro_author_avg_focus_score().
	 * @param array $disc_pack  Discover bundle from newsai_pro_author_discover_readiness().
	 * @return array{avg:int,sample:int,focus:int,discover:int}
	 */
	function newsai_pro_author_readiness_composite( $score_pack, $disc_pack ) {
		$focus_avg  = (int) ( is_array( $score_pack ) ? ( $score_pack['avg'] ?? 0 ) : 0 );
		$disc_avg   = (int) ( is_array( $disc_pack ) ? ( $disc_pack['avg'] ?? 0 ) : 0 );
		$focus_n    = (int) ( is_array( $score_pack ) ? ( $score_pack['sample'] ?? 0 ) : 0 );
		$disc_n     = (int) ( is_array( $disc_pack ) ? ( $disc_pack['sample'] ?? 0 ) : 0 );

		$avg    = 0;
		$sample = 0;
		if ( $focus_avg > 0 && $disc_avg > 0 ) {
			$avg    = (int) round( ( $focus_avg + $disc_avg ) / 2 );
			$sample = min( $focus_n, $disc_n ) > 0 ? min( $focus_n, $disc_n ) : max( $focus_n, $disc_n );
		} elseif ( $focus_avg > 0 ) {
			$avg    = $focus_avg;
			$sample = $focus_n;
		} elseif ( $disc_avg > 0 ) {
			$avg    = $disc_avg;
			$sample = $disc_n;
		}

		return [
			'avg'      => $avg,
			'sample'   => $sample,
			'focus'    => $focus_avg,
			'discover' => $disc_avg,
		];
	}
}

if ( ! function_exists( 'newsai_pro_render_metric_progress_column' ) ) {
	/**
	 * Metric column with label, value, and horizontal progress bar (list row).
	 *
	 * @param string $label   Column label (visible on mobile).
	 * @param array  $metric  From newsai_pro_format_editorial_metric().
	 * @param string $variant focus|discover — controls bar color.
	 * @return string HTML.
	 */
	function newsai_pro_render_metric_progress_column( $label, $metric, $variant = 'focus' ) {
		$pct     = 0;
		$display = '—';
		$class   = 'is-neutral';
		$pending = true;
		$title   = '';

		if ( is_array( $metric ) ) {
			$pending = ! empty( $metric['pending'] );
			$title   = (string) ( $metric['title'] ?? '' );
			if ( ! $pending && is_numeric( $metric['display'] ?? null ) ) {
				$pct     = min( 100, max( 0, (int) $metric['display'] ) );
				$display = $pct . '%';
				$class   = (string) ( $metric['class'] ?? 'is-neutral' );
			} else {
				$display = (string) ( $metric['display_long'] ?? $metric['display'] ?? '—' );
			}
		}

		$bar_class = 'nros-ap-progress__fill--focus';
		if ( $variant === 'discover' ) {
			$bar_class = 'nros-ap-progress__fill--discover';
		} elseif ( $variant === 'readiness' ) {
			$bar_class = 'nros-ap-progress__fill--readiness';
		}
		ob_start();
		?>
		<div class="nros-ap-row__metric nros-ap-row__metric--<?php echo esc_attr( $variant ); ?>">
			<span class="nros-ap-row__metric-lbl"><?php echo esc_html( $label ); ?></span>
			<div class="nros-ap-progress">
				<div class="nros-ap-progress__track" aria-hidden="true">
					<div class="nros-ap-progress__fill <?php echo esc_attr( $bar_class ); ?>" style="width:<?php echo esc_attr( (string) max( $pending ? 0 : 4, $pct ) ); ?>%"></div>
				</div>
				<span class="nros-ap-progress__val <?php echo esc_attr( $class ); ?>"<?php echo $title !== '' ? ' title="' . esc_attr( $title ) . '"' : ''; ?>><?php echo esc_html( $display ); ?></span>
			</div>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_pro_format_editorial_metric' ) ) {
	/**
	 * Human-friendly editorial metric display (avoids bare "—").
	 *
	 * @param int    $avg           Average score 0–100.
	 * @param int    $sample        Posts with a saved score.
	 * @param int    $posts_recent  Published posts in window.
	 * @param string $metric        focus|discover.
	 * @return array{display:string, class:string, title:string, pending:bool}
	 */
	function newsai_pro_format_editorial_metric( $avg, $sample, $posts_recent, $metric = 'focus' ) {
		$avg          = (int) $avg;
		$sample       = (int) $sample;
		$posts_recent = (int) $posts_recent;

		if ( $posts_recent < 1 ) {
			return [
				'display' => '—',
				'display_long' => __( 'No posts', 'newsroom-ai-assistant' ),
				'class'   => 'is-pending',
				'title'   => __( 'No published articles in this time window.', 'newsroom-ai-assistant' ),
				'pending' => true,
			];
		}

		if ( $avg > 0 ) {
			return [
				'display' => (int) $avg,
				'display_long' => '',
				'class'   => newsai_pro_score_tone_class( $avg, in_array( $metric, [ 'discover', 'readiness' ], true ) ? 80 : 85, 60 ),
				'title'   => '',
				'pending' => false,
			];
		}

		if ( $sample < 1 ) {
			$tip = __( 'Write or edit a post with the Newsroom OS panel open, then save — the editorial checklist will calculate this score.', 'newsroom-ai-assistant' );
			return [
				'display' => '…',
				'display_long' => __( 'Awaiting score', 'newsroom-ai-assistant' ),
				'class'   => 'is-pending',
				'title'   => $tip,
				'pending' => true,
			];
		}

		return [
			'display' => '0',
			'display_long' => '0%',
			'class'   => 'is-neutral',
			'title'   => __( 'Posts exist but the average score is zero in this window.', 'newsroom-ai-assistant' ),
			'pending' => false,
		];
	}
}

if ( ! function_exists( 'newsai_pro_render_metric_kpi' ) ) {
	/**
	 * One KPI cell with optional tooltip title.
	 *
	 * @param string $label KPI label.
	 * @param array  $metric From newsai_pro_format_editorial_metric() or numeric display.
	 * @param bool   $suffix_percent Append % for numeric scores.
	 * @return string HTML.
	 */
	function newsai_pro_render_metric_kpi( $label, $metric, $suffix_percent = false, $compact = false ) {
		if ( is_array( $metric ) ) {
			$val      = (string) ( $metric['display'] ?? '' );
			$val_long = (string) ( $metric['display_long'] ?? '' );
			$class    = (string) ( $metric['class'] ?? 'is-neutral' );
			$title    = (string) ( $metric['title'] ?? '' );
			$pending  = ! empty( $metric['pending'] );
		} else {
			$val      = (string) $metric;
			$val_long = '';
			$class    = 'is-neutral';
			$title    = '';
			$pending  = false;
		}
		if ( $suffix_percent && is_numeric( $val ) ) {
			$val .= '%';
		}
		// Compact cards: show short symbol (…) with tooltip; full cards: show long text.
		if ( $compact || ! $pending ) {
			$show = $val;
		} else {
			$show = ( $val_long !== '' ) ? $val_long : $val;
		}
		$html  = '<div class="nros-ap-kpi' . ( $pending ? ' nros-ap-kpi--pending' : '' ) . '">';
		$html .= '<div class="nros-ap-kpi__val ' . esc_attr( $class ) . '"';
		if ( $title !== '' ) {
			$html .= ' title="' . esc_attr( $title ) . '" tabindex="0"';
		}
		$html .= '>' . esc_html( $show ) . '</div>';
		$html .= '<div class="nros-ap-kpi__lbl">' . esc_html( $label ) . '</div></div>';
		return $html;
	}
}

if ( ! function_exists( 'newsai_pro_coaching_action_link' ) ) {
	/**
	 * Action link for a weak-spot row.
	 *
	 * @param string $spot_id Weak spot id.
	 * @param int    $user_id Author user ID.
	 * @param array  $bundle  Optional bundle for latest post edit URL.
	 * @return string HTML or empty.
	 */
	function newsai_pro_coaching_action_link( $spot_id, $user_id, $bundle = [] ) {
		$user_id = (int) $user_id;
		$url     = '';
		$label   = '';

		if ( 'author_profile' === $spot_id && $user_id > 0 ) {
			$url   = get_edit_user_link( $user_id );
			$label = __( 'Edit profile', 'newsroom-ai-assistant' );
		} elseif ( in_array( $spot_id, [ 'headline', 'image_16x9', 'og', 'topic' ], true ) ) {
			$arts = isset( $bundle['articles'] ) && is_array( $bundle['articles'] ) ? $bundle['articles'] : [];
			if ( ! empty( $arts[0]['edit_url'] ) ) {
				$url   = (string) $arts[0]['edit_url'];
				$label = __( 'Open latest post', 'newsroom-ai-assistant' );
			}
		}

		if ( $url === '' || $label === '' ) {
			return '';
		}
		return ' <a class="nros-ap-coach-action" href="' . esc_url( $url ) . '" target="_blank" rel="noopener">' . esc_html( $label ) . ' ↗</a>';
	}
}

if ( ! function_exists( 'newsai_pro_gsc_channel_coach_tip' ) ) {
	/**
	 * Micro-coaching line for low GSC channel numbers.
	 *
	 * @param string $channel discover|googleNews|web.
	 * @param int    $clicks  Click count.
	 * @param int    $impr    Impressions.
	 * @return string
	 */
	function newsai_pro_gsc_channel_coach_tip( $channel, $clicks, $impr ) {
		$clicks = (int) $clicks;
		$impr   = (int) $impr;
		if ( $clicks > 4 || $impr > 500 ) {
			return '';
		}
		if ( 'discover' === $channel ) {
			return __( 'Tip: Strong 1200×675 heroes and clear headlines help Discover pick up impressions.', 'newsroom-ai-assistant' );
		}
		if ( 'googleNews' === $channel ) {
			return __( 'Tip: News-friendly titles and timely publishing improve Google News visibility.', 'newsroom-ai-assistant' );
		}
		return __( 'Tip: Internal links, FAQ blocks, and fresh updates can lift web search clicks over time.', 'newsroom-ai-assistant' );
	}
}

if ( ! function_exists( 'newsai_pro_render_gsc_traffic_summary' ) ) {
	/**
	 * Compact or full GSC traffic block for Author Performance / My Inbox.
	 *
	 * @param array|null $discover From newsai_gsc_author_metrics( …, 'discover' ).
	 * @param array|null $gnews    googleNews metrics.
	 * @param array|null $web      Optional web search metrics.
	 * @param bool       $compact  One-line chips vs detailed grid.
	 * @return string HTML (escaped).
	 */
	function newsai_pro_render_gsc_traffic_summary( $discover, $gnews, $web = null, $compact = false, $show_all_channels = false ) {
		$blocks = [
			'discover'   => [
				'label' => __( 'Discover clicks', 'newsroom-ai-assistant' ),
				'data'  => $discover,
				'icon'  => '✨',
			],
			'googleNews' => [
				'label' => __( 'Google News clicks', 'newsroom-ai-assistant' ),
				'data'  => $gnews,
				'icon'  => '📰',
			],
			'web'        => [
				'label' => __( 'Web search clicks', 'newsroom-ai-assistant' ),
				'data'  => $web,
				'icon'  => '🔍',
			],
		];

		$has_any = false;
		foreach ( $blocks as $b ) {
			$data = is_array( $b['data'] ) ? $b['data'] : [];
			if ( ! empty( $data['clicks'] ) || ! empty( $data['impressions'] ) ) {
				$has_any = true;
				break;
			}
		}
		if ( ! $has_any && ! $show_all_channels ) {
			return '';
		}

		if ( $compact ) {
			$html = '<div class="nros-gsc-traffic-compact">';
			foreach ( $blocks as $b ) {
				$data   = is_array( $b['data'] ) ? $b['data'] : [];
				$clicks = (int) ( $data['clicks'] ?? 0 );
				$impr   = (int) ( $data['impressions'] ?? 0 );
				if ( ! $show_all_channels && $clicks < 1 && $impr < 1 ) {
					continue;
				}
				$chip_cls = ( $clicks > 0 || $impr > 0 ) ? 'nros-ap-gsc-chip has-data' : 'nros-ap-gsc-chip';
				$html    .= '<span class="' . esc_attr( $chip_cls ) . '">';
				$html    .= '<span class="nros-ap-gsc-chip__icon" aria-hidden="true">' . esc_html( $b['icon'] ) . '</span> ';
				$html    .= esc_html( $b['label'] . ': ' . number_format_i18n( $clicks ) );
				$html    .= '</span>';
			}
			$html .= '</div>';
			return $html;
		}

		$html  = '<div class="nros-gsc-traffic-detail">';
		$html .= '<p class="nros-ap-section__desc">' . esc_html__( 'Real clicks from Google — separate from the editorial Discover Score (readiness checklist).', 'newsroom-ai-assistant' ) . '</p>';
		$html .= '<div class="nros-ap-gsc-grid">';
		foreach ( $blocks as $ch_key => $b ) {
			$data   = is_array( $b['data'] ) ? $b['data'] : [];
			$clicks = (int) ( $data['clicks'] ?? 0 );
			$impr   = (int) ( $data['impressions'] ?? 0 );
			if ( ! $show_all_channels && $clicks < 1 && $impr < 1 ) {
				continue;
			}
			$dim_cls = ( $clicks < 1 && $impr < 1 ) ? ' nros-ap-gsc-stat is-dim' : ' nros-ap-gsc-stat';
			$coach   = newsai_pro_gsc_channel_coach_tip( (string) $ch_key, $clicks, $impr );
			$html .= '<div class="' . esc_attr( trim( $dim_cls ) ) . '">';
			$html .= '<div class="nros-ap-gsc-stat__lbl">' . esc_html( $b['icon'] . ' ' . $b['label'] ) . '</div>';
			$html .= '<div class="nros-ap-gsc-stat__val">' . esc_html( number_format_i18n( $clicks ) ) . '</div>';
			$html .= '<div class="nros-ap-gsc-stat__sub">';
			if ( $impr > 0 ) {
				$html .= esc_html(
					sprintf(
						/* translators: 1: impressions, 2: CTR, 3: avg position */
						__( 'Impr. %1$s · CTR %2$s · pos. %3$s', 'newsroom-ai-assistant' ),
						number_format_i18n( $impr ),
						newsai_pro_format_gsc_ctr( (float) ( $data['ctr'] ?? 0 ) ),
						number_format_i18n( (float) ( $data['position'] ?? 0 ), 1 )
					)
				);
			} else {
				$html .= esc_html__( 'No data in sync window', 'newsroom-ai-assistant' );
			}
			if ( $coach !== '' ) {
				$html .= '<p class="nros-ap-gsc-stat__coach">' . esc_html( $coach ) . '</p>';
			}
			$html .= '</div></div>';
		}
		$html .= '</div>';
		$last = '';
		if ( ! empty( $discover['last_sync'] ) ) {
			$last = (string) $discover['last_sync'];
		} elseif ( ! empty( $gnews['last_sync'] ) ) {
			$last = (string) $gnews['last_sync'];
		}
		if ( $last !== '' ) {
			$html .= '<p class="nros-ap-gsc-sync">' . esc_html(
				sprintf(
					/* translators: %s: last sync datetime (UTC) */
					__( 'Last GSC sync: %s UTC', 'newsroom-ai-assistant' ),
					$last
				)
			) . '</p>';
		}
		$html .= '</div>';
		return $html;
	}
}

if ( ! function_exists( 'newsai_pro_render_author_article_row' ) ) {
	/**
	 * One article row: editorial scores on line 1, optional GSC per-article on line 2.
	 *
	 * @param array $a         Article bundle row.
	 * @param bool  $show_gsc  Show Search Console line (even when zero).
	 * @return string HTML.
	 */
	function newsai_pro_render_author_article_row( $a, $show_gsc = true ) {
		$sc   = (int) ( $a['score'] ?? 0 );
		$disc = isset( $a['discover'] ) ? (int) $a['discover'] : 0;
		$sc_m = newsai_pro_format_editorial_metric( $sc, $sc > 0 ? 1 : 0, 1, 'focus' );
		$di_m = newsai_pro_format_editorial_metric( $disc, $disc > 0 ? 1 : 0, 1, 'discover' );
		if ( $sc > 0 ) {
			$sc_m['display'] = (int) $sc . '%';
			$sc_m['class']   = newsai_pro_score_tone_class( $sc, 85, 60 );
			$sc_m['pending'] = false;
		} else {
			$sc_m['display_long'] = __( 'Awaiting score', 'newsroom-ai-assistant' );
		}
		if ( $disc > 0 ) {
			$di_m['display'] = (int) $disc . '%';
			$di_m['class']   = newsai_pro_score_tone_class( $disc, 80, 60 );
			$di_m['pending'] = false;
		} else {
			$di_m['display_long'] = __( 'Awaiting score', 'newsroom-ai-assistant' );
		}
		$sc_show = $sc_m['pending'] ? (string) ( $sc_m['display_long'] ?: $sc_m['display'] ) : (string) $sc_m['display'];
		$di_show = $di_m['pending'] ? (string) ( $di_m['display_long'] ?: $di_m['display'] ) : (string) $di_m['display'];

		$html  = '<div class="nros-author-article-row">';
		$html .= '<div class="nros-ap-article__main">';
		$html .= '<span class="nros-ap-article__title">' . esc_html( (string) ( $a['title'] ?? '' ) ) . '</span>';
		$html .= '<div class="nros-ap-article__meta">';
		$html .= '<span>📅 ' . esc_html( (string) ( $a['date'] ?? '' ) ) . '</span>';
		$html .= '<span class="nros-ap-pill ' . esc_attr( $sc_m['class'] ) . '" title="' . esc_attr( $sc_m['title'] ) . '">' . esc_html__( 'Focus', 'newsroom-ai-assistant' ) . ': ' . esc_html( $sc_show ) . '</span>';
		$html .= '<span class="nros-ap-pill ' . esc_attr( $di_m['class'] ) . '" title="' . esc_attr( $di_m['title'] ) . '">' . esc_html__( 'Discover', 'newsroom-ai-assistant' ) . ': ' . esc_html( $di_show ) . '</span>';
		$html .= '</div>';

		if ( $show_gsc && function_exists( 'newsai_gsc_data_available' ) && newsai_gsc_data_available() ) {
			$gsc_disc = (int) ( $a['gsc_discover'] ?? 0 );
			$gsc_gn   = (int) ( $a['gsc_gnews'] ?? 0 );
			$gsc_web  = (int) ( $a['gsc_web'] ?? 0 );
			$html    .= '<div class="nros-ap-article__gsc">';
			$html    .= esc_html(
				sprintf(
					/* translators: 1: discover clicks, 2: google news clicks, 3: web clicks */
					__( 'GSC · Discover %1$s · News %2$s · Web %3$s', 'newsroom-ai-assistant' ),
					number_format_i18n( $gsc_disc ),
					number_format_i18n( $gsc_gn ),
					number_format_i18n( $gsc_web )
				)
			);
			$html .= '</div>';
		}

		$html .= '</div>';
		$html .= '<div class="nros-ap-article__actions">';
		if ( ! empty( $a['edit_url'] ) ) {
			$html .= '<a href="' . esc_url( (string) $a['edit_url'] ) . '" class="nros-ap-btn nros-ap-btn--edit" target="_blank" rel="noopener">' . esc_html__( 'Edit', 'newsroom-ai-assistant' ) . '</a>';
		}
		if ( ! empty( $a['view_url'] ) ) {
			$html .= '<a href="' . esc_url( (string) $a['view_url'] ) . '" class="nros-ap-link-view" target="_blank" rel="noopener">' . esc_html__( 'View', 'newsroom-ai-assistant' ) . ' ↗</a>';
		}
		$html .= '</div></div>';
		return $html;
	}
}

if ( ! function_exists( 'newsai_pro_render_discover_weak_spots' ) ) {
	/**
	 * @param array $weak_spots From newsai_pro_author_discover_weak_spots().
	 * @return string HTML fragment (escaped).
	 */
	function newsai_pro_render_discover_weak_spots( $weak_spots, $user_id = 0, $bundle = [], $unified = false ) {
		if ( empty( $weak_spots ) || ! is_array( $weak_spots ) ) {
			return '';
		}
		$items = [];
		foreach ( $weak_spots as $spot ) {
			if ( empty( $spot['label'] ) ) {
				continue;
			}
			$spot_id = isset( $spot['id'] ) ? (string) $spot['id'] : '';
			$items[] = [
				'label' => (string) $spot['label'],
				'link'  => newsai_pro_coaching_action_link( $spot_id, (int) $user_id, $bundle ),
			];
		}
		if ( empty( $items ) ) {
			return '';
		}

		if ( $unified ) {
			$html  = '<div class="nros-ap-coach-box">';
			$html .= '<h5 class="nros-ap-coach-box__title">' . esc_html(
				sprintf(
					/* translators: %d: number of coaching tips */
					_n( '%d coaching tip', '%d coaching tips', count( $items ), 'newsroom-ai-assistant' ),
					count( $items )
				)
			) . '</h5>';
			$html .= '<ul class="nros-ap-coach-box__list">';
			foreach ( $items as $item ) {
				$html .= '<li>' . esc_html( $item['label'] ) . $item['link'] . '</li>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			}
			$html .= '</ul></div>';
			return $html;
		}

		$html = '<div class="nros-author-discover-weakspots">';
		foreach ( $items as $item ) {
			$html .= '<div class="nros-ap-alert"><span class="nros-ap-alert__icon" aria-hidden="true">⚠</span><span>' . esc_html( $item['label'] ) . $item['link'] . '</span></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}
		$html .= '</div>';
		return $html;
	}
}

if ( ! function_exists( 'newsai_pro_author_top_categories' ) ) {
	/**
	 * @param int $user_id WP user ID.
	 * @param int $days    Window.
	 * @param int $limit   Max categories returned.
	 * @return array<int, array{term_id:int, name:string, count:int}>
	 */
	function newsai_pro_author_top_categories( $user_id, $days = 30, $limit = 3 ) {
		global $wpdb;
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$limit   = max( 1, (int) $limit );
		$since   = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );
		$rows    = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT t.term_id AS term_id, t.name AS name, COUNT(*) AS cnt
				 FROM {$wpdb->posts} p
				 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
				 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'category'
				 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
				 WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_author = %d
				 AND p.post_date_gmt > %s
				 GROUP BY t.term_id, t.name
				 ORDER BY cnt DESC
				 LIMIT %d",
				$user_id,
				$since,
				$limit
			)
		);
		$out = [];
		foreach ( (array) $rows as $r ) {
			$out[] = [
				'term_id' => (int) $r->term_id,
				'name'    => (string) $r->name,
				'count'   => (int) $r->cnt,
			];
		}
		return $out;
	}
}

if ( ! function_exists( 'newsai_pro_build_author_article_row' ) ) {
	/**
	 * @param WP_Post|int $post       Post object or ID.
	 * @param int         $user_id    Author (for GSC map scope).
	 * @param int         $days       GSC window.
	 * @param array|null  $traffic_map Optional preloaded map from newsai_gsc_post_traffic_map().
	 * @return array|null
	 */
	function newsai_pro_build_author_article_row( $post, $user_id, $days, $traffic_map = null ) {
		$post = is_object( $post ) ? $post : get_post( (int) $post );
		if ( ! $post || $post->post_type !== 'post' || $post->post_status !== 'publish' ) {
			return null;
		}
		$pid        = (int) $post->ID;
		$pinned_key = newsai_focus_score_history_meta_key();
		$live_key   = newsai_focus_score_meta_key();
		$disc_key   = function_exists( 'newsai_discover_score_meta_key' ) ? newsai_discover_score_meta_key() : '';
		$pinned     = (int) get_post_meta( $pid, $pinned_key, true );
		$live       = (int) get_post_meta( $pid, $live_key, true );
		$score      = max( $pinned, $live );
		$disc       = $disc_key !== '' ? (int) get_post_meta( $pid, $disc_key, true ) : 0;
		if ( ! is_array( $traffic_map ) && function_exists( 'newsai_gsc_post_traffic_map' ) ) {
			$traffic_map = newsai_gsc_post_traffic_map( (int) $user_id, (int) $days, [ $pid ] );
		}
		$gsc = ( is_array( $traffic_map ) && isset( $traffic_map[ $pid ] ) ) ? $traffic_map[ $pid ] : [];

		return [
			'post_id'          => $pid,
			'title'            => (string) get_the_title( $post ),
			'edit_url'         => (string) get_edit_post_link( $pid, 'raw' ),
			'view_url'         => (string) get_permalink( $post ),
			'date'             => (string) get_the_date( 'Y-m-d', $post ),
			'comments'         => (int) $post->comment_count,
			'score'            => (int) $score,
			'discover'         => (int) $disc,
			'gsc_discover'     => (int) ( $gsc['discover'] ?? 0 ),
			'gsc_gnews'        => (int) ( $gsc['googleNews'] ?? 0 ),
			'gsc_web'          => (int) ( $gsc['web'] ?? 0 ),
			'gsc_clicks_total' => (int) ( $gsc['total'] ?? 0 ),
		];
	}
}

if ( ! function_exists( 'newsai_pro_author_top_gsc_articles' ) ) {
	/**
	 * Posts with the most Search Console clicks in the window (may differ from latest by publish date).
	 *
	 * @param int $user_id Author ID.
	 * @param int $days    Window.
	 * @param int $limit   Max posts (default 5).
	 * @return array<int, array>
	 */
	function newsai_pro_author_top_gsc_articles( $user_id, $days = 30, $limit = 5 ) {
		if ( ! function_exists( 'newsai_gsc_data_available' ) || ! newsai_gsc_data_available() ) {
			return [];
		}
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$limit   = max( 1, min( 10, (int) $limit ) );

		$ids = function_exists( 'newsai_gsc_author_top_post_ids' )
			? newsai_gsc_author_top_post_ids( $user_id, $days, $limit )
			: [];
		if ( empty( $ids ) ) {
			return [];
		}

		if ( ! function_exists( 'newsai_gsc_post_traffic_map' ) ) {
			return [];
		}
		$traffic = newsai_gsc_post_traffic_map( $user_id, $days, $ids );
		$out     = [];
		foreach ( $ids as $pid ) {
			$pid = (int) $pid;
			$tot = isset( $traffic[ $pid ]['total'] ) ? (int) $traffic[ $pid ]['total'] : 0;
			if ( $tot < 1 ) {
				continue;
			}
			$row = newsai_pro_build_author_article_row( $pid, $user_id, $days, $traffic );
			if ( $row ) {
				$out[] = $row;
			}
		}
		return $out;
	}
}

if ( ! function_exists( 'newsai_pro_author_top_articles' ) ) {
	/**
	 * Top articles for the writer in the window. We don't have analytics yet, so we
	 * surface the most recent posts plus their comment counts as a soft engagement
	 * signal. When GSC data exists, orders by total clicks and attaches per-type counts.
	 *
	 * @param int $user_id WP user ID.
	 * @param int $days    Window.
	 * @param int $limit   Max articles returned.
	 * @return array<int, array{post_id:int, title:string, edit_url:string, view_url:string, date:string, comments:int, score:int, discover:int}>
	 */
	function newsai_pro_author_top_articles( $user_id, $days = 30, $limit = 8 ) {
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$limit   = max( 1, (int) $limit );

		// Always list recent published posts in the window (editorial scores).
		// GSC clicks are attached per row — do not replace this query with GSC-only IDs
		// (older traffic can fall outside the publish-date window and yield an empty list).
		$posts = get_posts(
			[
				'author'        => $user_id,
				'post_type'     => 'post',
				'post_status'   => 'publish',
				'numberposts'   => $limit,
				'orderby'       => 'date',
				'order'         => 'DESC',
				'date_query'    => [
					[
						'after'     => gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ),
						'inclusive' => true,
						'column'    => 'post_date_gmt',
					],
				],
				'no_found_rows' => true,
			]
		);
		$post_ids = array_map( static function ( $p ) {
			return (int) $p->ID;
		}, (array) $posts );
		$traffic  = function_exists( 'newsai_gsc_post_traffic_map' )
			? newsai_gsc_post_traffic_map( $user_id, $days, $post_ids )
			: [];
		$out      = [];
		foreach ( (array) $posts as $p ) {
			$row = newsai_pro_build_author_article_row( $p, $user_id, $days, $traffic );
			if ( $row ) {
				$out[] = $row;
			}
		}
		return $out;
	}
}

if ( ! function_exists( 'newsai_pro_author_score_sparklines_7d' ) ) {
	/**
	 * Daily Focus + Discover averages for an author (last 7 calendar days).
	 *
	 * @param int $user_id WP user ID.
	 * @return array{focus:int[],discover:int[]}
	 */
	function newsai_pro_author_score_sparklines_7d( $user_id ) {
		$user_id = (int) $user_id;
		if ( $user_id <= 0 ) {
			return [ 'focus' => array_fill( 0, 7, 0 ), 'discover' => array_fill( 0, 7, 0 ) ];
		}

		$cache_key = 'newsai_pro_author_spark7_' . $user_id;
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) && isset( $cached['focus'], $cached['discover'] ) ) {
			return $cached;
		}

		$focus_buckets   = array_fill( 0, 7, [ 'sum' => 0, 'n' => 0 ] );
		$discover_buckets = array_fill( 0, 7, [ 'sum' => 0, 'n' => 0 ] );
		$today_start     = strtotime( gmdate( 'Y-m-d' ) . ' UTC' );

		$posts = get_posts(
			[
				'author'        => $user_id,
				'post_type'     => 'post',
				'post_status'   => 'publish',
				'numberposts'   => -1,
				'orderby'       => 'date',
				'order'         => 'DESC',
				'date_query'    => [
					[
						'after'     => gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) ),
						'inclusive' => true,
						'column'    => 'post_date_gmt',
					],
				],
				'no_found_rows' => true,
			]
		);

		foreach ( (array) $posts as $post ) {
			$row = newsai_pro_build_author_article_row( $post, $user_id, 7 );
			if ( ! $row || empty( $row['date'] ) ) {
				continue;
			}
			$day_start = strtotime( $row['date'] . ' UTC' );
			if ( ! $day_start ) {
				continue;
			}
			$days_ago = (int) floor( ( $today_start - $day_start ) / DAY_IN_SECONDS );
			if ( $days_ago < 0 || $days_ago > 6 ) {
				continue;
			}
			$idx = 6 - $days_ago;
			if ( (int) $row['score'] > 0 ) {
				$focus_buckets[ $idx ]['sum'] += (int) $row['score'];
				$focus_buckets[ $idx ]['n']++;
			}
			if ( (int) $row['discover'] > 0 ) {
				$discover_buckets[ $idx ]['sum'] += (int) $row['discover'];
				$discover_buckets[ $idx ]['n']++;
			}
		}

		$focus_series    = [];
		$discover_series = [];
		for ( $i = 0; $i < 7; $i++ ) {
			$focus_series[]    = $focus_buckets[ $i ]['n'] > 0 ? (int) round( $focus_buckets[ $i ]['sum'] / $focus_buckets[ $i ]['n'] ) : 0;
			$discover_series[] = $discover_buckets[ $i ]['n'] > 0 ? (int) round( $discover_buckets[ $i ]['sum'] / $discover_buckets[ $i ]['n'] ) : 0;
		}

		$out = [
			'focus'    => $focus_series,
			'discover' => $discover_series,
		];
		set_transient( $cache_key, $out, 10 * MINUTE_IN_SECONDS );
		return $out;
	}
}

if ( ! function_exists( 'newsai_pro_render_author_sparklines_7d' ) ) {
	/**
	 * Mini Focus / Discover sparklines for author card header.
	 *
	 * @param array $bundle Author performance bundle.
	 * @return string
	 */
	function newsai_pro_render_author_sparklines_7d( $bundle ) {
		if ( ! function_exists( 'newsai_chief_render_mini_sparkline' ) ) {
			return '';
		}
		$spark = isset( $bundle['spark_7d'] ) && is_array( $bundle['spark_7d'] ) ? $bundle['spark_7d'] : [ 'focus' => [], 'discover' => [] ];
		$focus = isset( $spark['focus'] ) && is_array( $spark['focus'] ) ? array_map( 'intval', $spark['focus'] ) : [];
		$disc  = isset( $spark['discover'] ) && is_array( $spark['discover'] ) ? array_map( 'intval', $spark['discover'] ) : [];
		if ( array_sum( $focus ) <= 0 && array_sum( $disc ) <= 0 ) {
			return '';
		}

		$focus_html = newsai_chief_render_mini_sparkline( $focus, '#6366f1', 52, 18 );
		$disc_html  = newsai_chief_render_mini_sparkline( $disc, '#8b5cf6', 52, 18 );
		if ( $focus_html === '' && $disc_html === '' ) {
			return '';
		}

		ob_start();
		?>
		<div class="nros-ap-row__sparks" aria-label="<?php echo esc_attr__( 'Focus and Discover trend (7 days)', 'newsroom-ai-assistant' ); ?>">
			<?php if ( $focus_html !== '' ) : ?>
				<span class="nros-ap-spark-pair" title="<?php echo esc_attr__( 'Focus (7d)', 'newsroom-ai-assistant' ); ?>">
					<span class="nros-ap-spark-pair__lbl"><?php echo esc_html__( 'Focus', 'newsroom-ai-assistant' ); ?></span>
					<?php echo $focus_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
				</span>
			<?php endif; ?>
			<?php if ( $disc_html !== '' ) : ?>
				<span class="nros-ap-spark-pair" title="<?php echo esc_attr__( 'Discover (7d)', 'newsroom-ai-assistant' ); ?>">
					<span class="nros-ap-spark-pair__lbl"><?php echo esc_html__( 'Discover', 'newsroom-ai-assistant' ); ?></span>
					<?php echo $disc_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
				</span>
			<?php endif; ?>
		</div>
		<?php
		return (string) ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_pro_author_performance' ) ) {
	/**
	 * Full per-author bundle. Cached for 10 minutes per (user, days).
	 *
	 * @param int $user_id WP user ID.
	 * @param int $days    Window.
	 * @return array
	 */
	function newsai_pro_author_performance( $user_id, $days = 30 ) {
		$user_id = (int) $user_id;
		$days    = max( 1, (int) $days );
		$cache_key = 'newsai_pro_author_perf_' . $user_id . '_' . $days;
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}
		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return [];
		}
		$posts = newsai_pro_author_post_counts( $user_id, $days );
		$tasks = newsai_pro_author_task_counts( $user_id );
		$score    = newsai_pro_author_avg_focus_score( $user_id, $days );
		$discover = newsai_pro_author_discover_readiness( $user_id, $days );
		$readiness = newsai_pro_author_readiness_composite( $score, $discover );
		$cats     = newsai_pro_author_top_categories( $user_id, $days, 3 );
		$arts     = newsai_pro_author_top_articles( $user_id, $days, 8 );
		$gsc_top  = newsai_pro_author_top_gsc_articles( $user_id, $days, 5 );
		$gsc_ch   = function_exists( 'newsai_gsc_author_channel_metrics' )
			? newsai_gsc_author_channel_metrics( $user_id, $days )
			: null;
		$bundle = [
			'user_id'    => $user_id,
			'name'       => (string) $user->display_name,
			'initials'   => function_exists( 'newsai_pro_author_initials' ) ? newsai_pro_author_initials( $user->display_name ) : '?',
			'avatar'     => esc_url_raw( get_avatar_url( $user_id, [ 'size' => 64 ] ) ),
			'job_title'  => (string) get_user_meta( $user_id, 'nros_job_title', true ),
			'days'       => $days,
			'posts'      => $posts,
			'tasks'      => $tasks,
			'score'      => $score,
			'discover_readiness' => $discover,
			'readiness'  => $readiness,
			'spark_7d'   => newsai_pro_author_score_sparklines_7d( $user_id ),
			'categories' => $cats,
			'articles'         => $arts,
			'articles_top_gsc' => $gsc_top,
			'discover'   => apply_filters(
				'newsai_pro_author_discover_metrics',
				$gsc_ch ? $gsc_ch['discover'] : null,
				$user_id,
				$days
			),
			'gnews'      => apply_filters(
				'newsai_pro_author_gnews_metrics',
				$gsc_ch ? $gsc_ch['googleNews'] : null,
				$user_id,
				$days
			),
			'web'        => $gsc_ch ? $gsc_ch['web'] : null,
		];
		set_transient( $cache_key, $bundle, 10 * MINUTE_IN_SECONDS );
		return $bundle;
	}
}

if ( ! function_exists( 'newsai_pro_all_authors_performance' ) ) {
	/**
	 * Performance for every user that has at least one post in the window OR
	 * an open/closed task in the alerts table. Sorted by recent posts desc.
	 *
	 * @param int $days  Window.
	 * @param int $limit Max authors.
	 * @return array<int, array>
	 */
	function newsai_pro_all_authors_performance( $days = 30, $limit = 50 ) {
		global $wpdb;
		$days  = max( 1, (int) $days );
		$limit = max( 1, (int) $limit );

		if ( function_exists( 'newsai_admin_analytics_cache_stamp' ) && function_exists( 'newsai_admin_analytics_cache_ttl' ) ) {
			$grid_key = 'newsai_pro_authors_grid_' . $days . '_' . $limit . '_' . newsai_admin_analytics_cache_stamp();
			$cached   = get_transient( $grid_key );
			if ( is_array( $cached ) && array_key_exists( 'rows', $cached ) && is_array( $cached['rows'] ) ) {
				return $cached['rows'];
			}
		}

		$since = function_exists( 'newsai_sql_datetime_days_ago' ) ? newsai_sql_datetime_days_ago( $days, true ) : gmdate( 'Y-m-d H:i:s', strtotime( '-' . $days . ' days' ) );

		$ids = [];
		$post_rows = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT post_author FROM {$wpdb->posts}
				 WHERE post_type = 'post' AND post_status = 'publish'
				 AND post_date_gmt > %s",
				$since
			)
		);
		foreach ( (array) $post_rows as $uid ) {
			$ids[ (int) $uid ] = true;
		}

		$alerts_table = $wpdb->prefix . 'newsai_alerts';
		$has_alerts = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $alerts_table ) )
		);
		if ( $has_alerts ) {
			$task_rows = $wpdb->get_col( "SELECT DISTINCT author_id FROM {$alerts_table} WHERE author_id > 0" );
			foreach ( (array) $task_rows as $uid ) {
				$ids[ (int) $uid ] = true;
			}
		}

		if ( empty( $ids ) ) {
			$empty = [];
			if ( isset( $grid_key ) && function_exists( 'newsai_admin_analytics_cache_ttl' ) ) {
				set_transient(
					$grid_key,
					[
						'rows'      => $empty,
						'cached_at' => time(),
					],
					newsai_admin_analytics_cache_ttl()
				);
			}
			return $empty;
		}
		$out = [];
		foreach ( array_keys( $ids ) as $uid ) {
			$bundle = newsai_pro_author_performance( $uid, $days );
			if ( ! empty( $bundle ) ) {
				$out[] = $bundle;
			}
		}
		usort(
			$out,
			static function ( $a, $b ) {
				$ar = isset( $a['posts']['recent'] ) ? (int) $a['posts']['recent'] : 0;
				$br = isset( $b['posts']['recent'] ) ? (int) $b['posts']['recent'] : 0;
				if ( $ar !== $br ) {
					return $br <=> $ar;
				}
				$as = isset( $a['score']['avg'] ) ? (int) $a['score']['avg'] : 0;
				$bs = isset( $b['score']['avg'] ) ? (int) $b['score']['avg'] : 0;
				return $bs <=> $as;
			}
		);
		$out = array_slice( $out, 0, $limit );
		if ( isset( $grid_key ) && function_exists( 'newsai_admin_analytics_cache_ttl' ) ) {
			set_transient(
				$grid_key,
				[
					'rows'      => $out,
					'cached_at' => time(),
				],
				newsai_admin_analytics_cache_ttl()
			);
		}
		return $out;
	}
}

if ( ! function_exists( 'newsai_pro_authors_grid_cached_at' ) ) {
	/**
	 * When the author grid transient exists, return when it was built (UTC-based site time).
	 *
	 * @param int $days  Window.
	 * @param int $limit Max authors.
	 * @return int Unix timestamp, or 0 if not cached.
	 */
	function newsai_pro_authors_grid_cached_at( $days = 30, $limit = 50 ) {
		if ( ! function_exists( 'newsai_admin_analytics_cache_stamp' ) ) {
			return 0;
		}
		$days  = max( 1, (int) $days );
		$limit = max( 1, (int) $limit );
		$key   = 'newsai_pro_authors_grid_' . $days . '_' . $limit . '_' . newsai_admin_analytics_cache_stamp();
		$cached = get_transient( $key );
		if ( is_array( $cached ) && ! empty( $cached['cached_at'] ) ) {
			return (int) $cached['cached_at'];
		}
		return 0;
	}
}

// ============================================================
// Dashboard renderer
// ============================================================

if ( ! function_exists( 'newsai_pro_render_author_performance_panel' ) ) {
	/**
	 * Renders the Author Performance panel shell. The body is AJAX-driven so:
	 *   - the 7d/30d/90d toggle refreshes inline (no page reload)
	 *   - each writer card expands/collapses inline (no drill-down navigation)
	 *
	 * @param string $lang UI language ('el' or 'en').
	 * @return void
	 */
	function newsai_pro_render_author_performance_panel( $lang = 'en' ) {
		if ( ! function_exists( 'newsai_is_pro' ) || ! newsai_is_pro() ) {
			return;
		}
		if ( ! current_user_can( 'edit_others_posts' ) ) {
			return;
		}

		// Kept for backward compatibility; gettext picks the actual translation now.
		unset( $lang );
		$days_in = isset( $_GET['author_perf_days'] ) ? (int) $_GET['author_perf_days'] : 30;
		$days    = in_array( $days_in, [ 7, 30, 90 ], true ) ? $days_in : 30;
		$nonce   = wp_create_nonce( 'newsai_pro_author_perf' );
		$ui_lang = function_exists( 'newsai_get_ui_lang' ) ? newsai_get_ui_lang() : 'en';

		echo '<div class="nros-author-perf" id="nros-ap-print-area" data-nonce="' . esc_attr( $nonce ) . '" data-days="' . esc_attr( (string) $days ) . '" data-lang="' . esc_attr( $ui_lang ) . '"'
			. ' data-ajax-url="' . esc_url( admin_url( 'admin-ajax.php' ) ) . '"'
			. ' data-site-name="' . esc_attr( get_bloginfo( 'name' ) ) . '"'
			. ' data-report-title="' . esc_attr__( 'Author Performance Report', 'newsroom-ai-assistant' ) . '"'
			. ' data-export-preparing="' . esc_attr__( 'Preparing PDF export…', 'newsroom-ai-assistant' ) . '"'
			. ' data-export-ready="' . esc_attr__( 'Opening print dialog…', 'newsroom-ai-assistant' ) . '"'
			. ' data-export-error="' . esc_attr__( 'Could not prepare export.', 'newsroom-ai-assistant' ) . '"'
			. ' data-tab-summary="' . esc_attr__( 'Summary', 'newsroom-ai-assistant' ) . '"'
			. ' data-tab-articles="' . esc_attr__( 'Articles', 'newsroom-ai-assistant' ) . '"'
			. ' data-tab-coaching="' . esc_attr__( 'Coaching', 'newsroom-ai-assistant' ) . '"'
			. ' data-ai-loading="' . esc_attr__( 'Building AI desk overview…', 'newsroom-ai-assistant' ) . '"'
			. ' data-ai-error="' . esc_attr__( 'Could not generate overview.', 'newsroom-ai-assistant' ) . '"'
			. ' data-ai-ready="' . ( ( function_exists( 'newsai_ai_is_active' ) && newsai_ai_is_active() ) ? '1' : '0' ) . '">';
		echo '<div class="nros-ap-panel">';
		echo '<header class="nros-ap-panel__header">';
		echo '<div class="nros-ap-panel__title">';
		echo '<span class="nros-ap-panel__icon" aria-hidden="true">📈</span>';
		echo '<div><h3>' . esc_html__( 'Author Performance', 'newsroom-ai-assistant' ) . '</h3>';
		echo '<p class="nros-ap-panel__subtitle">' . esc_html__( 'Detailed metrics and AI coaching insights per author.', 'newsroom-ai-assistant' ) . '</p></div>';
		echo '</div>';
		echo '<div class="nros-ap-panel__header-right">';
		echo '<div class="nros-ap-seg nros-author-perf-range" role="group" aria-label="' . esc_attr__( 'Time window', 'newsroom-ai-assistant' ) . '">';
		foreach ( [ 7 => '7d', 30 => '30d', 90 => '90d' ] as $opt => $label ) {
			$is_on = ( (int) $opt === (int) $days );
			echo '<button type="button" class="nros-ap-seg__btn nros-author-perf-range-btn' . ( $is_on ? ' is-on' : '' ) . '" data-days="' . esc_attr( (string) $opt ) . '">' . esc_html( $label ) . '</button>';
		}
		echo '</div>';
		echo '<div class="nros-ap-panel__actions">';
		$ai_overview_ready = function_exists( 'newsai_ai_is_active' ) && newsai_ai_is_active();
		$is_pro            = function_exists( 'newsai_is_pro' ) && newsai_is_pro();
		if ( $is_pro ) {
			echo '<button type="button" class="button button-primary nros-ap-ai-btn" id="nros-ap-ai-overview-btn"' . ( $ai_overview_ready ? '' : ' disabled' ) . '>';
			echo '<span class="nros-ap-ai-btn__icon" aria-hidden="true">✨</span> ' . esc_html__( 'AI Desk Overview', 'newsroom-ai-assistant' );
			echo '</button>';
		}
		echo '<label class="screen-reader-text" for="nros-ap-export-scope">' . esc_html__( 'PDF export scope', 'newsroom-ai-assistant' ) . '</label>';
		echo '<select id="nros-ap-export-scope" class="nros-ap-export-scope" aria-label="' . esc_attr__( 'PDF export scope', 'newsroom-ai-assistant' ) . '">';
		echo '<option value="10">' . esc_html__( 'Top 10 authors (all tabs)', 'newsroom-ai-assistant' ) . '</option>';
		echo '<option value="all">' . esc_html__( 'All authors (all tabs)', 'newsroom-ai-assistant' ) . '</option>';
		echo '</select>';
		echo '<button type="button" class="button button-secondary" id="nros-ap-export-pdf">' . esc_html__( 'Export PDF', 'newsroom-ai-assistant' ) . '</button>';
		echo '<span id="nros-ap-export-status" class="nros-ap-export-status" aria-live="polite"></span>';
		echo '</div></div></header>';

		if ( $is_pro ) {
			$ai_settings_url = admin_url( 'admin.php?page=newsai-settings&tab=settings' );
			if ( ! $ai_overview_ready ) {
				echo '<div class="nros-ap-ai-overview-bar nros-ap-ai-overview-bar--hint">';
				echo '<span class="nros-ap-ai-overview-hint">';
				echo esc_html__( 'AI Desk Overview requires AI Assist (BYOK) — add an OpenAI, Gemini, or Groq API key.', 'newsroom-ai-assistant' );
				echo ' <a href="' . esc_url( $ai_settings_url ) . '">' . esc_html__( 'Open AI Assist settings', 'newsroom-ai-assistant' ) . '</a>';
				echo '</span></div>';
			}
			echo '<div id="nros-ap-ai-overview-output" class="nros-ap-ai-overview" aria-live="polite" hidden></div>';
		}

		echo '<div class="nros-ap-toolbar">';
		echo '<label class="screen-reader-text" for="nros-ap-search">' . esc_html__( 'Search authors', 'newsroom-ai-assistant' ) . '</label>';
		echo '<div class="nros-ap-search-wrap">';
		echo '<span class="nros-ap-search__icon" aria-hidden="true">🔍</span>';
		echo '<input type="search" id="nros-ap-search" class="nros-ap-search" placeholder="' . esc_attr__( 'Search authors by name or role…', 'newsroom-ai-assistant' ) . '" autocomplete="off">';
		echo '</div>';
		echo '<button type="button" class="button button-secondary nros-ap-filters-toggle" id="nros-ap-filters-toggle" aria-expanded="false" aria-controls="nros-ap-filters">';
		echo '<span aria-hidden="true">⛃</span> ' . esc_html__( 'Filters', 'newsroom-ai-assistant' );
		echo '</button>';
		echo '<div class="nros-ap-filters" id="nros-ap-filters" hidden>';
		echo '<select class="nros-ap-sort" aria-label="' . esc_attr__( 'Sort authors', 'newsroom-ai-assistant' ) . '">';
		echo '<option value="posts">' . esc_html__( 'Posts (high → low)', 'newsroom-ai-assistant' ) . '</option>';
		echo '<option value="clicks">' . esc_html__( 'Clicks (high → low)', 'newsroom-ai-assistant' ) . '</option>';
		echo '<option value="focus">' . esc_html__( 'Focus score (high → low)', 'newsroom-ai-assistant' ) . '</option>';
		echo '<option value="readiness">' . esc_html__( 'AI Readiness (high → low)', 'newsroom-ai-assistant' ) . '</option>';
		echo '<option value="discover">' . esc_html__( 'Discover score (high → low)', 'newsroom-ai-assistant' ) . '</option>';
		echo '<option value="name">' . esc_html__( 'Name (A → Z)', 'newsroom-ai-assistant' ) . '</option>';
		echo '</select>';
		echo '</div>';
		echo '</div>';

		echo '<div class="nros-ap-panel__body nros-author-perf-body">';
		echo newsai_pro_render_author_performance_grid( $days ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo '</div>';

		$gsc_note = function_exists( 'newsai_gsc_data_available' ) && newsai_gsc_data_available()
			? __( 'Editorial scores + Search Console traffic (when synced). Discover Score = readiness; GSC = real clicks.', 'newsroom-ai-assistant' )
			: __( 'Posts, tasks, Focus & Discover readiness. Enable GSC sync under Settings → PRO → Indexing for real traffic.', 'newsroom-ai-assistant' );
		$grid_cached_at = function_exists( 'newsai_pro_authors_grid_cached_at' ) ? newsai_pro_authors_grid_cached_at( $days, 30 ) : 0;
		echo '<p class="nros-ap-panel__foot">' . esc_html( $gsc_note );
		if ( $grid_cached_at > 0 ) {
			echo ' <span class="nros-insights-cache-hint">' . esc_html(
				sprintf(
					/* translators: %s: localized date/time when the grid cache was built */
					__( 'Metrics cached · refreshes daily or after GSC sync (last built %s).', 'newsroom-ai-assistant' ),
					wp_date( 'M j, H:i', $grid_cached_at )
				)
			) . '</span>';
		}
		echo '</p>';
		echo '</div></div>';
	}
}

if ( ! function_exists( 'newsai_pro_render_author_performance_grid' ) ) {
	/**
	 * Returns the HTML for: chief's personal card + grid of author cards. Each card has
	 * a hidden expanded section that the client toggles on demand.
	 *
	 * @param int $days Window in days.
	 * @return string
	 */
	function newsai_pro_render_author_performance_grid( $days ) {
		$days = (int) $days;
		ob_start();

		$me_id = (int) get_current_user_id();
		$rows  = newsai_pro_all_authors_performance( $days, 30 );
		$rows  = array_values( array_filter( $rows, static function ( $r ) use ( $me_id ) {
			return (int) $r['user_id'] !== (int) $me_id;
		} ) );

		$has_any = false;

		echo '<div class="nros-author-perf-list">';
		echo '<div class="nros-ap-list-head" aria-hidden="true">';
		echo '<span class="nros-ap-list-head__author">' . esc_html__( 'Author', 'newsroom-ai-assistant' ) . '</span>';
		echo '<span class="nros-ap-list-head__posts">' . esc_html__( 'Posts', 'newsroom-ai-assistant' ) . '</span>';
		echo '<span class="nros-ap-list-head__focus">' . esc_html__( 'Focus score', 'newsroom-ai-assistant' ) . '</span>';
		echo '<span class="nros-ap-list-head__readiness">' . esc_html__( 'AI Readiness', 'newsroom-ai-assistant' ) . '</span>';
		echo '<span class="nros-ap-list-head__clicks">' . esc_html__( 'Clicks', 'newsroom-ai-assistant' ) . '</span>';
		echo '<span class="nros-ap-list-head__toggle"></span>';
		echo '</div>';

		if ( $me_id > 0 ) {
			$me = newsai_pro_author_performance( $me_id, $days );
			if ( ! empty( $me ) ) {
				echo newsai_pro_render_author_perf_card( $me, $days, true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				$has_any = true;
			}
		}

		if ( empty( $rows ) ) {
			if ( ! $has_any ) {
				echo '<p class="nros-ap-empty nros-ap-empty--list">' . esc_html__( 'No author activity for this window yet.', 'newsroom-ai-assistant' ) . '</p>';
			}
			echo '</div>';
			return ob_get_clean();
		}

		foreach ( $rows as $r ) {
			echo newsai_pro_render_author_perf_card( $r, $days, false ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}
		echo '</div>';

		return ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_pro_render_author_perf_card' ) ) {
	/**
	 * Single author card (collapsed view + hidden expanded details that the client toggles).
	 *
	 * @param array $bundle    Author bundle (see newsai_pro_author_performance()).
	 * @param int   $days      Window in days.
	 * @param bool  $is_self   When true, paint it in the gradient "your own performance" style.
	 * @return string
	 */
	function newsai_pro_render_author_perf_card( $bundle, $days, $is_self = false ) {
		$score_pack = isset( $bundle['score'] ) && is_array( $bundle['score'] ) ? $bundle['score'] : [ 'avg' => 0, 'sample' => 0 ];
		$disc_pack  = isset( $bundle['discover_readiness'] ) && is_array( $bundle['discover_readiness'] ) ? $bundle['discover_readiness'] : [ 'avg' => 0, 'weak_spots' => [], 'sample' => 0 ];
		$score_avg  = (int) ( $score_pack['avg'] ?? 0 );
		$disc_avg   = (int) ( $disc_pack['avg'] ?? 0 );
		$posts_n    = (int) ( $bundle['posts']['recent'] ?? 0 );
		$total_clicks = newsai_pro_author_bundle_total_clicks( $bundle );
		$web_clicks   = newsai_pro_author_bundle_web_clicks( $bundle );
		$readiness_pack = isset( $bundle['readiness'] ) && is_array( $bundle['readiness'] ) ? $bundle['readiness'] : [ 'avg' => 0, 'sample' => 0 ];
		$readiness_avg  = (int) ( $readiness_pack['avg'] ?? 0 );
		$readiness_m    = newsai_pro_format_editorial_metric( $readiness_avg, (int) ( $readiness_pack['sample'] ?? 0 ), $posts_n, 'readiness' );
		$trend_icon = $bundle['posts']['trend'] === 'up' ? '↗' : ( $bundle['posts']['trend'] === 'down' ? '↘' : '→' );
		$trend_cls  = $bundle['posts']['trend'] === 'up' ? 'is-good' : ( $bundle['posts']['trend'] === 'down' ? 'is-bad' : 'is-neutral' );
		$name_key   = function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $bundle['name'] ) : strtolower( (string) $bundle['name'] );
		$role_key   = function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) ( $bundle['job_title'] ?? '' ) ) : strtolower( (string) ( $bundle['job_title'] ?? '' ) );
		$search_key = trim( $name_key . ' ' . $role_key );
		$clicks_display = newsai_pro_format_compact_clicks( $total_clicks > 0 ? $total_clicks : $web_clicks );

		ob_start();
		?>
		<div class="nros-author-perf-card<?php echo $is_self ? ' is-self' : ''; ?>"
			data-user-id="<?php echo (int) $bundle['user_id']; ?>"
			data-author-name="<?php echo esc_attr( $search_key !== '' ? $search_key : $name_key ); ?>"
			data-posts="<?php echo (int) $posts_n; ?>"
			data-focus="<?php echo (int) $score_avg; ?>"
			data-readiness="<?php echo (int) $readiness_avg; ?>"
			data-discover="<?php echo (int) $disc_avg; ?>"
			data-clicks="<?php echo (int) max( $total_clicks, $web_clicks ); ?>"
			data-web="<?php echo (int) $web_clicks; ?>">
			<div class="nros-ap-row">
				<div class="nros-ap-row__author">
					<img class="nros-ap-card__avatar" src="<?php echo esc_url( $bundle['avatar'] ); ?>" alt="" width="44" height="44" loading="lazy">
					<div class="nros-ap-card__meta">
						<?php if ( $is_self ) : ?>
							<span class="nros-ap-card__badge"><?php echo esc_html__( 'You', 'newsroom-ai-assistant' ); ?></span>
						<?php endif; ?>
						<span class="nros-ap-card__name"><?php echo esc_html( $bundle['name'] ); ?></span>
						<?php if ( ! empty( $bundle['job_title'] ) ) : ?>
							<span class="nros-ap-card__role"><?php echo esc_html( $bundle['job_title'] ); ?></span>
						<?php endif; ?>
						<?php
						$sparks_html = function_exists( 'newsai_pro_render_author_sparklines_7d' )
							? newsai_pro_render_author_sparklines_7d( $bundle )
							: '';
						if ( $sparks_html !== '' ) {
							echo $sparks_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
						}
						?>
					</div>
				</div>
				<div class="nros-ap-row__stat nros-ap-row__stat--posts">
					<span class="nros-ap-row__stat-lbl"><?php echo esc_html__( 'Posts', 'newsroom-ai-assistant' ); ?></span>
					<strong class="nros-ap-row__stat-val"><?php echo (int) $posts_n; ?></strong>
				</div>
				<?php
				$focus_m = newsai_pro_format_editorial_metric( $score_avg, (int) ( $score_pack['sample'] ?? 0 ), $posts_n, 'focus' );
				echo newsai_pro_render_metric_progress_column( __( 'Focus score', 'newsroom-ai-assistant' ), $focus_m, 'focus' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo newsai_pro_render_metric_progress_column( __( 'AI Readiness', 'newsroom-ai-assistant' ), $readiness_m, 'readiness' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				?>
				<div class="nros-ap-row__stat nros-ap-row__stat--clicks">
					<span class="nros-ap-row__stat-lbl"><?php echo esc_html__( 'Clicks', 'newsroom-ai-assistant' ); ?></span>
					<strong class="nros-ap-row__stat-val <?php echo esc_attr( $total_clicks > 0 || $web_clicks > 0 ? 'is-good' : 'is-neutral' ); ?>">
						<?php echo esc_html( $clicks_display ); ?>
						<span class="nros-ap-row__trend <?php echo esc_attr( $trend_cls ); ?>" aria-hidden="true"><?php echo esc_html( $trend_icon ); ?></span>
					</strong>
				</div>
				<button type="button" class="nros-author-perf-toggle nros-ap-row__chev" aria-expanded="false" aria-label="<?php echo esc_attr__( 'Expand author details', 'newsroom-ai-assistant' ); ?>">
					<span class="nros-author-perf-toggle-label" aria-hidden="true">▾</span>
				</button>
			</div>
			<div class="nros-author-perf-expand" data-loaded="0">
				<div class="nros-author-perf-expand-body">
					<div class="nros-ap-loading">
						<span class="spinner is-active" style="float:none;margin:0;"></span>
						<?php echo esc_html__( 'Loading details…', 'newsroom-ai-assistant' ); ?>
					</div>
				</div>
			</div>
		</div>
		<?php
		return ob_get_clean();
	}
}

if ( ! function_exists( 'newsai_pro_render_author_perf_expanded' ) ) {
	/**
	 * Returns the expanded HTML (articles + categories + Phase 2 placeholder) for a card.
	 *
	 * @param int $user_id User ID.
	 * @param int $days    Window in days.
	 * @return string
	 */
	function newsai_pro_render_author_perf_expanded( $user_id, $days ) {
		$user_id = (int) $user_id;
		$bundle  = newsai_pro_author_performance( $user_id, (int) $days );
		if ( empty( $bundle ) ) {
			return '<p class="nros-ap-note is-muted">' . esc_html__( 'No data found.', 'newsroom-ai-assistant' ) . '</p>';
		}
		ob_start();

		$score_pack = isset( $bundle['score'] ) && is_array( $bundle['score'] ) ? $bundle['score'] : [ 'avg' => 0, 'sample' => 0 ];
		$disc_pack  = isset( $bundle['discover_readiness'] ) && is_array( $bundle['discover_readiness'] ) ? $bundle['discover_readiness'] : [];
		$disc_avg   = (int) ( $disc_pack['avg'] ?? 0 );
		$posts_n    = (int) ( $bundle['posts']['recent'] ?? 0 );
		$weak_spots = isset( $disc_pack['weak_spots'] ) && is_array( $disc_pack['weak_spots'] ) ? $disc_pack['weak_spots'] : [];
		$focus_m    = newsai_pro_format_editorial_metric( (int) ( $score_pack['avg'] ?? 0 ), (int) ( $score_pack['sample'] ?? 0 ), $posts_n, 'focus' );
		$disc_m     = newsai_pro_format_editorial_metric( $disc_avg, (int) ( $disc_pack['sample'] ?? 0 ), $posts_n, 'discover' );
		$top_gsc    = isset( $bundle['articles_top_gsc'] ) && is_array( $bundle['articles_top_gsc'] ) ? $bundle['articles_top_gsc'] : [];
		$gsc_on     = function_exists( 'newsai_gsc_data_available' ) && newsai_gsc_data_available();
		echo '<div class="nros-ap-expand nros-ap-tabs">';
		echo '<div class="nros-ap-tabs__nav" role="tablist">';
		echo '<button type="button" class="nros-ap-tabs__btn is-on" role="tab" data-ap-tab="summary" aria-selected="true">' . esc_html__( 'Summary', 'newsroom-ai-assistant' ) . '</button>';
		echo '<button type="button" class="nros-ap-tabs__btn" role="tab" data-ap-tab="articles" aria-selected="false">' . esc_html__( 'Articles', 'newsroom-ai-assistant' ) . '</button>';
		echo '<button type="button" class="nros-ap-tabs__btn" role="tab" data-ap-tab="coaching" aria-selected="false">' . esc_html__( 'Coaching', 'newsroom-ai-assistant' ) . '</button>';
		echo '</div>';

		// —— Summary tab ——
		echo '<div class="nros-ap-tab-panel is-active" data-ap-panel="summary" role="tabpanel">';
		echo '<div class="nros-ap-split">';
		echo '<div class="nros-ap-split__col">';
		echo '<section class="nros-ap-section">';
		echo '<h4 class="nros-ap-section__title">✏️ ' . esc_html__( 'Editorial quality', 'newsroom-ai-assistant' ) . '</h4>';
		echo '<p class="nros-ap-section__desc">' . esc_html__( 'Scores from the Newsroom OS editor checklist — not Google Search Console.', 'newsroom-ai-assistant' ) . '</p>';
		echo '<div class="nros-ap-statline">';
		echo '<span class="nros-ap-statline__item"><span class="nros-ap-statline__lbl">' . esc_html__( 'Avg Focus', 'newsroom-ai-assistant' ) . '</span> ';
		$focus_show = $focus_m['pending'] ? (string) $focus_m['display_long'] : ( is_numeric( $focus_m['display'] ) ? (int) $focus_m['display'] . '%' : (string) $focus_m['display'] );
		$disc_show  = $disc_m['pending'] ? (string) $disc_m['display_long'] : ( is_numeric( $disc_m['display'] ) ? (int) $disc_m['display'] . '%' : (string) $disc_m['display'] );
		echo '<strong class="' . esc_attr( $focus_m['class'] ) . '" title="' . esc_attr( $focus_m['title'] ) . '">' . esc_html( $focus_show ) . '</strong></span>';
		echo '<span class="nros-ap-statline__item"><span class="nros-ap-statline__lbl">' . esc_html__( 'Avg Discover', 'newsroom-ai-assistant' ) . '</span> ';
		echo '<strong class="' . esc_attr( $disc_m['class'] ) . '" title="' . esc_attr( $disc_m['title'] ) . '">' . esc_html( $disc_show ) . '</strong></span>';
		echo '</div>';
		if ( ! empty( $disc_pack['sample'] ) ) {
			echo '<p class="nros-ap-note is-muted">' . esc_html( sprintf(
				/* translators: %d: number of posts with a saved Discover score */
				__( '%d posts with editorial scores in this window.', 'newsroom-ai-assistant' ),
				(int) $disc_pack['sample']
			) ) . '</p>';
		}
		echo '</section>';
		if ( ! empty( $bundle['categories'] ) ) {
			echo '<section class="nros-ap-section">';
			echo '<h4 class="nros-ap-section__title">🏷️ ' . esc_html__( 'Categories', 'newsroom-ai-assistant' ) . '</h4>';
			echo '<div class="nros-ap-tags">';
			foreach ( $bundle['categories'] as $cat ) {
				echo '<span class="nros-ap-tag">' . esc_html( $cat['name'] . ' · ' . (int) $cat['count'] ) . '</span>';
			}
			echo '</div></section>';
		}
		echo '</div>';

		echo '<div class="nros-ap-split__col">';
		if ( function_exists( 'newsai_pro_render_gsc_traffic_summary' ) ) {
			echo '<section class="nros-ap-section nros-ap-section--gsc">';
			echo '<h4 class="nros-ap-section__title">📊 ' . esc_html__( 'Search Console totals', 'newsroom-ai-assistant' ) . '</h4>';
			$gsc_html = newsai_pro_render_gsc_traffic_summary(
				isset( $bundle['discover'] ) ? $bundle['discover'] : null,
				isset( $bundle['gnews'] ) ? $bundle['gnews'] : null,
				isset( $bundle['web'] ) ? $bundle['web'] : null,
				false,
				$gsc_on
			);
			if ( $gsc_html !== '' ) {
				echo $gsc_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			} elseif ( $gsc_on ) {
				echo '<p class="nros-ap-callout">' . esc_html__( 'GSC sync is on but this author has no matched traffic yet.', 'newsroom-ai-assistant' ) . '</p>';
			} else {
				echo '<p class="nros-ap-callout">' . esc_html__( 'Enable GSC sync under Settings → PRO → Indexing.', 'newsroom-ai-assistant' ) . '</p>';
			}
			echo '</section>';
		}
		echo '</div></div></div>';

		// —— Articles tab ——
		echo '<div class="nros-ap-tab-panel" data-ap-panel="articles" role="tabpanel">';
		echo '<section class="nros-ap-section">';
		echo '<h4 class="nros-ap-section__title">📰 ' . esc_html__( 'Latest articles', 'newsroom-ai-assistant' ) . '</h4>';
		if ( empty( $bundle['articles'] ) ) {
			echo '<p class="nros-ap-empty">' . esc_html__( 'No published articles in the selected window.', 'newsroom-ai-assistant' ) . '</p>';
		} else {
			echo '<div class="nros-ap-articles">';
			foreach ( $bundle['articles'] as $a ) {
				echo newsai_pro_render_author_article_row( $a, true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			}
			echo '</div>';
		}
		echo '</section>';

		if ( $gsc_on ) {
			echo '<section class="nros-ap-section">';
			echo '<h4 class="nros-ap-section__title">📈 ' . esc_html__( 'Top articles by Search Console clicks', 'newsroom-ai-assistant' ) . '</h4>';
			if ( empty( $top_gsc ) ) {
				echo '<p class="nros-ap-empty">' . esc_html__( 'No post-level GSC clicks for this author in the selected window yet.', 'newsroom-ai-assistant' ) . '</p>';
			} else {
				echo '<p class="nros-ap-section__desc">' . esc_html__( 'Ranked by total clicks (Discover + News + Web).', 'newsroom-ai-assistant' ) . '</p>';
				echo '<ol class="nros-ap-rank-list">';
				$rank = 0;
				foreach ( $top_gsc as $a ) {
					++$rank;
					echo '<li class="nros-ap-rank-item">';
					echo '<span class="nros-ap-rank-badge">#' . (int) $rank . ' · ' . esc_html( sprintf( __( '%s total clicks', 'newsroom-ai-assistant' ), number_format_i18n( (int) ( $a['gsc_clicks_total'] ?? 0 ) ) ) ) . '</span>';
					echo newsai_pro_render_author_article_row( $a, true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
					echo '</li>';
				}
				echo '</ol>';
			}
			echo '</section>';
		}
		echo '</div>';

		// —— Coaching tab ——
		echo '<div class="nros-ap-tab-panel" data-ap-panel="coaching" role="tabpanel">';
		if ( ! empty( $weak_spots ) && function_exists( 'newsai_pro_render_discover_weak_spots' ) ) {
			echo newsai_pro_render_discover_weak_spots( $weak_spots, $user_id, $bundle, true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		} else {
			echo '<p class="nros-ap-note is-success">' . esc_html__( 'No coaching gaps detected for this author in the selected window.', 'newsroom-ai-assistant' ) . '</p>';
		}
		echo '</div>';

		echo '</div>';

		return ob_get_clean();
	}
}

// ============================================================
// AI desk overview (Pro BYOK)
// ============================================================

if ( ! function_exists( 'newsai_pro_desk_writer_user_ids' ) ) {
	/**
	 * User IDs for everyone who can publish posts on the desk.
	 *
	 * @return int[]
	 */
	function newsai_pro_desk_writer_user_ids() {
		$users = get_users(
			[
				'capability' => 'edit_posts',
				'fields'     => 'ID',
				'number'     => 100,
				'orderby'    => 'display_name',
				'order'      => 'ASC',
			]
		);
		return array_map( 'intval', (array) $users );
	}
}

if ( ! function_exists( 'newsai_pro_build_author_desk_payload' ) ) {
	/**
	 * Compact metrics payload for AI desk overview.
	 *
	 * @param int $days Window in days.
	 * @return array{window_days:int, gsc_connected:bool, authors:array<int, array>}
	 */
	function newsai_pro_build_author_desk_payload( $days = 30 ) {
		$days = in_array( (int) $days, [ 7, 30, 90 ], true ) ? (int) $days : 30;
		$gsc  = function_exists( 'newsai_gsc_data_available' ) && newsai_gsc_data_available();
		$out  = [];

		foreach ( newsai_pro_desk_writer_user_ids() as $uid ) {
			$bundle = newsai_pro_author_performance( $uid, $days );
			if ( empty( $bundle ) ) {
				continue;
			}
			$posts_n  = (int) ( $bundle['posts']['recent'] ?? 0 );
			$score    = isset( $bundle['score'] ) && is_array( $bundle['score'] ) ? $bundle['score'] : [];
			$disc     = isset( $bundle['discover_readiness'] ) && is_array( $bundle['discover_readiness'] ) ? $bundle['discover_readiness'] : [];
			$web      = newsai_pro_author_bundle_web_clicks( $bundle );
			$disc_cl  = ( is_array( $bundle['discover'] ?? null ) ) ? (int) ( $bundle['discover']['clicks'] ?? 0 ) : 0;
			$gn_cl    = ( is_array( $bundle['gnews'] ?? null ) ) ? (int) ( $bundle['gnews']['clicks'] ?? 0 ) : 0;
			$tasks    = isset( $bundle['tasks'] ) && is_array( $bundle['tasks'] ) ? $bundle['tasks'] : [];
			$weak     = isset( $disc['weak_spots'] ) && is_array( $disc['weak_spots'] ) ? array_slice( $disc['weak_spots'], 0, 3 ) : [];
			$top_cat  = '';
			if ( ! empty( $bundle['categories'][0]['name'] ) ) {
				$top_cat = sanitize_text_field( (string) $bundle['categories'][0]['name'] );
			}

			$out[] = [
				'name'             => sanitize_text_field( (string) $bundle['name'] ),
				'posts'            => $posts_n,
				'posts_prior'      => (int) ( $bundle['posts']['prior'] ?? 0 ),
				'posts_trend'      => sanitize_key( (string) ( $bundle['posts']['trend'] ?? 'flat' ) ),
				'published'        => $posts_n > 0,
				'focus_avg'        => (int) ( $score['avg'] ?? 0 ),
				'focus_sample'     => (int) ( $score['sample'] ?? 0 ),
				'discover_avg'     => (int) ( $disc['avg'] ?? 0 ),
				'weak_spots'       => array_values( array_map( 'sanitize_text_field', $weak ) ),
				'web_clicks'       => $web,
				'discover_clicks'  => $disc_cl,
				'gnews_clicks'     => $gn_cl,
				'tasks_open'       => (int) ( $tasks['open'] ?? 0 ),
				'tasks_overdue'    => (int) ( $tasks['overdue'] ?? 0 ),
				'tasks_done'       => (int) ( $tasks['done'] ?? 0 ),
				'top_category'     => $top_cat,
			];
		}

		usort(
			$out,
			static function ( $a, $b ) {
				$posts_cmp = (int) ( $b['posts'] ?? 0 ) <=> (int) ( $a['posts'] ?? 0 );
				if ( $posts_cmp !== 0 ) {
					return $posts_cmp;
				}
				return strcmp( (string) ( $a['name'] ?? '' ), (string) ( $b['name'] ?? '' ) );
			}
		);

		$total_authors = count( $out );
		$max_authors   = (int) apply_filters( 'newsai_ai_author_desk_max_authors', 24 );
		$max_authors   = max( 8, min( 40, $max_authors ) );
		if ( $total_authors > $max_authors ) {
			$out = array_slice( $out, 0, $max_authors );
		}

		return [
			'window_days'       => $days,
			'gsc_connected'     => (bool) $gsc,
			'author_count'      => count( $out ),
			'total_authors'     => $total_authors,
			'authors_truncated' => max( 0, $total_authors - count( $out ) ),
			'authors'           => $out,
		];
	}
}

if ( ! function_exists( 'newsai_pro_desk_overview_action_text' ) ) {
	/**
	 * Normalize desk_actions item (string or object) to display text.
	 */
	function newsai_pro_desk_overview_action_text( $item ) {
		if ( is_string( $item ) ) {
			return sanitize_text_field( $item );
		}
		if ( ! is_array( $item ) ) {
			return '';
		}
		foreach ( [ 'action', 'text', 'description', 'title', 'recommendation', 'detail' ] as $key ) {
			if ( ! empty( $item[ $key ] ) && is_scalar( $item[ $key ] ) ) {
				return sanitize_text_field( (string) $item[ $key ] );
			}
		}
		$parts = [];
		foreach ( $item as $key => $val ) {
			if ( ! is_scalar( $val ) || (string) $val === '' ) {
				continue;
			}
			if ( in_array( (string) $key, [ 'name', 'author', 'target' ], true ) ) {
				continue;
			}
			$parts[] = sanitize_text_field( (string) $val );
		}
		return implode( ' — ', array_slice( $parts, 0, 3 ) );
	}
}

if ( ! function_exists( 'newsai_pro_normalize_desk_overview_data' ) ) {
	/**
	 * Clean AI JSON before render (fix Array casts, dedupe sections, enforce metrics rules).
	 *
	 * @param array $data    Parsed AI response.
	 * @param array $payload Author metrics from newsai_pro_build_author_desk_payload().
	 * @return array<string, mixed>
	 */
	function newsai_pro_normalize_desk_overview_data( $data, $payload ) {
		if ( ! is_array( $data ) ) {
			return [];
		}

		$by_name = [];
		foreach ( (array) ( $payload['authors'] ?? [] ) as $author ) {
			if ( ! is_array( $author ) ) {
				continue;
			}
			$key = mb_strtolower( trim( (string) ( $author['name'] ?? '' ) ), 'UTF-8' );
			if ( $key !== '' ) {
				$by_name[ $key ] = $author;
			}
		}

		$normalize_people = static function ( $items ) {
			$out = [];
			if ( ! is_array( $items ) ) {
				return $out;
			}
			foreach ( $items as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$name = sanitize_text_field( (string) ( $row['name'] ?? '' ) );
				if ( $name === '' ) {
					continue;
				}
				$why    = sanitize_text_field( (string) ( $row['why'] ?? '' ) );
				$issue  = sanitize_text_field( (string) ( $row['issue'] ?? '' ) );
				$action = sanitize_text_field( (string) ( $row['action'] ?? '' ) );
				if ( $why === '' && $issue !== '' ) {
					$why = $issue;
				} elseif ( $why !== '' && $issue !== '' && stripos( $why, $issue ) === false ) {
					$why = $issue . ' — ' . $why;
				}
				$out[] = [
					'name'   => $name,
					'why'    => $why,
					'action' => $action,
				];
			}
			return $out;
		};

		$data['top_performers']  = $normalize_people( $data['top_performers'] ?? [] );
		$data['needs_attention'] = $normalize_people( $data['needs_attention'] ?? [] );
		$data['silent_authors']  = $normalize_people( $data['silent_authors'] ?? [] );

		$seen_silent = [];
		foreach ( $data['silent_authors'] as $row ) {
			$seen_silent[ mb_strtolower( $row['name'], 'UTF-8' ) ] = true;
		}

		$filtered_attention = [];
		foreach ( $data['needs_attention'] as $row ) {
			$key = mb_strtolower( $row['name'], 'UTF-8' );
			$metrics = $by_name[ $key ] ?? null;
			if ( is_array( $metrics ) && (int) ( $metrics['posts'] ?? 0 ) < 1 ) {
				if ( ! isset( $seen_silent[ $key ] ) ) {
					$data['silent_authors'][] = [
						'name'   => $row['name'],
						'why'    => $row['why'] !== '' ? $row['why'] : __( '0 posts in this window.', 'newsroom-ai-assistant' ),
						'action' => '',
					];
					$seen_silent[ $key ] = true;
				}
				continue;
			}
			if ( $row['why'] === '' && is_array( $metrics ) ) {
				$row['why'] = sprintf(
					/* translators: 1: posts, 2: focus avg, 3: web clicks */
					__( '%1$d posts · focus %2$d · web %3$d clicks', 'newsroom-ai-assistant' ),
					(int) ( $metrics['posts'] ?? 0 ),
					(int) ( $metrics['focus_avg'] ?? 0 ),
					(int) ( $metrics['web_clicks'] ?? 0 )
				);
			}
			$filtered_attention[] = $row;
		}
		$data['needs_attention'] = $filtered_attention;

		$actions = [];
		foreach ( (array) ( $data['desk_actions'] ?? [] ) as $item ) {
			$text = newsai_pro_desk_overview_action_text( $item );
			if ( $text !== '' ) {
				$actions[] = $text;
			}
		}
		$data['desk_actions'] = array_slice( array_values( array_unique( $actions ) ), 0, 5 );

		$window = (int) ( $payload['window_days'] ?? 30 );
		foreach ( $by_name as $key => $metrics ) {
			if ( (int) ( $metrics['posts'] ?? 0 ) > 0 || isset( $seen_silent[ $key ] ) ) {
				continue;
			}
			$name = sanitize_text_field( (string) ( $metrics['name'] ?? '' ) );
			if ( $name === '' ) {
				continue;
			}
			$data['silent_authors'][] = [
				'name'   => $name,
				'why'    => sprintf(
					/* translators: %d: window days */
					__( '0 posts in the last %d days.', 'newsroom-ai-assistant' ),
					$window
				),
				'action' => '',
			];
			$seen_silent[ $key ] = true;
		}
		$data['silent_authors'] = array_slice( $data['silent_authors'], 0, 12 );

		return $data;
	}
}

if ( ! function_exists( 'newsai_pro_render_author_desk_overview_html' ) ) {
	/**
	 * Render structured AI desk overview as HTML.
	 *
	 * @param array $data Parsed AI JSON.
	 * @param bool  $cached Whether response came from AI cache.
	 * @return string
	 */
	function newsai_pro_render_author_desk_overview_html( $data, $cached = false, $provider = '' ) {
		if ( ! is_array( $data ) ) {
			return '';
		}

		$headline = sanitize_text_field( (string) ( $data['headline'] ?? __( 'Desk overview', 'newsroom-ai-assistant' ) ) );
		$summary  = sanitize_textarea_field( (string) ( $data['summary'] ?? '' ) );

		$html = '<div class="nros-ap-ai-overview__inner">';
		$html .= '<h4 class="nros-ap-ai-overview__title">🧠 ' . esc_html( $headline ) . '</h4>';
		if ( $summary !== '' ) {
			$html .= '<p class="nros-ap-ai-overview__summary">' . esc_html( $summary ) . '</p>';
		}

		$render_people = static function ( $title, $items, $class ) {
			if ( empty( $items ) || ! is_array( $items ) ) {
				return '';
			}
			$out = '<div class="nros-ap-ai-overview__block ' . esc_attr( $class ) . '">';
			$out .= '<strong class="nros-ap-ai-overview__block-title">' . esc_html( $title ) . '</strong><ul class="nros-ap-ai-overview__list">';
			foreach ( $items as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$name = sanitize_text_field( (string) ( $row['name'] ?? '' ) );
				if ( $name === '' ) {
					continue;
				}
				$why = sanitize_text_field( (string) ( $row['why'] ?? $row['issue'] ?? '' ) );
				$action = sanitize_text_field( (string) ( $row['action'] ?? '' ) );
				$out .= '<li><span class="nros-ap-ai-overview__name">' . esc_html( $name ) . '</span>';
				if ( $why !== '' ) {
					$out .= ' — <span class="nros-ap-ai-overview__why">' . esc_html( $why ) . '</span>';
				}
				if ( $action !== '' ) {
					$out .= ' <em class="nros-ap-ai-overview__action">(' . esc_html( $action ) . ')</em>';
				}
				$out .= '</li>';
			}
			$out .= '</ul></div>';
			return $out;
		};

		$html .= $render_people( __( 'Top performers', 'newsroom-ai-assistant' ), $data['top_performers'] ?? [], 'is-top' );
		$html .= $render_people( __( 'Needs attention', 'newsroom-ai-assistant' ), $data['needs_attention'] ?? [], 'is-warn' );
		$html .= $render_people( __( 'Did not publish', 'newsroom-ai-assistant' ), $data['silent_authors'] ?? [], 'is-silent' );

		$actions = isset( $data['desk_actions'] ) && is_array( $data['desk_actions'] ) ? array_slice( $data['desk_actions'], 0, 5 ) : [];
		if ( ! empty( $actions ) ) {
			$html .= '<div class="nros-ap-ai-overview__block is-actions"><strong class="nros-ap-ai-overview__block-title">' . esc_html__( 'Suggested desk actions', 'newsroom-ai-assistant' ) . '</strong><ul class="nros-ap-ai-overview__list">';
			foreach ( $actions as $item ) {
				$text = function_exists( 'newsai_pro_desk_overview_action_text' )
					? newsai_pro_desk_overview_action_text( $item )
					: sanitize_text_field( is_string( $item ) ? $item : '' );
				if ( $text !== '' ) {
					$html .= '<li>' . esc_html( $text ) . '</li>';
				}
			}
			$html .= '</ul></div>';
		}

		if ( $cached ) {
			$html .= '<p class="nros-ap-ai-overview__cache">' . esc_html__( 'Served from AI cache.', 'newsroom-ai-assistant' ) . '</p>';
		}
		$provider = sanitize_key( (string) $provider );
		if ( $provider !== '' && function_exists( 'newsai_ai_provider_label' ) ) {
			$html .= '<p class="nros-ap-ai-overview__cache">' . esc_html(
				sprintf(
					/* translators: %s: AI provider label */
					__( 'Generated via %s.', 'newsroom-ai-assistant' ),
					newsai_ai_provider_label( $provider )
				)
			) . '</p>';
		}
		$html .= '</div>';
		return $html;
	}
}

if ( ! function_exists( 'newsai_ai_author_desk_overview' ) ) {
	/**
	 * AI narrative desk overview for Author Performance.
	 *
	 * @param int $days Window in days.
	 * @return array{ok:bool, html:string, error:string, cached:bool}
	 */
	function newsai_ai_author_desk_overview( $days = 30 ) {
		if ( ! function_exists( 'newsai_ai_call' ) || ! function_exists( 'newsai_ai_is_active' ) || ! newsai_ai_is_active() ) {
			return [
				'ok'    => false,
				'html'  => '',
				'error' => 'ai_not_configured',
				'cached' => false,
			];
		}

		$payload = newsai_pro_build_author_desk_payload( $days );
		if ( empty( $payload['authors'] ) ) {
			return [
				'ok'    => false,
				'html'  => '',
				'error' => 'no_authors',
				'cached' => false,
			];
		}

		$lang_rule = function_exists( 'newsai_ai_language_directive' ) ? newsai_ai_language_directive() : '';
		$language  = function_exists( 'newsai_ai_lang_label' ) ? newsai_ai_lang_label() : 'English';

		$system = 'You are a newsroom chief editor reviewing writer performance for an editorial stand-up. '
			. 'Use ONLY the JSON metrics supplied — do not invent traffic numbers or article titles. '
			. 'Output language: ' . $language . '. ' . $lang_rule . ' '
			. newsai_ai_json_output_rules(
				'{"headline":"title","summary":"2-3 sentences","top_performers":[{"name":"Author","why":"reason with number"}],"needs_attention":[{"name":"Author","why":"reason","action":"step"}],"silent_authors":[{"name":"Author","why":"reason"}],"desk_actions":["concrete action string"]}'
			)
			. ' Rules: top_performers max 3; needs_attention max 5 (ONLY authors with posts>=1 who underperform); silent_authors max 8 (posts=0); desk_actions max 4 plain strings. '
			. 'Every "why" MUST include at least one number from the payload. Never put zero-post authors in needs_attention or top_performers. '
			. 'If gsc_connected is false, ignore web_clicks=0.';

		$payload_json = wp_json_encode( $payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
		$user         = 'Build a desk overview for the last ' . (int) $payload['window_days'] . " days.\n\nMetrics JSON:\n" . $payload_json;
		$seed         = 'author-desk-overview-v3|' . (int) $days . '|' . md5( $payload_json . '|' . gmdate( 'Y-m-d-H' ) );
		$ai           = newsai_ai_call( $system, $user, $seed );

		if ( empty( $ai['ok'] ) || empty( $ai['data'] ) || ! is_array( $ai['data'] ) ) {
			$err = (string) ( $ai['error'] ?? 'ai_error' );
			if ( function_exists( 'newsai_ai_format_error_for_ui' ) ) {
				$err = newsai_ai_format_error_for_ui( $err );
			}
			return [
				'ok'     => false,
				'html'   => '',
				'error'  => $err,
				'cached' => ! empty( $ai['cached'] ),
				'provider' => (string) ( $ai['provider'] ?? '' ),
			];
		}

		$normalized = newsai_pro_normalize_desk_overview_data( $ai['data'], $payload );

		return [
			'ok'       => true,
			'html'     => newsai_pro_render_author_desk_overview_html(
				$normalized,
				! empty( $ai['cached'] ),
				(string) ( $ai['provider'] ?? '' )
			),
			'error'    => '',
			'cached'   => ! empty( $ai['cached'] ),
			'provider' => (string) ( $ai['provider'] ?? '' ),
		];
	}
}

if ( ! function_exists( 'newsai_ajax_ai_author_desk_overview' ) ) {
	/**
	 * AJAX: AI desk overview for Author Performance tab.
	 */
	function newsai_ajax_ai_author_desk_overview() {
		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;
		$res     = newsai_ai_author_desk_overview( $days );

		if ( empty( $res['ok'] ) ) {
			wp_send_json_error( [ 'message' => (string) ( $res['error'] ?? 'ai_error' ) ], 400 );
		}

		wp_send_json_success(
			[
				'html'   => $res['html'],
				'cached' => ! empty( $res['cached'] ),
				'days'   => $days,
			]
		);
	}
}
add_action( 'wp_ajax_newsai_ai_author_desk_overview', 'newsai_ajax_ai_author_desk_overview' );

// ============================================================
// AJAX endpoints (live refresh + inline expand)
// ============================================================

if ( ! function_exists( 'newsai_pro_ajax_author_perf_grid' ) ) {
	/**
	 * AJAX: refresh the grid for a different time window without a page reload.
	 */
	function newsai_pro_ajax_author_perf_grid() {
		check_ajax_referer( 'newsai_pro_author_perf', 'nonce' );
		if ( ! current_user_can( 'edit_others_posts' ) || ! function_exists( 'newsai_is_pro' ) || ! newsai_is_pro() ) {
			wp_send_json_error( [ 'message' => 'forbidden' ], 403 );
		}
		$days_in = isset( $_POST['days'] ) ? (int) $_POST['days'] : 30;
		$days    = in_array( $days_in, [ 7, 30, 90 ], true ) ? $days_in : 30;
		wp_send_json_success(
			[
				'html' => newsai_pro_render_author_performance_grid( $days ),
				'days' => $days,
			]
		);
	}
}
add_action( 'wp_ajax_newsai_pro_author_perf_grid', 'newsai_pro_ajax_author_perf_grid' );

if ( ! function_exists( 'newsai_pro_ajax_author_perf_expanded' ) ) {
	/**
	 * AJAX: return the expanded details (articles list + categories + Phase 2 slot)
	 * for a single author. Used by the per-card inline toggle.
	 */
	function newsai_pro_ajax_author_perf_expanded() {
		check_ajax_referer( 'newsai_pro_author_perf', 'nonce' );
		if ( ! current_user_can( 'edit_others_posts' ) || ! function_exists( 'newsai_is_pro' ) || ! newsai_is_pro() ) {
			wp_send_json_error( [ 'message' => 'forbidden' ], 403 );
		}
		$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
		$days_in = isset( $_POST['days'] ) ? (int) $_POST['days'] : 30;
		$days    = in_array( $days_in, [ 7, 30, 90 ], true ) ? $days_in : 30;
		if ( $user_id < 1 ) {
			wp_send_json_error( [ 'message' => 'invalid_user' ], 400 );
		}
		wp_send_json_success(
			[
				'html' => newsai_pro_render_author_perf_expanded( $user_id, $days ),
			]
		);
	}
}
add_action( 'wp_ajax_newsai_pro_author_perf_expanded', 'newsai_pro_ajax_author_perf_expanded' );

