<?php

/**
 * Costudio_References_Renderer
 *
 * Post-Writer pass that turns the `{{ref:N}}` placeholders left inline by
 * Agent 04 into deterministic, validated citations and assembles the
 * unified Referências section appended to the article.
 *
 * Citable pool (numbered consecutively, 1-based) is the union of:
 *   1. Grounded sources persisted by /ai/search (`sources_json`)
 *   2. User-provided externals from `links_internos.external[]`
 *
 * Internal links from `links_internos.internal[]` (selected only) are NOT
 * citable via {{ref:N}} — they appear in a separate "Conteúdo relacionado"
 * subsection so the user's input never gets lost even when the model
 * forgot to weave them inline.
 *
 * Pipeline:
 *  1. Looks each N up in the citable pool. Vertex grounding-redirect URLs
 *     are followed to canonical destination (30-day positive cache).
 *  2. Validates the final URL is reachable via HTTP GET (12-hour positive
 *     cache, short negative cache so 503s recover quickly). Soft-404 detection
 *     covers Apache/IIS default error titles + i18n variants.
 *  3. On failure, falls back to the host root URL — many "deep URL not found"
 *     symptoms are LLM hallucinations of paths under a legitimate authority.
 *  4. Substitutes each `{{ref:N}}` placeholder with academic-style superscript:
 *       <sup><a href="..." rel="nofollow noopener" target="_blank">[N]</a></sup>
 *  5. For all article types except `news`, appends `## Referências`
 *     with two locale-aware subsections:
 *       - Fontes externas: every citable that resolves (cited first with [N],
 *         then non-cited externals as a curated bibliography)
 *       - Conteúdo relacionado: user-selected internal links
 *  6. Strips prior handwritten `## Referências` blocks from the Markdown so
 *     duplicate/broken IA lists cannot reach publishing.
 *
 * Designed to be idempotent and side-effect-free relative to the article:
 * if a source can't be resolved or validated, the placeholder is silently
 * dropped (the broken-link class of bug stays out of published content).
 *
 * @package Contentosapp_Studio
 * @since   1.15.0
 */

defined('ABSPATH') || exit;

class Costudio_References_Renderer
{

	const PLACEHOLDER_PATTERN  = '/\{\{ref:([a-z0-9\-_]+)\}\}/iu';
	// Markdown link with the placeholder as the URL — `[anchor]({{ref:N}})`.
	// Captures group 1 = anchor text, group 2 = source index or name.
	const NAMED_LINK_PATTERN   = '/\[([^\]\n]+)\]\(\{\{ref:([a-z0-9\-_]+)\}\}\)/iu';
	// HTML link rendered by Parsedown — `<a href="{{ref:N}}">anchor</a>`.
	const NAMED_LINK_HTML_PATTERN = '/<a\s+[^>]*href="\{\{ref:([a-z0-9\-_]+)\}\}"[^>]*>(.*?)<\/a>/iu';
	const VERTEX_REDIRECT_HOST = 'vertexaisearch.cloud.google.com';

	const VERTEX_RESOLVE_TTL = 30 * DAY_IN_SECONDS;
	const HTTP_VALIDATE_TTL  = 12 * HOUR_IN_SECONDS;
	const NEGATIVE_CACHE_TTL = 5 * MINUTE_IN_SECONDS;

	const HEAD_TIMEOUT = 8;
	const MAX_HOPS     = 8;
	const USER_AGENT   = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';

	/** @var array<int,array<string,mixed>> 0-based pool of citable refs (grounded ∪ user-externals) */
	private array $sources;

	/** @var int Boundary: indexes < this are grounded, ≥ this are user-supplied externals. */
	private int $user_external_offset;

	/** @var array<int,array{title:string,url:string,site_name:string,post_type:string}> User-selected internal links. */
	private array $internals;

	/** @var array<int,array{url:string,title:string}|null> idx (1-based) => resolved data | null */
	private array $resolved_cache = array();

	/** @var array<int,int> source_index (1-based) => display number assigned in render order */
	private array $used_refs = array();

	private int $next_n = 1;

	/**
	 * Accent colour used in the inline `<sup>` references CSS. Resolved from
	 * the site's "Cor do tema" (sites repo). Default keeps the original blue
	 * so installations without a custom site colour keep their look.
	 */
	const DEFAULT_ACCENT_COLOR = '#006ee5';

	private string $accent_color = self::DEFAULT_ACCENT_COLOR;

	/**
	 * @param array  $grounded_sources List of { url, title, snippet, … } (from /ai/search).
	 * @param array  $user_externals   List of { url, description } (links_internos.external).
	 * @param array  $user_internals   List of { title, url, site_name, post_type } (selected internals).
	 * @param string $accent_color     Hex colour (e.g. '#3b82f6') for inline ref links. Empty = default.
	 */
	public function __construct(array $grounded_sources, array $user_externals = array(), array $user_internals = array(), string $accent_color = '')
	{
		if ($accent_color !== '' && preg_match('/^#[0-9a-f]{3,8}$/i', $accent_color)) {
			$this->accent_color = $accent_color;
		}
		$grounded             = array_values($grounded_sources);
		$externals_normalised = array();
		foreach (array_values($user_externals) as $ext) {
			$url = isset($ext['url']) ? trim((string) $ext['url']) : '';
			if ($url === '') {
				continue;
			}
			$desc = isset($ext['description']) ? trim((string) $ext['description']) : '';
			$externals_normalised[] = array(
				'url'          => $url,
				'title'        => $desc,
				'snippet'      => $desc,
				'is_user_ext'  => true,
			);
		}

		$this->user_external_offset = count($grounded);
		$this->sources              = array_merge($grounded, $externals_normalised);
		$this->internals            = array_values(array_filter(
			array_map(static function ($item) {
				$url = isset($item['url']) ? trim((string) $item['url']) : '';
				if ($url === '') {
					return null;
				}
				return array(
					'title'     => isset($item['title']) ? trim((string) $item['title']) : '',
					'url'       => $url,
					'site_name' => isset($item['site_name']) ? trim((string) $item['site_name']) : '',
					'post_type' => isset($item['post_type']) ? trim((string) $item['post_type']) : 'post',
				);
			}, $user_internals),
			static fn($item) => $item !== null
		));
	}

	/**
	 * Build a renderer from the production's persisted state. Reads:
	 *   - `sources_json`              → grounded citable pool
	 *   - `links_internos.external[]` → user-provided external citable pool
	 *   - `links_internos.internal[]` → user-selected internal links (informational)
	 *
	 * Returns null only when ALL THREE collections are empty (nothing to render).
	 * In any other case the caller should always run `render()`, even if just
	 * to strip a manual `## Referências` block emitted by the LLM.
	 */
	public static function from_production(int $production_id): ?self
	{
		if ($production_id <= 0) {
			return null;
		}
		$prod = Costudio_Production::get_by_id($production_id);
		if (! $prod) {
			return null;
		}

		$grounded = array();
		if (! empty($prod->sources_json)) {
			$decoded = json_decode((string) $prod->sources_json, true);
			if (is_array($decoded)) {
				$grounded = $decoded;
			}
		}

		$user_externals = array();
		$user_internals = array();
		if (! empty($prod->links_internos) && class_exists('Costudio_Pipeline')) {
			$parsed         = Costudio_Pipeline::parse_links_internos((string) $prod->links_internos);
			$user_externals = (array) ($parsed['external'] ?? array());
			// Internals: only the user-selected ones.
			foreach ((array) ($parsed['internal'] ?? array()) as $item) {
				if (! empty($item['selected'])) {
					$user_internals[] = $item;
				}
			}
		}

		if (empty($grounded) && empty($user_externals) && empty($user_internals)) {
			return null;
		}

		// Resolve the site's "Cor do tema" so the inline references CSS
		// inherits the brand colour instead of the legacy hard-coded blue.
		$accent_color = '';
		if (! empty($prod->site_key) && function_exists('costudio_sites')) {
			$repo = costudio_sites();
			if ($repo) {
				$site_cfg = $repo->get((string) $prod->site_key);
				if (! empty($site_cfg['color'])) {
					$accent_color = (string) $site_cfg['color'];
				}
			}
		}

		$instance = new self($grounded, $user_externals, $user_internals, $accent_color);

		costudio_log_debug(
			'references.from_production',
			array(
				'production_id'  => $production_id,
				'grounded'       => count($grounded),
				'user_externals' => count($user_externals),
				'user_internals' => count($user_internals),
				'citable_pool'   => count($instance->sources),
				'accent_color'   => $instance->accent_color,
			)
		);

		return $instance;
	}

	/**
	 * Strip a trailing `## Referências` / `## References` / `## Referencias`
	 * block written by the Writer.
	 *
	 * Public + static so the Publisher's no-renderer fallback can apply the
	 * same defence-in-depth strip without instantiating this class.
	 */
	public static function strip_manual_references(string $content): string
	{
		$content = preg_replace(
			'/(?:\R|^)\s*##\s+(?:Referências|References|Referencias)\b.*\z/su',
			'',
			$content
		);
		return rtrim($content);
	}

	/**
	 * Deterministic hallucination guard: remove EXTERNAL links the Writer
	 * invented when the production had no verified (grounded) sources.
	 *
	 * Only links whose host is NOT in $allowed_hosts are stripped — converting
	 * `[anchor](url)` / `<a href="url">anchor</a>` to the bare anchor text and
	 * dropping bare external URLs. Internal site links and user-provided
	 * external links (the whitelist) are preserved untouched. Markdown image
	 * syntax `![alt](url)` is never matched.
	 *
	 * Idempotent and prose-safe: never removes reader-visible text, only the
	 * unverifiable link wrapper/URL.
	 *
	 * @param string            $content       Article body (Markdown or mixed HTML), pre-Gutenberg.
	 * @param array<int,string> $allowed_hosts Hosts/URLs that may keep their links.
	 * @return string
	 */
	public static function strip_ungrounded_external_links(string $content, array $allowed_hosts): string
	{
		$allowed = array();
		foreach ($allowed_hosts as $h) {
			$host = self::normalize_link_host((string) $h);
			if ($host !== '') {
				$allowed[$host] = true;
			}
		}

		$is_allowed = static function (string $url) use ($allowed): bool {
			$host = self::normalize_link_host($url);
			return $host !== '' && isset($allowed[$host]);
		};

		$stripped = 0;

		// 1. Markdown links `[anchor](http(s)://...)` — but NOT images `![alt](...)`.
		$content = preg_replace_callback(
			'/(?<!\!)\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/i',
			function (array $m) use ($is_allowed, &$stripped) {
				if ($is_allowed($m[2])) {
					return $m[0];
				}
				$stripped++;
				return $m[1]; // keep the anchor text, drop the link.
			},
			$content
		);

		// 2. HTML anchors `<a href="http(s)://...">anchor</a>`.
		$content = preg_replace_callback(
			'/<a\s+[^>]*href="(https?:\/\/[^"]+)"[^>]*>(.*?)<\/a>/is',
			function (array $m) use ($is_allowed, &$stripped) {
				if ($is_allowed($m[1])) {
					return $m[0];
				}
				$stripped++;
				return $m[2]; // keep the anchor text.
			},
			$content
		);

		// 3. Bare external URLs left in prose (after the link forms above).
		$content = preg_replace_callback(
			'/(^|[\s(])(https?:\/\/[^\s<>)\]]+)/i',
			function (array $m) use ($is_allowed, &$stripped) {
				if ($is_allowed($m[2])) {
					return $m[0];
				}
				$stripped++;
				return $m[1]; // keep the boundary char, drop the URL.
			},
			$content
		);

		if ($stripped > 0) {
			$content = preg_replace('/[ \t]{2,}/u', ' ', $content);
			costudio_log_debug('references.ungrounded_externals_stripped', array('count' => $stripped));
		}

		return $content;
	}

	/**
	 * Lowercase, www-stripped host of a URL (or a bare host). Returns '' on
	 * failure.
	 */
	private static function normalize_link_host(string $host_or_url): string
	{
		$value = trim($host_or_url);
		if ($value === '') {
			return '';
		}
		if (strpos($value, '://') !== false) {
			$parsed = wp_parse_url($value, PHP_URL_HOST);
			$value  = is_string($parsed) ? $parsed : '';
		}
		$value = strtolower(trim($value));
		return (string) preg_replace('/^www\./', '', $value);
	}

	/**
	 * Substitute inline placeholders and (when applicable) append the
	 * References section to the article body.
	 *
	 * @param string $content      Markdown article body from Agent 04.
	 * @param string $article_type Canonical article type slug (must already be normalized via Costudio_Article_Types::normalize()).
	 * @param string $locale       Content language (pt_BR | en_US | es_ES).
	 */
	public function render(string $content, string $article_type = '', string $locale = 'pt_BR'): string
	{
		// Writer may still emit a manual `## Referências` block with raw URLs; we only
		// publish the validated list appended at the end — drop the LLM block first.
		$content = self::strip_manual_references($content);

		// Cleanup: drop paragraphs that contain ONLY `{{ref:N}}` placeholders
		// (optionally separated by whitespace/punctuation). The Writer
		// occasionally lists refs as a "notes" block at the rodapé; without
		// this strip PASS 2 would convert each into <sup>[N]</sup>, leaving
		// orphan superscripts hanging visually before the rendered References
		// section. Targets blocks bordered by blank lines OR by EOF/EOS.
		$orphan_count = 0;
		$content      = preg_replace_callback(
			'/(?:\R\s*\R|^)\s*((?:\{\{ref:[a-z0-9\-_]+\}\}[\s,;.]*)+)\s*(?=\R\s*\R|\z)/iu',
			function () use (&$orphan_count) {
				$orphan_count++;
				return "\n\n";
			},
			$content
		);
		if ($orphan_count > 0) {
			costudio_log_debug(
				'references.orphan_paragraphs_dropped',
				array( 'count' => $orphan_count )
			);
		}

		$auto_anchored_bare_refs = 0;
		$content = $this->anchor_bare_sentence_refs($content, $auto_anchored_bare_refs);

		preg_match_all(self::PLACEHOLDER_PATTERN, $content, $found_matches);
		preg_match_all(self::NAMED_LINK_PATTERN, $content, $named_matches);
		$placeholders_found      = isset($found_matches[1]) ? count($found_matches[1]) : 0;
		$named_links_found       = isset($named_matches[2]) ? count($named_matches[2]) : 0;
		$bare_placeholders_found = max(0, $placeholders_found - $named_links_found);
		$unique_indexes          = isset($found_matches[1]) ? array_unique(array_map('intval', $found_matches[1])) : array();

		$validations_ok     = 0;
		$validations_failed = 0;

		// PASS 1 — named inline citations: `[anchor]({{ref:N}})` → `[anchor](resolved-url)`.
		// Must run BEFORE the bare `{{ref:N}}` pass so the inner placeholder isn't
		// converted to a numeric superscript inside the link.
		$content = preg_replace_callback(
			self::NAMED_LINK_PATTERN,
			function (array $m) use (&$validations_ok, &$validations_failed) {
				$anchor   = trim($m[1]);
				$idx      = (int) $m[2];
				$resolved = $this->resolve_ref($idx);
				if (! $resolved || $anchor === '') {
					$validations_failed++;
					// Drop the link wrapper but keep the anchor text — never lose
					// reader-visible prose to a missing source.
					return $anchor;
				}
				$validations_ok++;
				// Track this source as cited so it appears in the References list.
				$this->display_number_for($idx);
				return sprintf('[%s](%s)', $anchor, $resolved['url']);
			},
			$content
		);

		// PASS 1.1 — HTML named inline citations (if already converted by Parsedown): `<a href="{{ref:N}}">anchor</a>` → `<a href="resolved-url">anchor</a>`.
		$content = preg_replace_callback(
			self::NAMED_LINK_HTML_PATTERN,
			function (array $m) use (&$validations_ok, &$validations_failed) {
				$idx      = (int) $m[1];
				$anchor   = $m[2];
				$resolved = $this->resolve_ref($idx);
				if (! $resolved) {
					$validations_failed++;
					return $anchor;
				}
				$validations_ok++;
				$this->display_number_for($idx);
				return sprintf('<a href="%s" rel="nofollow noopener" target="_blank">%s</a>', esc_url($resolved['url']), $anchor);
			},
			$content
		);

		// PASS 2 — bare `{{ref:N}}` fallback → inline named link using the
		// source title as anchor text. The preferred path is Pass 1 (Writer
		// supplies `[anchor]({{ref:N}})`); this pass catches any bare
		// placeholders that anchor_bare_sentence_refs couldn't auto-convert.
		// Using the source title keeps citations readable — no numeric
		// superscripts ever appear inline in the published article.
		$content = preg_replace_callback(
			self::PLACEHOLDER_PATTERN,
			function (array $m) use (&$validations_ok, &$validations_failed) {
				$raw_key = $m[1];
				$idx     = $this->resolve_key_to_index($raw_key);

				if (! $idx ) {
					return '';
				}

				$resolved = $this->resolve_ref($idx);
				if (! $resolved) {
					$validations_failed++;
					return '';
				}
				$validations_ok++;
				$this->display_number_for($idx);
				$anchor = trim((string) $resolved['title']);
				if ($anchor === '') {
					$anchor = $this->beautify_domain($resolved['url']);
				}
				return sprintf(
					'<a href="%s" rel="nofollow noopener" target="_blank">%s</a>',
					esc_url($resolved['url']),
					esc_html($anchor)
				);
			},
			$content
		);

		// Cleanup: collapse stray spaces left from dropped placeholders.
		$content = preg_replace('/[ \t]{2,}/u', ' ', $content);
		$content = preg_replace("/\n{3,}/", "\n\n", $content);

		// Append unified Referências section unless this is a news article.
		// For news, citations remain inline only — academic block is editorially wrong.
		$section_block = '';
		if (! $this->is_news_type($article_type)) {
			$section_block = $this->build_references_section($locale);
			if ($section_block !== '') {
				$content = rtrim($content) . "\n\n" . $section_block . "\n";
			}
		}

		costudio_log_debug(
			'references.render',
			array(
				'placeholders_found'      => $placeholders_found,
				'named_links_found'       => $named_links_found,
				'bare_placeholders_found' => $bare_placeholders_found,
				'auto_anchored_bare_refs' => $auto_anchored_bare_refs,
				'unique_indexes'          => count($unique_indexes),
				'citable_pool'            => count($this->sources),
				'grounded_count'          => $this->user_external_offset,
				'user_externals'          => count($this->sources) - $this->user_external_offset,
				'user_internals'          => count($this->internals),
				'validations_ok'          => $validations_ok,
				'validations_failed'      => $validations_failed,
				'article_type'            => $article_type,
				'is_news'                 => $this->is_news_type($article_type),
				'section_emitted'         => $section_block !== '',
			)
		);

		return $content;
	}

	// ── Internals ───────────────────────────────────────────────────────────

	private function is_news_type(string $article_type): bool
	{
		return Costudio_Article_Types::normalize($article_type) === Costudio_Article_Types::NEWS;
	}

	/**
	 * Best-effort editorial fallback for legacy Writer outputs that still place
	 * `{{ref:N}}` bare at the end of a sentence. The prompt now requires
	 * `[anchor]({{ref:N}})`, but this keeps published articles from falling back
	 * to academic-looking [N] markers when the model ignores that instruction.
	 */
	private function anchor_bare_sentence_refs(string $content, int &$converted): string
	{
		$converted = 0;

		return preg_replace_callback(
			'/(^|[\r\n]+|[.!?]\s+)([^{}\r\n]{12,500}?)\s+\{\{ref:([a-z0-9\-_]+)\}\}([,.;:]?)/iu',
			function (array $m) use (&$converted) {
				$prefix   = $m[1];
				$sentence = $m[2];
				$key      = $m[3];
				$trail    = $m[4] ?? '';

				// Avoid nesting links or altering HTML-ish fragments. Existing
				// Markdown links are already handled by NAMED_LINK_PATTERN.
				if (strpos($sentence, '](') !== false || stripos($sentence, '<a ') !== false || stripos($sentence, '</a>') !== false) {
					return $m[0];
				}

				$sentence_rtrim = rtrim($sentence);
				$spaces         = substr($sentence, strlen($sentence_rtrim));
				$anchor         = $this->pick_auto_anchor_text($sentence_rtrim);
				if ($anchor === '') {
					return $m[0];
				}

				$head = substr($sentence_rtrim, 0, strlen($sentence_rtrim) - strlen($anchor));
				$converted++;

				return $prefix . $head . '[' . $anchor . ']({{ref:' . $key . '}})' . $spaces . $trail;
			},
			$content
		);
	}

	/**
	 * Select a short suffix from the sentence as the linked phrase. This avoids
	 * inventing new words while usually linking the factual claim immediately
	 * before the reference marker.
	 */
	private function pick_auto_anchor_text(string $sentence): string
	{
		if ($sentence === '') {
			return '';
		}

		// Prefer the last clause when it is a readable, compact phrase.
		$parts     = preg_split('/[,;:]\s*/u', $sentence);
		$last_part = is_array($parts) ? trim((string) end($parts)) : '';
		if (is_array($parts) && count($parts) > 1 && $last_part !== '' && $this->count_anchor_words($last_part) >= 2 && strlen($last_part) <= 120) {
			return $last_part;
		}

		preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\-]*(?:[\'’][\p{L}]+)?/u', $sentence, $matches, PREG_OFFSET_CAPTURE);
		$words = $matches[0] ?? array();
		$count = count($words);
		if ($count < 2) {
			return '';
		}

		$take = min(4, $count);
		if ($take < 3 && $count >= 3) {
			$take = 3;
		}

		$start  = (int) $words[$count - $take][1];
		$anchor = trim(substr($sentence, $start));

		if (strlen($anchor) > 140 && $count > 4) {
			$start  = (int) $words[$count - 4][1];
			$anchor = trim(substr($sentence, $start));
		}

		return $this->count_anchor_words($anchor) >= 2 ? $anchor : '';
	}

	private function count_anchor_words(string $text): int
	{
		preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\-]*(?:[\'’][\p{L}]+)?/u', $text, $matches);
		return count($matches[0] ?? array());
	}

	/**
	 * Resolve a 1-based source index to a final URL/title pair, or null when
	 * the source is genuinely unrecoverable.
	 *
	 * Resolution flow (each step gives the URL a chance to be useful before
	 * the next, harsher step is tried):
	 *
	 *   1. Vertex grounding redirect → follow to canonical destination.
	 *   2. Probe the URL via HTTP GET. If it answers cleanly (no error
	 *      title), we use it as-is.
	 *   3. If step 2 fails, try the same host's root URL as a fallback.
	 *      Many "deep URL not found" symptoms are LLM hallucinations of
	 *      paths under an otherwise legitimate authority site (FGV, BCB,
	 *      Nubank, etc.). The root tends to work and keeps the citation's
	 *      authority intact.
	 *   4. If both fail, give up — null. The renderer drops the placeholder
	 *      silently so the article never publishes a broken link.
	 */
	private function resolve_ref(int $idx): ?array
	{
		if (array_key_exists($idx, $this->resolved_cache)) {
			return $this->resolved_cache[$idx];
		}

		$src = $this->sources[$idx - 1] ?? null; // 1-based addressing.
		if (! is_array($src) || empty($src['url'])) {
			return $this->resolved_cache[$idx] = null;
		}

		$url = costudio_safe_http_url(trim((string) $src['url']));
		if ($url === '') {
			return $this->resolved_cache[$idx] = null;
		}

		// Step 1 — Vertex redirect resolution.
		if (strpos($url, self::VERTEX_REDIRECT_HOST) !== false) {
			$resolved_url = $this->resolve_vertex_redirect($url);
			if ($resolved_url === '') {
				return $this->resolved_cache[$idx] = null;
			}
			$url = $resolved_url;
		}

		$probe = $this->probe_url($url);

		// Step 2 — clean response: use this URL.
		if ($probe['status'] === 'ok') {
			$title = $this->pick_title((string) ($src['title'] ?? ''), $probe['title'], $url);
			return $this->resolved_cache[$idx] = array(
				'url'   => $url,
				'title' => $title,
			);
		}

		// Step 3 — try the host root as a graceful fallback.
		$root = $this->root_of($url);
		if ($root !== '' && $root !== $url) {
			$root_probe = $this->probe_url($root);
			if ($root_probe['status'] === 'ok') {
				$title = $this->pick_title((string) ($src['title'] ?? ''), $root_probe['title'], $root);
				return $this->resolved_cache[$idx] = array(
					'url'   => $root,
					'title' => $title,
				);
			}
		}

		// Step 4 — irrecoverable.
		return $this->resolved_cache[$idx] = null;
	}

	/**
	 * Choose the best title for a reference list entry, in this order:
	 *   1. The probed page's `<title>` (real, recently fetched).
	 *   2. The source's stored title — but only if it's not just the bare
	 *      hostname (`/ai/search` often stores hosts as titles).
	 *   3. A beautified version of the hostname.
	 */
	private function pick_title(string $stored, string $fetched, string $url): string
	{
		$fetched = trim($fetched);
		if ($fetched !== '') {
			return $fetched;
		}

		$stored = trim($stored);
		if ($stored !== '' && ! $this->looks_like_domain_only($stored)) {
			return $stored;
		}

		return $this->beautify_domain($url);
	}

	/**
	 * Build the host's canonical root URL (`https://example.com/`) from any
	 * URL on that host. Returns '' when parsing fails.
	 */
	private function root_of(string $url): string
	{
		$parts = wp_parse_url($url);
		if (! is_array($parts) || empty($parts['host'])) {
			return '';
		}
		$scheme = ! empty($parts['scheme']) ? strtolower((string) $parts['scheme']) : 'https';
		if (! in_array($scheme, array('http', 'https'), true)) {
			return '';
		}

		return costudio_safe_http_url($scheme . '://' . $parts['host'] . '/');
	}

	/**
	 * Detect HTML titles that mean "this page is broken" so we can fall back
	 * to a working alternative instead of citing a 404 in the bibliography.
	 *
	 * Covers the most common stubs from Apache/IIS default error pages plus
	 * the Portuguese/Spanish equivalents we've seen in the wild.
	 */
	private function is_error_page_title(string $title): bool
	{
		$t = trim($title);
		if ($t === '') {
			return false;
		}
		return (bool) preg_match(
			'/(\b404\b|page\s*not\s*found|file\s*or\s*directory\s*not\s*found|p[áa]gina\s*n[ãa]o\s*encontrada|p[áa]gina\s*no\s*encontrada|not\s*found\s*[-:]?\s*404|error\s*404)/i',
			$t
		);
	}

	/**
	 * Maps a key (could be numeric "1" or string "RDStation") to a 1-based index 
	 * in the sources array.
	 */
	private function resolve_key_to_index($key): ?int
	{
		if (is_numeric($key)) {
			$idx = (int) $key;
			return ($idx > 0 && $idx <= count($this->sources)) ? $idx : null;
		}

		// Search by title or URL substring.
		$key_lower = strtolower($key);
		foreach ($this->sources as $i => $src) {
			$title = strtolower((string) ($src['title'] ?? ''));
			$url   = strtolower((string) ($src['url'] ?? ''));

			if (strpos($title, $key_lower) !== false || strpos($url, $key_lower) !== false) {
				return $i + 1;
			}
		}

		return null;
	}

	private function beautify_domain(string $url): string
	{
		$host = wp_parse_url($url, PHP_URL_HOST);
		if (! $host) return $url;

		$host = preg_replace('/^www\./', '', $host);
		$parts = explode('.', $host);

		if (count($parts) >= 1) {
			$name = $parts[0];
			// Handle cases like "rdstation" -> "RD Station"
			$name = preg_replace('/([a-z])([A-Z])/', '$1 $2', $name);
			// Known brands special casing
			$map = [
				'rdstation' => 'RD Station',
				'wix'       => 'Wix',
				'elementor' => 'Elementor',
				'mailchimp' => 'Mailchimp',
				'google'    => 'Google',
				'youtube'   => 'YouTube',
			];
			if (isset($map[strtolower($name)])) {
				return $map[strtolower($name)];
			}
			return ucfirst($name);
		}

		return $host;
	}

	/**
	 * Heuristic: a "title" is treated as a domain-only stub when it contains
	 * no spaces and matches a hostname shape (label.tld, optionally with
	 * subdomains). In those cases we try to fetch the page's real `<title>`.
	 */
	private function looks_like_domain_only(string $title): bool
	{
		$t = trim($title);
		if ($t === '') {
			return true;
		}
		if (strpos($t, ' ') !== false) {
			return false;
		}
		return (bool) preg_match('/^[a-z0-9][a-z0-9.\-]+\.[a-z]{2,}$/i', $t);
	}

	private function display_number_for(int $idx): int
	{
		if (! isset($this->used_refs[$idx])) {
			$this->used_refs[$idx] = $this->next_n++;
		}
		return $this->used_refs[$idx];
	}

	/**
	 * Follow the redirect chain from a Vertex grounding-redirect URL until a
	 * non-redirecting response is reached or MAX_HOPS is exhausted.
	 *
	 * Cached for 30 days on success, short cache on failure.
	 */
	private function resolve_vertex_redirect(string $url): string
	{
		// Shared implementation (also used by the Gemini search engine at search
		// time). Same `costudio_vref_*` cache, so a redirect resolved in either
		// place is reused here.
		if (class_exists('Costudio_Url')) {
			return Costudio_Url::resolve_vertex_redirect($url);
		}
		return $url;
	}

	/**
	 * Fetch a URL and return structured evidence about whether it's a
	 * publishable reference target.
	 *
	 * Returns:
	 *   [ 'status' => 'ok' | 'unreachable' | 'soft_404' | 'http_error',
	 *     'title'  => string,   // page <title>, '' when unavailable
	 *     'code'   => int ]     // HTTP status, 0 when transport failed
	 *
	 * Cached for 12h on `ok`, 5min on every failure mode so transient outages
	 * recover quickly. The body is capped at 32 KB — plenty to grab the
	 * `<title>` of a normal HTML document while keeping per-URL latency
	 * bounded for large-scale renders.
	 */
	private function probe_url(string $url): array
	{
		$key    = 'costudio_vurl_' . md5($url);
		$cached = get_transient($key);
		if (is_array($cached) && isset($cached['status'])) {
			return $cached;
		}

		$miss = function (string $status, int $code = 0, string $title = ''): array {
			return array('status' => $status, 'code' => $code, 'title' => $title);
		};

		$safe_url = costudio_safe_http_url($url);
		if ($safe_url === '') {
			$out = $miss('unreachable');
			set_transient($key, $out, self::NEGATIVE_CACHE_TTL);
			return $out;
		}

		$resp = wp_safe_remote_get(
			$safe_url,
			costudio_safe_remote_args(array(
				'timeout'     => self::HEAD_TIMEOUT,
				'redirection' => 5,
				'user-agent'  => self::USER_AGENT,
				'headers'     => array('Accept' => 'text/html,application/xhtml+xml,*/*;q=0.8'),
			))
		);

		if (is_wp_error($resp)) {
			$out = $miss('unreachable');
			set_transient($key, $out, self::NEGATIVE_CACHE_TTL);
			return $out;
		}

		$code = (int) wp_remote_retrieve_response_code($resp);
		if ($code < 200 || $code >= 400) {
			$out = $miss('http_error', $code);
			set_transient($key, $out, self::NEGATIVE_CACHE_TTL);
			return $out;
		}

		$page_title = '';
		$body       = (string) wp_remote_retrieve_body($resp);
		if ($body !== '') {
			$scan = substr($body, 0, 32768);
			if (preg_match('#<title[^>]*>(.*?)</title>#is', $scan, $m)) {
				$t = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
				$t = preg_replace('/\s+/u', ' ', trim($t));
				if ($t !== '') {
					if ($this->is_error_page_title($t)) {
						$out = $miss('soft_404', $code, $t);
						set_transient($key, $out, self::NEGATIVE_CACHE_TTL);
						return $out;
					}
					if (function_exists('mb_strlen') && mb_strlen($t) > 140) {
						$t = mb_substr($t, 0, 137) . '…';
					} elseif (strlen($t) > 140) {
						$t = substr($t, 0, 137) . '…';
					}
					$page_title = $t;
				}
			}
		}

		$out = array('status' => 'ok', 'code' => $code, 'title' => $page_title);
		set_transient($key, $out, self::HTTP_VALIDATE_TTL);
		return $out;
	}

	private function absolutize_url(string $location, string $base): string
	{
		if ($location === '') {
			return $base;
		}
		// Already absolute.
		if (preg_match('#^https?://#i', $location)) {
			return $location;
		}
		$parts = wp_parse_url($base);
		if (! $parts || empty($parts['scheme']) || empty($parts['host'])) {
			return $location;
		}
		$origin = $parts['scheme'] . '://' . $parts['host']
			. (isset($parts['port']) ? ':' . $parts['port'] : '');
		if ($location[0] === '/') {
			return $origin . $location;
		}
		// Relative to current path.
		$path = isset($parts['path']) ? preg_replace('#/[^/]*$#', '/', $parts['path']) : '/';
		return $origin . $path . $location;
	}

	/**
	 * Build the unified Referências section in Markdown, with up to two
	 * locale-aware subsections:
	 *
	 *   ### Fontes externas
	 *     - Cited (numbered [N], in render order) first.
	 *     - Then non-cited externals from the citable pool, listed without
	 *       a number — preserves user-supplied externals + grounded sources
	 *       the Writer didn't weave inline.
	 *
	 *   ### Conteúdo relacionado
	 *     - User-selected internal links, as a simple bullet list. No HTTP
	 *       validation (these come from the user's own site).
	 *
	 * Returns '' when nothing is left to emit (avoids dangling headings).
	 */
	private function build_references_section(string $locale): string
	{
		$labels    = $this->locale_labels($locale);
		$externals = $this->collect_external_section();
		$internals = $this->collect_internal_section();

		if (empty($externals) && empty($internals)) {
			return '';
		}

		$lines = array($labels['heading'], '');

		if (! empty($externals)) {
			$lines[] = '### ' . $labels['externals'];
			$lines[] = '';
			foreach ($externals as $row) {
				$lines[] = $row;
			}
			$lines[] = '';
		}

		if (! empty($internals)) {
			$lines[] = '### ' . $labels['internals'];
			$lines[] = '';
			foreach ($internals as $row) {
				$lines[] = $row;
			}
		}

		return rtrim(implode("\n", $lines));
	}

	/**
	 * Build the "Fontes externas" subsection bullet rows.
	 *
	 * Order:
	 *   1. Sources actually cited inline ({{ref:N}}) — in render order, with [N].
	 *   2. Sources in the citable pool that were never cited — appended without
	 *      a number so user-supplied externals don't disappear when the Writer
	 *      forgot to weave them. Grounded-but-uncited sources are omitted to
	 *      avoid noise (the LLM had the choice and didn't need them).
	 *
	 * @return array<int,string>
	 */
	private function collect_external_section(): array
	{
		$rows = array();

		// 1. Cited entries (sorted by display number).
		$by_n = array();
		foreach ($this->used_refs as $idx => $n) {
			$by_n[$n] = $idx;
		}
		ksort($by_n);

		$cited_indexes = array();
		foreach ($by_n as $n => $idx) {
			$resolved = $this->resolved_cache[$idx] ?? null;
			if (! $resolved || empty($resolved['url'])) {
				continue;
			}
			$cited_indexes[$idx] = true;
			$title  = $resolved['title'] !== '' ? $resolved['title'] : $resolved['url'];
			$title  = $this->sanitise_link_text($title);
			$rows[] = sprintf('%d. **%s** — [%s](%s)', $n, $title, $resolved['url'], $resolved['url']);
		}

		// 2. Non-cited user-supplied externals — append so user input isn't lost.
		// Grounded-but-uncited entries are skipped: the LLM had access and chose
		// not to cite them; including them would clutter the bibliography.
		for ($i = $this->user_external_offset; $i < count($this->sources); $i++) {
			$idx = $i + 1; // 1-based.
			if (isset($cited_indexes[$idx])) {
				continue;
			}
			$resolved = $this->resolve_ref($idx);
			if (! $resolved || empty($resolved['url'])) {
				continue;
			}
			$title  = $resolved['title'] !== '' ? $resolved['title'] : $resolved['url'];
			$title  = $this->sanitise_link_text($title);
			$rows[] = sprintf('*   **%s** — [%s](%s)', $title, $resolved['url'], $resolved['url']);
		}

		return $rows;
	}

	/**
	 * Build the "Conteúdo relacionado" subsection from selected internal links.
	 *
	 * No HTTP validation is performed — these URLs originate from the user's
	 * own site catalogue, not from LLM output. Errors here would mean a bug
	 * in the user's link picker, not a hallucination class to defend against.
	 *
	 * @return array<int,string>
	 */
	private function collect_internal_section(): array
	{
		$rows = array();
		foreach ($this->internals as $item) {
			$url = (string) $item['url'];
			if ($url === '') {
				continue;
			}
			$title = $this->sanitise_link_text($item['title'] !== '' ? $item['title'] : $url);
			$site  = $item['site_name'] !== '' ? ' — ' . $this->sanitise_link_text($item['site_name']) : '';
			$rows[] = sprintf('*   [%s](%s)%s', $title, $url, $site);
		}
		return $rows;
	}

	/**
	 * Locale-aware labels for the Referências heading + subsection titles.
	 *
	 * @return array{heading:string, externals:string, internals:string}
	 */
	private function locale_labels(string $locale): array
	{
		switch ($locale) {
			case 'pt_BR':
				return array(
					'heading'   => '## Referências',
					'externals' => 'Fontes externas',
					'internals' => 'Conteúdo relacionado',
				);
			case 'es_ES':
				return array(
					'heading'   => '## Referencias',
					'externals' => 'Fuentes externas',
					'internals' => 'Contenido relacionado',
				);
			default:
				return array(
					'heading'   => '## References',
					'externals' => 'External sources',
					'internals' => 'Related content',
				);
		}
	}

	/**
	 * Strip Markdown-breaking characters from titles used inside link text.
	 * Brackets close link anchors prematurely; we replace them with parens.
	 */
	private function sanitise_link_text(string $text): string
	{
		return str_replace(array('[', ']'), array('(', ')'), trim($text));
	}
}
