<?php
/**
 * Cache module — Drop-in Installer
 *
 * Installs/updates/removes:
 *   - wp-content/advanced-cache.php (templated from dropin/advanced-cache.php)
 *   - the `define( 'WP_CACHE', true );` line in wp-config.php
 *
 * All operations are idempotent and refuse to touch artefacts that don't carry
 * our marker (so we never delete another plugin's drop-in by mistake).
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Dropin_Installer {

	const DROPIN_VERSION = '1.3';
	const MARKER         = 'BLAMINHOR_ESSENTIALS_DROPIN';

	/** @var Blaminhor_Essentials_Cache_Storage */
	private $storage;

	/** @var Blaminhor_Essentials_Cache_Environment */
	private $environment;

	public function __construct( Blaminhor_Essentials_Cache_Storage $storage, Blaminhor_Essentials_Cache_Environment $environment ) {
		$this->storage     = $storage;
		$this->environment = $environment;
	}

	/**
	 * Whether our drop-in is currently installed at the canonical path.
	 *
	 * @return bool
	 */
	public function is_installed() {
		$path = $this->dropin_path();
		if ( ! file_exists( $path ) ) {
			return false;
		}
		return $this->is_our_dropin( $path );
	}

	/**
	 * Install / re-install the drop-in. Returns true on success.
	 *
	 * If a foreign drop-in is already present and $force is false, returns a
	 * WP_Error so the caller can decide. With $force=true (the default at
	 * activation time) we move the foreign file aside to a timestamped backup
	 * and proceed — same convention as WP Rocket / W3TC / etc., which all
	 * routinely take over the canonical drop-in path.
	 *
	 * @param array $params Substitution parameters: cache_dir, safe_flag, ttl, mobile_separate.
	 * @param bool  $force  When true, back up and replace any foreign drop-in.
	 * @return true|WP_Error
	 */
	public function install( array $params, $force = false ) {
		$path = $this->dropin_path();

		if ( file_exists( $path ) && ! $this->is_our_dropin( $path ) ) {
			if ( ! $force ) {
				return new WP_Error(
					'foreign_dropin',
					__( 'Another plugin\'s advanced-cache.php drop-in is already installed. Pass $force=true to take it over.', 'blaminhor-essentials' )
				);
			}
			$backup = $path . '.be-bak-' . time();
			if ( ! @rename( $path, $backup ) ) {
				return new WP_Error(
					'foreign_takeover_failed',
					__( 'Could not back up the existing advanced-cache.php drop-in before taking over.', 'blaminhor-essentials' )
				);
			}
			blaminhor_essentials_log(
				'Cache: foreign advanced-cache.php drop-in moved to ' . basename( $backup ) . ' before installing ours.',
				'info'
			);
		}

		$template = $this->load_template();
		if ( null === $template ) {
			return new WP_Error( 'template_missing', __( 'Drop-in template not found in the plugin.', 'blaminhor-essentials' ) );
		}

		$contents = $this->fill_template( $template, $params );

		// Ensure parent dir is writable.
		if ( ! is_writable( WP_CONTENT_DIR ) ) {
			return new WP_Error( 'wp_content_not_writable', __( 'wp-content/ is not writable; cannot install advanced-cache.php drop-in.', 'blaminhor-essentials' ) );
		}

		// Atomic write.
		$tmp = $path . '.be-tmp-' . wp_generate_password( 6, false, false );
		if ( false === @file_put_contents( $tmp, $contents, LOCK_EX ) ) {
			return new WP_Error( 'write_failed', __( 'Could not write the drop-in to a temporary file.', 'blaminhor-essentials' ) );
		}
		if ( ! @rename( $tmp, $path ) ) {
			@unlink( $tmp );
			return new WP_Error( 'rename_failed', __( 'Could not move the drop-in into place.', 'blaminhor-essentials' ) );
		}
		// Permissions: world-readable, owner-writable. WordPress core defaults.
		@chmod( $path, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 );

		// Record installed version.
		@file_put_contents( $this->storage->dropin_version_path(), self::DROPIN_VERSION );

		// Make sure WP_CACHE is true in wp-config.php.
		$this->ensure_wp_cache_constant( true );

		return true;
	}

	/**
	 * Remove the drop-in (only if it carries our marker) and reset
	 * `WP_CACHE` to false in wp-config.php.
	 *
	 * @return true|WP_Error
	 */
	public function uninstall() {
		$path = $this->dropin_path();

		// Refuse to delete a foreign drop-in.
		if ( file_exists( $path ) && $this->is_our_dropin( $path ) ) {
			if ( ! @unlink( $path ) ) {
				return new WP_Error( 'unlink_failed', __( 'Could not remove the drop-in file.', 'blaminhor-essentials' ) );
			}
		}

		// Drop the version marker too.
		$ver = $this->storage->dropin_version_path();
		if ( file_exists( $ver ) ) {
			@unlink( $ver );
		}

		// Flip WP_CACHE off in wp-config.php (or remove our managed line).
		$this->ensure_wp_cache_constant( false );

		return true;
	}

	/**
	 * Where the drop-in lives (always WP_CONTENT_DIR/advanced-cache.php — WP convention).
	 *
	 * @return string
	 */
	public function dropin_path() {
		return WP_CONTENT_DIR . '/advanced-cache.php';
	}

	/**
	 * Whether the installed drop-in matches the current DROPIN_VERSION constant.
	 * Returns true when the on-disk drop-in is older than the bundled template
	 * (so the caller knows to reinstall to ship new in-drop-in logic).
	 *
	 * @return bool
	 */
	public function is_outdated() {
		if ( ! $this->is_installed() ) {
			return false; // No drop-in to upgrade.
		}
		$marker = $this->storage->dropin_version_path();
		if ( ! file_exists( $marker ) ) {
			return true; // Pre-versioning install — treat as outdated.
		}
		$installed = trim( (string) @file_get_contents( $marker ) );
		if ( '' === $installed ) {
			return true;
		}
		return version_compare( $installed, self::DROPIN_VERSION, '<' );
	}

	/**
	 * Verify a file is ours by inspecting the marker in the first 4KB.
	 *
	 * @param string $path
	 * @return bool
	 */
	private function is_our_dropin( $path ) {
		$head = @file_get_contents( $path, false, null, 0, 4096 );
		if ( false === $head ) {
			return false;
		}
		return false !== strpos( $head, self::MARKER );
	}

	/**
	 * Read the bundled template.
	 *
	 * @return string|null
	 */
	private function load_template() {
		$path = __DIR__ . '/../dropin/advanced-cache.php';
		if ( ! is_readable( $path ) ) {
			return null;
		}
		$contents = @file_get_contents( $path );
		return false === $contents ? null : $contents;
	}

	/**
	 * Substitute placeholders. We use plain string replacement (not str_replace
	 * with arrays) to make the substituted values double-quoted-safe.
	 *
	 * @param string $template
	 * @param array  $params
	 * @return string
	 */
	private function fill_template( $template, array $params ) {
		$cache_dir       = isset( $params['cache_dir'] ) ? rtrim( $params['cache_dir'], '/' ) : '';
		$safe_flag       = isset( $params['safe_flag'] ) ? $params['safe_flag'] : '';
		$ttl             = isset( $params['ttl'] ) ? (int) $params['ttl'] : 86400;
		$mobile_separate = ! empty( $params['mobile_separate'] ) ? '1' : '0';

		// Escape single quotes in absolute paths (extremely defensive — paths
		// with quotes would already break a lot of things in WP, but no risk taken).
		$cache_dir = str_replace( "'", "\\'", $cache_dir );
		$safe_flag = str_replace( "'", "\\'", $safe_flag );

		$template = str_replace( '__BE_CACHE_DIR__', $cache_dir, $template );
		$template = str_replace( '__BE_SAFE_FLAG__', $safe_flag, $template );
		$template = str_replace( '__BE_TTL__', (string) $ttl, $template );
		$template = str_replace( '__BE_MOBILE_SEPARATE__', $mobile_separate, $template );

		return $template;
	}

	/**
	 * Ensure `define( 'WP_CACHE', $value );` is present (and only once) in
	 * wp-config.php. Inserts our line near the top of the file (after the
	 * opening `<?php` tag) if missing.
	 *
	 * @param bool $value
	 * @return bool True on success.
	 */
	public function ensure_wp_cache_constant( $value ) {
		$contents = $this->environment->read_wp_config();
		if ( null === $contents ) {
			// No wp-config readable — we silently no-op. The drop-in would not
			// be invoked anyway without WP_CACHE, but at least we don't crash.
			return false;
		}

		$bool_str = $value ? 'true' : 'false';

		// Strategy:
		//   1. If a `define( 'WP_CACHE', ... );` already exists, replace its
		//      value AND any trailing inline comment on the same line, so
		//      reactivations don't stack `// Managed by …` markers.
		//   2. Otherwise, insert our managed block right after `<?php`.
		//
		// We use a regex that tolerates whitespace and quoting variations and
		// captures up to (but not including) the next line break so the
		// trailing comment is replaced rather than left in place.
		$pattern = '/define\s*\(\s*([\'"])WP_CACHE\1\s*,\s*[^\)]+\)\s*;[^\r\n]*/i';

		if ( preg_match( $pattern, $contents ) ) {
			$replacement = "define( 'WP_CACHE', " . $bool_str . " ); // Managed by Blaminhor Essentials Cache.";
			$new         = preg_replace( $pattern, $replacement, $contents, 1 );
		} else {
			$insert = "\n/** Managed by Blaminhor Essentials Cache. */\ndefine( 'WP_CACHE', " . $bool_str . " );\n";
			// Insert after the opening `<?php` tag (with optional declare line).
			$new = preg_replace( '/^(<\?php\s*(?:declare\s*\([^)]+\)\s*;)?)/i', "$1\n" . $insert, $contents, 1 );
			if ( null === $new ) {
				return false;
			}
		}

		// Backup before overwriting.
		$backup = $this->environment->get_wp_config_path() . '.be-bak';
		@copy( $this->environment->get_wp_config_path(), $backup );

		$ok = $this->environment->write_wp_config( $new );

		// Persist a flag so on_deactivate knows we modified the file.
		update_option( 'blaminhor_essentials_cache_wpconfig_modified', (bool) $ok );

		return (bool) $ok;
	}
}
