<?php
/**
 * Shared helpers for root-level plain-text files (ads.txt, robots.txt).
 *
 * @package WP_PowerSuite\Core
 * @since 1.0.0
 */

namespace WP_PowerSuite\Core;

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

final class RootTextFileHelper {

	public const MAX_CONTENT_BYTES = 524288; // 512 KB.

	private const PRIVATE_DIR = 'wp-powersuite-private';
	private const BACKUP_DIR  = 'root-text-backups';
	private const MAX_BACKUPS = 50;

	/**
	 * Resolve a root-level text file path (e.g. ads.txt, robots.txt).
	 */
	public static function getRootFilePath( string $filename ): string {
		$filename = ltrim( $filename, '/' );
		$filename = basename( $filename );

		return wp_normalize_path( ABSPATH . $filename );
	}

	/**
	 * Whether a root text file (or its parent directory) is writable.
	 */
	public static function isWritable( string $path ): bool {
		$path = wp_normalize_path( $path );
		if ( file_exists( $path ) ) {
			return is_writable( $path );
		}

		return is_writable( dirname( $path ) );
	}

	/**
	 * Read file contents.
	 *
	 * @return array{success: bool, content: string, message: string}
	 */
	public static function readFileContents( string $path ): array {
		$path = wp_normalize_path( $path );
		if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
			return [
				'success' => false,
				'content' => '',
				'message' => __( 'File not found or not readable.', 'wp-powersuite' ),
			];
		}

		$content = file_get_contents( $path );
		if ( $content === false ) {
			return [
				'success' => false,
				'content' => '',
				'message' => __( 'Could not read file.', 'wp-powersuite' ),
			];
		}

		return [
			'success' => true,
			'content' => $content,
			'message' => '',
		];
	}

	/**
	 * Validate content size.
	 *
	 * @return string|null Error message when invalid.
	 */
	public static function validateContentSize( string $content ): ?string {
		if ( strlen( $content ) > self::MAX_CONTENT_BYTES ) {
			return __( 'Content too large. Maximum 512 KB.', 'wp-powersuite' );
		}

		return null;
	}

	/**
	 * Create a backup in private storage. Returns true when there is nothing to back up.
	 *
	 * @return string|bool Backup path, true when no source file, or false on failure.
	 */
	public static function createSecureBackup( string $source_path, string $label ): string|bool {
		$source_path = wp_normalize_path( $source_path );
		if ( ! file_exists( $source_path ) || ! is_readable( $source_path ) ) {
			return true;
		}

		$read = self::readFileContents( $source_path );
		if ( ! $read['success'] ) {
			return false;
		}

		if ( ! self::ensureBackupStorageDir() ) {
			return false;
		}

		$label    = sanitize_key( $label );
		$label    = $label !== '' ? $label : 'root-text';
		$token    = wp_generate_password( 16, false, false );
		$filename = sprintf( '%s-%s-%s.bak', $label, gmdate( 'Y-m-d-His' ), $token );
		$backup   = wp_normalize_path( self::getBackupStorageDir() . '/' . $filename );

		if ( ! self::atomicWrite( $backup, $read['content'] ) ) {
			return false;
		}

		self::pruneOldBackups( $label );

		return $backup;
	}

	/**
	 * Write a root text file atomically.
	 */
	public static function atomicWrite( string $path, string $content ): bool {
		$path = wp_normalize_path( $path );
		$dir  = wp_normalize_path( dirname( $path ) );

		if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
			return false;
		}

		$temp = wp_normalize_path( $dir . '/.wpps-root-' . wp_generate_password( 16, false, false ) . '.tmp' );
		if ( file_put_contents( $temp, $content, LOCK_EX ) === false ) {
			return false;
		}

		@chmod( $temp, 0640 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod

		if ( ! @rename( $temp, $path ) ) {
			@unlink( $temp );
			return false;
		}

		return true;
	}

	/**
	 * Absolute path to the private backup storage directory.
	 */
	public static function getBackupStorageDir(): string {
		return wp_normalize_path( WP_CONTENT_DIR . '/' . self::PRIVATE_DIR . '/' . self::BACKUP_DIR );
	}

	/**
	 * Create backup directory and deny direct HTTP access.
	 */
	public static function ensureBackupStorageDir(): bool {
		$root = wp_normalize_path( WP_CONTENT_DIR . '/' . self::PRIVATE_DIR );
		$dir  = self::getBackupStorageDir();

		foreach ( [ $root, $dir ] as $path ) {
			if ( ! wp_mkdir_p( $path ) ) {
				return false;
			}
			self::writeDirectorySilencer( $path );
			self::writeAccessDenyRules( $path );
		}

		return is_dir( $dir ) && is_writable( $dir );
	}

	private static function writeDirectorySilencer( string $dir ): void {
		$index = $dir . '/index.php';
		if ( file_exists( $index ) ) {
			return;
		}

		file_put_contents( $index, "<?php\n// Silence is golden.\n", LOCK_EX );
	}

	private static function writeAccessDenyRules( string $dir ): void {
		$htaccess = $dir . '/.htaccess';
		$rules    = "# WP PowerSuite: deny direct web access\n"
			. "<IfModule mod_authz_core.c>\n"
			. "\tRequire all denied\n"
			. "</IfModule>\n"
			. "<IfModule !mod_authz_core.c>\n"
			. "\tOrder deny,allow\n"
			. "\tDeny from all\n"
			. "</IfModule>\n";

		if ( ! file_exists( $htaccess ) || file_get_contents( $htaccess ) !== $rules ) {
			file_put_contents( $htaccess, $rules, LOCK_EX );
		}

		$web_config = $dir . '/web.config';
		$xml        = '<?xml version="1.0" encoding="UTF-8"?>'
			. '<configuration><system.webServer><security><authorization>'
			. '<remove users="*" roles="" verbs="" />'
			. '<add accessType="Deny" users="*" />'
			. '</authorization></security></system.webServer></configuration>';

		if ( ! file_exists( $web_config ) || file_get_contents( $web_config ) !== $xml ) {
			file_put_contents( $web_config, $xml, LOCK_EX );
		}
	}

	private static function pruneOldBackups( string $label ): void {
		if ( ! is_dir( self::getBackupStorageDir() ) ) {
			return;
		}

		$pattern = self::getBackupStorageDir() . '/' . sanitize_key( $label ) . '-*.bak';
		$files   = glob( $pattern );
		if ( ! is_array( $files ) || count( $files ) <= self::MAX_BACKUPS ) {
			return;
		}

		usort(
			$files,
			static function ( string $a, string $b ): int {
				return filemtime( $b ) <=> filemtime( $a );
			}
		);

		foreach ( array_slice( $files, self::MAX_BACKUPS ) as $file ) {
			if ( is_file( $file ) ) {
				@unlink( $file );
			}
		}
	}
}
