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

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

	// ── 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;
	}

	/**
	 * 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 );
	}
}
