<?php
/**
 * Extracts links and images from post content.
 *
 * Pure PHP (no WordPress dependencies) so it can be unit tested in isolation.
 *
 * @package 404Zen
 */

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

class LGBF_Link_Extractor {

	/**
	 * Schemes that can never be "broken" and are skipped entirely.
	 *
	 * @var string[]
	 */
	private static $skipped_schemes = array( 'mailto', 'tel', 'javascript', 'data', 'sms', 'skype', 'geo', 'callto' );

	/**
	 * Extracts hyperlinks and images from an HTML fragment.
	 *
	 * @param string $html     Post content.
	 * @param string $base_url Absolute URL used to resolve relative links (usually the permalink).
	 * @return array[] Each item: array( 'url' => string, 'anchor' => string, 'type' => 'link'|'image' ).
	 */
	public static function extract( $html, $base_url = '' ) {
		$found = array();

		if ( ! is_string( $html ) || '' === trim( $html ) ) {
			return $found;
		}

		// Hyperlinks: capture href and inner content.
		if ( preg_match_all( '#<a\s[^>]*?href\s*=\s*(["\'])(.*?)\1[^>]*>(.*?)</a>#is', $html, $matches, PREG_SET_ORDER ) ) {
			foreach ( $matches as $match ) {
				$url = self::normalize( $match[2], $base_url );
				if ( null === $url ) {
					continue;
				}
				$anchor = trim( preg_replace( '/\s+/', ' ', html_entity_decode( wp_strip_all_tags( $match[3] ), ENT_QUOTES, 'UTF-8' ) ) );
				if ( '' === $anchor ) {
					$anchor = false !== stripos( $match[3], '<img' ) ? '[image]' : '[no text]';
				}
				$found[] = array(
					'url'    => $url,
					'anchor' => self::truncate( $anchor ),
					'type'   => 'link',
				);
			}
		}

		// Images: capture src.
		if ( preg_match_all( '#<img\s[^>]*?src\s*=\s*(["\'])(.*?)\1#is', $html, $matches, PREG_SET_ORDER ) ) {
			foreach ( $matches as $match ) {
				$url = self::normalize( $match[2], $base_url );
				if ( null === $url ) {
					continue;
				}
				$found[] = array(
					'url'    => $url,
					'anchor' => '[image]',
					'type'   => 'image',
				);
			}
		}

		// De-duplicate on url + type, keeping the first anchor seen.
		$unique = array();
		foreach ( $found as $item ) {
			$key = $item['type'] . '|' . $item['url'];
			if ( ! isset( $unique[ $key ] ) ) {
				$unique[ $key ] = $item;
			}
		}

		return array_values( $unique );
	}

	/**
	 * Decodes, resolves and validates a raw attribute URL.
	 *
	 * @param string $raw      Raw href/src attribute value.
	 * @param string $base_url Base URL for relative resolution.
	 * @return string|null Absolute http(s) URL, or null to skip.
	 */
	public static function normalize( $raw, $base_url = '' ) {
		$url = trim( html_entity_decode( $raw, ENT_QUOTES, 'UTF-8' ) );

		if ( '' === $url || '#' === $url[0] ) {
			return null;
		}

		$scheme = '';
		if ( preg_match( '#^([a-z][a-z0-9+.\-]*):#i', $url, $m ) ) {
			$scheme = strtolower( $m[1] );
		}

		if ( $scheme && in_array( $scheme, self::$skipped_schemes, true ) ) {
			return null;
		}

		if ( $scheme && 'http' !== $scheme && 'https' !== $scheme ) {
			return null;
		}

		// Protocol-relative URL.
		if ( 0 === strpos( $url, '//' ) ) {
			$base_scheme = 'https';
			if ( $base_url && preg_match( '#^(https?)://#i', $base_url, $bm ) ) {
				$base_scheme = strtolower( $bm[1] );
			}
			$url = $base_scheme . ':' . $url;
		} elseif ( ! $scheme ) {
			// Relative URL: resolve against base.
			if ( ! $base_url ) {
				return null;
			}
			$url = self::resolve( $url, $base_url );
			if ( null === $url ) {
				return null;
			}
		}

		// Drop the fragment; it is irrelevant to HTTP status.
		$hash_pos = strpos( $url, '#' );
		if ( false !== $hash_pos ) {
			$url = substr( $url, 0, $hash_pos );
		}

		if ( '' === $url || strlen( $url ) > 2000 ) {
			return null;
		}

		if ( ! preg_match( '#^https?://[^/]+#i', $url ) ) {
			return null;
		}

		return $url;
	}

	/**
	 * Resolves a relative URL against an absolute base URL (RFC 3986, simplified).
	 *
	 * @param string $relative Relative path/query.
	 * @param string $base     Absolute base URL.
	 * @return string|null
	 */
	public static function resolve( $relative, $base ) {
		$parts = wp_parse_url( $base );
		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return null;
		}

		$origin = $parts['scheme'] . '://' . $parts['host'];
		if ( ! empty( $parts['port'] ) ) {
			$origin .= ':' . $parts['port'];
		}

		if ( '' === $relative ) {
			return null;
		}

		if ( '/' === $relative[0] ) {
			return $origin . self::remove_dot_segments( $relative );
		}

		if ( '?' === $relative[0] ) {
			$path = isset( $parts['path'] ) ? $parts['path'] : '/';
			return $origin . $path . $relative;
		}

		$base_path = isset( $parts['path'] ) ? $parts['path'] : '/';
		$directory = substr( $base_path, 0, (int) strrpos( $base_path, '/' ) + 1 );
		if ( '' === $directory ) {
			$directory = '/';
		}

		return $origin . self::remove_dot_segments( $directory . $relative );
	}

	/**
	 * Removes "./" and "../" segments from a path.
	 *
	 * @param string $path URL path.
	 * @return string
	 */
	private static function remove_dot_segments( $path ) {
		$query = '';
		$q_pos = strpos( $path, '?' );
		if ( false !== $q_pos ) {
			$query = substr( $path, $q_pos );
			$path  = substr( $path, 0, $q_pos );
		}

		$output = array();
		foreach ( explode( '/', $path ) as $segment ) {
			if ( '.' === $segment ) {
				continue;
			}
			if ( '..' === $segment ) {
				array_pop( $output );
				continue;
			}
			$output[] = $segment;
		}

		$result = implode( '/', $output );
		if ( '' === $result || '/' !== $result[0] ) {
			$result = '/' . $result;
		}

		return $result . $query;
	}

	/**
	 * Whether a URL matches any exclusion pattern (simple substring match).
	 *
	 * @param string   $url      URL to test.
	 * @param string[] $patterns Exclusion patterns.
	 * @return bool
	 */
	public static function is_excluded( $url, $patterns ) {
		if ( empty( $patterns ) || ! is_array( $patterns ) ) {
			return false;
		}

		foreach ( $patterns as $pattern ) {
			$pattern = trim( (string) $pattern );
			if ( '' !== $pattern && false !== stripos( $url, $pattern ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Truncates anchor text for storage.
	 *
	 * @param string $text Anchor text.
	 * @return string
	 */
	private static function truncate( $text ) {
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $text, 0, 200 );
		}

		return substr( $text, 0, 200 );
	}
}
