<?php

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

final class IComm_SM_DB {

	public static function table_daily(): string {
		global $wpdb;
		return $wpdb->prefix . 'icomm_sm_daily';
	}

	public static function table_queries(): string {
		global $wpdb;
		return $wpdb->prefix . 'icomm_sm_queries';
	}

	public static function table_pages(): string {
		global $wpdb;
		return $wpdb->prefix . 'icomm_sm_pages';
	}

	public static function table_query_pages(): string {
		global $wpdb;
		return $wpdb->prefix . 'icomm_sm_query_pages';
	}

	public static function table_ga4_breakdown(): string {
		global $wpdb;
		return $wpdb->prefix . 'icomm_sm_ga4_breakdown';
	}

	/** Number of stored GSC query rows (any date). */
	public static function queries_row_count(): int {
		global $wpdb;
		$table = self::table_queries();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$n = $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
		return max(0, (int) $n);
	}

	/** Number of stored GSC page rows (any date). */
	public static function pages_row_count(): int {
		global $wpdb;
		$table = self::table_pages();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$n = $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
		return max(0, (int) $n);
	}

	/** Number of stored GSC query×page mapping rows (any date). */
	public static function query_pages_row_count(): int {
		global $wpdb;
		$table = self::table_query_pages();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$n = $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
		return max(0, (int) $n);
	}

	public static function maybe_upgrade(): void {
		$ver = (int) get_option('icomm_sm_db_version', 0);
		if ($ver < (int) ICOMM_SM_DB_VERSION) {
			try {
				self::install();
			} catch (Throwable $e) {
				// Avoid white-screening the whole site if a host throws on DDL (index length, etc.).
				if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
					error_log('icomm-metrix DB upgrade failed: ' . $e->getMessage());
				}
			}
		}
	}

	public static function install(): void {
		global $wpdb;
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset = $wpdb->get_charset_collate();
		$daily = self::table_daily();
		$queries = self::table_queries();
		$pages = self::table_pages();
		$query_pages = self::table_query_pages();
		$breakdown = self::table_ga4_breakdown();

		$sql_daily = "CREATE TABLE $daily (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			metric_date date NOT NULL,
			source varchar(16) NOT NULL DEFAULT '',
			metric_key varchar(64) NOT NULL DEFAULT '',
			metric_value double NOT NULL DEFAULT 0,
			meta_json longtext NULL,
			PRIMARY KEY (id),
			UNIQUE KEY metric_unique (metric_date, source, metric_key),
			KEY metric_date (metric_date),
			KEY source (source)
		) $charset;";

		$sql_queries = "CREATE TABLE $queries (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			metric_date date NOT NULL,
			query_text varchar(512) NOT NULL DEFAULT '',
			impressions int(11) NOT NULL DEFAULT 0,
			clicks int(11) NOT NULL DEFAULT 0,
			position double NOT NULL DEFAULT 0,
			ctr double NOT NULL DEFAULT 0,
			PRIMARY KEY (id),
			KEY metric_date (metric_date),
			KEY query_text (query_text(191))
		) $charset;";

		$sql_pages = "CREATE TABLE $pages (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			metric_date date NOT NULL,
			page_url varchar(768) NOT NULL DEFAULT '',
			impressions int(11) NOT NULL DEFAULT 0,
			clicks int(11) NOT NULL DEFAULT 0,
			position double NOT NULL DEFAULT 0,
			ctr double NOT NULL DEFAULT 0,
			PRIMARY KEY (id),
			KEY metric_date (metric_date),
			KEY page_url (page_url(191))
		) $charset;";

		$sql_query_pages = "CREATE TABLE $query_pages (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			metric_date date NOT NULL,
			query_text varchar(512) NOT NULL DEFAULT '',
			page_url varchar(768) NOT NULL DEFAULT '',
			impressions int(11) NOT NULL DEFAULT 0,
			clicks int(11) NOT NULL DEFAULT 0,
			position double NOT NULL DEFAULT 0,
			ctr double NOT NULL DEFAULT 0,
			PRIMARY KEY (id),
			KEY metric_date (metric_date),
			KEY query_text (query_text(191)),
			KEY page_url (page_url(191))
		) $charset;";

		// Keep unique index under older MySQL/MariaDB 767-byte limits (utf8mb4).
		$sql_breakdown = "CREATE TABLE $breakdown (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			period_start date NOT NULL,
			period_end date NOT NULL,
			dim_type varchar(16) NOT NULL DEFAULT '',
			dim_key varchar(100) NOT NULL DEFAULT '',
			dim_label varchar(191) NOT NULL DEFAULT '',
			sessions int(11) NOT NULL DEFAULT 0,
			users int(11) NOT NULL DEFAULT 0,
			bounce_rate double NOT NULL DEFAULT 0,
			PRIMARY KEY (id),
			UNIQUE KEY breakdown_unique (period_start, period_end, dim_type, dim_key),
			KEY period_end (period_end),
			KEY dim_type (dim_type)
		) $charset;";

		dbDelta($sql_daily);
		dbDelta($sql_queries);
		dbDelta($sql_pages);
		dbDelta($sql_query_pages);
		dbDelta($sql_breakdown);
		update_option('icomm_sm_db_version', ICOMM_SM_DB_VERSION, false);
	}

	/** Whether the GA4 breakdown table exists (safe if upgrade failed). */
	public static function ga4_breakdown_table_ready(): bool {
		global $wpdb;
		$table = self::table_ga4_breakdown();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
		return is_string($found) && $found === $table;
	}

	/**
	 * @return array{clicks:int, impressions:int, sessions:int, users:int, period_days:int}
	 */
	public static function summary(int $days = 7): array {
		global $wpdb;
		$days = max(1, min(IComm_SM_Features::history_days(), $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$daily = self::table_daily();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT source, metric_key, SUM(metric_value) AS total
				FROM $daily
				WHERE metric_date >= %s
				GROUP BY source, metric_key",
				$from
			),
			ARRAY_A
		);

		$out = [
			'clicks' => 0,
			'impressions' => 0,
			'sessions' => 0,
			'users' => 0,
			'period_days' => $days,
		];
		if (!is_array($rows)) {
			return $out;
		}
		foreach ($rows as $row) {
			$src = (string) ($row['source'] ?? '');
			$key = (string) ($row['metric_key'] ?? '');
			$total = (int) ($row['total'] ?? 0);
			if ($src === 'gsc' && $key === 'clicks') {
				$out['clicks'] = $total;
			} elseif ($src === 'gsc' && $key === 'impressions') {
				$out['impressions'] = $total;
			} elseif ($src === 'ga4' && $key === 'sessions') {
				$out['sessions'] = $total;
			} elseif ($src === 'ga4' && $key === 'users') {
				$out['users'] = $total;
			}
		}
		return $out;
	}

	/**
	 * Window sums for alerts: last N full days ending yesterday.
	 * offset_days=0 => [yesterday-(days-1) .. yesterday]
	 * offset_days=days => previous window.
	 *
	 * @return array{clicks:int, impressions:int, sessions:int, users:int, period_days:int}
	 */
	public static function summary_window(int $days, int $offset_days = 0): array {
		$days = max(1, min(30, $days));
		$offset_days = max(0, min(365, $offset_days));

		$end_ts = strtotime('-' . (1 + $offset_days) . ' day');
		$start_ts = strtotime('-' . ($days + $offset_days) . ' day');
		$from = gmdate('Y-m-d', $start_ts);
		$to = gmdate('Y-m-d', $end_ts);
		return self::summary_between($from, $to, $days);
	}

	/**
	 * @return array{clicks:int, impressions:int, sessions:int, users:int, period_days:int, avg_position:float}
	 */
	public static function summary_between(string $from, string $to, int $days = 0): array {
		global $wpdb;
		$daily = self::table_daily();
		if ($days <= 0) {
			$from_ts = strtotime($from . ' UTC');
			$to_ts = strtotime($to . ' UTC');
			if ($from_ts !== false && $to_ts !== false && $to_ts >= $from_ts) {
				$days = (int) floor(($to_ts - $from_ts) / DAY_IN_SECONDS) + 1;
			} else {
				$days = 30;
			}
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT source, metric_key, SUM(metric_value) AS total, AVG(metric_value) AS avg_val
				FROM $daily
				WHERE metric_date BETWEEN %s AND %s
				GROUP BY source, metric_key",
				$from,
				$to
			),
			ARRAY_A
		);

		$out = [
			'clicks' => 0,
			'impressions' => 0,
			'sessions' => 0,
			'users' => 0,
			'period_days' => $days,
			'avg_position' => 0.0,
		];
		if (!is_array($rows)) {
			return $out;
		}
		foreach ($rows as $row) {
			$src = (string) ($row['source'] ?? '');
			$key = (string) ($row['metric_key'] ?? '');
			$total = (float) ($row['total'] ?? 0);
			$avg = (float) ($row['avg_val'] ?? 0);
			if ($src === 'gsc' && $key === 'clicks') {
				$out['clicks'] = (int) round($total);
			} elseif ($src === 'gsc' && $key === 'impressions') {
				$out['impressions'] = (int) round($total);
			} elseif ($src === 'gsc' && $key === 'position') {
				$out['avg_position'] = round($avg, 2);
			} elseif ($src === 'ga4' && $key === 'sessions') {
				$out['sessions'] = (int) round($total);
			} elseif ($src === 'ga4' && $key === 'users') {
				$out['users'] = (int) round($total);
			}
		}
		return $out;
	}

	/**
	 * Queries with high impressions but low CTR, actionable recommendations.
	 *
	 * @return array<int, array{query:string, clicks:int, impressions:int, ctr:float, position:float}>
	 */
	public static function query_opportunities(int $limit = 8, int $days = 30, int $min_impressions = 200, float $max_ctr = 0.02): array {
		global $wpdb;
		$limit = max(1, min(50, $limit));
		$days = max(7, min(IComm_SM_Features::history_days(), $days));
		$min_impressions = max(0, min(1000000, $min_impressions));
		$max_ctr = max(0.0, min(1.0, $max_ctr));

		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_queries();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(position) AS position,
					CASE WHEN SUM(impressions) > 0 THEN (SUM(clicks) / SUM(impressions)) ELSE 0 END AS ctr_calc
				FROM $table
				WHERE metric_date >= %s
				GROUP BY query_text
				HAVING SUM(impressions) >= %d AND ctr_calc <= %f
				ORDER BY SUM(impressions) DESC
				LIMIT %d",
				$from,
				$min_impressions,
				$max_ctr,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$out[] = [
				'query' => (string) ($row['query_text'] ?? ''),
				'clicks' => (int) ($row['clicks'] ?? 0),
				'impressions' => (int) ($row['impressions'] ?? 0),
				'ctr' => round((float) ($row['ctr_calc'] ?? 0), 4),
				'position' => round((float) ($row['position'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Aggregated GSC metrics for one query over the last N days.
	 *
	 * @return array{query:string,clicks:int,impressions:int,ctr:float,position:float}|null
	 */
	public static function query_metrics_for(string $query, int $days = 28): ?array {
		global $wpdb;
		$query = trim($query);
		if ($query === '') {
			return null;
		}
		$days = max(7, min(IComm_SM_Features::history_days(), $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_queries();
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(NULLIF(position, 0)) AS position,
					CASE WHEN SUM(impressions) > 0 THEN (SUM(clicks) / SUM(impressions)) ELSE 0 END AS ctr_calc
				FROM $table
				WHERE metric_date >= %s AND query_text = %s
				GROUP BY query_text
				LIMIT 1",
				$from,
				mb_substr($query, 0, 512)
			),
			ARRAY_A
		);
		if (!is_array($row) || trim((string) ($row['query_text'] ?? '')) === '') {
			return null;
		}
		return [
			'query' => (string) $row['query_text'],
			'clicks' => (int) ($row['clicks'] ?? 0),
			'impressions' => (int) ($row['impressions'] ?? 0),
			'ctr' => round((float) ($row['ctr_calc'] ?? 0), 4),
			'position' => round((float) ($row['position'] ?? 0), 1),
		];
	}

	/**
	 * Queries in positions 8-20 with decent impressions, easiest wins.
	 *
	 * @return array<int, array{query:string, clicks:int, impressions:int, ctr:float, position:float}>
	 */
	public static function query_near_top(int $limit = 8, int $days = 30, int $min_impressions = 120, float $pos_min = 8.0, float $pos_max = 20.0): array {
		global $wpdb;
		$limit = max(1, min(50, $limit));
		$days = max(7, min(IComm_SM_Features::history_days(), $days));
		$min_impressions = max(0, min(1000000, $min_impressions));
		$pos_min = max(1.0, min(100.0, $pos_min));
		$pos_max = max($pos_min, min(100.0, $pos_max));

		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_queries();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(position) AS position,
					CASE WHEN SUM(impressions) > 0 THEN (SUM(clicks) / SUM(impressions)) ELSE 0 END AS ctr_calc
				FROM $table
				WHERE metric_date >= %s
				GROUP BY query_text
				HAVING SUM(impressions) >= %d AND AVG(position) >= %f AND AVG(position) <= %f
				ORDER BY AVG(position) ASC, SUM(impressions) DESC
				LIMIT %d",
				$from,
				$min_impressions,
				$pos_min,
				$pos_max,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$out[] = [
				'query' => (string) ($row['query_text'] ?? ''),
				'clicks' => (int) ($row['clicks'] ?? 0),
				'impressions' => (int) ($row['impressions'] ?? 0),
				'ctr' => round((float) ($row['ctr_calc'] ?? 0), 4),
				'position' => round((float) ($row['position'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Queries with rising clicks: last 7 days vs previous 7 days.
	 *
	 * @return array<int, array{query:string, clicks_prev:int, clicks_cur:int, impressions_cur:int, position:float}>
	 */
	public static function query_rising(int $limit = 8, int $min_clicks_cur = 10, int $min_delta = 5): array {
		global $wpdb;
		$limit = max(1, min(50, $limit));
		$min_clicks_cur = max(0, min(1000000, $min_clicks_cur));
		$min_delta = max(1, min(1000000, $min_delta));

		$cur_from = gmdate('Y-m-d', strtotime('-7 days'));
		$prev_from = gmdate('Y-m-d', strtotime('-14 days'));
		$table = self::table_queries();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(CASE WHEN metric_date >= %s THEN clicks ELSE 0 END) AS clicks_cur,
					SUM(CASE WHEN metric_date >= %s AND metric_date < %s THEN clicks ELSE 0 END) AS clicks_prev,
					SUM(CASE WHEN metric_date >= %s THEN impressions ELSE 0 END) AS impressions_cur,
					AVG(CASE WHEN metric_date >= %s THEN position ELSE NULL END) AS position_cur
				FROM $table
				WHERE metric_date >= %s
				GROUP BY query_text
				HAVING clicks_cur >= %d AND (clicks_cur - clicks_prev) >= %d
				ORDER BY (clicks_cur - clicks_prev) DESC, clicks_cur DESC
				LIMIT %d",
				$cur_from,
				$prev_from,
				$cur_from,
				$cur_from,
				$cur_from,
				$prev_from,
				$min_clicks_cur,
				$min_delta,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$out[] = [
				'query' => (string) ($row['query_text'] ?? ''),
				'clicks_prev' => (int) ($row['clicks_prev'] ?? 0),
				'clicks_cur' => (int) ($row['clicks_cur'] ?? 0),
				'impressions_cur' => (int) ($row['impressions_cur'] ?? 0),
				'position' => round((float) ($row['position_cur'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Queries whose text overlaps a free-text topic (token LIKE), for niche research.
	 *
	 * @return array<int, array{query:string, clicks:int, impressions:int, position:float, ctr:float, relevance:float}>
	 */
	public static function queries_related_to_topic(string $topic, int $days = 90, int $limit = 40): array {
		global $wpdb;
		$topic = trim($topic);
		if ($topic === '') {
			return [];
		}
		$limit = max(1, min(80, $limit));
		$days = max(1, min(IComm_SM_Features::history_days(), $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_queries();

		$tokens = preg_split('/[\s\-\|,\/\.\:\;\(\)\[\]\"\'«»]+/u', $topic) ?: [];
		$tokens = array_values(array_unique(array_filter(array_map('trim', $tokens), static function ($w) {
			$w = (string) $w;
			if (function_exists('mb_strlen')) {
				return mb_strlen($w) >= 2;
			}
			return strlen($w) >= 2;
		})));
		if ($tokens === []) {
			$tokens = [$topic];
		}
		$tokens = array_slice($tokens, 0, 8);

		$likes = [];
		$params = [$from];
		foreach ($tokens as $tok) {
			$likes[] = 'query_text LIKE %s';
			$params[] = '%' . $wpdb->esc_like($tok) . '%';
		}
		$params[] = $limit;
		$sql = "SELECT query_text,
				SUM(clicks) AS clicks,
				SUM(impressions) AS impressions,
				AVG(position) AS position
			FROM $table
			WHERE metric_date >= %s AND (" . implode(' OR ', $likes) . ")
			GROUP BY query_text
			ORDER BY impressions DESC, clicks DESC
			LIMIT %d";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- placeholders built dynamically from token count.
		$rows = $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A);
		if (!is_array($rows)) {
			return [];
		}

		$topic_l = function_exists('mb_strtolower') ? mb_strtolower($topic) : strtolower($topic);
		$out = [];
		foreach ($rows as $row) {
			$q = trim((string) ($row['query_text'] ?? ''));
			if ($q === '') {
				continue;
			}
			$impr = (int) ($row['impressions'] ?? 0);
			$clk = (int) ($row['clicks'] ?? 0);
			$q_l = function_exists('mb_strtolower') ? mb_strtolower($q) : strtolower($q);
			$hits = 0;
			foreach ($tokens as $tok) {
				$tl = function_exists('mb_strtolower') ? mb_strtolower((string) $tok) : strtolower((string) $tok);
				if ($tl !== '' && (function_exists('mb_strpos') ? mb_strpos($q_l, $tl) !== false : strpos($q_l, $tl) !== false)) {
					$hits++;
				}
			}
			$rel = $tokens === [] ? 0.0 : ($hits / count($tokens));
			if ($topic_l !== '' && (function_exists('mb_strpos') ? mb_strpos($q_l, $topic_l) !== false : strpos($q_l, $topic_l) !== false)) {
				$rel = max($rel, 1.0);
			}
			$out[] = [
				'query' => $q,
				'clicks' => $clk,
				'impressions' => $impr,
				'position' => round((float) ($row['position'] ?? 0), 1),
				'ctr' => $impr > 0 ? ($clk / $impr) : 0.0,
				'relevance' => round($rel, 3),
			];
		}
		usort($out, static function ($a, $b) {
			$ra = (float) ($a['relevance'] ?? 0);
			$rb = (float) ($b['relevance'] ?? 0);
			if ($ra !== $rb) {
				return $rb <=> $ra;
			}
			return ((int) ($b['impressions'] ?? 0)) <=> ((int) ($a['impressions'] ?? 0));
		});
		return $out;
	}

	/**
	 * @return array<int, array{query:string, clicks:int, impressions:int, position:float}>
	 */
	public static function top_queries(int $limit = 5, int $days = 7): array {
		global $wpdb;
		$limit = max(1, min(100, $limit));
		$days = max(1, min(IComm_SM_Features::history_days(), $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_queries();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(position) AS position
				FROM $table
				WHERE metric_date >= %s
				GROUP BY query_text
				ORDER BY clicks DESC, impressions DESC
				LIMIT %d",
				$from,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$out[] = [
				'query' => (string) ($row['query_text'] ?? ''),
				'clicks' => (int) ($row['clicks'] ?? 0),
				'impressions' => (int) ($row['impressions'] ?? 0),
				'position' => round((float) ($row['position'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Ranking leaderboard for Keywords screen: top GSC queries (site-wide or niche-filtered),
	 * with landing page + MoM position/clicks delta (lower position number = better).
	 *
	 * @return array<int, array{
	 * query:string,
	 * page_url:string,
	 * page_label:string,
	 * page_path:string,
	 * edit_url:string,
	 * clicks:int,
	 * impressions:int,
	 * position:float,
	 * clicks_prev:int,
	 * position_prev:float,
	 * delta_position:float,
	 * delta_clicks:int
	 * }>
	 */
	public static function ranking_table(string $topic = '', int $limit = 25, int $days = 30): array {
		$limit = max(1, min(40, $limit));
		$days = max(7, min(IComm_SM_Features::history_days(), $days));
		$topic = trim($topic);

		if ($topic !== '') {
			$base = self::queries_related_to_topic($topic, $days, $limit);
		} else {
			$base = self::top_queries_by_impressions($limit, $days);
		}
		if ($base === []) {
			return [];
		}

		$queries = [];
		foreach ($base as $row) {
			$q = trim((string) ($row['query'] ?? ''));
			if ($q === '' || (int) ($row['impressions'] ?? 0) < 1) {
				continue;
			}
			$queries[] = $q;
		}
		$queries = array_values(array_unique($queries));
		if ($queries === []) {
			return [];
		}

		$cur = self::keywords_summary($queries, $days, 0);
		$prev = self::keywords_summary($queries, $days, $days);
		$page_days = min(IComm_SM_Features::history_days(), max($days, 90));
		$out = [];

		foreach ($queries as $q) {
			$c = (array) ($cur[$q] ?? ['clicks' => 0, 'impressions' => 0, 'position' => 0.0]);
			$p = (array) ($prev[$q] ?? ['clicks' => 0, 'impressions' => 0, 'position' => 0.0]);
			$impr = (int) ($c['impressions'] ?? 0);
			if ($impr < 1) {
				continue;
			}
			$page = self::top_page_for_query($q, $page_days);
			$page_url = is_array($page) ? (string) ($page['page_url'] ?? '') : '';
			$pos_cur = (float) ($c['position'] ?? 0);
			$pos_prev = (float) ($p['position'] ?? 0);
			// Positive delta = improved (moved up in SERP).
			$delta_pos = ($pos_prev > 0 && $pos_cur > 0) ? round($pos_prev - $pos_cur, 1) : 0.0;
			$clicks_cur = (int) ($c['clicks'] ?? 0);
			$clicks_prev = (int) ($p['clicks'] ?? 0);

			$edit_url = '';
			$page_label = '';
			$page_path = '';
			if ($page_url !== '') {
				$pid = self::url_to_local_post_id($page_url);
				if ($pid > 0) {
					$title = (string) get_the_title($pid);
					$page_label = $title !== '' ? $title : self::page_label_for_url($page_url);
					$edit_url = admin_url('post.php?action=edit&post=' . $pid);
				} else {
					$page_label = self::page_label_for_url($page_url);
				}
				$path = (string) (wp_parse_url($page_url, PHP_URL_PATH) ?? '');
				$page_path = $path !== '' ? $path : $page_url;
			}

			$out[] = [
				'query' => $q,
				'page_url' => $page_url,
				'page_label' => $page_label,
				'page_path' => $page_path,
				'edit_url' => $edit_url,
				'clicks' => $clicks_cur,
				'impressions' => $impr,
				'position' => round($pos_cur, 1),
				'clicks_prev' => $clicks_prev,
				'position_prev' => round($pos_prev, 1),
				'delta_position' => $delta_pos,
				'delta_clicks' => $clicks_cur - $clicks_prev,
			];
		}

		usort($out, static function ($a, $b) {
			$ia = (int) ($a['impressions'] ?? 0);
			$ib = (int) ($b['impressions'] ?? 0);
			if ($ia !== $ib) {
				return $ib <=> $ia;
			}
			return ((float) ($a['position'] ?? 99)) <=> ((float) ($b['position'] ?? 99));
		});

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

	/**
	 * Top queries ordered by impressions (for ranking table default view).
	 *
	 * @return array<int, array{query:string, clicks:int, impressions:int, position:float}>
	 */
	public static function top_queries_by_impressions(int $limit = 25, int $days = 30): array {
		global $wpdb;
		$limit = max(1, min(100, $limit));
		$days = max(1, min(IComm_SM_Features::history_days(), $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_queries();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(NULLIF(position, 0)) AS position
				FROM $table
				WHERE metric_date >= %s
				GROUP BY query_text
				HAVING SUM(impressions) > 0
				ORDER BY SUM(impressions) DESC, SUM(clicks) DESC
				LIMIT %d",
				$from,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$q = (string) ($row['query_text'] ?? '');
			if ($q === '') {
				continue;
			}
			$out[] = [
				'query' => $q,
				'clicks' => (int) ($row['clicks'] ?? 0),
				'impressions' => (int) ($row['impressions'] ?? 0),
				'position' => round((float) ($row['position'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Short human label for a GSC landing URL (post title when known, else path).
	 */
	public static function page_label_for_url(string $url): string {
		$url = trim($url);
		if ($url === '') {
			return '';
		}
		$post_id = 0;
		if (function_exists('url_to_postid')) {
			$post_id = (int) url_to_postid($url);
		}
		if ($post_id > 0) {
			$title = get_the_title($post_id);
			if (is_string($title) && $title !== '') {
				return $title;
			}
		}
		$path = (string) (wp_parse_url($url, PHP_URL_PATH) ?? '');
		$path = trim($path, '/');
		if ($path === '') {
			$host = (string) (wp_parse_url($url, PHP_URL_HOST) ?? '');
			return $host !== '' ? $host : $url;
		}
		if (function_exists('mb_strlen') && mb_strlen($path) > 64) {
			return '…' . mb_substr($path, -60);
		}
		if (strlen($path) > 64) {
			return '…' . substr($path, -60);
		}
		return $path;
	}

	/**
	 * Human SERP place for admins from GSC position.
	 * Example: position 29.4 → "מקום 29 עמוד 3"
	 *
	 * @return array{rank:int,serp_page:int,label:string}|null
	 */
	public static function serp_place_label(float $position): ?array {
		if ($position <= 0) {
			return null;
		}
		$rank = max(1, (int) round($position));
		$serp_page = max(1, (int) ceil($rank / 10));
		return [
			'rank' => $rank,
			'serp_page' => $serp_page,
			'label' => sprintf(
				/* translators: 1: Google rank, 2: SERP page (10 organic results per page) */
				__('מקום %1$d עמוד %2$d', 'icomm-metrix'),
				$rank,
				$serp_page
			),
		];
	}

	/**
	 * Top queries for a calendar window. Falls back to nearest sync stamp if the range is empty
	 * (GSC detail rows are often stored on sync end-date, not day-by-day).
	 *
	 * @return array<int, array{query:string, clicks:int, impressions:int, position:float, ctr:float}>
	 */
	public static function top_queries_between(string $start, string $end, int $limit = 20): array {
		global $wpdb;
		$limit = max(1, min(50, $limit));
		if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
			return [];
		}
		$table = self::table_queries();
		$pad_end = gmdate('Y-m-d', strtotime($end . ' UTC +14 days'));

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT query_text,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(NULLIF(position, 0)) AS position
				FROM $table
				WHERE metric_date BETWEEN %s AND %s
				GROUP BY query_text
				ORDER BY clicks DESC, impressions DESC
				LIMIT %d",
				$start,
				$pad_end,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows) || $rows === []) {
			$stamp = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT MAX(metric_date) FROM $table WHERE metric_date <= %s",
					$pad_end
				)
			);
			if (is_string($stamp) && $stamp !== '') {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT query_text, clicks, impressions, position
						FROM $table
						WHERE metric_date = %s
						ORDER BY clicks DESC, impressions DESC
						LIMIT %d",
						$stamp,
						$limit
					),
					ARRAY_A
				);
			}
		}
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$q = (string) ($row['query_text'] ?? '');
			if ($q === '') {
				continue;
			}
			$clicks = (int) ($row['clicks'] ?? 0);
			$impr = (int) ($row['impressions'] ?? 0);
			$out[] = [
				'query' => $q,
				'clicks' => $clicks,
				'impressions' => $impr,
				'position' => round((float) ($row['position'] ?? 0), 1),
				'ctr' => $impr > 0 ? round($clicks / $impr, 4) : 0.0,
			];
		}
		return $out;
	}

	/**
	 * @return array<int, array{page_url:string, clicks:int, impressions:int, position:float, ctr:float}>
	 */
	public static function top_pages_between(string $start, string $end, int $limit = 15): array {
		global $wpdb;
		$limit = max(1, min(40, $limit));
		if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
			return [];
		}
		$table = self::table_pages();
		$pad_end = gmdate('Y-m-d', strtotime($end . ' UTC +14 days'));

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT page_url,
					SUM(clicks) AS clicks,
					SUM(impressions) AS impressions,
					AVG(NULLIF(position, 0)) AS position
				FROM $table
				WHERE metric_date BETWEEN %s AND %s
				GROUP BY page_url
				ORDER BY clicks DESC, impressions DESC
				LIMIT %d",
				$start,
				$pad_end,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows) || $rows === []) {
			$stamp = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT MAX(metric_date) FROM $table WHERE metric_date <= %s",
					$pad_end
				)
			);
			if (is_string($stamp) && $stamp !== '') {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT page_url, clicks, impressions, position
						FROM $table
						WHERE metric_date = %s
						ORDER BY clicks DESC, impressions DESC
						LIMIT %d",
						$stamp,
						$limit
					),
					ARRAY_A
				);
			}
		}
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$url = trim((string) ($row['page_url'] ?? ''));
			if ($url === '') {
				continue;
			}
			$clicks = (int) ($row['clicks'] ?? 0);
			$impr = (int) ($row['impressions'] ?? 0);
			$out[] = [
				'page_url' => $url,
				'clicks' => $clicks,
				'impressions' => $impr,
				'position' => round((float) ($row['position'] ?? 0), 1),
				'ctr' => $impr > 0 ? round($clicks / $impr, 4) : 0.0,
			];
		}
		return $out;
	}

	/**
	 * Daily series for charts inside a date range.
	 *
	 * @return array<int, array{date:string, label:string, clicks:int, impressions:int, sessions:int, users:int}>
	 */
	public static function daily_series_between(string $start, string $end): array {
		global $wpdb;
		if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
			return [];
		}
		$daily = self::table_daily();
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT metric_date, source, metric_key, metric_value
				FROM $daily
				WHERE metric_date BETWEEN %s AND %s
				 AND (
					(source = 'gsc' AND metric_key IN ('clicks','impressions'))
					OR (source = 'ga4' AND metric_key IN ('sessions','users'))
				 )
				ORDER BY metric_date ASC",
				$start,
				$end
			),
			ARRAY_A
		);
		$map = [];
		$ts = strtotime($start . ' UTC');
		$end_ts = strtotime($end . ' UTC');
		if ($ts === false || $end_ts === false) {
			return [];
		}
		while ($ts <= $end_ts) {
			$d = gmdate('Y-m-d', $ts);
			$map[$d] = [
				'date' => $d,
				'label' => IComm_SM_Dates::format_short($d),
				'clicks' => 0,
				'impressions' => 0,
				'sessions' => 0,
				'users' => 0,
			];
			$ts = strtotime('+1 day', $ts);
			if ($ts === false) {
				break;
			}
		}
		if (is_array($rows)) {
			foreach ($rows as $row) {
				$d = (string) ($row['metric_date'] ?? '');
				if (!isset($map[$d])) {
					continue;
				}
				$src = (string) ($row['source'] ?? '');
				$key = (string) ($row['metric_key'] ?? '');
				$val = (int) round((float) ($row['metric_value'] ?? 0));
				if ($src === 'gsc' && $key === 'clicks') {
					$map[$d]['clicks'] = $val;
				} elseif ($src === 'gsc' && $key === 'impressions') {
					$map[$d]['impressions'] = $val;
				} elseif ($src === 'ga4' && $key === 'sessions') {
					$map[$d]['sessions'] = $val;
				} elseif ($src === 'ga4' && $key === 'users') {
					$map[$d]['users'] = $val;
				}
			}
		}
		return array_values($map);
	}

	/**
	 * Aggregate daily metrics into week buckets for bar charts.
	 *
	 * @return array<int, array{label:string, start:string, end:string, clicks:int, impressions:int, sessions:int, users:int}>
	 */
	public static function weekly_series_between(string $start, string $end): array {
		$days = self::daily_series_between($start, $end);
		$weeks = self::month_week_buckets($start, $end);
		$out = [];
		foreach ($weeks as $w) {
			$bucket = [
				'label' => (string) ($w['label'] ?? ''),
				'start' => (string) ($w['start'] ?? ''),
				'end' => (string) ($w['end'] ?? ''),
				'clicks' => 0,
				'impressions' => 0,
				'sessions' => 0,
				'users' => 0,
			];
			foreach ($days as $day) {
				$d = (string) ($day['date'] ?? '');
				if ($d >= $bucket['start'] && $d <= $bucket['end']) {
					$bucket['clicks'] += (int) ($day['clicks'] ?? 0);
					$bucket['impressions'] += (int) ($day['impressions'] ?? 0);
					$bucket['sessions'] += (int) ($day['sessions'] ?? 0);
					$bucket['users'] += (int) ($day['users'] ?? 0);
				}
			}
			$out[] = $bucket;
		}
		return $out;
	}

	/**
	 * Aggregate daily metrics into calendar-month buckets for client report charts.
	 *
	 * @return array<int, array{label:string, start:string, end:string, clicks:int, impressions:int, sessions:int, users:int}>
	 */
	public static function monthly_series_between(string $start, string $end): array {
		$days = self::daily_series_between($start, $end);
		if ($days === []) {
			return [];
		}
		/** @var array<string, array{label:string, start:string, end:string, clicks:int, impressions:int, sessions:int, users:int}> $buckets */
		$buckets = [];
		foreach ($days as $day) {
			$d = (string) ($day['date'] ?? '');
			if (!preg_match('/^(\d{4})-(\d{2})-\d{2}$/', $d, $m)) {
				continue;
			}
			$ym = $m[1] . '-' . $m[2];
			if (!isset($buckets[$ym])) {
				$year = (int) $m[1];
				$month = (int) $m[2];
				$dim = (int) date('t', strtotime($ym . '-01 12:00:00')); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
				$month_start = $ym . '-01';
				$month_end = sprintf('%s-%02d', $ym, $dim);
				// Clip bucket to requested range.
				$b_start = $month_start < $start ? $start : $month_start;
				$b_end = $month_end > $end ? $end : $month_end;
				$buckets[$ym] = [
					'label' => self::hebrew_month_label($month, $year),
					'start' => $b_start,
					'end' => $b_end,
					'clicks' => 0,
					'impressions' => 0,
					'sessions' => 0,
					'users' => 0,
				];
			}
			$buckets[$ym]['clicks'] += (int) ($day['clicks'] ?? 0);
			$buckets[$ym]['impressions'] += (int) ($day['impressions'] ?? 0);
			$buckets[$ym]['sessions'] += (int) ($day['sessions'] ?? 0);
			$buckets[$ym]['users'] += (int) ($day['users'] ?? 0);
		}
		return array_values($buckets);
	}

	/**
	 * Aggregated stats per query_text for a given list (exact match), used for keyword watch.
	 *
	 * @param string[] $keywords
	 * @return array<string, array{clicks:int, impressions:int, position:float, ctr:float}>
	 */
	public static function keywords_summary(array $keywords, int $days = 30, int $offset_days = 0): array {
		global $wpdb;
		$days = max(1, min(IComm_SM_Features::history_days(), $days));
		$offset_days = max(0, min(365, $offset_days));

		$keywords = array_values(array_unique(array_filter(array_map('strval', array_map('trim', $keywords)))));
		if ($keywords === []) {
			return [];
		}

		// Window: last N days ending yesterday (offset_days=0) or earlier windows.
		$end_ts = strtotime('-' . (1 + $offset_days) . ' day');
		$start_ts = strtotime('-' . ($days + $offset_days) . ' day');
		$from = gmdate('Y-m-d', $start_ts);
		$to = gmdate('Y-m-d', $end_ts);

		$table = self::table_queries();
		$placeholders = implode(',', array_fill(0, count($keywords), '%s'));
		$params = array_merge([$from, $to], $keywords);

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$sql = $wpdb->prepare(
			"SELECT query_text,
				SUM(clicks) AS clicks,
				SUM(impressions) AS impressions,
				AVG(position) AS position,
				CASE WHEN SUM(impressions) > 0 THEN (SUM(clicks) / SUM(impressions)) ELSE 0 END AS ctr_calc
			FROM $table
			WHERE metric_date BETWEEN %s AND %s
			 AND query_text IN ($placeholders)
			GROUP BY query_text",
			...$params
		);
		$rows = $wpdb->get_results($sql, ARRAY_A);
		if (!is_array($rows)) {
			return [];
		}

		$out = [];
		foreach ($rows as $row) {
			$q = (string) ($row['query_text'] ?? '');
			if ($q === '') {
				continue;
			}
			$clicks = (int) ($row['clicks'] ?? 0);
			$impr = (int) ($row['impressions'] ?? 0);
			$pos = (float) ($row['position'] ?? 0);
			$ctr = (float) ($row['ctr_calc'] ?? 0);
			$out[$q] = [
				'clicks' => $clicks,
				'impressions' => $impr,
				'position' => round($pos, 1),
				'ctr' => round($ctr, 4),
			];
		}

		// Ensure every requested keyword exists in output.
		foreach ($keywords as $k) {
			if (!isset($out[$k])) {
				$out[$k] = ['clicks' => 0, 'impressions' => 0, 'position' => 0.0, 'ctr' => 0.0];
			}
		}
		return $out;
	}

	/**
	 * Rising clicks for a specific keyword list: last 7 days vs previous 7 days.
	 *
	 * @param string[] $keywords
	 * @return array<string, array{clicks_prev:int, clicks_cur:int, impressions_cur:int, position:float}>
	 */
	public static function keywords_rising(array $keywords, int $min_clicks_cur = 10, int $min_delta = 5): array {
		global $wpdb;
		$min_clicks_cur = max(0, min(1000000, $min_clicks_cur));
		$min_delta = max(1, min(1000000, $min_delta));

		$keywords = array_values(array_unique(array_filter(array_map('strval', array_map('trim', $keywords)))));
		if ($keywords === []) {
			return [];
		}

		$cur_from = gmdate('Y-m-d', strtotime('-7 days'));
		$prev_from = gmdate('Y-m-d', strtotime('-14 days'));
		$table = self::table_queries();

		$placeholders = implode(',', array_fill(0, count($keywords), '%s'));
		$params = array_merge([$cur_from, $prev_from, $cur_from, $cur_from, $cur_from], $keywords, [$prev_from, $min_clicks_cur, $min_delta]);

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$sql = $wpdb->prepare(
			"SELECT query_text,
				SUM(CASE WHEN metric_date >= %s THEN clicks ELSE 0 END) AS clicks_cur,
				SUM(CASE WHEN metric_date >= %s AND metric_date < %s THEN clicks ELSE 0 END) AS clicks_prev,
				SUM(CASE WHEN metric_date >= %s THEN impressions ELSE 0 END) AS impressions_cur,
				AVG(CASE WHEN metric_date >= %s THEN position ELSE NULL END) AS position_cur
			FROM $table
			WHERE query_text IN ($placeholders)
			 AND metric_date >= %s
			GROUP BY query_text
			HAVING clicks_cur >= %d AND (clicks_cur - clicks_prev) >= %d",
			...$params
		);
		$rows = $wpdb->get_results($sql, ARRAY_A);
		if (!is_array($rows)) {
			return [];
		}

		$out = [];
		foreach ($rows as $row) {
			$q = (string) ($row['query_text'] ?? '');
			if ($q === '') {
				continue;
			}
			$out[$q] = [
				'clicks_prev' => (int) ($row['clicks_prev'] ?? 0),
				'clicks_cur' => (int) ($row['clicks_cur'] ?? 0),
				'impressions_cur' => (int) ($row['impressions_cur'] ?? 0),
				'position' => round((float) ($row['position_cur'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Replace GA4 channel/device breakdown for a sync window.
	 *
	 * @param array<int, array{dim_key:string, dim_label?:string, sessions:int, users:int, bounce_rate:float}> $rows
	 */
	public static function replace_ga4_breakdown(string $start, string $end, string $dim_type, array $rows): void {
		global $wpdb;
		$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) ? $start : '';
		$end = preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) ? $end : '';
		$dim_type = sanitize_key($dim_type);
		if ($start === '' || $end === '' || $dim_type === '' || !self::ga4_breakdown_table_ready()) {
			return;
		}
		$table = self::table_ga4_breakdown();
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE period_start = %s AND period_end = %s AND dim_type = %s",
				$start,
				$end,
				$dim_type
			)
		);
		foreach ($rows as $row) {
			if (!is_array($row)) {
				continue;
			}
			$key = substr(sanitize_text_field((string) ($row['dim_key'] ?? '')), 0, 100);
			if ($key === '') {
				continue;
			}
			$label = substr(sanitize_text_field((string) ($row['dim_label'] ?? $key)), 0, 191);
			$wpdb->insert(
				$table,
				[
					'period_start' => $start,
					'period_end' => $end,
					'dim_type' => $dim_type,
					'dim_key' => $key,
					'dim_label' => $label,
					'sessions' => max(0, (int) ($row['sessions'] ?? 0)),
					'users' => max(0, (int) ($row['users'] ?? 0)),
					'bounce_rate' => (float) ($row['bounce_rate'] ?? 0),
				],
				['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f']
			);
		}
	}

	/**
	 * Latest stored breakdown for dim_type whose period overlaps [$start,$end], or newest overall.
	 *
	 * @return array<int, array{dim_key:string, dim_label:string, sessions:int, users:int, bounce_rate:float, period_start:string, period_end:string}>
	 */
	public static function ga4_breakdown(string $dim_type, string $start = '', string $end = ''): array {
		global $wpdb;
		$dim_type = sanitize_key($dim_type);
		if ($dim_type === '' || !self::ga4_breakdown_table_ready()) {
			return [];
		}
		$table = self::table_ga4_breakdown();
		$period = null;
		if ($start !== '' && $end !== '') {
			$period = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT period_start, period_end FROM $table
					WHERE dim_type = %s
					 AND period_end >= %s AND period_start <= %s
					ORDER BY period_end DESC, period_start DESC
					LIMIT 1",
					$dim_type,
					$start,
					$end
				),
				ARRAY_A
			);
		}
		if (!is_array($period)) {
			$period = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT period_start, period_end FROM $table
					WHERE dim_type = %s
					ORDER BY period_end DESC, period_start DESC
					LIMIT 1",
					$dim_type
				),
				ARRAY_A
			);
		}
		if (!is_array($period)) {
			return [];
		}
		$ps = (string) ($period['period_start'] ?? '');
		$pe = (string) ($period['period_end'] ?? '');
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT dim_key, dim_label, sessions, users, bounce_rate, period_start, period_end
				FROM $table
				WHERE dim_type = %s AND period_start = %s AND period_end = %s
				ORDER BY sessions DESC",
				$dim_type,
				$ps,
				$pe
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$out[] = [
				'dim_key' => (string) ($row['dim_key'] ?? ''),
				'dim_label' => (string) ($row['dim_label'] ?? ''),
				'sessions' => (int) ($row['sessions'] ?? 0),
				'users' => (int) ($row['users'] ?? 0),
				'bounce_rate' => (float) ($row['bounce_rate'] ?? 0),
				'period_start' => (string) ($row['period_start'] ?? ''),
				'period_end' => (string) ($row['period_end'] ?? ''),
			];
		}
		return $out;
	}

	/**
	 * Watch-keyword positions bucketed by ISO week within a date range.
	 *
	 * @param string[] $keywords
	 * @return array{weeks:array<int, array{label:string, start:string, end:string}>, rows:array<string, array{weeks:array<string, float|null>, start_pos:float|null, end_pos:float|null, avg:float|null, impressions:int}>}
	 */
	public static function keywords_weekly_positions(array $keywords, string $start, string $end): array {
		global $wpdb;
		$keywords = array_values(array_unique(array_filter(array_map('strval', array_map('trim', $keywords)))));
		$weeks = self::month_week_buckets($start, $end);
		$empty = ['weeks' => $weeks, 'rows' => []];
		if ($keywords === [] || $weeks === []) {
			return $empty;
		}
		$table = self::table_queries();
		$placeholders = implode(',', array_fill(0, count($keywords), '%s'));
		$params = array_merge([$start, $end], $keywords);
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$sql = $wpdb->prepare(
			"SELECT query_text, metric_date, position, impressions
			FROM $table
			WHERE metric_date BETWEEN %s AND %s
			 AND query_text IN ($placeholders)
			ORDER BY metric_date ASC",
			...$params
		);
		$raw = $wpdb->get_results($sql, ARRAY_A);
		if (!is_array($raw)) {
			$raw = [];
		}

		$rows = [];
		foreach ($keywords as $kw) {
			$rows[$kw] = [
				'weeks' => [],
				'start_pos' => null,
				'end_pos' => null,
				'avg' => null,
				'impressions' => 0,
			];
			foreach ($weeks as $w) {
				$rows[$kw]['weeks'][$w['start']] = null;
			}
		}

		$week_acc = []; // kw => week_start => [sum, n]
		$all_pos = []; // kw => [positions]
		foreach ($raw as $row) {
			$q = (string) ($row['query_text'] ?? '');
			if ($q === '' || !isset($rows[$q])) {
				continue;
			}
			$d = (string) ($row['metric_date'] ?? '');
			$pos = (float) ($row['position'] ?? 0);
			$impr = (int) ($row['impressions'] ?? 0);
			$rows[$q]['impressions'] += $impr;
			if ($pos > 0) {
				$all_pos[$q][] = $pos;
				if ($rows[$q]['start_pos'] === null) {
					$rows[$q]['start_pos'] = round($pos, 1);
				}
				$rows[$q]['end_pos'] = round($pos, 1);
			}
			$bucket = null;
			foreach ($weeks as $w) {
				if ($d >= $w['start'] && $d <= $w['end']) {
					$bucket = $w['start'];
					break;
				}
			}
			if ($bucket === null || $pos <= 0) {
				continue;
			}
			if (!isset($week_acc[$q][$bucket])) {
				$week_acc[$q][$bucket] = ['sum' => 0.0, 'n' => 0];
			}
			$week_acc[$q][$bucket]['sum'] += $pos;
			$week_acc[$q][$bucket]['n']++;
		}

		foreach ($rows as $kw => &$info) {
			if (!empty($week_acc[$kw])) {
				foreach ($week_acc[$kw] as $ws => $acc) {
					if ($acc['n'] > 0) {
						$info['weeks'][$ws] = round($acc['sum'] / $acc['n'], 1);
					}
				}
			}
			if (!empty($all_pos[$kw])) {
				$info['avg'] = round(array_sum($all_pos[$kw]) / count($all_pos[$kw]), 2);
			}
		}
		unset($info);

		return ['weeks' => $weeks, 'rows' => $rows];
	}

	/**
	 * @return array<int, array{label:string, start:string, end:string}>
	 */
	public static function month_week_buckets(string $start, string $end): array {
		$start_ts = strtotime($start . ' UTC');
		$end_ts = strtotime($end . ' UTC');
		if ($start_ts === false || $end_ts === false || $end_ts < $start_ts) {
			return [];
		}
		$out = [];
		$cursor = $start_ts;
		while ($cursor <= $end_ts) {
			$w_end = min(strtotime('+6 days', $cursor), $end_ts);
			$out[] = [
				'label' => IComm_SM_Dates::format_short(gmdate('Y-m-d', $cursor)),
				'start' => gmdate('Y-m-d', $cursor),
				'end' => gmdate('Y-m-d', $w_end),
			];
			$cursor = strtotime('+1 day', $w_end);
			if ($cursor === false) {
				break;
			}
		}
		return $out;
	}

	public static function upsert_daily(string $date, string $source, string $key, float $value): void {
		global $wpdb;
		$table = self::table_daily();
		$wpdb->query(
			$wpdb->prepare(
				"INSERT INTO $table (metric_date, source, metric_key, metric_value)
				VALUES (%s, %s, %s, %f)
				ON DUPLICATE KEY UPDATE metric_value = VALUES(metric_value)",
				$date,
				$source,
				$key,
				$value
			)
		);
	}

	public static function upsert_query(string $date, string $query, int $clicks, int $impressions, float $position, float $ctr): void {
		global $wpdb;
		$table = self::table_queries();
		$wpdb->insert(
			$table,
			[
				'metric_date' => $date,
				'query_text' => mb_substr($query, 0, 512),
				'clicks' => $clicks,
				'impressions' => $impressions,
				'position' => $position,
				'ctr' => $ctr,
			],
			['%s', '%s', '%d', '%d', '%f', '%f']
		);
	}

	public static function upsert_page(string $date, string $page_url, int $clicks, int $impressions, float $position, float $ctr): void {
		global $wpdb;
		$table = self::table_pages();
		$wpdb->insert(
			$table,
			[
				'metric_date' => $date,
				'page_url' => mb_substr($page_url, 0, 768),
				'clicks' => $clicks,
				'impressions' => $impressions,
				'position' => $position,
				'ctr' => $ctr,
			],
			['%s', '%s', '%d', '%d', '%f', '%f']
		);
	}

	public static function upsert_query_page(string $date, string $query, string $page_url, int $clicks, int $impressions, float $position, float $ctr): void {
		global $wpdb;
		$table = self::table_query_pages();
		$wpdb->insert(
			$table,
			[
				'metric_date' => $date,
				'query_text' => mb_substr($query, 0, 512),
				'page_url' => mb_substr($page_url, 0, 768),
				'clicks' => $clicks,
				'impressions' => $impressions,
				'position' => $position,
				'ctr' => $ctr,
			],
			['%s', '%s', '%s', '%d', '%d', '%f', '%f']
		);
	}

	public static function purge_range(string $start, string $end, string $source): void {
		global $wpdb;
		$table = self::table_daily();
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE metric_date BETWEEN %s AND %s AND source = %s",
				$start,
				$end,
				$source
			)
		);
	}

	public static function purge_queries_range(string $start, string $end): void {
		global $wpdb;
		$table = self::table_queries();
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE metric_date BETWEEN %s AND %s",
				$start,
				$end
			)
		);
	}

	public static function purge_pages_range(string $start, string $end): void {
		global $wpdb;
		$table = self::table_pages();
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE metric_date BETWEEN %s AND %s",
				$start,
				$end
			)
		);
	}

	public static function purge_query_pages_range(string $start, string $end): void {
		global $wpdb;
		$table = self::table_query_pages();
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE metric_date BETWEEN %s AND %s",
				$start,
				$end
			)
		);
	}

	/**
	 * Top GSC landing page for a search query (Stage B mapping).
	 *
	 * @return array{page_url:string,impressions:int,clicks:int,position:float,ctr:float}|null
	 */
	public static function top_page_for_query(string $query, int $days = 28): ?array {
		global $wpdb;
		$query = trim($query);
		if ($query === '') {
			return null;
		}
		$days = max(1, min(90, $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_query_pages();
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT page_url,
					SUM(impressions) AS impressions,
					SUM(clicks) AS clicks,
					AVG(NULLIF(position, 0)) AS position,
					CASE WHEN SUM(impressions) > 0 THEN SUM(clicks) / SUM(impressions) ELSE 0 END AS ctr
				FROM $table
				WHERE metric_date >= %s AND query_text = %s
				GROUP BY page_url
				ORDER BY impressions DESC, clicks DESC
				LIMIT 1",
				$from,
				mb_substr($query, 0, 512)
			),
			ARRAY_A
		);
		if (!is_array($row) || trim((string) ($row['page_url'] ?? '')) === '') {
			return null;
		}
		return [
			'page_url' => (string) $row['page_url'],
			'impressions' => (int) ($row['impressions'] ?? 0),
			'clicks' => (int) ($row['clicks'] ?? 0),
			'position' => round((float) ($row['position'] ?? 0), 1),
			'ctr' => (float) ($row['ctr'] ?? 0),
		];
	}

	/**
	 * Distinct GSC page URLs from the latest synced window.
	 *
	 * @return array<int, array{page_url:string,impressions:int,clicks:int,position:float}>
	 */
	public static function gsc_pages_list(int $limit = 200, int $days = 28): array {
		global $wpdb;
		$limit = max(1, min(500, $limit));
		$days = max(1, min(90, $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$table = self::table_pages();
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT page_url,
					SUM(impressions) AS impressions,
					SUM(clicks) AS clicks,
					AVG(NULLIF(position, 0)) AS position
				FROM $table
				WHERE metric_date >= %s
				GROUP BY page_url
				ORDER BY impressions DESC
				LIMIT %d",
				$from,
				$limit
			),
			ARRAY_A
		);
		if (!is_array($rows)) {
			return [];
		}
		$out = [];
		foreach ($rows as $row) {
			$url = trim((string) ($row['page_url'] ?? ''));
			if ($url === '') {
				continue;
			}
			$out[] = [
				'page_url' => $url,
				'impressions' => (int) ($row['impressions'] ?? 0),
				'clicks' => (int) ($row['clicks'] ?? 0),
				'position' => round((float) ($row['position'] ?? 0), 1),
			];
		}
		return $out;
	}

	/**
	 * Published public URLs (pages + posts) for coverage comparison.
	 *
	 * @return array<int, array{url:string,post_id:int,title:string,type:string}>
	 */
	public static function published_content_urls(int $limit = 400): array {
		$limit = max(50, min(800, $limit));
		$q = new \WP_Query([
			'post_type' => ['page', 'post'],
			'post_status' => 'publish',
			'posts_per_page' => $limit,
			'orderby' => 'modified',
			'order' => 'DESC',
			'no_found_rows' => true,
			'ignore_sticky_posts' => true,
		]);
		$out = [];
		foreach ($q->posts as $post) {
			if (!$post || empty($post->ID)) {
				continue;
			}
			$pid = (int) $post->ID;
			$url = (string) get_permalink($pid);
			if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
				continue;
			}
			$out[] = [
				'url' => $url,
				'post_id' => $pid,
				'title' => (string) get_the_title($pid),
				'type' => (string) $post->post_type,
			];
		}
		return $out;
	}

	/**
	 * Coverage gaps: published without GSC demand, and GSC URLs without a WP post.
	 *
	 * @return array{
	 * published_no_gsc: array<int, array<string,mixed>>,
	 * gsc_no_wp: array<int, array<string,mixed>>,
	 * matched: int,
	 * published_total: int,
	 * gsc_total: int
	 * }
	 */
	public static function coverage_gaps(int $days = 28): array {
		$published = self::published_content_urls(400);
		$gsc = self::gsc_pages_list(300, $days);

		$norm_pub = [];
		foreach ($published as $item) {
			$key = self::normalize_url_key((string) $item['url']);
			if ($key === '') {
				continue;
			}
			$norm_pub[$key] = $item;
		}

		$norm_gsc = [];
		foreach ($gsc as $item) {
			$key = self::normalize_url_key((string) $item['page_url']);
			if ($key === '') {
				continue;
			}
			$norm_gsc[$key] = $item;
		}

		$published_no_gsc = [];
		foreach ($norm_pub as $key => $item) {
			if (!isset($norm_gsc[$key])) {
				$published_no_gsc[] = $item;
			}
		}

		$gsc_no_wp = [];
		$matched = 0;
		foreach ($norm_gsc as $key => $item) {
			if (isset($norm_pub[$key])) {
				$matched++;
				continue;
			}
			$post_id = self::url_to_local_post_id((string) $item['page_url']);
			if ($post_id > 0) {
				$matched++;
				continue;
			}
			$gsc_no_wp[] = $item;
		}

		usort($published_no_gsc, static function ($a, $b) {
			return strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? ''));
		});
		usort($gsc_no_wp, static function ($a, $b) {
			return ((int) ($b['impressions'] ?? 0)) <=> ((int) ($a['impressions'] ?? 0));
		});

		return [
			'published_no_gsc' => array_slice($published_no_gsc, 0, 25),
			'gsc_no_wp' => array_slice($gsc_no_wp, 0, 25),
			'matched' => $matched,
			'published_total' => count($norm_pub),
			'gsc_total' => count($norm_gsc),
			'published_no_gsc_total' => count($published_no_gsc),
			'gsc_no_wp_total' => count($gsc_no_wp),
		];
	}

	/** Normalize URL for coverage matching (host + path, no query/fragment, trailing slash trimmed). */
	public static function normalize_url_key(string $url): string {
		$url = trim($url);
		if ($url === '') {
			return '';
		}
		$parts = wp_parse_url($url);
		if (!is_array($parts) || empty($parts['host'])) {
			return '';
		}
		$host = mb_strtolower((string) $parts['host']);
		$host = preg_replace('/^www\./', '', $host) ?: $host;
		$path = (string) ($parts['path'] ?? '/');
		$path = rawurldecode($path);
		$path = untrailingslashit($path);
		if ($path === '') {
			$path = '/';
		}
		return $host . $path;
	}

	/** Resolve a GSC URL to a local post ID when possible. */
	public static function url_to_local_post_id(string $url): int {
		$url = trim($url);
		if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
			return 0;
		}
		$pid = (int) url_to_postid($url);
		if ($pid > 0) {
			return $pid;
		}
		// Try without query string / trailing slash variants.
		$clean = preg_replace('/[?#].*$/', '', $url) ?: $url;
		$pid = (int) url_to_postid($clean);
		if ($pid > 0) {
			return $pid;
		}
		$pid = (int) url_to_postid(trailingslashit($clean));
		if ($pid > 0) {
			return $pid;
		}
		$pid = (int) url_to_postid(untrailingslashit($clean));
		return max(0, $pid);
	}

	/**
	 * Monthly GSC SEO series for trend charts (calendar months within history window).
	 *
	 * @return array<int, array{
	 * ym:string,
	 * label:string,
	 * year:int,
	 * month:int,
	 * clicks:int,
	 * impressions:int,
	 * ctr:float,
	 * position:float,
	 * days_with_data:int,
	 * is_partial:bool
	 * }>
	 */
	public static function monthly_seo_series(int $months = 16): array {
		global $wpdb;
		$months = max(2, min(16, $months));
		$hist_days = max(7, IComm_SM_Features::trend_history_days());
		$from = gmdate('Y-m-d', strtotime('-' . $hist_days . ' days'));
		$today = gmdate('Y-m-d');
		$daily = self::table_daily();
		$queries = self::table_queries();

		$daily_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT DATE_FORMAT(metric_date, '%%Y-%%m') AS ym,
					metric_key,
					SUM(metric_value) AS total,
					AVG(metric_value) AS avg_val,
					COUNT(DISTINCT metric_date) AS days_n
				FROM {$daily}
				WHERE source = 'gsc'
					AND metric_key IN ('clicks', 'impressions', 'position')
					AND metric_date BETWEEN %s AND %s
				GROUP BY ym, metric_key
				ORDER BY ym ASC",
				$from,
				$today
			),
			ARRAY_A
		);

		$pos_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT DATE_FORMAT(metric_date, '%%Y-%%m') AS ym,
					CASE WHEN SUM(impressions) > 0
						THEN SUM(position * impressions) / SUM(impressions)
						ELSE AVG(NULLIF(position, 0))
					END AS avg_pos
				FROM {$queries}
				WHERE metric_date BETWEEN %s AND %s
				GROUP BY ym
				ORDER BY ym ASC",
				$from,
				$today
			),
			ARRAY_A
		);

		/** @var array<string, array{clicks:int,impressions:int,days_with_data:int,position:float}> $by */
		$by = [];
		if (is_array($daily_rows)) {
			foreach ($daily_rows as $row) {
				$ym = (string) ($row['ym'] ?? '');
				if ($ym === '' || !preg_match('/^\d{4}-\d{2}$/', $ym)) {
					continue;
				}
				if (!isset($by[$ym])) {
					$by[$ym] = ['clicks' => 0, 'impressions' => 0, 'days_with_data' => 0, 'position' => 0.0];
				}
				$key = (string) ($row['metric_key'] ?? '');
				$total = (int) ($row['total'] ?? 0);
				$days_n = (int) ($row['days_n'] ?? 0);
				if ($key === 'clicks') {
					$by[$ym]['clicks'] = $total;
					$by[$ym]['days_with_data'] = max($by[$ym]['days_with_data'], $days_n);
				} elseif ($key === 'impressions') {
					$by[$ym]['impressions'] = $total;
					$by[$ym]['days_with_data'] = max($by[$ym]['days_with_data'], $days_n);
				} elseif ($key === 'position') {
					$by[$ym]['position'] = round((float) ($row['avg_val'] ?? 0), 1);
				}
			}
		}
		if (is_array($pos_rows)) {
			foreach ($pos_rows as $row) {
				$ym = (string) ($row['ym'] ?? '');
				if ($ym === '' || !isset($by[$ym])) {
					continue;
				}
				// Prefer query-weighted position when available for that month.
				$qpos = round((float) ($row['avg_pos'] ?? 0), 1);
				if ($qpos > 0) {
					$by[$ym]['position'] = $qpos;
				}
			}
		}

		$out = [];
		$cur_ym = gmdate('Y-m');
		$cursor = strtotime(gmdate('Y-m-01'));
		for ($i = $months - 1; $i >= 0; $i--) {
			$ts = strtotime('-' . $i . ' months', $cursor);
			$ym = gmdate('Y-m', $ts);
			$year = (int) gmdate('Y', $ts);
			$month = (int) gmdate('n', $ts);
			$row = $by[$ym] ?? ['clicks' => 0, 'impressions' => 0, 'days_with_data' => 0, 'position' => 0.0];
			$clicks = (int) $row['clicks'];
			$impr = (int) $row['impressions'];
			$days_in_month = (int) gmdate('t', $ts);
			$is_partial = ($ym === $cur_ym) || ((int) $row['days_with_data'] > 0 && (int) $row['days_with_data'] < max(20, (int) floor($days_in_month * 0.7)));
			$out[] = [
				'ym' => $ym,
				'label' => self::hebrew_month_label($month, $year),
				'year' => $year,
				'month' => $month,
				'clicks' => $clicks,
				'impressions' => $impr,
				'ctr' => $impr > 0 ? round($clicks / $impr, 4) : 0.0,
				'position' => (float) $row['position'],
				'days_with_data' => (int) $row['days_with_data'],
				'is_partial' => $is_partial,
			];
		}

		return $out;
	}

	/**
	 * Daily GSC series for detailed charts.
	 *
	 * @return array<int, array{date:string,clicks:float,impressions:float,position:float}>
	 */
	public static function daily_seo_series(int $days = 486): array {
		global $wpdb;
		$days = max(7, min(IComm_SM_Features::trend_history_days(), $days));
		$from = gmdate('Y-m-d', strtotime('-' . $days . ' days'));
		$today = gmdate('Y-m-d');
		$daily = self::table_daily();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT metric_date, metric_key, metric_value
				FROM {$daily}
				WHERE source = 'gsc'
					AND metric_key IN ('clicks', 'impressions', 'position')
					AND metric_date BETWEEN %s AND %s
				ORDER BY metric_date ASC",
				$from,
				$today
			),
			ARRAY_A
		);

		/** @var array<string, array{date:string,clicks:float,impressions:float,position:float}> $by */
		$by = [];
		if (is_array($rows)) {
			foreach ($rows as $row) {
				$date = (string) ($row['metric_date'] ?? '');
				if ($date === '') {
					continue;
				}
				if (!isset($by[$date])) {
					$by[$date] = ['date' => $date, 'clicks' => 0.0, 'impressions' => 0.0, 'position' => 0.0];
				}
				$key = (string) ($row['metric_key'] ?? '');
				$val = (float) ($row['metric_value'] ?? 0);
				if ($key === 'clicks' || $key === 'impressions' || $key === 'position') {
					$by[$date][$key] = $val;
				}
			}
		}
		return array_values($by);
	}

	/**
	 * Monthly OHLC (open/high/low/close) from daily metric for candle charts.
	 *
	 * @param 'clicks'|'impressions' $metric
	 * @return array<int, array{ym:string,label:string,o:float,h:float,l:float,c:float,sum:float}>
	 */
	public static function monthly_ohlc(string $metric = 'clicks', int $months = 16): array {
		$metric = ($metric === 'impressions') ? 'impressions' : 'clicks';
		$months = max(2, min(16, $months));
		$daily = self::daily_seo_series(IComm_SM_Features::trend_history_days());
		/** @var array<string, array{vals:array<int,float>,sum:float}> $by */
		$by = [];
		foreach ($daily as $row) {
			$date = (string) ($row['date'] ?? '');
			if ($date === '' || strlen($date) < 7) {
				continue;
			}
			$ym = substr($date, 0, 7);
			$val = (float) ($row[$metric] ?? 0);
			if (!isset($by[$ym])) {
				$by[$ym] = ['vals' => [], 'sum' => 0.0];
			}
			$by[$ym]['vals'][] = $val;
			$by[$ym]['sum'] += $val;
		}

		$out = [];
		$cursor = strtotime(gmdate('Y-m-01'));
		for ($i = $months - 1; $i >= 0; $i--) {
			$ts = strtotime('-' . $i . ' months', $cursor);
			$ym = gmdate('Y-m', $ts);
			$year = (int) gmdate('Y', $ts);
			$month = (int) gmdate('n', $ts);
			$vals = $by[$ym]['vals'] ?? [];
			if ($vals === []) {
				continue;
			}
			$out[] = [
				'ym' => $ym,
				'label' => self::hebrew_month_label($month, $year),
				'o' => (float) $vals[0],
				'h' => (float) max($vals),
				'l' => (float) min($vals),
				'c' => (float) $vals[count($vals) - 1],
				'sum' => (float) ($by[$ym]['sum'] ?? 0),
			];
		}
		return $out;
	}

	private static function hebrew_month_label(int $month, int $year): string {
		$names = [
			1 => 'ינואר',
			2 => 'פברואר',
			3 => 'מרץ',
			4 => 'אפריל',
			5 => 'מאי',
			6 => 'יוני',
			7 => 'יולי',
			8 => 'אוגוסט',
			9 => 'ספטמבר',
			10 => 'אוקטובר',
			11 => 'נובמבר',
			12 => 'דצמבר',
		];
		$name = $names[$month] ?? (string) $month;
		return $name . ' ' . $year;
	}
}
