<?php
/**
 * The7 Less compiler.
 *
 * @package The7
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class The7_Less_Compiler
 */
class The7_Less_Compiler {

	/**
	 * Less compiler.
	 *
	 * @var The7\Vendor\Lessphp\Compiler Less compiler.
	 */
	protected $lessc;

	/**
	 * The7_Less_Compiler constructor.
	 *
	 * @param array      $less_vars  Less vars array.
	 * @param array|null $import_dir Import dir.
	 *
	 * @throws Exception Throws Exception if WP Filesystem is not accessible.
	 */
	public function __construct( $less_vars = array(), $import_dir = null ) {
		global $wp_filesystem;

		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		$wp_upload = wp_get_upload_dir();
		if ( ! $wp_filesystem && ! WP_Filesystem( false, $wp_upload['basedir'] ) ) {
			throw new Exception( __( 'Cannot access file system.', 'the7mk2' ) );
		}

		$this->lessc = new The7\Vendor\Lessphp\Compiler();

		// Register custom less functions.
		The7_Less_Functions::register_functions( $this->lessc );

		if ( $import_dir === null ) {
			$import_dir = array( PRESSCORE_THEME_DIR . '/css', PRESSCORE_THEME_DIR . '/css/dynamic-less' );
		}

		$this->lessc->setImportDir( $import_dir );
		$this->lessc->setVariables( $less_vars );
	}

	/**
	 * Proxy for the7_lessc->setImportDir.
	 *
	 * @since 8.3.0
	 *
	 * @param $import_dir
	 */
	public function set_import_dir( $import_dir ) {
		$this->lessc->setImportDir( $import_dir );
	}

	/**
	 * Proxy for the7_lessc->setVariables.
	 *
	 * @since 8.3.0
	 *
	 * @param $less_vars
	 */
	public function set_variables( $less_vars ) {
		$this->lessc->setVariables( $less_vars );
	}

	/**
	 * Compile less to css.
	 *
	 * Never overwrites $output_file with an empty or partial result: an
	 * unreadable source or a failed write throws instead, so the previous
	 * (complete) file keeps being served.
	 *
	 * @param string $source_file Source file.
	 * @param string $output_file Output file.
	 * @param array  $imports     Imports.
	 *
	 * @throws Exception If the source cannot be read or the file cannot be written.
	 */
	public function compile_to_file( $source_file, $output_file, $imports = array() ) {
		$less = $this->get_file_contents( $source_file );
		if ( $less === false ) {
			throw new Exception(
				sprintf(
					/* translators: %s: less file name. */
					__( 'Failed to read %s.', 'the7mk2' ),
					$source_file
				)
			);
		}

		$css = $this->make_relative_urls( $this->compile( $less, $imports ), $source_file, $output_file );

		if ( $css === '' ) {
			/*
			 * A less file may legitimately compile to nothing. Keep a file on
			 * disk anyway so the enqueued url does not 404, and so put_contents()
			 * can keep refusing empty content as a last-resort safety net.
			 */
			$css = '/* empty */' . PHP_EOL;
		}

		if ( ! $this->put_contents( $output_file, $css ) ) {
			throw new Exception(
				sprintf(
					/* translators: %s: css file name. */
					__( 'Failed to write %s.', 'the7mk2' ),
					$output_file
				)
			);
		}
	}

	/**
	 * Compile less file.
	 *
	 * @param string $less_file Less file.
	 * @param array  $imports   Imports.
	 *
	 * @return false|string
	 */
	public function compile_file( $less_file, $imports = array() ) {
		$less = $this->get_file_contents( $less_file );

		return $this->compile( $less, $imports );
	}

	/**
	 * Compile less code.
	 *
	 * @param string $less    Less code.
	 * @param array  $imports Imports.
	 *
	 * @return false|string
	 */
	public function compile( $less, $imports = array() ) {
		if ( $imports ) {
			$imports           = array_filter( (array) $imports );
			$import_anchors    = array();
			$import_statements = array();
			foreach ( $imports as $import_anchor => $import_files ) {
				$import_anchors[]    = '// ' . strtoupper( $import_anchor );
				$import_statements[] = array_reduce(
					(array) $import_files,
					array( $this, 'reduce_dynamic_imports' )
				);
			}
			$less = str_replace(
				$import_anchors,
				$import_statements,
				$less
			);
		}

		return $this->lessc->compile( $less );
	}

	/**
	 * Return file contents.
	 *
	 * @global WP_Filesystem_Base $wp_filesystem
	 *
	 * @param string $source_file Source file.
	 *
	 * @return string|false Read data on success, false on failure.
	 */
	public function get_file_contents( $source_file ) {
		/**
		 * WP Filesystem global.
		 *
		 * @var WP_Filesystem_Base $wp_filesystem
		 */
		global $wp_filesystem;

		return $wp_filesystem->get_contents( $source_file );
	}

	/**
	 * Determine that $abspath is writable.
	 *
	 * @param string $abspath Absolute path.
	 *
	 * @return bool
	 */
	public function is_writable( $abspath ) {
		global $wp_filesystem;

		return $wp_filesystem->is_writable( $abspath );
	}

	/**
	 * Meant to internal usage. Reduce import files array to import statements string.
	 *
	 * @param string $cary Import statements string.
	 * @param string $file Import file.
	 *
	 * @return string
	 */
	public function reduce_dynamic_imports( $cary, $file ) {
		$file = (string) $file;

		return "$cary\n@import \"{$file}\";";
	}

	/**
	 * Create/overwrite $output_file file with $content, atomically.
	 *
	 * The content is written to a temporary file in the same directory and then
	 * renamed into place, so a concurrent reader always sees either the whole
	 * old file or the whole new one - never a truncated or half-written one.
	 * Empty content is refused: an empty stylesheet is never a valid result and
	 * would silently drop every rule it contains.
	 *
	 * @global WP_Filesystem_Base $wp_filesystem
	 *
	 * @param string $output_file Output file.
	 * @param string $content Content.
	 *
	 * @return bool True on success, false on failure. The existing file is left
	 *              untouched on failure.
	 */
	public function put_contents( $output_file, $content ) {
		/**
		 * WP Filesystem global.
		 *
		 * @var WP_Filesystem_Base $wp_filesystem
		 */
		global $wp_filesystem;

		if ( ! is_string( $content ) || $content === '' ) {
			return false;
		}

		wp_mkdir_p( dirname( $output_file ) );

		$tmp_file = $output_file . '.tmp-' . wp_generate_password( 8, false );
		$chmod    = defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : false;

		if ( ! $wp_filesystem->put_contents( $tmp_file, $content, $chmod ) ) {
			return $this->discard_temp_file( $tmp_file );
		}

		/*
		 * Guard against a short write (out of disk space, quota). Remote
		 * transports may not be able to report a size at all; in that case there
		 * is nothing to compare against and the write is taken at face value.
		 */
		$written = $wp_filesystem->size( $tmp_file );
		if ( $written !== false && (int) $written !== strlen( $content ) ) {
			return $this->discard_temp_file( $tmp_file );
		}

		if ( ! $this->move_into_place( $tmp_file, $output_file ) ) {
			return $this->discard_temp_file( $tmp_file );
		}

		return true;
	}

	/**
	 * Replace $output_file with $tmp_file in a single step where possible.
	 *
	 * WP_Filesystem_Base::move() deletes the destination before renaming when
	 * overwriting, which reopens the very window this class exists to close: for
	 * the length of that delete there is no stylesheet on disk at all. A plain
	 * rename() replaces the file in one atomic step on POSIX, so a concurrent
	 * reader sees either the whole old file or the whole new one.
	 *
	 * Only the direct filesystem works on real local paths; for remote transports
	 * (ftp, ssh) rename() cannot reach the file, so move() is the best available
	 * option there.
	 *
	 * @global WP_Filesystem_Base $wp_filesystem
	 *
	 * @param string $tmp_file    Temporary file holding the new content.
	 * @param string $output_file Destination file.
	 *
	 * @return bool
	 */
	protected function move_into_place( $tmp_file, $output_file ) {
		/**
		 * WP Filesystem global.
		 *
		 * @var WP_Filesystem_Base $wp_filesystem
		 */
		global $wp_filesystem;

		if ( $wp_filesystem->method === 'direct' ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- failure is handled by the caller.
			return @rename( $tmp_file, $output_file );
		}

		return (bool) $wp_filesystem->move( $tmp_file, $output_file, true );
	}

	/**
	 * Remove a temporary file left behind by a failed write and report failure.
	 *
	 * @global WP_Filesystem_Base $wp_filesystem
	 *
	 * @param string $tmp_file Temporary file.
	 *
	 * @return false
	 */
	protected function discard_temp_file( $tmp_file ) {
		/**
		 * WP Filesystem global.
		 *
		 * @var WP_Filesystem_Base $wp_filesystem
		 */
		global $wp_filesystem;

		$wp_filesystem->delete( $tmp_file );

		return false;
	}

	/**
	 * Make relative urls.
	 *
	 * @param string $css CSS.
	 * @param string $source_file Source file.
	 * @param string $output_file Output file.
	 *
	 * @return string
	 */
	protected function make_relative_urls( $css, $source_file, $output_file ) {
		$url_filter = new The7_Relative_Url_Filter( $source_file, $output_file );
		$css        = preg_replace_callback(
			'#url\s*\((?P<quote>[\'"]{0,1})(?P<url>[^\'"\)]+)\1\)#siU',
			array( $url_filter, 'filter' ),
			$css
		);

		return $css;
	}

	public function get_lessc( ) {
		return $this->lessc;
	}
}

class The7_Relative_Url_Filter {

	protected $path_from_uploads_to_wp_root;
	protected $path_from_wp_root_to_theme;

	public function __construct( $source_file, $output_file ) {
		$wp_upload_dir = wp_get_upload_dir();
		$content_relative_path = str_replace( $wp_upload_dir['basedir'], $wp_upload_dir['baseurl'], dirname( $output_file ) );
		$content_relative_path = str_replace( trailingslashit( site_url() ), '', $content_relative_path );
		$this->path_from_uploads_to_wp_root = implode( '/', array_fill( 0, count( explode( '/', $content_relative_path ) ), '..' ) );
		$this->path_from_wp_root_to_theme = str_replace( ABSPATH, '', dirname( $source_file ) );
	}

	public function filter( $matches ) {
		if ( preg_match( '#^(http|@|data:|/)#Ui', $matches[2] ) ) {
			return str_replace( site_url(), $this->path_from_uploads_to_wp_root, $matches[0] );
		}

		return sprintf( 'url(%s%s%1$s)', $matches[1], "{$this->path_from_uploads_to_wp_root}/{$this->path_from_wp_root_to_theme}/{$matches[2]}" );
	}
}
