<?php
/**
 * Common helpers for destination implementations.
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;

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

abstract class AbstractDestination implements Destination {

	/**
	 * Default validation: enforce required + sanitize types per config_schema().
	 *
	 * Override in your subclass to add custom checks (e.g. live host probe).
	 */
	public function validate( array $config ): array {
		$errors = [];
		$out    = [];

		foreach ( static::config_schema() as $field ) {
			$key   = $field['key'];
			$value = $config[ $key ] ?? $field['default'] ?? null;

			if ( ! empty( $field['required'] ) && ( null === $value || '' === $value ) ) {
				$errors[ $key ] = sprintf( '%s is required.', $field['label'] );
				continue;
			}

			// SECURITY/RELIABILITY: never run sanitize_text_field() on a secret.
			// That function strips HTML-like sequences ("<" / ">"), removes
			// octets, collapses whitespace, drops null-bytes, and removes the
			// special UTF-8 BOM. Every one of those is routine inside a strong
			// password or a multi-line PEM key:
			//
			//   • FTP/SFTP passwords often contain "<" / ">" / "&" / "%".
			//   • SFTP private keys are multi-line PEMs — sanitize_text_field
			//     collapses every newline into a space, destroying the format.
			//   • S3 secret keys are base64 strings that may include "+" / "/".
			//
			// Sanitizing silently mangles them, so the credential we send to the
			// destination no longer matches what the user typed — and the user
			// just sees "auth failed" with no way to recover (every re-entry
			// runs through the same sanitizer). This was the recurring
			// "ftp_login authentication failed after re-entering the password"
			// report.
			//
			// Treat ANY field declared `secret => true` as opaque bytes. The
			// only safe normalization is trimming whitespace at the outer
			// edges of single-line secrets — paste from a control panel often
			// grabs a trailing space. Multi-line secrets (PEM keys) keep
			// their newlines intact.
			$is_secret      = ! empty( $field['secret'] );
			$is_password_ty = ( $field['type'] ?? 'text' ) === 'password';
			if ( $is_secret || $is_password_ty ) {
				if ( is_string( $value ) ) {
					// Trim only when the value is single-line. For multi-line
					// (PEM private keys) we keep the value exactly as given.
					if ( false === strpos( $value, "\n" ) ) {
						$value = trim( $value );
					}
				}
			} else {
				switch ( $field['type'] ?? 'text' ) {
					case 'int':    $value = (int) $value;            break;
					case 'bool':   $value = (bool) $value;           break;
					case 'select': // fall through
					case 'text':
					default:       $value = is_string( $value ) ? sanitize_text_field( $value ) : $value;
				}
			}

			$out[ $key ] = $value;
		}

		if ( $errors ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- $errors is a field=>message validation map consumed as structured JSON by the REST controller, never echoed.
			throw new ValidationException( $errors );
		}
		return $out;
	}

	/**
	 * Default: no usage reporting. Subclasses override for storage types
	 * that can be measured (only Local currently — remote destinations
	 * like S3/FTP would need a separate API call we don't make on every
	 * dashboard render).
	 *
	 * Values are BYTES so the frontend can format them with the shared
	 * formatBytes() helper. `used` = bytes the plugin's data occupies on
	 * this destination, `free` = bytes still available, `total` = capacity
	 * of the underlying filesystem.
	 */
	public function usage( DestinationRecord $dest ): array {
		return [ 'used' => 0, 'free' => 0, 'total' => 0 ];
	}

	/**
	 * Enumerate the backups stored in this destination.
	 *
	 * A backup lives in a `bk_<ext_id>/` folder containing its archive parts and
	 * (for backups made by recent versions) a `manifest.json`. This lets a fresh
	 * site discover and import backups created elsewhere — the core of the
	 * cross-server migration flow.
	 *
	 * Default: returns [] — the destination is "not browsable". Transports that
	 * can enumerate remote directories override this.
	 *
	 * @return array<int,array{ext_id:string,has_manifest:bool,manifest:?array,parts:array<int,array{name:string,size:int}>}>
	 */
	public function list_backups( DestinationRecord $dest ): array {
		return [];
	}

	/** Whether this destination can enumerate its backups (see list_backups()). */
	public function is_browsable(): bool {
		return ( new \ReflectionMethod( $this, 'list_backups' ) )->getDeclaringClass()->getName() !== self::class;
	}

	/**
	 * Create a writable temp file path. Lazy-loads wp_tempnam() from
	 * wp-admin/includes/file.php (REST endpoints don't autoload it) and
	 * falls back to PHP's native tempnam() on any failure.
	 *
	 * Use this from any destination method that needs scratch disk space —
	 * test probes, manifest reads, key files — so we get the WP-aware path
	 * resolution when available and never fatal on shared hosts where
	 * wp-admin isn't pre-loaded.
	 */
	protected static function temp_path( string $prefix = 'bk' ): string {
		if ( ! function_exists( 'wp_tempnam' ) && defined( 'ABSPATH' ) ) {
			$file = ABSPATH . 'wp-admin/includes/file.php';
			if ( is_file( $file ) ) {
				require_once $file;
			}
		}
		if ( function_exists( 'wp_tempnam' ) ) {
			$path = wp_tempnam( $prefix );
			if ( $path ) return $path;
		}
		$path = @tempnam( sys_get_temp_dir(), $prefix . '-' );
		if ( false === $path ) {
			throw new \RuntimeException( esc_html( 'Could not create a temporary file (sys_get_temp_dir not writable).' ) );
		}
		return $path;
	}
}
