<?php

namespace ClikIT\InfiniteUploads;

/**
 * Infinite_Uploads_Rewriter
 *
 * @since 1.0
 */
class InfiniteUploadsRewriter {
	protected $uploads_path = null;    // uploads PATH
	protected $replacements = [];    // urls to be searched for and replaced with CDN URL
	protected $cdn_url = null;    // CDN URL
	protected $exclusions = [];
	protected $smush_url_fix_pairs = [];    // bad smush next-gen URL => correct URL
	protected $bb_cache_synced = null; // null = not yet loaded; array = hashset of relative paths with synced=1

	/**
	 * constructor
	 *
	 * @param  string  $uploads_url   Original upload url
	 * @param  array   $replacements  Urls to filter
	 * @param  string  $cdn_url       Destination CDN url
	 *
	 * @since 1.0
	 */
	function __construct( $uploads_url, $replacements, $cdn_url ) {
		$this->uploads_path = trailingslashit( parse_url( $uploads_url, PHP_URL_PATH ) );

		$this->replacements = array_unique(
			array_map( [ $this, 'protocolize_url' ],
				apply_filters( 'infinite_uploads_replacement_urls', $replacements )
			)
		);

		$this->cdn_url = $this->protocolize_url( $cdn_url );

		//generate upload url paths that should be excluded from url replacement
		$filelist   = new InfiniteUploadsFilelist( '/' ); //path doesn't matter
		$exclusions = apply_filters( 'infinite_uploads_sync_exclusions', $filelist->exclusions );
		foreach ( $exclusions as $exclusion ) {
			if ( 0 === strpos( $exclusion, '/' ) ) {
				$this->exclusions[ $exclusion ] = untrailingslashit( $uploads_url ) . $exclusion;
			}
		}

		// Use priority PHP_INT_MIN so our ob_start() fires FIRST on template_redirect,
		// making our output buffer the OUTERMOST. Other plugins (notably Smush Pro) register
		// their own ob_start at lower priorities and their callbacks (which inject malformed
		// https:/smush-webp/… URLs) would otherwise run AFTER ours on buffer close. With
		// ours outermost, our rewrite() runs LAST and can repair Smush's output.
		add_action( 'template_redirect', [ &$this, 'handle_rewrite_hook' ], PHP_INT_MIN );

		// Make sure we replace urls in REST API responses
		add_filter( 'the_content', [ &$this, 'rewrite_the_content' ], 100 );

		// Build Smush Pro next-gen URL correction pairs.
		// Smush derives its WebP/AVIF base URL via dirname(wp_upload_dir()['baseurl']) . '/smush-webp/'.
		// Two failure modes to fix:
		//   1. CDN URL has a path (e.g. https://cdn/user_id/site_id) — dirname() strips site_id.
		//      Fix pair: https://cdn/user_id/smush-webp/ → https://cdn/user_id/site_id/smush-webp/
		//   2. CDN URL is a vanity host with no path (e.g. https://tenant.infiniteuploads.cloud) —
		//      dirname() returns just "https:" (scheme + colon), concatenation yields a junk URL
		//      like https:/smush-webp/... which browsers resolve as a relative path against the
		//      origin and 404. Fix pair: https:/smush-webp/ → https://cdn/smush-webp/.
		$cdn_no_slash = rtrim( $this->cdn_url, '/' );
		$scheme       = wp_parse_url( $cdn_no_slash, PHP_URL_SCHEME );
		$host         = wp_parse_url( $cdn_no_slash, PHP_URL_HOST );
		$last_slash   = strrpos( $cdn_no_slash, '/' );

		if ( $last_slash !== false ) {
			$parent_base = substr( $cdn_no_slash, 0, $last_slash );

			foreach ( [ 'smush-webp', 'smush-avif' ] as $next_gen_dir ) {
				$good = $this->cdn_url . $next_gen_dir . '/'; // cdn_url already has trailing slash

				// Case 1: dirname gave a valid URL (CDN URL had a path component).
				if ( strpos( $parent_base, '://' ) !== false ) {
					$bad = $parent_base . '/' . $next_gen_dir . '/';
					if ( $bad !== $good ) {
						$this->smush_url_fix_pairs[ $bad ] = $good;
					}
				}

				// Case 2: dirname degenerated to "scheme:" for a host-only CDN URL.
				// The emitted URL looks like "https:/smush-webp/..." — single slash, no host.
				if ( $scheme && $host ) {
					$degenerate = $scheme . ':/' . $next_gen_dir . '/';
					if ( $degenerate !== $good ) {
						$this->smush_url_fix_pairs[ $degenerate ] = $good;
					}
					// Some PHP builds strip the colon too, producing "/smush-webp/..." — that
					// also 404s against the origin. Only fix when smush-webp path has no
					// immediate preceding host segment (distinct from a legitimate local URL).
				}
			}
		}

		// Fix Smush next-gen URLs in REST API responses.
		// WordPress REST API requests bypass template_redirect, so the ob_start approach
		// does not capture them. Page builders (Elementor, Gutenberg, etc.) fetch attachment
		// data via /wp-json/wp/v2/media/{id} and use the returned URLs directly; without
		// this filter those URLs contain the wrong smush-webp CDN path.
		if ( ! empty( $this->smush_url_fix_pairs ) ) {
			add_filter( 'rest_prepare_attachment', [ &$this, 'rewrite_rest_attachment' ], 100, 3 );
		}
	}

	/**
	 * Fix Smush next-gen (WebP/AVIF) URLs in WordPress REST API attachment responses.
	 *
	 * The ob_start hook on template_redirect does not capture REST API responses.
	 * This filter serialises the response data to JSON, applies the same
	 * smush_url_fix_pairs substitutions used by the HTML rewriter, and restores
	 * the corrected data so consumers receive valid CDN URLs.
	 *
	 * @param  \WP_REST_Response  $response  The REST API response object.
	 * @param  \WP_Post           $post      The attachment post.
	 * @param  \WP_REST_Request   $request   The REST request.
	 *
	 * @return \WP_REST_Response
	 */
	public function rewrite_rest_attachment( $response, $post, $request ) {
		if ( empty( $this->smush_url_fix_pairs ) || ! ( $response instanceof \WP_REST_Response ) ) {
			return $response;
		}

		$data = $response->get_data();
		$json = wp_json_encode( $data );

		if ( $json === false ) {
			return $response;
		}

		$changed = false;
		foreach ( $this->smush_url_fix_pairs as $bad => $good ) {
			if ( strpos( $json, $bad ) !== false ) {
				$json    = str_replace( $bad, $good, $json );
				$changed = true;
			}
		}

		if ( $changed ) {
			$response->set_data( json_decode( $json, true ) );
		}

		return $response;
	}

	/**
	 * Add https protocol to url when needed
	 *
	 * @since   1.0
	 */
	public function protocolize_url( $url ) {
		if ( strpos( $url, ':' ) === false && ! in_array( $url[0], [ '/', '#', '?' ], true ) &&
		     ! preg_match( '/^[a-z0-9-]+?\.php/i', $url ) ) {
			$url = 'https://' . $url;
		}

		return trailingslashit( $url );
	}

	/**
	 * run rewrite hook
	 *
	 * @since   1.0
	 */
	public function handle_rewrite_hook() {
		ob_start( [ &$this, 'rewrite' ] );
	}


	/**
	 * rewrite html content
	 *
	 * @since   1.0
	 */
	public function rewrite_the_content( $html ) {
		return $this->rewrite( $html );
	}

	/**
	 * rewrite url
	 *
	 * @param  string  $html  current raw HTML doc
	 *
	 * @return  string  updated HTML doc with CDN links
	 * @since 1.0
	 *
	 */
	public function rewrite( $html ) {
		// start regex
		$regex_rule = '#((?:https?:)?(?:';

		//add all the domains to replace
		$regex_rule .= implode( '|',
			array_map( [ $this, 'relative_url' ],
				array_map( 'quotemeta', $this->replacements )
			)
		);

		// check for relative paths
		$regex_rule .= ')|(?<=[(\"\'=\s])' . quotemeta( $this->uploads_path ) . ')([^\#\"\'\s]*)#';

		// call the cdn rewriter callback
		$cdn_html = preg_replace_callback( $regex_rule, [ $this, 'rewrite_url' ], $html );

		// Fix Smush next-gen (WebP/AVIF) URLs where dirname() stripped the site_id from the CDN path.
		foreach ( $this->smush_url_fix_pairs as $bad_url => $good_url ) {
			if ( strpos( $cdn_html, $bad_url ) !== false ) {
				$cdn_html = str_replace( $bad_url, $good_url, $cdn_html );
			}
		}

		return $cdn_html;
	}

	/**
	 * Get relative url
	 *
	 * @param  string  $url  a full url
	 *
	 * @return  string  protocol relative url
	 * @since   1.0
	 *
	 */
	protected function relative_url( $url ) {
		return substr( $url, strpos( $url, '//' ) );
	}

	/**
	 * Check whether a BB-cache-image regex match is already synced to the cloud.
	 *
	 * Loads the set of synced BB cache file paths from `infinite_uploads_files`
	 * lazily on first call, caches it on the instance for the rest of the
	 * request, and looks the regex match up by the relative path stored in the
	 * `file` column (leading slash, no query string).
	 *
	 * @param  array  $matches  Regex match array — $matches[2] holds the path-relative-to-uploads.
	 *
	 * @return bool
	 */
	protected function is_bb_cache_synced( $matches ) {
		global $wpdb;

		if ( $this->bb_cache_synced === null ) {
			$cache_key   = 'iu_bb_cache_synced_paths';
			$cache_group = 'infinite_uploads';
			$cached      = wp_cache_get( $cache_key, $cache_group );

			if ( is_array( $cached ) ) {
				$this->bb_cache_synced = $cached;
			} else {
				// Anchored LIKE (no leading %): `file` values in this table are
				// path-relative-to-uploads-root with a leading slash, so
				// '/bb-plugin/cache/...' is a literal prefix of every BB cache
				// row. This lets MySQL use the PRIMARY KEY range scan on the
				// `file` column instead of doing a full table scan.
				$rows = $wpdb->get_col(
					"SELECT file FROM `{$wpdb->base_prefix}infinite_uploads_files` WHERE synced = 1 AND file LIKE '/bb-plugin/cache/%'"
				);
				$this->bb_cache_synced = $rows ? array_flip( $rows ) : [];
				wp_cache_set( $cache_key, $this->bb_cache_synced, $cache_group, 60 );
			}
		}

		// $matches[2] is the path-after-uploads-root captured by the regex (no leading slash).
		// `file` column stores the same path with a leading slash (see InfiniteUploadsFilelist::relative_path).
		$rel = isset( $matches[2] ) ? $matches[2] : '';
		if ( $rel === '' ) {
			return false;
		}
		// Strip query string / fragment if any leaked in.
		$q = strpos( $rel, '?' );
		if ( $q !== false ) {
			$rel = substr( $rel, 0, $q );
		}
		$h = strpos( $rel, '#' );
		if ( $h !== false ) {
			$rel = substr( $rel, 0, $h );
		}

		return isset( $this->bb_cache_synced[ '/' . $rel ] );
	}

	/**
	 * rewrite url
	 *
	 * @param  string  $matches  the matches from regex
	 *
	 * @return  string  updated url if not excluded
	 * @since   1.0
	 *
	 */
	protected function rewrite_url( $matches ) {
		//don't filter excluded dirs — EXCEPT for Beaver Builder cropped images,
		// which live under /bb-plugin/cache/ alongside the layout .css/.js. The
		// folder is excluded so the layout assets stay local; the image carve-out
		// lets the actual rendered crops fall through to CDN replacement below.
		// The user-controlled file exclusion check that follows is still respected
		// (those are explicit per-site opt-outs, not default chrome exclusions).
		if ( InfiniteUploadsHelper::is_offloadable_bb_cache_image( $matches[0] ) ) {
			// Sync gate: BB writes cropped images directly to disk and bypasses
			// the stream wrapper, so the file may exist locally but not yet on the
			// CDN (initial backfill window, or the ~minute lag between BB's write
			// and our scheduled async push). Returning the local URL until the
			// row is marked synced=1 avoids visitor-facing 404s.
			if ( ! $this->is_bb_cache_synced( $matches ) ) {
				return $matches[0];
			}
		} else {
			foreach ( $this->exclusions as $exclusion ) {
				if ( 0 === strpos( $matches[0], $exclusion ) ) {
					return $matches[0];
				}
			}
		}

		// Check if file exclusion is enabled and if the path is excluded.
		if ( InfiniteUploadsHelper::is_file_exclusion_enabled() ) {
			// If the path is in the exclusion list, return the original match.
			$path = isset( $matches[2] ) ? $matches[2] : '';

			$original_upload = InfiniteUploadsHelper::get_original_upload_dir_root();

			$original_base_dir = $original_upload['basedir'];

			$path = $original_base_dir . '/' . $path;
			if ( InfiniteUploadsHelper::is_path_excluded( $path ) ) {
				return $matches[0];
			}
		}

		$replace = str_replace( $matches[1], $this->cdn_url, $matches[0] );

		/**
		 * Filters the find/replace url rewriter that replaces matches in HTML output with CDN url.
		 *
		 * @param  {string}  $replace  The url to replace the match with, like `https://xxxxx.infiniteuploads.cloud/somefile.png`.
		 * @param  {array}   $matches  The the matches found in HTML, like `[0 => 'https://mysite.com/wp-content/uploads/somefile.png', 1 => 'https://mysite.com/wp-content/uploads/', 2 => 'somefile.png']`.
		 *
		 * @return {string} The base url to replace the match with.
		 * @since  1.0
		 * @hook   infinite_uploads_rewrite_url
		 *
		 */
		return apply_filters( 'infinite_uploads_rewrite_url', $replace, $matches );
	}
}
