<?php
/**
 * Pre-flight checks that run before a backup starts.
 *
 * Catches conditions that would otherwise cause a mid-run failure 10–60
 * minutes in — frustrating for the user and wasteful of disk/bandwidth.
 *
 * Two severities:
 *   - fail  → the backup cannot succeed; abort before doing any work
 *   - warn  → likely-but-not-certain trouble; logged, run continues
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

use Backreload\Models\BackupRecord;
use Backreload\Models\DestinationRecord;

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

class Preflight {

	private Logger $logger;

	public function __construct( Logger $logger ) {
		$this->logger = $logger;
	}

	/**
	 * Run the full set of pre-flight checks for this backup.
	 *
	 * @throws \RuntimeException on any fail-level condition.
	 */
	public function check( BackupRecord $rec ): void {
		$this->check_staging_writable( $rec );
		$this->check_destinations_present( $rec );
		$this->check_disk_space( $rec );
		$this->check_memory_limit( $rec );
		$this->check_zip_extension( $rec );
	}

	/* ───────────────── individual checks ───────────────── */

	private function check_staging_writable( BackupRecord $rec ): void {
		$staging = \Backreload\Storage::root() . '/.staging';
		if ( ! is_dir( $staging ) ) {
			wp_mkdir_p( $staging );
		}
		if ( ! is_writable( $staging ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of a target path before backup/restore.
			throw new \RuntimeException(
				 esc_html( "Staging directory is not writable: {$staging}. " .
				'Check file permissions, the web server user needs write access.'
			 ) );
		}
	}

	private function check_destinations_present( BackupRecord $rec ): void {
		if ( empty( $rec->destinations ) ) {
			throw new \RuntimeException(
				 esc_html( 'No destinations selected for this backup. ' .
				'Connect at least one destination (Local, FTP, etc.) and try again.'
			 ) );
		}
		// Verify each is still connected AND not paused. A paused destination
		// is treated identically to a disconnected one for backup purposes —
		// the engine will skip it, so we shouldn't count it toward "available".
		$missing = [];
		$paused  = [];
		foreach ( $rec->destinations as $id ) {
			$d = DestinationRecord::find( $id );
			if ( ! $d || 'connected' !== $d->status ) {
				$missing[] = $id;
				continue;
			}
			if ( ! empty( $d->paused ) ) {
				$paused[] = $d->name;
			}
		}
		$unusable = count( $missing ) + count( $paused );
		if ( $unusable >= count( $rec->destinations ) ) {
			$bits = [];
			if ( $missing ) $bits[] = count( $missing ) . ' disconnected';
			if ( $paused )  $bits[] = count( $paused ) . ' paused (' . implode( ', ', $paused ) . ')';
			throw new \RuntimeException(
				 esc_html( 'No usable destination for this backup: ' . implode( ' + ', $bits ) . '. ' .
				'Resume or reconnect at least one destination, or run the backup with a different target.'
			 ) );
		}
		if ( ! empty( $missing ) ) {
			$this->logger->warn(
				'Some destinations are unavailable; backup will continue with the rest',
				[ 'missing' => $missing ],
				$rec->ext_id
			);
		}
		if ( ! empty( $paused ) ) {
			$this->logger->info(
				'Paused destinations will be skipped: ' . implode( ', ', $paused ),
				[],
				$rec->ext_id
			);
		}
	}

	/**
	 * Refuse to start if the host clearly doesn't have enough free disk for
	 * the staging copies we're about to write. Estimate is half of the source
	 * size (typical 2:1 compression) plus a 200 MB safety buffer.
	 */
	private function check_disk_space( BackupRecord $rec ): void {
		$source_bytes = $this->estimate_source_bytes( $rec->components );
		$needed       = (int) ( $source_bytes * 0.55 ) + 200 * 1048576;
		$free         = (int) ( @disk_free_space( \Backreload\Storage::root() ) ?: 0 );
		if ( $free <= 0 ) {
			// Couldn't measure — log a warning and proceed. Don't block on
			// hosts that disable disk_free_space().
			$this->logger->info( 'disk_free_space() unavailable; skipping disk pre-check', [], $rec->ext_id );
			return;
		}
		if ( $free < $needed ) {
			throw new \RuntimeException(  esc_html( sprintf(
				'Not enough free disk for staging. Need ~%s, have %s. ' .
				'Delete old backups, clear caches, or free up space and try again.',
				size_format( $needed ),
				size_format( $free )
			) )  );
		}
		$this->logger->info(
			sprintf( 'Disk check OK: %s free, ~%s estimated for staging', size_format( $free ), size_format( $needed ) ),
			[],
			$rec->ext_id
		);
	}

	private function check_memory_limit( BackupRecord $rec ): void {
		$limit_str = (string) ini_get( 'memory_limit' );
		$bytes     = function_exists( 'wp_convert_hr_to_bytes' )
			? (int) wp_convert_hr_to_bytes( $limit_str )
			: 0;
		// -1 means unlimited; that's fine.
		if ( $bytes < 0 ) return;
		if ( $bytes > 0 && $bytes < 128 * 1048576 ) {
			$this->logger->warn(
				"Low PHP memory_limit ({$limit_str}). Large backups may run out of memory. " .
				'Consider raising memory_limit to 256M in php.ini or wp-config.php.',
				[],
				$rec->ext_id
			);
		}
	}

	private function check_zip_extension( BackupRecord $rec ): void {
		if ( ! class_exists( '\\ZipArchive' ) ) {
			throw new \RuntimeException(
				 esc_html( 'PHP ZipArchive extension is not available. ' .
				'Backups need it to build archives. Ask your host to enable the `zip` extension.'
			 ) );
		}
	}

	/* ───────────────── helpers ───────────────── */

	/**
	 * Sum the on-disk size of every source directory we're about to archive.
	 * Walks recursively; bounded by the time it takes to du -s wp-content,
	 * usually a couple of seconds even for 20 GB sites.
	 */
	private function estimate_source_bytes( array $components ): int {
		$paths = [];
		foreach ( $components as $c ) {
			switch ( $c ) {
				case 'plugins': $paths[] = WP_PLUGIN_DIR;    break;
				case 'themes':  $paths[] = get_theme_root();     break;
				case 'uploads':
					$ui = wp_get_upload_dir();
					$paths[] = ! empty( $ui['basedir'] ) ? (string) $ui['basedir'] : wp_get_upload_dir()['basedir'];
					break;
				case 'mu':      $paths[] = WPMU_PLUGIN_DIR; break;
				// 'others' and 'db' are unbounded / small — folded into the
				// safety buffer rather than walked here.
			}
		}
		$total = 0;
		foreach ( $paths as $p ) {
			if ( ! is_dir( $p ) ) continue;
			$total += $this->dir_size( $p );
		}
		return $total;
	}

	private function dir_size( string $path ): int {
		$total = 0;
		try {
			$it = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS )
			);
			foreach ( $it as $f ) {
				if ( $f->isFile() ) $total += $f->getSize();
			}
		} catch ( \Throwable $e ) {
			// Permission denied somewhere in the tree — return what we got.
		}
		return $total;
	}
}
