<?php
/**
 * Costudio_Link_Reader
 *
 * Fetches a user-provided external reference URL and extracts its main
 * readable text, so the user's curated links become first-class grounding
 * sources for the pipeline (read, not merely cited).
 *
 * This is a LOCAL capability: it runs entirely inside the user's WordPress
 * install via the WP HTTP API. It is therefore identical on every plan and in
 * BYOK mode — only the autonomous web-search (server-side) differentiates
 * plans. The fetch is triggered solely by the presence of user external links,
 * never by the "Busca na web" toggle (the user's own links are always read).
 *
 * Security: requests go through `wp_safe_remote_get()` with
 * `reject_unsafe_urls`, so private/loopback/link-local hosts are blocked
 * (SSRF guard) by WordPress itself. Only http/https URLs are accepted, the
 * response is size-capped, and non-HTML content types are rejected.
 *
 * Output of read() — a "source"-shaped array the pipeline can merge into the
 * citable pool:
 *   [ 'ok' => bool, 'url' => string, 'final_url' => string,
 *     'title' => string, 'snippet' => string, 'words' => int,
 *     'status' => string, 'error' => string ]
 *
 * @package Contentosapp_Studio
 * @since   1.1.0
 */

defined( 'ABSPATH' ) || exit;

final class Costudio_Link_Reader {

	/** Transient prefix for cached reads. Bump the version suffix to bust cache. */
	const CACHE_PREFIX = 'costudio_lread_v1_';

	/** Positive cache: a successful read is reused for a week. */
	const POSITIVE_TTL = 7 * DAY_IN_SECONDS;

	/** Negative cache: failures recover quickly (transient outages, 503s). */
	const NEGATIVE_TTL = 30 * MINUTE_IN_SECONDS;

	/** Per-request transport timeout (seconds). */
	const TIMEOUT = 8;

	/** Hard cap on the downloaded body (1.5 MB) — bounds latency and memory. */
	const MAX_BODY_BYTES = 1572864;

	/** Extracted snippet is truncated to this many characters. */
	const SNIPPET_MAX_CHARS = 2500;

	/** Title is truncated to this many characters. */
	const TITLE_MAX_CHARS = 160;

	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';

	/** Block-level tags whose content is structural noise, not article text. */
	const NOISE_TAGS = array( 'script', 'style', 'noscript', 'template', 'nav', 'header', 'footer', 'aside', 'form', 'iframe', 'svg', 'button' );

	/**
	 * Exact allow-list of YouTube hosts (Item 2, subitem 3A). Matched
	 * case-insensitively against the FULL host, never a substring/suffix
	 * check — that is what keeps `youtube.com.evil.test` and
	 * `youtube.example.com` out, since both merely CONTAIN "youtube.com".
	 */
	const YOUTUBE_HOSTS = array( 'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be' );

	/** Transient prefix for cached video-candidate reads. */
	const VIDEO_CACHE_PREFIX = 'costudio_vread_v1_';

	/** oEmbed request timeout (seconds) — short, this blocks the pipeline. */
	const OEMBED_TIMEOUT = 6;

	/** Hard cap on the oEmbed response body (64 KB — real responses are a few
	 *  hundred bytes; this only bounds an abnormal/hostile response). */
	const OEMBED_MAX_BODY_BYTES = 65536;

	private function __construct() {
	}

	/**
	 * Read one URL and return its extracted source data (cached).
	 *
	 * @param string $url  The external reference URL.
	 * @param array  $opts { Optional. 'force' => bool to bypass cache. }
	 * @return array See class docblock for the shape.
	 */
	public static function read( string $url, array $opts = array() ): array {
		$url       = trim( $url );
		$safe_url  = function_exists( 'costudio_safe_http_url' ) ? costudio_safe_http_url( $url ) : '';
		$canonical = $safe_url !== '' ? $safe_url : $url;

		if ( $safe_url === '' ) {
			return self::fail( $canonical, 'invalid_url' );
		}

		$key = self::CACHE_PREFIX . md5( $safe_url );
		if ( empty( $opts['force'] ) ) {
			$cached = get_transient( $key );
			if ( is_array( $cached ) && isset( $cached['ok'] ) ) {
				return $cached;
			}
		}

		$args = function_exists( 'costudio_safe_remote_args' )
			? costudio_safe_remote_args( array(
				'timeout'             => self::TIMEOUT,
				'redirection'         => 3,
				'user-agent'          => self::USER_AGENT,
				'limit_response_size' => self::MAX_BODY_BYTES,
				'headers'             => array( 'Accept' => 'text/html,application/xhtml+xml,*/*;q=0.8' ),
			) )
			: array( 'timeout' => self::TIMEOUT, 'redirection' => 3 );

		$resp = wp_safe_remote_get( $safe_url, $args );

		if ( is_wp_error( $resp ) ) {
			return self::fail( $safe_url, 'unreachable', self::NEGATIVE_TTL, $key );
		}

		$code = (int) wp_remote_retrieve_response_code( $resp );
		if ( $code < 200 || $code >= 400 ) {
			$out = self::fail( $safe_url, 'http_' . $code, self::NEGATIVE_TTL, $key );
			return $out;
		}

		$ctype = strtolower( (string) wp_remote_retrieve_header( $resp, 'content-type' ) );
		if ( $ctype !== '' && strpos( $ctype, 'html' ) === false && strpos( $ctype, 'text/' ) === false && strpos( $ctype, 'xml' ) === false ) {
			return self::fail( $safe_url, 'unsupported_type', self::NEGATIVE_TTL, $key );
		}

		$body = (string) wp_remote_retrieve_body( $resp );
		if ( strlen( $body ) > self::MAX_BODY_BYTES ) {
			$body = substr( $body, 0, self::MAX_BODY_BYTES );
		}
		if ( trim( $body ) === '' ) {
			return self::fail( $safe_url, 'empty_body', self::NEGATIVE_TTL, $key );
		}

		$final_url = self::resolve_final_url( $resp, $safe_url );

		$extracted = self::extract_main_text( $body );
		$title     = self::truncate_plain( $extracted['title'], self::TITLE_MAX_CHARS );
		list( $snippet, $words ) = self::truncate_words( $extracted['text'], self::SNIPPET_MAX_CHARS );

		if ( $snippet === '' ) {
			// Reachable but no usable text (JS-rendered SPA, paywall, etc.).
			$out = self::fail( $safe_url, 'no_extractable_text', self::NEGATIVE_TTL, $key );
			$out['title']     = $title;
			$out['final_url'] = $final_url;
			return $out;
		}

		$out = array(
			'ok'        => true,
			'url'       => $safe_url,
			'final_url' => $final_url,
			'title'     => $title,
			'snippet'   => $snippet,
			'words'     => $words,
			'status'    => 'ok',
			'error'     => '',
		);

		set_transient( $key, $out, self::POSITIVE_TTL );

		if ( function_exists( 'costudio_log_debug' ) ) {
			costudio_log_debug( 'link_reader.read', array(
				'url'   => $safe_url,
				'ok'    => true,
				'words' => $words,
				'chars' => strlen( $snippet ),
			) );
		}

		return $out;
	}

	/**
	 * Read several URLs, returning a list of source arrays in input order.
	 * Each URL is independently cached, so re-runs of the same production are
	 * effectively free.
	 *
	 * @param array $urls List of URL strings.
	 * @param array $opts Passed through to read().
	 * @return array<int,array> One result per input URL (deduplicated by safe URL).
	 */
	public static function read_many( array $urls, array $opts = array() ): array {
		$out  = array();
		$seen = array();
		foreach ( $urls as $url ) {
			$safe = function_exists( 'costudio_safe_http_url' ) ? costudio_safe_http_url( trim( (string) $url ) ) : trim( (string) $url );
			if ( $safe === '' || isset( $seen[ $safe ] ) ) {
				continue;
			}
			$seen[ $safe ] = true;
			$out[]         = self::read( $url, $opts );
		}
		return $out;
	}

	/**
	 * Is this URL a YouTube video/short URL we can extract a strict video ID
	 * from? A convenience wrapper around extract_youtube_id() for callers
	 * that only need the yes/no (e.g. routing an external reference to
	 * read_video() instead of the generic HTML read()).
	 */
	public static function is_youtube_url( string $url ): bool {
		return '' !== self::extract_youtube_id( $url );
	}

	/**
	 * Extract a strict, validated 11-character YouTube video ID from a URL,
	 * or '' if the URL is not a recognized YouTube video URL. Item 3A guardrail:
	 * this is the ONLY way a video ID enters the system — never trust an ID
	 * the model might type elsewhere, always re-derive it from a URL through
	 * this exact function.
	 *
	 * Accepts: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID,
	 * youtube.com/embed/ID (with or without www./m.). Rejects lookalike hosts
	 * (`youtube.com.evil.test`, `youtube.example.com`) via an exact host
	 * allow-list — never a substring/suffix match — and rejects any candidate
	 * that isn't exactly `[A-Za-z0-9_-]{11}`, YouTube's real ID shape.
	 */
	public static function extract_youtube_id( string $url ): string {
		$url = trim( $url );
		if ( '' === $url ) {
			return '';
		}

		$parts = parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return '';
		}
		if ( ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
			return '';
		}

		$host = strtolower( $parts['host'] );
		if ( ! in_array( $host, self::YOUTUBE_HOSTS, true ) ) {
			return '';
		}

		$path = isset( $parts['path'] ) ? $parts['path'] : '';
		$query = array();
		if ( ! empty( $parts['query'] ) ) {
			parse_str( $parts['query'], $query );
		}

		$candidate = '';
		if ( 'youtu.be' === $host ) {
			$candidate = trim( $path, '/' );
		} elseif ( preg_match( '#^/shorts/([^/]+)#', $path, $m ) ) {
			$candidate = $m[1];
		} elseif ( preg_match( '#^/embed/([^/]+)#', $path, $m ) ) {
			$candidate = $m[1];
		} elseif ( '/watch' === rtrim( $path, '/' ) && isset( $query['v'] ) ) {
			// The `v` query param only means "video ID" on /watch — other
			// youtube.com paths (search results, /redirect, etc.) can carry an
			// unrelated `v` param that must NOT be misread as a video ID.
			$candidate = (string) $query['v'];
		}

		$candidate = trim( $candidate );
		return preg_match( '/^[A-Za-z0-9_-]{11}$/', $candidate ) ? $candidate : '';
	}

	/**
	 * Read a YouTube video candidate via the public oEmbed endpoint — NEVER
	 * scrapes the YouTube watch page's HTML (that is unreliable and against
	 * the plan's explicit instruction). No API key required. Cached the same
	 * way as read(): positive results for a week, failures for 30 minutes.
	 *
	 * The returned `url`/`video_id` are always REBUILT from the validated ID,
	 * never the caller's raw query string — this is what makes "the model
	 * tries to swap the ID" structurally impossible downstream: nothing but
	 * this function's own output ever reaches the publisher as a candidate.
	 *
	 * @param string $url  A candidate YouTube URL (any of the accepted shapes).
	 * @param array  $opts { Optional. 'force' => bool to bypass cache.
	 *                       'timeout' => int, overrides OEMBED_TIMEOUT (Item 3,
	 *                       subitem 3B's time-budgeted discovery) — omit to keep
	 *                       the default, fully backward compatible. }
	 * @return array{ok:bool,url:string,video_id:string,title:string,author_name:string,thumbnail_url:string,origin:string,status:string,error:string}
	 */
	public static function read_video( string $url, array $opts = array() ): array {
		$video_id = self::extract_youtube_id( $url );
		if ( '' === $video_id ) {
			// Never return/log the raw input for a validation failure — the
			// query string may carry sensitive data (tokens, session params)
			// that has nothing to do with why this wasn't a valid video URL.
			// Host+path is enough to diagnose "why didn't this URL qualify".
			return self::fail_video( self::safe_url_for_logging( $url ), 'invalid_youtube_url' );
		}

		$canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
		$key           = self::VIDEO_CACHE_PREFIX . $video_id;

		if ( empty( $opts['force'] ) ) {
			$cached = get_transient( $key );
			if ( is_array( $cached ) && isset( $cached['ok'] ) ) {
				return $cached;
			}
		}

		$oembed_url = 'https://www.youtube.com/oembed?' . http_build_query( array(
			'url'    => $canonical_url,
			'format' => 'json',
		) );

		$timeout = isset( $opts['timeout'] ) ? max( 1, (int) $opts['timeout'] ) : self::OEMBED_TIMEOUT;

		$oembed_args = function_exists( 'costudio_safe_remote_args' )
			? costudio_safe_remote_args( array(
				'timeout'             => $timeout,
				'redirection'         => 2,
				'user-agent'          => self::USER_AGENT,
				'limit_response_size' => self::OEMBED_MAX_BODY_BYTES,
				'headers'             => array( 'Accept' => 'application/json' ),
			) )
			: array(
				'timeout'             => $timeout,
				'redirection'         => 2,
				'limit_response_size' => self::OEMBED_MAX_BODY_BYTES,
			);

		$resp = wp_safe_remote_get( $oembed_url, $oembed_args );

		if ( is_wp_error( $resp ) ) {
			return self::fail_video( $canonical_url, 'unreachable', $video_id, self::NEGATIVE_TTL, $key );
		}

		$code = (int) wp_remote_retrieve_response_code( $resp );
		if ( $code < 200 || $code >= 300 ) {
			return self::fail_video( $canonical_url, 'oembed_http_' . $code, $video_id, self::NEGATIVE_TTL, $key );
		}

		$body = (string) wp_remote_retrieve_body( $resp );
		$data = json_decode( $body, true );
		if ( ! is_array( $data ) || empty( $data['title'] ) ) {
			return self::fail_video( $canonical_url, 'oembed_invalid_response', $video_id, self::NEGATIVE_TTL, $key );
		}

		$out = array(
			'ok'            => true,
			'url'           => $canonical_url,
			'video_id'      => $video_id,
			'title'         => self::truncate_plain( (string) $data['title'], self::TITLE_MAX_CHARS ),
			'author_name'   => self::truncate_plain( (string) ( $data['author_name'] ?? '' ), self::TITLE_MAX_CHARS ),
			'thumbnail_url' => self::sanitize_thumbnail_url( (string) ( $data['thumbnail_url'] ?? '' ) ),
			'origin'        => 'user_reference',
			'status'        => 'ok',
			'error'         => '',
		);

		set_transient( $key, $out, self::POSITIVE_TTL );

		if ( function_exists( 'costudio_log_debug' ) ) {
			costudio_log_debug( 'link_reader.read_video', array(
				'video_id' => $video_id,
				'ok'       => true,
			) );
		}

		return $out;
	}

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

	/**
	 * Build a failure result, optionally caching it under $key for $ttl.
	 */
	private static function fail( string $url, string $error, int $ttl = 0, string $key = '' ): array {
		$out = array(
			'ok'        => false,
			'url'       => $url,
			'final_url' => $url,
			'title'     => '',
			'snippet'   => '',
			'words'     => 0,
			'status'    => 'error',
			'error'     => $error,
		);
		if ( $key !== '' && $ttl > 0 ) {
			set_transient( $key, $out, $ttl );
		}
		if ( function_exists( 'costudio_log_debug' ) ) {
			costudio_log_debug( 'link_reader.fail', array( 'url' => $url, 'error' => $error ) );
		}
		return $out;
	}

	/**
	 * Build a failed video-read result, optionally caching it under $key for $ttl.
	 */
	private static function fail_video( string $url, string $error, string $video_id = '', int $ttl = 0, string $key = '' ): array {
		$out = array(
			'ok'            => false,
			'url'           => $url,
			'video_id'      => $video_id,
			'title'         => '',
			'author_name'   => '',
			'thumbnail_url' => '',
			'origin'        => 'user_reference',
			'status'        => 'error',
			'error'         => $error,
		);
		if ( '' !== $key && $ttl > 0 ) {
			set_transient( $key, $out, $ttl );
		}
		if ( function_exists( 'costudio_log_debug' ) ) {
			costudio_log_debug( 'link_reader.read_video_fail', array( 'url' => $url, 'error' => $error ) );
		}
		return $out;
	}

	/**
	 * Strip a URL down to scheme://host/path for safe use in a validation-
	 * failure result/log entry — the query string (where tokens, session
	 * params, or other sensitive data typically live) is deliberately
	 * discarded. Returns '' if the input isn't even parseable as a URL.
	 */
	private static function safe_url_for_logging( string $url ): string {
		$parts = parse_url( trim( $url ) );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return '';
		}
		$scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : 'http';
		$path   = isset( $parts['path'] ) ? $parts['path'] : '';
		return $scheme . '://' . strtolower( $parts['host'] ) . $path;
	}

	/**
	 * oEmbed thumbnails always come from an *.ytimg.com host. Reject anything
	 * else defensively — a compromised/unexpected oEmbed response should
	 * never hand the publisher a thumbnail URL from an arbitrary host.
	 */
	private static function sanitize_thumbnail_url( string $url ): string {
		if ( '' === $url ) {
			return '';
		}
		$parts = parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return '';
		}
		if ( ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
			return '';
		}
		$host = strtolower( $parts['host'] );
		if ( 'ytimg.com' !== $host && 1 !== preg_match( '/\.ytimg\.com$/', $host ) ) {
			return '';
		}
		return $url;
	}

	/**
	 * Resolve the final URL after redirects, when the transport exposes it.
	 * Falls back to the requested URL.
	 */
	private static function resolve_final_url( $resp, string $requested ): string {
		if ( is_array( $resp ) && isset( $resp['http_response'] ) && is_object( $resp['http_response'] ) ) {
			$http = $resp['http_response'];
			if ( method_exists( $http, 'get_response_object' ) ) {
				$obj = $http->get_response_object();
				if ( is_object( $obj ) && isset( $obj->url ) && is_string( $obj->url ) && $obj->url !== '' ) {
					$safe = function_exists( 'costudio_safe_http_url' ) ? costudio_safe_http_url( $obj->url ) : $obj->url;
					if ( $safe !== '' ) {
						return $safe;
					}
				}
			}
		}
		return $requested;
	}

	/**
	 * Parse an HTML document and extract the best-effort title + main text.
	 *
	 * @return array{title:string,text:string}
	 */
	private static function extract_main_text( string $html ): array {
		if ( ! class_exists( 'DOMDocument' ) ) {
			// No DOM extension — degrade to a crude strip so we still ground.
			return array(
				'title' => '',
				'text'  => self::normalize_ws( wp_strip_all_tags( $html ) ),
			);
		}

		$prev = libxml_use_internal_errors( true );
		$dom  = new DOMDocument();
		// The meta charset hint forces UTF-8 interpretation across PHP 7.4–8.x
		// without the deprecated HTML-ENTITIES mb_convert_encoding path.
		$loaded = $dom->loadHTML(
			'<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . $html,
			LIBXML_NOWARNING | LIBXML_NOERROR
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );

		if ( ! $loaded ) {
			return array(
				'title' => '',
				'text'  => self::normalize_ws( wp_strip_all_tags( $html ) ),
			);
		}

		$title = self::extract_title( $dom );

		// Strip structural noise before reading text content.
		foreach ( self::NOISE_TAGS as $tag ) {
			$nodes = $dom->getElementsByTagName( $tag );
			// Iterate backwards: live NodeList shrinks as we remove.
			for ( $i = $nodes->length - 1; $i >= 0; $i-- ) {
				$node = $nodes->item( $i );
				if ( $node && $node->parentNode ) {
					$node->parentNode->removeChild( $node );
				}
			}
		}

		$text = self::pick_main_text( $dom );

		return array(
			'title' => $title,
			'text'  => $text,
		);
	}

	/**
	 * Prefer og:title, then <title>.
	 */
	private static function extract_title( DOMDocument $dom ): string {
		$xpath = new DOMXPath( $dom );

		$og = $xpath->query( '//meta[@property="og:title"]/@content | //meta[@name="og:title"]/@content' );
		if ( $og && $og->length > 0 ) {
			$val = self::normalize_ws( (string) $og->item( 0 )->nodeValue );
			if ( $val !== '' ) {
				return $val;
			}
		}

		$titles = $dom->getElementsByTagName( 'title' );
		if ( $titles->length > 0 ) {
			return self::normalize_ws( (string) $titles->item( 0 )->textContent );
		}

		return '';
	}

	/**
	 * Choose the densest main-content container (<article> / <main>), falling
	 * back to <body>, and return its normalized text.
	 */
	private static function pick_main_text( DOMDocument $dom ): string {
		$best = '';

		foreach ( array( 'article', 'main' ) as $tag ) {
			$nodes = $dom->getElementsByTagName( $tag );
			foreach ( $nodes as $node ) {
				$candidate = self::normalize_ws( (string) $node->textContent );
				if ( strlen( $candidate ) > strlen( $best ) ) {
					$best = $candidate;
				}
			}
		}

		// Require a meaningful amount of text from the semantic container;
		// otherwise fall back to the whole body (many sites lack <article>).
		if ( strlen( $best ) >= 200 ) {
			return $best;
		}

		$body = $dom->getElementsByTagName( 'body' );
		if ( $body->length > 0 ) {
			$body_text = self::normalize_ws( (string) $body->item( 0 )->textContent );
			if ( strlen( $body_text ) > strlen( $best ) ) {
				return $body_text;
			}
		}

		return $best;
	}

	/**
	 * Collapse all whitespace runs to single spaces and decode entities.
	 */
	private static function normalize_ws( string $text ): string {
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = preg_replace( '/\s+/u', ' ', $text );
		return trim( (string) $text );
	}

	/**
	 * Truncate a plain string to $max characters (multibyte-aware).
	 */
	private static function truncate_plain( string $text, int $max ): string {
		$text = self::normalize_ws( $text );
		if ( function_exists( 'mb_strlen' ) ) {
			if ( mb_strlen( $text ) > $max ) {
				$text = rtrim( mb_substr( $text, 0, $max - 1 ) ) . '…';
			}
			return $text;
		}
		if ( strlen( $text ) > $max ) {
			$text = rtrim( substr( $text, 0, $max - 1 ) ) . '…';
		}
		return $text;
	}

	/**
	 * Truncate text to ~$max chars on a word boundary and count words.
	 *
	 * @return array{0:string,1:int} [snippet, word_count]
	 */
	private static function truncate_words( string $text, int $max ): array {
		$text = self::normalize_ws( $text );
		if ( $text === '' ) {
			return array( '', 0 );
		}

		$len = function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );
		$words = str_word_count( $text );
		if ( $words === 0 ) {
			// str_word_count is ASCII-centric; approximate for non-Latin text.
			$words = count( preg_split( '/\s+/u', $text ) ?: array() );
		}

		if ( $len <= $max ) {
			return array( $text, $words );
		}

		$cut = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $max ) : substr( $text, 0, $max );
		// Back off to the last space so we don't slice a word in half.
		$last_space = function_exists( 'mb_strrpos' ) ? mb_strrpos( $cut, ' ' ) : strrpos( $cut, ' ' );
		if ( $last_space !== false && $last_space > (int) ( $max * 0.6 ) ) {
			$cut = function_exists( 'mb_substr' ) ? mb_substr( $cut, 0, $last_space ) : substr( $cut, 0, $last_space );
		}

		$snippet = rtrim( $cut ) . '…';
		$words   = str_word_count( $snippet );
		if ( $words === 0 ) {
			$words = count( preg_split( '/\s+/u', $snippet ) ?: array() );
		}

		return array( $snippet, $words );
	}
}
