<?php
/**
 * Local-filesystem destination. Fully working reference implementation.
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;

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

class LocalDestination extends AbstractDestination {

	public static function type(): string { return 'local'; }
	public static function label(): string { return 'Local Storage'; }

	public static function config_schema(): array {
		return [
			[
				'key'      => 'path',
				'label'    => 'Storage path',
				'type'     => 'text',
				'required' => true,
				'help'     => 'Absolute path. Must be writable by the web server.',
				'default'  => \Backreload\Storage::root(),
			],
		];
	}

	public function validate( array $config ): array {
		$config = parent::validate( $config );
		$path   = rtrim( (string) $config['path'], '/\\' );

		// SECURITY: refuse paths outside the WP install. Without this, an admin
		// (or attacker with admin credentials) can set the storage path to
		// /etc, /var/www, or anywhere PHP can write — combined with the
		// download endpoint this becomes a full filesystem read/write primitive.
		if ( '' === $path ) {
			throw new ValidationException( [ 'path' => 'Path cannot be empty.' ] );
		}
		if ( false !== strpos( $path, "\0" ) || preg_match( '#(^|/)\.\.(/|$)#', $path ) ) {
			throw new ValidationException( [ 'path' => 'Path contains an unsafe segment.' ] );
		}

		if ( ! is_dir( $path ) ) {
			wp_mkdir_p( $path );
		}
		$real_path = realpath( $path );
		if ( ! $real_path ) {
			throw new ValidationException( [ 'path' => 'Path could not be resolved on the filesystem.' ] );
		}

		// Never allow writing inside the plugins, mu-plugins, themes, or
		// WordPress core directories — those are wiped on update and/or are
		// code dirs that must not hold user data (a publicly-accessible plugin
		// folder is exactly what the directory guidelines forbid). Checked
		// first so it covers these even though some sit under wp-content.
		$forbidden = array_filter( [
			defined( 'WP_PLUGIN_DIR' )   ? realpath( WP_PLUGIN_DIR )   : '',
			defined( 'WPMU_PLUGIN_DIR' ) ? realpath( WPMU_PLUGIN_DIR ) : '',
			realpath( get_theme_root() ),
			realpath( ABSPATH . 'wp-admin' ),
			realpath( ABSPATH . 'wp-includes' ),
		] );
		foreach ( $forbidden as $bad ) {
			if ( $bad && 0 === strpos( trailingslashit( $real_path ), trailingslashit( $bad ) ) ) {
				throw new ValidationException( [
					'path' => 'Storage path cannot be inside the plugins, themes, or WordPress core directories. '
					       . 'Use a folder such as wp-content/uploads/backreload or your uploads directory.',
				] );
			}
		}

		// Containment check: the resolved path must live under one of the
		// allowed roots — the content dir (the default backreload-backups folder
		// lives here) or the uploads dir. ABSPATH is intentionally NOT allowed,
		// as it would permit wp-admin / wp-includes / the plugins folder.
		// Admins can extend via the `backreload_local_destination_roots` filter.
		$uploads = wp_get_upload_dir();
		$allowed = (array) apply_filters(
			'backreload_local_destination_roots',
			array_filter( [
				realpath( WP_CONTENT_DIR ),
				! empty( $uploads['basedir'] ) ? realpath( $uploads['basedir'] ) : '',
			] )
		);
		$inside = false;
		foreach ( $allowed as $root ) {
			if ( $root && 0 === strpos( trailingslashit( $real_path ), trailingslashit( $root ) ) ) {
				$inside = true;
				break;
			}
		}
		if ( ! $inside ) {
			throw new ValidationException( [
				'path' => 'Storage path must live inside wp-content or your uploads directory. '
				       . 'For unusual setups, use the backreload_local_destination_roots filter to extend the allowlist.',
			] );
		}

		if ( ! is_writable( $real_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of the target path before upload.
			throw new ValidationException( [ 'path' => 'Path is not writable.' ] );
		}
		$config['path'] = $real_path;
		return $config;
	}

	public function test( DestinationRecord $dest ): bool {
		$path = $dest->config['path'] ?? '';
		return is_dir( $path ) && is_writable( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of the target path before upload.
	}

	public function upload( DestinationRecord $dest, string $local_path, string $remote_name ): string {
		$dir = $dest->config['path'];
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}
		// $remote_name can include a subfolder (e.g. "bk_xyz/plugins-1.zip") so
		// per-backup parts land grouped together. Auto-create the subdir tree.
		$target = trailingslashit( $dir ) . $remote_name;
		$parent = dirname( $target );
		if ( ! is_dir( $parent ) ) {
			wp_mkdir_p( $parent );
		}
		if ( ! @copy( $local_path, $target ) ) {
			throw new \RuntimeException( esc_html(  "Failed to copy backup to {$target}"  )  );
		}
		return $target;
	}

	public function download( DestinationRecord $dest, string $remote_name, string $local_path ): bool {
		$src = trailingslashit( $dest->config['path'] ) . $remote_name;
		return is_file( $src ) && @copy( $src, $local_path );
	}

	public function delete( DestinationRecord $dest, string $remote_name ): bool {
		$root = rtrim( (string) $dest->config['path'], '/\\' );
		$src  = trailingslashit( $root ) . $remote_name;
		if ( ! is_file( $src ) ) {
			return true; // already gone — treat as success
		}
		$ok = @unlink( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.

		// If this was the last file in its parent subdir, prune the now-empty
		// directory. Stops storage from accumulating ghost folders after
		// retention sweeps. Never climbs above the configured storage root.
		$parent = dirname( $src );
		if ( $parent !== $root && 0 === strpos( $parent, $root ) ) {
			$leftovers = @scandir( $parent );
			if ( is_array( $leftovers ) && count( array_diff( $leftovers, [ '.', '..' ] ) ) === 0 ) {
				@rmdir( $parent ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the plugin's own empty destination subdirectory.
			}
		}
		return (bool) $ok;
	}

	public function list_backups( DestinationRecord $dest ): array {
		$root = rtrim( (string) ( $dest->config['path'] ?? '' ), '/\\' );
		if ( '' === $root || ! is_dir( $root ) ) {
			return [];
		}
		$out = [];
		foreach ( (array) glob( $root . '/bk_*', GLOB_ONLYDIR ) as $dir ) {
			$ext_id = basename( $dir );
			$parts  = [];
			foreach ( (array) glob( $dir . '/*' ) as $f ) {
				if ( ! is_file( $f ) ) {
					continue;
				}
				$base = basename( $f );
				if ( \Backreload\Core\Manifest::FILENAME === $base ) {
					continue;
				}
				$parts[] = [ 'name' => $ext_id . '/' . $base, 'size' => (int) filesize( $f ) ];
			}
			$manifest = \Backreload\Core\Manifest::read( $dir . '/' . \Backreload\Core\Manifest::FILENAME );
			if ( empty( $parts ) && null === $manifest ) {
				continue;
			}
			$out[] = [
				'ext_id'       => $ext_id,
				'has_manifest' => null !== $manifest,
				'manifest'     => $manifest,
				'parts'        => $parts,
			];
		}
		return $out;
	}

	public function usage( DestinationRecord $dest ): array {
		$dir = $dest->config['path'] ?? '';
		if ( ! is_dir( $dir ) ) {
			return [ 'used' => 0, 'free' => 0, 'total' => 0 ];
		}

		// Cache the recursive walk for 5 minutes. The Dashboard hits this on
		// every render, and on sites with hundreds of GB of backups the walk
		// becomes a multi-second stat-loop that blocks the response. The
		// dashboard's storage card doesn't need second-by-second accuracy —
		// numbers a few minutes stale are fine, and a fresh backup triggers
		// the same code path which can re-warm the cache (see
		// invalidate_usage_cache() below).
		$cache_key = 'backreload_local_usage_' . md5( (string) $dir );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) && isset( $cached['free'] ) ) {
			// Only honour cached entries that already use the new shape
			// (`free` field present). Old caches from the GB-float era
			// fall through and get re-computed.
			return $cached;
		}

		// Walk recursively now that backups live in per-backup subfolders.
		$used = 0;
		try {
			$it = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS )
			);
			foreach ( $it as $f ) {
				if ( $f->isFile() ) $used += $f->getSize();
			}
		} catch ( \Throwable $e ) {
			// Permission errors mid-walk shouldn't crash the dashboard — report
			// what we managed to total so far.
		}

		// Report all three values in BYTES so the frontend can format with
		// formatBytes() and pick KB/MB/GB appropriately. The old shape
		// reported GB as a float which made sub-gigabyte backups round to
		// "0 GB" — useless for small sites.
		//
		//   used  — size of files inside this destination's folder (our own data)
		//   free  — bytes still available on the filesystem before it fills up
		//   total — capacity of the filesystem the folder sits on (NOT a quota)
		//
		// `total` is reported so the UI can render a disk-fullness bar
		// (used+other-stuff vs total), but the UI must NOT present total as
		// the plugin's quota — Local Storage has no plugin-owned quota.
		$total = (int) ( @disk_total_space( $dir ) ?: 0 );
		$free  = (int) ( @disk_free_space( $dir )  ?: 0 );
		$out   = [
			'used'  => (int) $used,
			'free'  => $free,
			'total' => $total,
		];
		set_transient( $cache_key, $out, 5 * MINUTE_IN_SECONDS );
		return $out;
	}

	/**
	 * Drop the cached usage figure for this destination. Call after a backup
	 * completes or retention deletes files, so the dashboard reflects the new
	 * total on the next render instead of waiting up to 5 minutes.
	 */
	public static function invalidate_usage_cache( DestinationRecord $dest ): void {
		$dir = (string) ( $dest->config['path'] ?? '' );
		if ( '' === $dir ) return;
		delete_transient( 'backreload_local_usage_' . md5( $dir ) );
	}
}
